Hlavní navigace

Trable s moduly v C++20/23

14. 1. 2024 18:58 (aktualizováno) Ondřej Novák

Již v roce 2020 jsme se dočkali nového nástroje na organizaci zdrojových souborů u velkých projektů v C++, a to jsou moduly. V tomto článku se pokusím formulovat svůj rozporuplný pocit, který z toho mám.

Jak asi tušíte, považuji se za „C++ maximalistu“ a též se snažím držet krok s vývojem tohoto jazyka. S různými úspěchy se mi to daří a to navzdory tomu, že píšeme rok 2024 a poslední schválenou verzí by měla být C++23, já se neustále probírám novinkami v C++20. Už jsem se úspěšně popral s korutinami, waitable atomiky, momentálně se snažím maximálně vytěžit z constexpr (článek chystám) a posledním neprobádaným teritoriem jsou moduly. A z toho tak veselý nejsem

Motivace 

V této části většinou následuje vysvětlení toho, proč C++ potřebuje moduly. Kritici toto umí jednoduše shrnout ve smyslu „je to feature moderního programovacího jazyka a je to cool, tak proč to nenacpat do C++“

  • Konzistentní stav překladače při překladu interface a vkládání modulu
  • Redukce duplicitních deklarací (v .h a .cpp)
  • Řízení viditelnosti symbolů (export, import)
  • Příslib rychlejšího překladu v důsledku zamezení opakovaného překladu sdílených částí
  • Další oddělení C++ od jazyka C

Trochu si připomeňme, jak import symbolů probíhá v současném C++. Napíšeme náš kód do souboru *.cpp, ale interface toho kódu, tedy definice typu, funkcí, tříd a metod, napíšeme do souboru *.h. Tento soubor se pak vkládá do jiného zdrojového kódu ať už *.cpp nebo *.h, a to jako prostý text pomocí direktivy preprocesoru #include. Opravdu si to představte jako automaticky „copy&paste“ na místo, kde se direktiva nachází. C++ překladač tak dostává k překladu kompletní zdrojový kód se všemi definicemi a bez závislostí na externí soubory. (To má například tu výhodu, že překlad lze uskutečnit v naprosto odlišném prostředí vzdáleně na jiném stroji, takto fungují distribuované překladače).

Výhoda Konzistentní stav překladače při překladu interface je něco co se snaží adresovat problém vyplývající s vložením textu bez dalšího kontextu. V tom místě, kam byl text vložen může být aktuální kontext jiný, než kdyby text byl vložen od začátku souboru. Například:

namespace moje {
#include <vector>
}

#define vector blablabla
#include <vector>

V obou případech se stane něco velice špatného v jehož důsledku náš kód nepůjde přeložit. Tady je naštěstí snadný fix, ale představte si, že vkládat hlavičku, která vkládá hlavičku, která vkládá hlavičku, která obsahuje tuto definici

#define max(a,b) ((a)<(b)?(b):(a))

Toto bohužel najdete v útrobách hlavičky <windows.h>. Nejen to, zkuste pod touto hlavičkou definovat funkci DeleteFile. A budete se ptát, proč linker nemůže najít funkci DeleteFileA (nebo DeleteFileW). Microsoft by potřeboval pořádně přes tlamu!

Moduly tento problém adresují a tedy by se vám nemělo stát, že vložením modulu do programu se vám změní stav překladače do té míry, že další kód začne dávat úplně jiný smysl

Mnoho stížností zejména nováčků je nutnost psát většinu deklarací dvakrát, nebo nutnost deklarovat věci na jiném místě, než jejich implementaci. Moduly by měli řešit Redukci duplicitních deklarací tím, že při překladu modulu překladač sám generuje soubor pro interface modulu a zároveň objektový soubor obsahující příslušnou implementaci. 

Řízení viditelnosti symbolů je další vlastnost která řeší problém původního vkládání textu do zdrojového kódu. Tam totiž to funguje na principu všechno nebo nic. Vložím svou header-only knihovnu do zdrojového kód a s tím tam vložím i celou stl knihovny, na linuxu ještě celý unistd, sockets a bůh ví co ještě a ve windows v zásadě celé <windows.h>. Pokud bych mohl pouze svou knihovnu naimportovat, získám k dispozici pouze exportované symboly mé knihovny, nikoliv však celý windows.

