{{page>courses:b6b36pjc:styles#common&noheader&nofooter}}
{{page>courses:b6b36pjc:styles#cviceni&noheader&nofooter}}
===== Cvičení 1: Základy kompilace, CMake a vstup/výstup =====
Úč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.
==== Kompilace ====
Začneme tím, že zkompilujeme "Hello world" v C%%++%%:
#include
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 zkládá ze 3 fází(([[https://en.cppreference.com/w/cpp/language/translation_phases|Striktně
řečeno jich je 9]])).
- Preproces
- Kompilace
- Linkování
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.
=== Preproces(sor) ===
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 ===
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_PKc
Stav 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 | _strlen
které nám říkají, že symboly ''_atexit'', ''_memcpy'', atd jsou žádané,
ale nejsou definované.
=== Linkování ===
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
==== CMake ====
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.
=== Základní šablona ===
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 {{:courses:b6b36pjc:cviceni:cviceni-01_cmake-example.zip|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
# implementacni soubory jsou dulezite
hello.cpp
world.cpp
main.cpp
# hlavickove 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ů((ve
skutečnosti by stačilo zde napsat pouze implementační soubory, ale když
se zadají i hlavičky, tak lépe fungují generátory projektů pro IDE)).
Poslední část naší ukázky pak zapne rozumnou sadu varování, pro specifický
"target", v našem případě binárku ''hello-world''.
=== CMake CLI ===
Kompilace pomocí CMaku probíhá ve dvou fázích:
- Konfigurace
- Kompilace
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 -H''.
Kde '''' 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 '', 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 Make((Build systém, který se na Linuxu vybere pokud nespecifikujeme
jinak)), to znamená ''make -C '', pro MSBuild((Build systém,
který je používán Visual Studiem)) to znamená otevřít ''.sln''
soubor ve Visual Studiu.
------------------------------
===== Ukázky =====
==== Vstup a Výstup ====
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''.
=== Hello World ===
Tato ukázka reprezentuje minimální program "Hello world". Po jeho vykonání bude
na konzoli napsáno //Hello World//.
#include
int main(){
std::cout << "Hello world\n";
}
''#include '' umožní programu používat třídy pro standardní vstup.
Operátor %%<<%% slouží k zápisu do proudu.
=== Jednoduchý vstup ===
Tento program ukazuje načítání vstupu.
#include
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 >> num2
Tyto 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.
==== Kousek standardní knihovny ====
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::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
#include
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 Jan
Pokud 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 ===
''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
#include
int main() {
std::vector 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';
}
}
=== Iterace ===
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í((UB bude
vysvětleno na konci cvičení)), 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
#include
#include
int main() {
std::vector 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.
==== Koutek nedefinovaného chování ====
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.
=== Příklad 1 ===
Zkuste si zkompilovat a spustit tento program, nejdříve bez optimalizací:
#include
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ď?
=== Příklad 2 ===
Zkuste si zkompilovat a spustit tento program. Nezapomeňte zapnout C%%++%%14.
#include
int test(int x) {
if (x > 0) {
return 1;
}
x -= 1'000'000'000;
if (x > 0) {
return 2;
}
return 1;
}
int main(){
std::cout << test(1) << std::endl;
std::cout << test(-2'000'000'000) << std::endl;
}
Co je na výstupu, pokud spustíte program zkompilovaný s optimalizacemi? A co
pokud spustíte program zkompilovaný bez nich?
=== Příklad 3 ===
Možná jste slyšeli o
[[https://en.wikipedia.org/wiki/Fermat%27s_Last_Theorem|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ódu((Přejato z [[http://blog.regehr.org/archives/161|]])) ji
vyzkouší pro ''n == 3'' a ''a, b, c %%<=%% 1000.''
Zkuste si ho zkompilovat s optimalizacemi.
#include
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ď?