Search
Úč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.
.cpp
#include
#define
#ifdef
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.
KONSTANTA
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
clang
cl.exe
-E
/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.
-c
/c
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.
hello.o
hello.obj
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.
nm
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_PKc
U
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:
dumpbin hello.obj /symbols /out:hello.txt
161 00000000 UNDEF notype () External | _atexit 165 00000000 UNDEF notype () External | _memcpy 166 00000000 UNDEF notype () External | _memmove 167 00000000 UNDEF notype () External | _strlen
_atexit
_memcpy
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?
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.
CMakeLists.txt
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.
cmake-example
set
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).
add_executable
hello-world
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.
cmake -B<build-dir> -H<source-dir>
<build-dir>
source-dir
-D
CMAKE_BUILD_TYPE
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.
-G
cmake -Bbuild -H. -G “CodeBlocks - Unix Makefiles”
-A
cmake -Bbuild -H. -A Win64
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.
cmake --build <build-dir>
make -C <build-dir>
<project-name>.sln
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.
std::cout
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>
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'; }
std::cin >> num;
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 >> num2
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
std::vector
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'; }
Jan
Napis sve jmeno: Jan Ahoj Jan
Jan Novak
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'; } }
... std::cout << "Jmena byla:\n"; for (auto s : vec) { std::cout << s << '\n'; }
auto
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; } }
-O3
Release
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 }
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.
n == 3
a, b, c <= 1000.
#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"; } }
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.
i
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 main
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 main
i = 0
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é.
test()
x
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 main
test
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.
a, b, c
while
fermat(
fermat()