Příslib rychlejšího překladu je opět něco co souvisí se vkládáním textu do mého kódu. Pokud do mého programu čítajíc 50 cpp souborů vkládám nějakou hlavičku, která vkládá celý <windows.h>, pak překladač bude 50× překládat stejnou sadu definicí hlavičky windows.h a v tomto překladu stráví většinu času. Pokud by tyto věci existovaly jako binární reprezentace již přeložených modulu, překladač by si ty soubory pouze načetl a už by nic nemusel překládat

Další oddělení C++ od jazyka C je trend, který vnímám už dlouhodobě a také tomu tleskám. Jazyk C++ zdědil mnoho nechvalně známých problémů jazyka C. Mnoho z nich už dnes moderní C++ nepotřebuje. Například dávno ve svém kódu nepoužívám makra, a vyhýbám se použití #define úplně i pro konstanty. Bylo by fajn zbavit se #include a preprocesor nechat jen pro podmíněný překlad (#ifdef #endif) a i tady bych rád viděl, aby byl používán co nejméně a podmíněnému generování kódu se používaly šablony a if constexpr.

Jak se moduly používají

C++20 zavádí nová klíčová slova (a jejich kombinace): module, export module, import, export import, module : private, atd

Modul zahájíme klíčovým slovem module <jméno> nebo export module <jméno>. Slovíčko export před module značí, že následující kód (až do konce textu) je interface, tedy deklarované symboly budou k dispozici všude tam, kde modul importujeme. V tomto případě vzinká při překladu nejen soubor *.o ale také soubor s deklaracemi, které defacto jsou tím modulem. Co je to za soubor si řekneme v další části.

Pokud kód začíná pouze module <jméno> znamená to, že následující kód jen obsahuje implementaci modulu a vzniká pouze soubor *.o.  Pokud tedy použiji module moje; tak je to ekvivalentní jako do souboru moje.cpp vložit hlavičku moje.h, která obsahuje interface k mému modulu moje. Implementací daného modulu mohu mít víc, vzájemnou provázanost symbolů řeší až linker.

Když se vrátím k export module, tak i když jde o deklaraci interface, tak symboly se automaticky neexportují. Všechny symboly musím označit klíčovým slovem export

export module moje;
export int foo() {...}
export class Bar {...};
export namespace Baz {...}
export {....}  //export všeho co je uvnitř
//atd

Pokud pak v jiném cpp souboru napíšu import moje; tak všechny exportované symboly se automaticky stanou viditelné v tom cpp souboru. Mohu také uvést export import moje; jako součást jiného modul a pak ten modul bude také mimojiné exportovat všechny symboly modulu moje jako bych oba moduly naimportoval

Je tam ještě víc nuancí, jako podmoduly, části modulů atd, víc asi najdete na stránkách cppreference [1]

Stav implementace modulů v překladačích

Budu teď hodnotit pouze dva překladače z velké trojky a to Gcc a Clang. Neměl jsem možnost si hrát s Visual C. 

A situace je … tragická 

Problém 1. Chybějící build system

Moderní jazyky často přichází i se standardizovaným build systémem, například Python má PyBuild, Rust má cargo. C++ nemá žádný. Za dobu co pracuji s C++ jsem se přitom setkal s mnoha různými build systémy, dnes mi přijde, že jeden byl horší než druhý. Začal bych klasickým make, kde si člověk musí build spravovat kompletně sám a už v roce 1997, kdy jsem pracoval na vývoji hry Brány Skeldalu, ješte tehdy v C, jsem byl tímto systémem taj otráven, že jsem si pro tuto hru napsal vlastní build systém, tehdy ještě v Borland Pascalu. 

Mimochodem, pamatujete si na to? Na Turbo/Borland Pascal. A vzpomenete si na unity? Na uses, interface, implementation. Vzpomenete si jaký tam byl build systém? Já taky ne, protože to nikoho nezajímalo, prostě se program používající unity nějak přeložil, maximálně jsem pomocí direktiv definoval místa, kde má překladač unity hledat. A vše fungoval na první dobrou.

