Účelem prvního cvičení je naučit se jak funguje kompilace C++ kódu a jak použít CMake aby za nás tuto práci automatizoval. Předpokládáme, že již máte funkční prostředí, buďto protože používáte školní stroje, nebo jste si dle návodu zprovoznili CMake a git na svém stroji.
V současné době se C++ řadí podle indexu TIOBE (https://www.tiobe.com/tiobe-index/) na 3.-4. místo. Je to extrémně rychlý a efektivní jazyk, mnoho nástrojů a frameworků spoléhá na rychlost a efektivitu C++. C++ je skvělý jazyk, pokud chcete porozumět tomu, jak počítače fungují. Mnohé koncepty programování vám mohou dávat větší smysl, když se C++ naučíte.
Je známo, že C++ je jedním z nejobtížnějších programovacích jazyků na naučení – oproti jiným populárním jazykům, jako je Python a Java. C++ je těžké se naučit kvůli jeho multiparadigmatické povaze a pokročilejší syntaxi. Jazyk C++ je ale neustále modernizován a verze C++14, C++17 a C++20 umožňují psát kód poměrně rychle a efektivně.
Důvodů, proč i v dnešní době je C++ tak atraktivní, lze najít více – viz (https://www.educative.io/blog/learn-cpp-for-2022).
Začneme tím, že zkompilujeme “Hello world” v C++:
#include <iostream> int main() { std::cout << "Hello world\n"; }
K tomu, aby kompilátor udělal z tohoto zdrojového kódu spustitelný program, ho musel zkompilovat. My budeme pracovat se zjednodušeným modelem kompilace, který se skládá ze 3 fází1).
které jsou prováděny preprocessorem, kompilátorem a linkerem respektive. Jak jsme viděli, pro základní použití je nemusíme rozlišovat, postará se o to tzv. “driver” kompilátoru, ale stejně se nám hodí o nich vědět.
Když kompilujeme .cpp
soubor, preprocessor je první nástroj, který
ho přečte a musí nějak zpracovat. Je zodpovědný za vyřešení tzv.
“preprocessor directives”, například #include
, #define
, #ifdef
.
Je též zodpovědný za expanzi maker, kdy se token v programu nahrazuje
za jiný, dle předchozích #define
direktiv.
Příklad
Originální kód
#define KONSTANTA 123 int main() { return KONSTANTA; }
Možný výstup z preprocessoru
#line 1 "test.cpp" int main() { return 123; }
Jak vidíme, token KONSTANTA
byl definován jako 123
, a jeho
pozdější výskyt tedy byl nahrazen přímo za 123
.
Preprocessor se většinou volá přes tzv. “driver” kompilátoru, tj přímo
spustitelná binárka gcc
, clang
, cl.exe
, etc. s přepínačem
-E
(nebo /E
):
gcc -E test.cpp clang -E test.cpp cl /E test.cpp
Kvůli #include
direktivám preproces obvykle soubory značně zvětší.
Zjistěte, kolik řádků má náš “Hello world” program poté, co proběhne preprocessing.
Kompilace samotná probíhá na výstupu z preprocesu a převádí C++ kód, již bez direktiv pro preprocessor, do podoby které již rozumí procesor. Výsledkům kompilace se obvykle říká “object file”, my jim budeme říkat objektové soubory. Objektové soubory nejsou spustitelné, protože jejich obsah sice je ve formátu kterému procesor rozumí, ale mohou obsahovat reference k funkcím (symbolům), které nejsou definovány v daném objektovém souboru.
Kompilace bez linkování se provede předáním -c
(nebo /c
) driveru
kompilátoru.
Příklad
g++ -c hello.cpp clang++ -c hello.cpp cl /c hello.cpp
V případě GCC a Clangu bude objektový souboru uložen jako hello.o
,
v případe MSVC bude uložen jako hello.obj
. Během této fáze překladu
dochází k najítí a hlášení většiny chyb v kódu.
V čem může být výhoda toho když se jednotlivé .cpp
soubory nejdříve
zkompilují do objektových, a pak se z nich teprve vytváří spustitelný
program?
Do objektových souborů se dá různými způsoby podívat, my si ukážeme jak zjistit které symboly v daném souboru jsou, a které používá, ale v daném souboru nejsou.
Na Linux/OS X k tomu slouží program s kryptickým jménem nm
, na Windows
se za tímto účelem používá dumpbin
.
Použití
$ nm hello.o U __cxa_atexit 0000000000000000 t __cxx_global_var_init U __dso_handle 0000000000000050 t _GLOBAL__sub_I_test_io.cpp 0000000000000000 T main U _ZNSt8ios_base4InitC1Ev U _ZNSt8ios_base4InitD1Ev U _ZSt4cout 0000000000000000 b _ZStL8__ioinit U _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKcStav každého symbolu se dá poznat jeho prefix, například
U
znamená,
že daný symbol je potřeba, ale není v objektovém souboru definován.
Seznam všech prefixů získáte pomocí man nm
.
Výstup z dumpbin
je mnohem ukecanější, takže ho rovnou necháme zapsat
do souboru: dumpbin hello.obj /symbols /out:hello.txt
. Když se pak
podíváme do souboru “hello.txt”, najdeme tam například tyto řádky:
161 00000000 UNDEF notype () External | _atexit 165 00000000 UNDEF notype () External | _memcpy 166 00000000 UNDEF notype () External | _memmove 167 00000000 UNDEF notype () External | _strlenkteré nám říkají, že symboly
_atexit
, _memcpy
, atd jsou žádané,
ale nejsou definované.
Linkování je krok, během kterého se spojí více objektových souborů, případně knihoven, do jedné spustitelné binárky. To znamená primárně nahrazení referencí k nedefinovaným symbolům správnou adresou daného symbolu, ale pokročilejší použití linkování umožňuje například z binárky vyhodit funkce, které se nikdy nemohou volat.
Pokud by nějaký symbol zůstal nedefinovaný, jedné se o chybu a linker nahlásí “missing symbol” chybu.
Co by se mělo stát v případě, že nějaký symbol je naopak definovaný vícekrát?
Příklad
g++ hello.o cl hello.obj
Protože pro netriviální programy je výhodné rozlišovat mezi kompilací a linkováním, ale je otravné se o to starat manuálně, používáme v praxi tzv. “build” systémy. Jejich účelem je starat se o kompilaci místo programátora, včetně kontroly které části programu je potřeba překompilovat po změnách, a které není. My z nich budeme pracovat s tzv “meta build” systémem, který se jmenuje CMake. Meta je pro to, že v něm popíšeme jak se nějaký program staví, a CMake následně vygeneruje soubory, které ten proces popisují pro build systém, který danou kompilaci doopravdy provede.
Díky tomuto funguje CMake na většině platforem které člověk může potkat (nejenom Linux, OS X a Windows, ale i různé HPC systémy), a podporuje spoustu různých IDE.
CMake vždy pracuje se souborem pojmenovaným CMakeLists.txt
a my si
ukážeme jak takový CMakeLists.txt
má vypadat na jednoduchém příkladu,
který si můžete stáhnout zde.
Mějme 5 souborů:
main.cpp
hello.h
hello.cpp
world.h
world.cpp
Jejich odpovídající CMakeLists.txt pak je
cmake_minimum_required(VERSION 3.5) project(cmake-example LANGUAGES CXX) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) add_executable(hello-world # implementační soubory jsou důležité hello.cpp world.cpp main.cpp # hlavičkové soubory jsou pro IDE hello.h world.h ) # Zapneme rozumnou sadu varování if ( CMAKE_CXX_COMPILER_ID MATCHES "Clang|AppleClang|GNU" ) target_compile_options( hello-world PRIVATE -Wall -Wextra -Wunreachable-code -Wpedantic) endif() if ( CMAKE_CXX_COMPILER_ID MATCHES "MSVC" ) target_compile_options( hello-world PRIVATE /W4 ) endif()
První řádek říká, že musí být použit CMake ve verzi 3.5 nebo novější.
Druhý řádek říká, že se tento projekt jmenuje cmake-example
a
jako jazyk používá pouze C++. Příkaz set
nastavuje proměnou,
v našem případě nastavujeme speciální proměnné na hodnoty které říkají,
že chceme kompilovat se standardem C++14, nechceme používat rozšíření
jednotlivých kompilátorů a že pokud nemůžeme kompilovat C++14 (třeba
kvůli starému kompilátoru), tak nechceme používat starší standard.
add_executable
pak, jak název napovídá, definuje spustitelný program,
který se bude jmenovat hello-world
a skládá se z našich 5 souborů2).
Poslední část naší ukázky pak zapne rozumnou sadu varování, pro specifický
“target”, v našem případě binárku hello-world
.
Kompilace pomocí CMaku probíhá ve dvou fázích:
Konfigurace slouží k tomu, aby CMake vygeneroval soubory, dle kterých pak jiný build systém bude umět projekt postavit. Během kompilace pak dojde ke stavbě projektu samotného.
My si teď ukážeme jak použít CMake CLI ke konfiguraci projektu.
Základní invokace CMake je triviální: cmake -B<build-dir> -H<source-dir>
.
Kde <build-dir>
je cesta ve které chcete aby probíhal build,
a source-dir
je cesta ze které se má přečíst CMakeLists.txt
.
V případě, že chcete nastavit nějakou proměnnou uvnitř CMake, používá se
na to -D
prefix. Například při přípravě svého prostředí jste takto
nastavili CMAKE_BUILD_TYPE
, což je proměnná která u některých
generátorů nastavuje v jakém módu (Debug, Release, atd) má kompilace
probíhat.
Mezi další užitečné přepínátka pak patří -G
, které mění generátor
použit během konfigurace, takže například cmake -Bbuild -H.
-G “CodeBlocks - Unix Makefiles”
vygeneruje projektové soubory pro
CodeBlocks, které pak používají Make pro samotné stavění projektu, a
-A
, které mění tzv. platformu pro konfiguraci, takže například
cmake -Bbuild -H. -A Win64
na Windows vytvoří projekt který staví
64 bitové aplikace místo 32 bitových.
Ke stavbě projektu pak buďto můžeme použít cmake samotný, přes cmake
--build <build-dir>
, jako se dělalo během přípravy prostředí, nebo
přímo přes nástroj pro který jsme vybudovali projektové soubory. Například
pro Make3), to znamená make -C <build-dir>
, pro MSBuild4) to znamená otevřít <project-name>.sln
soubor ve Visual Studiu.
Základní výstup (tisk do konzole) funguje v C++ přes tzv. “streamy”
(proudy). Konzolový výstup (stdout) je reprezentován objektem std::cout
,
vstup (stdin) je pak reprezentován objektem std::cin
.
Tato ukázka reprezentuje minimální program “Hello world”. Po jeho vykonání bude na konzoli napsáno Hello World.
#include <iostream> int main(){ std::cout << "Hello world\n"; }
#include <iostream>
umožní programu používat třídy pro standardní vstup.
Operátor << slouží k zápisu do proudu.
Tento program ukazuje načítání vstupu.
#include <iostream> int main(){ std::cout << "Dej mi cislo\n"; int num; std::cin >> num; std::cout << "Cislo bylo: " << num << '\n'; }>> je operátor čtení z proudu.
std::cin >> num;Program přečte ze standardního vstupu jedno číslo a uloží jeho hodnotu do num. Následně jeho hodnotu vypíše na výstup (spolu s trochou textu).
Všimněte si, že se operátor výstupu dá řetězit. Stejným způsobem se dá řetězit i čtení:
int num1, num2; std::cin >> num1 >> num2Tyto dva řádky zadeklarují dvě čísla (proměnné typu int) a následně načtou dvě čísla ze stdin a uloží je do zadeklarovaných proměnných.
Nebudeme zatím řešit standardní knihovnu nijak do hloubky, ale krom standardního
vstupu a výstupu se ještě podíváme na dva kousky standardní knihovny. Konkrétně na
třídu pro
řetězce, std::string
, která zjednodušuje práci s řetězci, a std::vector
,
což je třída podobná javovské třídě ArrayList
.
std::string
se dá načítat přes std::cin
a vypisovat přes std::cout
.
Pozor, načítání funguje po “slovech”, tj. načtou se všechny znaky mezi dvěma
prázdnými místy (mezery, tabulátory, konce řádků…).
Co to znamená? Mějme tento kousek kódu:
#include <iostream> #include <string> int main() { std::cout << "Napis sve jmeno:\n"; std::string jmeno; std::cin >> jmeno; std::cout << "Ahoj " << jmeno << '\n'; }Pokud mu jako vstup dáme
Jan
, pak výstup bude
Napis sve jmeno: Jan Ahoj JanPokud mu ale jako vstup dáme celé jméno, třeba
Jan Novak
, pak výstup bude
stále pouze samotné jméno.
Napis sve jmeno: Jan Novak Ahoj Jan
std::vector
je rostoucí pole. Není potřeba předem určit velikost, stačí
vytvořit vektor a přidávat/odebírat prvky dle libosti. Jedná se o šablonovou
třídu, což znamená, že je potřeba deklarovat, jaký typ chceme do vektoru
ukládat.
Do vektoru můžeme ukládat primitivní i uživatelem definované typy.
#include <iostream> #include <vector> int main() { std::vector<int> numbers; std::cout << "Napis 5 cisel.\n"; for (int i = 0; i < 5; ++i){ int temp; std::cin >> temp; numbers.push_back(temp); } std::cout << "Cisla byla:\n"; for (int i = 0; i < 5; ++i){ std::cout << numbers[i] << '\n'; } }
Předchozí ukázka nejdříve načetla 5 čísel a pak vypsala prvních 5 prvků z vektoru. Co by se ale stalo, pokud bychom změnili načítací smyčku, aby načítala pouze 4 prvky? Není to jisté, protože to je tzv. nedefinované chování5), ale důležité je, že bychom smyčky, kde chceme přejít přes celý kontejner, měli psát trochu jinak.
#include <iostream> #include <vector> #include <string> int main() { std::vector<std::string> vec; std::cout << "Zadej 5 jmen.\n"; for (int i = 0; i < 5; ++i){ std::string temp; std::cin >> temp; vec.push_back(temp); } std::cout << "Jmena byla:\n"; for (std::string s : vec){ std::cout << s << '\n'; } }Někteří z vás určitě poznávají tzv. “for-each” smyčku, stejnou jako v Javě. Určitě si též umíte představit, že pokud by daný vector byl šablonován na složitém typu, nebylo by pohodlné jej použít. Naštěstí existuje i mnohem jednodušší forma:
... std::cout << "Jmena byla:\n"; for (auto s : vec) { std::cout << s << '\n'; }
auto
jako typ říká kompilátoru, ať zjistí typ “na pravé straně” výrazu a
doplní ho. Více o použití auto
v deklaracích proměnných si povíme později.
Pojem nedefinovaného chování (UB – Undefined Behaviour) v C++ vyjadřuje situaci, ke které dle standardu jazyka “nemůže dojít”. Pozor, to neznamená, že jazyk brání tomu, aby k nim došlo, ale kompilátor při optimalizacích může nedefinované chování zanedbat a předpokládat, že si programátor “nějak” zajistil, aby k němu nedošlo.
Co to znamená v praxi, si ukážeme na několika příkladech.
Zkuste si zkompilovat a spustit tento program, nejdříve bez optimalizací:
#include <iostream> int main(){ int i = 1; while (i > 0){ std::cout << "Ahoj\n"; i *= 2; } }Měl by vám vytisknout 31 řádků s “Ahoj”. Nyní si ho zkuste zkompilovat s optimalizacemi (
-O3
pro GCC, Release
konfigurace ve Visual Studiu a
XCode).
Co vám vytiskne teď?
Zkuste si zkompilovat a spustit tento program. Nezapomeňte zapnout C++14.
1 #include <iostream> 2 3 int test(int x) { 4 if (x > 0) { 5 return 1; 6 } 7 x -= 1000000000; 8 if (x > 0) { 9 return 2; 10 } 11 return 1; 12 } 13 14 int main(){ 15 std::cout << test(1) << std::endl; 16 std::cout << test(-2000000000) << std::endl; 17 }Co je na výstupu, pokud spustíte program zkompilovaný s optimalizacemi? A co pokud spustíte program zkompilovaný bez nich?
Možná jste slyšeli o Velké Fermatově větě. Dlouho se nevědělo, jestli je opravdu pravdivá, nebo ne, ale v roce 1995 se ji po dlouhé době podařilo prokázat. Představte si, že tomu důkazu nevěřite a chcete si ji ověřit.
Tento kousek kódu6) ji
vyzkouší pro n == 3
a a, b, c <= 1000.
Zkuste si ho zkompilovat s optimalizacemi.
#include <iostream> bool fermat() { const int MAX = 1000; int a = 1, b = 1, c = 1; while (true) { if ((a*a*a) == ((b*b*b) + (c*c*c))) { return true; } a++; if (a > MAX) { a = 1; b++; } if (b > MAX) { b = 1; c++; } if (c > MAX) { c = 1; } } return false; } int main() { if (fermat()) { std::cout << "Fermat's Last Theorem has been disproved.\n"; } else { std::cout << "Fermat's Last Theorem has not been disproved.\n"; } }Pokud vám program vypsal, že Velká Fermatova věta byla vyvrácena, nechte si vypsat hodnoty a, b, c, pro které je vyvrácena. Co vám program vypsal teď?
Očekáváme, že program skončí, když dojde k přetečení hodnoty proměnné i
definované se znaménkem, tj. i
bude nabývat nulovou nebo zápornou hodnotu.
Standardní překlad z příkazové řádky:
g++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 main.cpp -o main clang++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 main.cpp -o mainVytiskne se 31 řádků s “Ahoj”.
Kompilace s optimalizací:
g++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 –O3 main.cpp -o mainNekonečný výpis řádků s “Ahoj”.
clang++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 –O3 main.cpp -o mainVytiskne se 33 řádků s “Ahoj”. V přeloženém programu se nekontroluje záporná hodnota proměnné
i
, cyklus se vykoná i pro hodnotu i = 0
a pak skončí.
Program skončí s hodnotou 2 tehdy, když parametr funkce test()
bude záporný a po odečtení hodnoty 1’000’000’000 dojte k přetečení hodnoty proměnné x
, tj. x
bude kladné.
Standardní překlad z příkazové řádky:
g++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 main.cpp -o main clang++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 main.cpp -o mainVytiskne se 1 a pak 2.
Kompilace s optimalizací:
g++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 –O3 main.cpp -o main clang++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 –O3 main.cpp -o mainVytiskne se 1 a pak 1. Druhá jednička je návratová hodnota z funkce
test
na řádku číslo 11. Překladač při optimalizaci předpokládá, že pokud od záporného čísla odečteme miliardu, nemůžeme dostat číslo kladné.
Pokud neexistují hodnoty a, b, c
, které by vyvracely Velkou Fermatovu větu, neskončí smyčka while
ve funkci fermat(
) a tudíž se zacyklí celý program. Velká Fermatova věta nemůže být programem prokázána.
Standardní překlad z příkazové řádky:
g++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 main.cpp -o main clang++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 main.cpp -o mainProgram obsahuje nekonečnou smyčku, program se zacyklí.
Kompilace s optimalizací:
g++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 –O3 main.cpp -o mainProgram se opět zacyklí.
clang++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 –O3 main.cpp -o mainProgram skončí, vytiskne se “Fermat's Last Theorem has been disproved.”. Pokud se snažíme před návratem z funkce
fermat()
vypsat hodnoty a, b, c
, program se opět zacyklí. Smyčka najednou není zcela bez vedlejších účinků, jak je tomu bez snahy o výpis hodnot.