Cvičení 1: Základy kompilace, CMake a vstup/výstup

 Průvodce studiem Úč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.

Motivace – proč se učit C++

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).

Kompilace

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).

  1. Preproces
  2. Kompilace
  3. 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 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.

CMake CLI

Kompilace pomocí CMaku probíhá ve dvou fázích:

  1. Konfigurace
  2. 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<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.


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 <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.


Jednoduchý vstup

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 >> 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 <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 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 <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';
    }
}

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í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.

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 <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ď?

Příklad 2

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?

Příklad 3

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ď?

 Řešení a odpovědi

Řešení a odpovědi k nedefinovanému chování

Příklad 1 - přetečení hodnoty se znaménkem

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 main
Vytiskne se 31 řádků s “Ahoj”.

Kompilace s optimalizací:

g++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 –O3 main.cpp -o main
Nekonečný výpis řádků s “Ahoj”.

clang++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 –O3 main.cpp -o main
Vytiskne 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čí.


Příklad 2 - přetečení hodnoty se znaménkem

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 main
Vytiskne 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 main
Vytiskne 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é.


Příklad 3 – nekonečná smyčka bez vedlejších účinků

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 main
Program obsahuje nekonečnou smyčku, program se zacyklí.

Kompilace s optimalizací:

g++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 –O3 main.cpp -o main
Program se opět zacyklí.

clang++ -Wall -Wextra -Wunreachable-code -Wpedantic -std=c++17 –O3 main.cpp -o main
Program 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.

2)
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
3)
Build systém, který se na Linuxu vybere pokud nespecifikujeme jinak
4)
Build systém, který je používán Visual Studiem
5)
UB bude vysvětleno na konci cvičení
courses/b6b36pcc/cviceni/cviceni-01.txt · Last modified: 2023/09/27 11:57 by nagyoing