Pak jsem delší dobu programoval ve Windows, kde dodnes existuje systém vcproj/sln, což je pro začátečníky asi dobré, ale jinak naprosto nekompatibilní s čímkoliv a na přenášení vysoce těžkopádné. 

V linuxu jsem se přes naprosto tragický autotools / autogen /libtoolize/ configure, až k cmake, který používám dodnes. Částečně jsem se ještě „otřel“ o ninju. Samotný CMake je kapitola sama pro sebe, protože stejně jako výše zmíněné systémy, je to pouhý generátor Makefile a samotný build pořád řeší make.

Všechny současné a minule build systémy přitom využívají skutečnosti, že všechny kompilační jednotky (*.cpp soubory)  se překládají nezávisle na sobě a pořadí se většinou řeší jen na úrovni závislostí knihoven, což jsou často statické vazby – a když do nich někdo zasáhne, musí upravit patřičný soubor projektu (Ať už jde o CMakeLists.txt nebo jiný). Závislosti mezi headery vyřešily překladače tak, že při překladu emitují soubor, který má stejný formát jako Makefile (proto je asi všechno závislé na make) a který přidává make pravidla mezi *.cpp souborem a jeho headery a to zajišťuje, že při dalším překladu se správně prohledají změny v hlavičkách a to rozhodne o tom, jestli je nutné soubor *.cpp přeložit znovu nebo ne.

U modulů je situace trochu jiná, tam přichází ke slovu závislosti mezi jednotlivými soubory. To že někde uvedu import moje znamená, že překladač bude někde hledat moje.gcm (případně ixx, nebo pcm nebo bmi, záleží na překladači), který obsahuje interface mého modulu. Zatímco v případě #include byla věc snadná, text se pouze vložil, v případě importu znamená, že se musí modul přeložit před tím, než je vložen. To ale přece překladač dělat nebude, to je věc build systému - který neexistuje

Aby současné build systémy byly kompatibilní s moduly, musí při každém překladu provést nové prohledání závislostí a nechat přeložit všechny moduly, které se změnily, nebo byly přidány patřičné importy do *.cpp souborů. To znamená, že build systému musí umět parsovat kód C++?

Pokud vím, tvůrci CMake na tomto usilovně už několik let pracují. 

Problém 2. nekompatibilita překladačů

C++ norma sice definuje nová klíčová slova, pravidla jejich použití, jejich význam atd, ale nedefinuje jak se věci mají implementovat pod kapotou. To na jednu stranu má logiku, protože implementace značně závisí na prostředí, kde se jazyk používá, na druhou stranu to vede k tomu, že každý překladač začal řešit toto téma po svém a na konci dne tu vznikly 3 naprosto odlišné světy.

  • Command line – aktivace modulů je stále v režimu „experimentální“ a to se vyznačuje tím, že překladače nejsou ochotny překládat moduly dokud jim to není vnuceno na příkazové řádce. Gcc má přepínač -fmodules-ts, Clang má přepínač -fmodules
  • Formát přeloženého interface - Gcc generuje souboru s příponou gcm, Clang generuje soubory s příponou bmi nebo pcm. Jeden z těch souborů je textový a obsahuje pouze preprocesovaný C++ zdroják. Microsoft ukládá přeložené moduly do ixx
  • Hledání modulů - Gcc všechny moduly ukládá do složky gcm.cache v rámci aktuálního adresáře a tam je také hledá. Clang to generuje do aktuálního adresáře … a pak je nemůže najít, dokud mu explicitně neřekneme, že je má hledat v ‚./‘.  Gcc oproti tomu přichází se zajímavým systémem module mapperu, který umožňuje instalovat server, jehož se Gcc ptá průběžně, kde má hledat moduly. Tento server přitom může zajistit just in time překlad modulu před jejich vložením. V ostatních překladačích musíme v příkazové řádce uvést cesty na všechny moduly, které zdrojový soubor používá. U clangu -fmodule-file=, u msvc přepínačem /reference

Problém 3. Binární nekompatibilita

Problémy se začnou nabalovat, když se začneme bavit o distribuci našich knihoven používající moduly. Jen pro připomenutí, jak se to dělá dnes. Vyrobíte balíček libmoje-dev (na debianu), který obsahuje adresář include, soubor libmoje.a a pak popis package (cmake package), který pomáhá build systému CMake ten balíček najít. Hlavičkové soubory vkládáme pomocí #include a knihovnu přidáme mezi seznam knihoven linkeru. A to je celé

Jak distribuce bude vypadat v případě modulů? Romantické představy si nechte pro své přítelkyně. Problém je, že soubor obsahující přeložený interface modulu není binárně kompatibilní a to nejen napříč překladači, ale ani napříč platformami, a to ani mezi verzemi platformy a ani verzemi překladače. Ten soubor je kompatibilní pouze s překladačem na stejném stroji, platformě, verze distribuce, a v některých případech hrají roli i přepínače překladače. Takže na distribuci přeloženého interface zapomeňte.

To znamená, že distribuce knihovny bude zahrnovat interface jako textový soubor, který se při prvním použití určitým překladačem přeloží do binární podoby. 

Pokud vaše knihovna obsahuje pouze exportující moduly, pak musí být distribuovaná v podobě zdrojových kódu, protože svou libku bude generovat až během překladu finálního produktu. Je otázkou, jestli ten překlad pak bude rychlejší, než při použití libek.

Pokud implementaci chci nějak skrýt do libky, pak se vracím ke starému konceptu headerů (zde exportujících modulů – interface) a libky (složene z neexportujících modulů – implementation). Tím padá jedna výhoda a to nutnost psát deklarace 2×. 

Problém 4. přechodné období

Přidat moduly do C++ se neobejde bez problémů s kompatibilitou se starými způsobem práce. Tady je třeba zdůraznit, že je to všechno nebo nic. Pokud začnete používat moduly, zapomeňte na #include. Toto directiva je navždy zakázána. Použití #include uprostřed modulu pro vložení hlavičky většinou vede k chybě při překladu a u většího projektu na místech, které by to člověk nečekal, a někdy se chyba může začít projevovat později, kdy přeorganizování projektu znamená významné pracovní úsilí. Jde o to, že vložíte-li hlavičku do vícero modulů, budou její symboly duplicitní a to může na různých místech překladač znervóznit. 

Kardinální otázkou je, jak tedy do svého programu vložit hlavičky knihoven, které nejsou napsané jako moduly, což na zpočátku bude drtivá většina. A co hlavičky standardní knihovny? Jak tam to je?

Standardní knihovnu řeší standard C++23 a to pomocí zápisu import std; Neznamená to ale, že by v předchozí verzi nebylo možné používat std. Je také otázkou, jestli distribuce překladačů pro C++23 obsahují tento modul již přeložený (asi se nejspíš přeloží při instalaci?)

Pro vložení hlavičky do modulu máme hned dva způsoby

  1. header module
  2. global module fragmenty (non-module sekce)

Header module je takový hybridní modul, který vznikne konverzí hlavičky na něco, co se chová jako modul. Header module importujeme ve zdrojovém kódu takto

import <iostream>;
import <string>;
import "muj_header.h"

Problém je, že to samo o sobě nestačí a bez dalšího nastavení nebude fungovat. Překladač bude tyto moduly hledat, s dostupností headeru se nespokojí. Moduly musíme předkompilovat. GCC nabízí funkci konverze hlavičky na header-module

g++ -fmodules-ts -xc++-system-header iostream
g++ -fmodules-ts -xc++-system-header string
g++ -fmodules-ts -xc++-header muj_header.h

Je třeba si uvědomit, že header-module není standardní module. Nelze jej zaměňovat. 

Druhým způsobem je global module fragment. Tento fragment začíná klíčovým slovem module; a končí prvním použitím module <name> nebo import. Toto je jediná sekce, kam lze vložit libovolné hlavičky, ovšem s tím, že se nebudou exportovat. Tam patří všechny nutné hlavičkové závislosti bez kterých se modul neobejde. Z výše uvedeného popisu vyplývá, že tento fragment může být pouze na začátku modulu

module;

#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>

export module A;

Změnit programátorské návyky a přežít přechodné období, než začnou ostatní programátoři  své programy psát jako moduly, já osobně považuji za nejtěžší fázi adopce, kterou nijak neulehčují žádný z výše uvedených problémů a to jsem ještě neuvažoval chyby překladačů, které se v souvislosti s moduly objevují až moc často na můj vkus.

Problémy překladačů

Budu se zabývat jen dvěmi, protože aktuálně nemám jak testovat Microsoft překladač MSVC. Napsal jsem si jednoduchý program složený ze dvou modulů

------- mod1.cpp ---------
export module mod1;


export int foo() {
    return 42;
}
--------main.cpp---------

import mod1;
import <iostream>;

int main() {
    std::cout << "Foo: " << foo() << std::endl;
}

GCC – if (!internal_error) works_well();

Přístup GCC se mi líbí, protože bez dalších znalostí lze výše uvedený kód přeložit jedním řádkem

g++-13 -fmodules-ts -std=c++20 \
      -xc++-system-header iostream \
      -xc++ mod1.cpp main.cpp

Kromě nezbytných flagů musím nechat přeložit iostream na header-module, následně oba soubory cpp. Gcc překladač vygeneruje ./a.out, který lze spustit

$ ./a.out
Foo: 42

Bohužel, GCC i ve své poslední stable verzi při použití modulů se hroutí v různých jazykových konstrukcí chybou internal error a to s diagnostikou, která málokdy ukazuje na to, kde je problém. Často jde o programátorské obraty, které fungují dobře bez modulů. Tím důvodem je nejspíš to, že GCC sestavuje AST (abstract syntax tree) ze zdrojového kódu, který pro účely modulu serializuje do souboru. A někde při serializaci nebo deserializaci dochází k chybě, ztrátě dat, atd, zřejmě proto, že ten strom je velice košatý a obsahuje spoustu různých uzlů a ne všude se podařila serializace či deserializace naprogramovat bez chyb. Jeden úlovek i za mě visí na bugzille [2]Hloupé je, že velice podobná chyba (zmíněno v bugreportu) se opravuje už od roku 2021. 

Module mapper

Gcc nabízí docela pěknou vlastnost zvanou module-mapper. Toto by mohl významně pomoci nějakému lepšímu build systému pro C++. Module mapper je server, který má provádět vyhledání modulů v projektu, přitom to nevylučuje kompilaci modulů just-in-time. Překladač jednoduše během překladu oslovuje server s dotazu na jednotlivé moduly které nalezl v kódu a server musí nějakým způsobem dodat cestu na jeho přeloženou binární reprezentaci. A protože na to server má nekonečně času, může v případě potřeby nechat modul vytvořit (rekurzivně zavolat gcc). Tímto způsobem by bylo možné naprogramovat build systém, který by se svým chováním přiblížil tomu, jak fungovaly unity v Turbo Pascalu. Na 21. století celkem skvělý počin.

Skenování závislostí

Gcc 14 by měla přinášet funkci scanování C++ souborů a generování souboru se závislostmi. Tento formát se jmenuje p1689r5 [4], stejně jako číslo proposalu, ve kterém je popsán.  Pro build system to znamená, že před zahájením překladu musí proskenovat všechny soubory projektu a vygenerovat si seznam závislostí a následně ve správnem pořadí přeložit všechny moduly a zbývající soubory. 

Clang – Vogonská poezie

Pokud se pokusíte přeložit výše uvedený příklad v clangu, tak neuspějete. 

/usr/bin/../lib/gcc/x86_64-linux-gnu/13/../../../../include/c++/13/iostream:36:13: warning: #pragma system_header ignored in main file [-Wpragma-system-header-outside-header]
#pragma GCC system_header
            ^
1 warning generated.
main.cpp:2:8: fatal error: module 'mod1' not found
import mod1;
~~~~~~~^~~~
1 error generated.

Snažil jsem se z dokumentace vyčíst správný postup. Bohužel mám k dispozici jen clang-15 (na ubuntu 22). Postup by měl fungovat na clang-18 [3], ale částečně fungoval i zde

1. Generovat modul

$ clang++-15 -std=c++20 -x c++-module mod1.cpp --precompile -o mod1.pcm

2. Generovat header-module pro iostream

$ clang++-15 -fmodules -std=c++20  -xc++-system-header --precompile iostream   -o iostream.pcm

3. Přeložit main.cpp a všechno dohromady

$ clang++-15 -fmodules -std=c++20  -fprebuilt-module-path=. -fmodule-file=iostream.pcm  mod1.cpp main.cpp

Bohužel v mém případě vypadá výsledek takto:

/usr/bin/ld: /tmp/main-44e611.o: in function `std::bad_alloc::bad_alloc()':
main.cpp:(.text+0x0): multiple definition of `std::bad_alloc::bad_alloc()'; /tmp/mod1-a51e03.o:mod1.cpp:(.text+0x0): first defined here
/usr/bin/ld: /tmp/main-44e611.o: in function `std::exception::exception()':
main.cpp:(.text+0x40): multiple definition of `std::exception::exception()'; /tmp/mod1-a51e03.o:mod1.cpp:(.text+0x40): first defined here
/usr/bin/ld: /tmp/main-44e611.o: in function `__pthread_cleanup_class::__pthread_cleanup_class(void (*)(void*), void*)':
main.cpp:(.text+0x60): multiple definition of `__pthread_cleanup_class::__pthread_cleanup_class(void (*)(void*), void*)'; /tmp/mod1-a51e03.o:mod1.cpp:(.text+0x60): first defined here

... dalších cca 500 chybových hlášek

/usr/bin/ld: /tmp/mod1-a51e03.o: in function `std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_S_allocate(std::allocator<char>&, unsigned long)':
mod1.cpp:(.text._ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE11_S_allocateERS3_m[_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE11_S_allocateERS3_m]+0x29): undefined reference to `std::allocator<char>::allocate(unsigned long)'
clang: error: linker command failed with exit code 1 (use -v to see invocation)

Je možný, že v novější verzi bude výsledek jiný.

Clang++ nenabízí module-mapper jako gcc, namísto toho nabízí nástroj na generování souboru závislostí ve formátu p1689r5 [4]

Závěr

Osobně si myslím, že směr, kterým se jazyk C++ vydal je správný, ale cesta k cíli je ještě dlouhá. Co se současné syntaxe týče, tak tam je málo co vytknout. Asi největší problém vidím v tom, že moduly nejsou namespaces. Ačkoliv já si dnes netroufnu umísťovat symboly do globálního namespace a to nejen u knihoven, poslední dobou i u kódu finálních aplikací, tak jazyk nikoho nenutí tvořit namespace. Takže se těším na hromadu budoucích problémů.

Dlouhou cestu mají před sebou hlavně překladače, než se najde nějaká společná cesta, která bude vyhovovat všem. Líbí se mi přístup GNU, tam to sráží interní chyby v překladači. Module mapper je zajímavý nápad, ale dle posledního vývoje vše směřuje ke „skenovači“ závislostí.

Chybějící build system je samozřejmě kapitola sama pro sebe. A jsem přesvědčen, že byť je CMake určitě dobrý build system, tak by neměl fungovat jako budoucí oficiální build system. Myslím si, že C++ by se mohlo „odpojit“ také od GNU make. Snaha o standardizaci by měla přijít od tvůrců překladačů, ne od tvůrců, kteří se snaží napsat univerzální generátor skriptů na všechny různé kombinace překladačů a platforem.

Já zatím moduly nepoužívám, stále volím tradiční cestu pomocí headerů a cpp souborů. Změna vyžaduje zásadní úpravy nejen v build systémech. Ale třeba různá IDE (používám Eclipse) si s moduly neví rady, a nejsou schopny při používání modulů správně asistovat.

Odkazy

[1] cppreference: https://en.cppre­ference.com/w/cpp/language/mo­dules

[2] bugzilla: https://gcc.gnu­.org/bugzilla/show_bug.cgi?id=113292

[3] clang modules: https://clang.llvm­.org/docs/StandardCPlusPlus­Modules.htm

[4] P1689R5 https://isocpp.org/files/pa­pers/P1689R5.html

Sdílet