Zkušenost s moduly v C++20

Včera 19:12 Ondřej Novák

Po  skoro dvou letech se vracím k modulům. Už se to dá používat? Jak daleko se posunul vývoj? A jaké máme nástroje?

Moduly v C++

Moduly se do C++ dostaly s verzí 20 a byly doručeny jen jako papírový „koncept“, než reálná funkční věc. V tomto směru byla situace horší, než třeba u korutin, jedná se zase o další oblast, kde standarizační komise řeší budoucnost, než aby se snažila pokrýt současnost. Rozdílem oproti korutinám byl ten, že korutiny fungovaly a pouze chyběl (chybí) standardní výbava STL. Korutiny tak stále jsou v režimu „udělej si sám“.

S moduly je to horší, protože jejich fungování je závislé na překladačích, a ty v roce 2020 nebyly ready. Po cca 3 letech sice proběhl živelný vývoj, ale o připravenosti se nedalo mluvit, nejdále v tomhle směru byl Microsoft, který byl schopen (prý) celý svůj office přeložit v modulech a výrazně tak zrychlit překlad. Problém je v tom, že Microsoft si opět šel vlastní cestou ve formě nějakých extensions do svého MSVC ekosystému, z poloviny neveřejných. Ostatních překladače na tom byly hůře, překladače padaly, hlásily chyby ICE (internal compiler error) a některé konstrukce nebylo možné přeložit. Nejhůře na tom byl GCC. Ovládací rozhraní nebyla sjednocena a způsob sestavení se lišil podle překladače. Více najdete v mém starším článku

Uběhly další skoro dva roky, a jaká je situace dnes? Lepší, ale ne ideální.

  • Překladače padají méně, clang i msvc prakticky vůbec
  • GCC také padá méně, ale i GCC-15 stále trpí chybami po vydání, například ICE jsem objevil při kompilaci nlohmann::json a std::future
  • Rozhraní se nezměnila
  • Způsob překladu se nezměnil
  • Všechny překladače nyní podporuji P1689r5, ukážeme si, že je naprosto k ničemu
  • Sestavovací systémy? – tady mohu jen dodat zkušenosti se CMake. Zklamalo mne to, čekal jsem mnohem víc. Pravda je, že jsem neměl úplně poslední verzi, ale měl jsem verzi, která oficiálně deklarovala podporu modulů

Dodám, že mě to zklamalo natolik, že jsem si vyvinul vlastní build system! A díky tomuto build systému se programování modulů stalo výrazně jednodušší a hlavně jsem s tím schopen přeložit středně velký projekt do finální binárny (ve všech třech překladačích) a funguje! 

Proto tento článek berte jako takové představení nástroje, který se jmenuje cairn

(tento název je anglickým překladem slova mužík, což je taková ta hromada kamenů, které tvoři mohylu, nebo turistickou značku, vyznačuje se tím, že horní kámen je podpírán spodními kameny, jako náš modulový program)

Cairn (mužík)

Problémy, které „cairn“ řeší

Moduly v C++ jsou  navrženy tak, že se zásadně mění pohled na organizaci projektu. Už první změnou, kterou si člověk uvědomí je způsob vyvolávání modulu. Na rozdíl od headerů se totiž nepoužívají názvy souborů jako referenci na zdrojový kód. Místo toho se používá název modulu. Porovnejte následující příklad

Klasické
#include "utils/threadpool.hpp"
#include <nlohamm/json.h>
#include <vector>

Moduly
import cairn.utils.threadpool
import nlohamm.json
import std;  //import <vector>

Rozeberme se jednotlivé řádky

  1. zatímco v include uvádíme relativní cestu k headeru, a tato cesta může být jiná v závislosti na umístění souboru, ve kterém je to uvedeno, v případě modulu uvádíme celé jméno modulu, žádná informace o jeho lokaci
  2. Podobný případ, i když tady se uplatní výchozí cesty k hlavičkám, sama reference obsahuje cestu a jméno souboru, výsledný soubor bude /usr/local/include/nlohamm/json.h. U modulů opět jen plně kvalifikované jméno modulu a nic víc
  3. Poslední případ řeší referenci systémového headeru. Od C++ 23 lze uvádět import celého std pod jedním importem, držme se zatím v C++20, zde ovšem jedná o referenci na soubor. Ukážeme si, že už tenhle fakt zanáší do systému zmatek

To hlavní, co si z toho je třeba odnést je, že nové modulové zdrojové kódy se vůbec neodvolávají na soubory, a je věcí build systému, aby dokázal vyhledat a poskytnout překladačům referenci na soubory, v nichž se importované moduly nachází. 

Můj osobní názor: Je to skvělá věc, protože přidává svobodu do organizaci projektu. Není třeba přesně dodržovat struktůru projektu diktovanou externímy knihovnami. Můžeme si projekt uspořáda tak ja potřebujeme , stačí jen build systému sdělit, jak má patřičné moduly najít.

Další úlohou nástroje cairn je samozřejmě pomáhat s identifikací jednotlivých souborů. To že se soubor jmenuje log.ifc.cpp neříká nic o tom, co je zač. Jedná se možná o interface, ale neměli bychom se spoléhat na jméno. Navíc těch informací budeme potřebovat víc.

Jakmile překladač má přeložit nějaký soubor importující moduly, musí dostat fyzický obsah modulů jako součást příkazové řádky, nebo nepřímo přes referenci na repozitář. To co ale překladač potřebuje není zdrojový kód, ale binární reprezentace interface. Co to znamená?

TIP: prostudujte si jak jsou moduly definované ve standardu

Pokud nějaký modul něco exportuje, je třeba z jeho textu vygenerovat soubor BMI. To je zkratka binary module  interface. – pozor, žádný překladač to nepoužívá jako příponu, najdete tam přípony gcm (gcc compiled module – gcc), pcm (precompiled module – clang) nebo ifc (interface – msvc). Soubor obsahuje všechny exportované symboly, tedy nejen funkce, ale i celé třídy, typy, dokonce šablony a to v binární podobě. Výhodou je, že překladač už nemusí provádět textovou analýzu (parsing), samotné načtení BMI je velice rychlé a mělo by to výrazně urychlit překlad.

Nevýhodou tohoto řešení je, že najednou záleží na pořadí překladu. Pokud nějaký modul importuje jiný modul, musí tento importovaný modul být přeložen první a teprve potom může být referencován a importován. Závislosti je třeba vyřešit před zahájením překladu. Proto na to nelze použít make se soubory deps, protože ty jsou nepovinné, generují se při prvním překladu a v zásadě jen pomáhají build systému detekovat změny. Tam chybějící závislost není chyba, pouze způsobí nový překlad, zatímco zde chybějící závislost způsobí selhání překladu

Shrnutí: Nástroj cairn řeší

  • discovery - dohledání modulů a jejich zdrojáku
  • scanning - identifikace modulů – skenování zdrojáků – vytvoření metadat
  • building - vytvoření plánu sestavení na základě závislostí a jeho exekuce

Kromě jiného se snaží řešit i jednoduchou standarizaci rozhraní pro všechny tři překladače, aspoň na úrovni rozhraní k modulům.

Jak se cairn používá

     O
    ( )       Cairn
   ()( )      C++20 module builder
  ( )( )
   ( )( )
  ()(  ) )
 (  )( ) )
(__)(__)(_)

Když už jsem se rozhodl, že napíšu vlastní build systém, kladl jsem si za cíl neduplikovat funkci konkurenčních systému, jako je třeba cmake. Spíš než komplikovaný nástroj na definici projektu mi tak jde jen o něco, co spustí překladač a vytvoří spustitelný soubor.

Nejprve malé zamyšlení. V C++ modules platí že všechno je modul. Prakticky se nebudete setkávat s jiným typem souborů. Tedy až na jednu výjimku a to je počáteční soubor. Ten totiž jediný nemusí být modul, ale pouze soubor cpp, který importuje ostatní moduly, ale sám nic neexportuje. Přeložením tohoto souboru tak můžeme získat jedině spustitelný soubor – no a nebo bootstrap knihovny, dynamické knihovny atd. Protože tento soubor obsahuje všechny importy, které drží projekt pohromadě, to je ten nejvyšší kámen v mohyle. 

Z pohledu širšího build systému (zde myslím cmake) jde o sestavení targetu z jednoho jediného cpp. Stačí tedy target nadefinovat jako add_custom_target a uvést celou příkazovou řádku.

A tu si teď ukážeme

$ cairn <přepínače> <target>=<soubor.cpp> <compiler> <přepínače překladače>
  • První přepínače ovlivňují samotné chování nástroje. Najdete zde třeba přepínač -j<threads>, čímž lze spouštět překlad paralelně. K ním se postupně dostaneme
  • Definice cíl=soubor říká, jak vytvořit executable cíl s počátečního souboru soubor. Techto cílů lze uvést i víc za sebou
  • Na místě <compiler> se uvádí cesta na překladač, např: g++, g++-15, clang++, clang++-20, cl.exe, případně rozšířeně pro msvc cl.exe@x64-17.13 , kdy se za @ uvádí verze překladač a cílová platforma (ti co mají msvc budou vědět)
  • Poslední přepínače se přímo kopírují jako přepínače překladače. Tady by nemělo chybět -std=c++20. Dodatečně lze přepínače oddělit –compile: pro přepínače použité během překladu a –link: pro přepínače použité během link fáze

Příklad:

$ cairn -j16 -cbuild/compile_commands.json -Bbuild  \
        bin/cairn=src/cairn/main.cpp                 \
        clang++-18 -std=c++20 -O3

Tento zápis sice spustí překlad, ale samotný nemusí stačit. Hodí se jen na malé projekty, které se vejdou do jednoho adresáře. Předpokládá se, že všechny soubory se nachází v adresáři src/cairn , případně v podadresářích. Pro rozsáhlejší projekty je třeba pomoci speciálním souborem.

Soubor modules.yaml

Soubor by se měl minimálně nacházet v adresáři počátečního souboru (takže src/cairn/modules.yaml), případně lze explicitně jeden uvést parameterem -f. Soubor by měl obsahovat seznam všech souborů, které tvoří moduly, a případně nápovědu na další cesty, kde hledat další moduly. Pokud chybí, vezme cairn celý adresář a bude také v případě potřeby prohledávat podadresáře. Ale definiční soubor je lepší (lze snadno detekovat změny v něm)

.Příklad souboru modules.yaml
----
files
  - main.cpp
  - build_plan.cpp
  - build.cpp
  - abstract_compiler.cpp
  - source_def.cpp
  - ...
  - ... další soubory
  - ...
prefixes:
  cairn.utils: utils
  cairn.compiler: compilers

Jak je vidět, není to nic komplikovaného, prostě se uvede seznam souborů, které obsahují moduly a které se účastní sestavení. V sekci prefixes se pak uvádí, kde má cairn hledat další moduly, podle prefixu, takže například cairn.utils najde ve složce ./utils. A i tam bude hledat modules.yaml. Celkový formát vypadá takto:

  • files - seznam souborů které se účastní sestaveni. Pozor: neznamená to, že se všechny budou překládat. Nástroj vždy vybere k překladu jen ty soubory, které jsou potřeba. To znamená, že tady lze uvést úplně všechny soubory celé knihovny i v případě, že finální program z toho použije jeden modul.
  • prefixes - je mapování prefixů na složky. Může být uvedena jen jedna složka, nebo i seznam složek. Speciální mapování "" znamená jakékoliv moduly a prochází se vždy, je možné ji chápat jako extenzi aktuálního seznamu o další seznam v jiném adresáři
  • includes - přidává do seznamu cest na headery (moduly stále mohou vkládat headery), platící pro tento seznam
  • options - seznam dalších příznaků pro překladače platící pro tento seznam 
  • work_dir - umožňuje změnit cestu kde se soubory nachází, například src, default je aktuální adresář
  • targets - má speciální použití pro situaci, kdy je soubor použit s příznakem -f, a umožňuje definovat cíle, pokud nemají být uvedeny na příkazové řádce. Uvádí se jako exec: soubor.cpp

Detaily z implementace

Zaměřme se nyní na pár detailů z implementace, které asi nejvíc vystihují celé obtíže s orchestrací modulů. A není to kupodivu exekuce build procesu, i když i o tom by se dalo hovořit dlouze, třeba jaké cesty zvolili jednotlivé překladače k řešení tak jednoduchého úkolu jako je reference všech potřebných BMI pro překlad jednoho souboru.  Zaměřme se na discovery a hlavně na scanning

Scanning je process, při které se cairn seznamuje s typem souboru (v modulovém pojetí). Navek jsou to všechno textové soubory, ale způsob, jak jsou zapsané pak určuje jejich význam. Pokud je v souboru uvedeno:

  • export module A; - jedná se modul, který exportuje, tedy generuje BMI a dá se předpokládat, že bude někam importován. Tento soubor může být jen jeden (jeden soubor pro modul A). Kromě toho se generuje soubor  .o
  • pouze module A; - jedná se o implementační část modulu A. Tento soubor pouze generuje generuje soubor .o a musí se přiložit linkeru při finálním sestavení. Může existovat víc souborů implementující modul A. Je třeba je všechny najít, chybějící implementace pak vede na chyby linkeru
  • export module A:B; - jedná se o exportující paritition. Ta se sestavuje stejně jako exporující modul, tedy generuje BMI.o
  • pouze module A:B; - jedná se neexportující parition – POZOR, generuje BMI a může být importována do hlavního modulu. V případě MSVC musím explicitně uvádět, že překládám partition
  • import A; - soubor importuje A, musím přiložit jeho BMI 
  • import :B; - bude uvedeno v modulu A a znamená, že importuju jeho partition B – musím přiložit BMI parition bez ohledu na to, jestli exportující nebo není
  • export import A - modul zároveň exportuje jiný modul. Tyhle reexporty je potřeba pořešit: Pokud mám modul B, který reexportuje modul A, pak do souboru, který importuje B, musím přiložit BMI pro oba moduly, tedy A i B, protože importem B jsem importoval i A. Pro partition to platí taky
  • import <header> - i tady budu potřebovat BMI, ale vlastního headeru. Všechny překladače mají variantu příkazové řádky, jak převést header na BMI. Header jediný negeneruje .o. Nemusí jít o vyloženě o systémový header, postačí, když je header v cestách na headery (-I)
    import „header“ – je potřeba připravit a přiložit BMI uvedeného headeru, stejně jako u systémové verze, zde jde o referenci na konkrétní soubor. Bacha na situaci, kdy header importuju s různými cestami. Některé překladače použijí absolutní cestu, jiné to uvidí jako dva headery a budou padat na konfliktech. Proto se doporučuju vyhnout headerům úplně

P1689r5

Chytří lidé dali hlavy dohromady a přišly s myšlenkou, že by bylo vhodné, kdyby překladače uměly prohlédnout soubor a rozhodnout, o jaký typ modulu jde, co exportuje a importuje. A k tomuto účelu navrhli i formát popsaný v proposalu P1689r5. Když si ale pořádně pročtete, jaké informace vám tento formát poskytne, zjistíte, že to je naprosto nedostatečně. Ze všech údajů, které lze z formátu vyextrahovat máme k dispozici jen requires a provides.

Například, toto je výstup pro soubor scanner.cpp mého projektu ve formě P1689r5 za použití `clang-scan-deps

  {
      "primary-output": "build/obj/scanner_f4ce731b09f65819.o",
      "provides": [
        {
          "is-interface": true,
          "logical-name": "cairn.source_scanner",
          "source-path": "src/cairn/scanner.cpp"
        }
      ],
      "requires": [
        {
          "logical-name": "cairn.module_type",
          "source-path": "src/cairn/module_type.cpp"
        }
      ]
    },

A takto vypadá výstup z nástroje cairn

$ cairn --scan src/cairn/scanner.cpp clang++-18
---
module_name: cairn.source_scanner
type: interface
exports: []
imports:
 - type: interface
   name: cairn.module_type
 - type: system_header
   name: algorithm
 - type: system_header
   name: string
 - type: system_header
   name: vector
 - type: system_header
   name: filesystem

To je docela velký rozdíl ne? Nástroj cairn poskytne informaci, že se jedná o interface (generuje BMI, exportuje sebe), neexportuje nic (to jsou reexporty), a importuje jiný modul a další 4 header modules.

Oproti tomu se z P1689 dozvíme, že modul poskytuje nějaký modul (tady vidímě, že jde patrně o export module), přičemž „is_interface“ říká, že to bude jeho vlastní interface. Dozvíme se i, že importuje jiný modul, ale nic o headerech. Je fakt, že se dozvíme i jména patřičných souborů, kde lze moduly hledat, ale k tomu je třeba poznámku – toto clang poskytne až po tom, co máme všechny soubory pohromadě v jeho compile databazi. Pokud tam nejsou jména souborů tam nebudou. Oproti tomu cairn používá scan před discovery fáze, kdy se budou moduly teprve hledat.

Ještě jeden příklad

    {
      "primary-output": "build/obj/log_9a8dee51f130cf79.o",
      "requires": [
        {
          "logical-name": "cairn.utils.log",
          "source-path": "src/cairn/utils/log.ifc.cpp"
        }
      ]
    },

== vs ==

module_name: cairn.utils.log
type: implementation
exports: []
imports:
 - type: interface
   name: cairn.utils.log
 - type: system_header
   name: iostream
 - type: system_header
   name: mutex
 - type: system_header
   name: functional
 - type: system_header
   name: vector

Výstup z clanga je naprosto tragický, dozvídáme se, že se jedná o nějaký soubor, který požaduje cairn.urils.log. V případě cairn získáme informaci

  • jedná se o implementaci modulu cairn.utils.log (module cairn.utils.log).
  • Tento soubor samozřejmě vyžaduje import jeho rozhraní (interface)
  • a další 4 systémové headery
  • Z hlediska dalšího zpracování – bude přiložen k seznamu objektů pro linker, pokud bude požadována závislost na tento modul

Možná teď někoho napadlo, jak cairn vlastně získává metadata o vlastních souborech. A odpoveď asi někoho překvapí

Cairn si sám parsuje C++ zdrojový kód

Opravdu, nekecám! Ale nejde o plnohodnotný parsing, jen o jednoduchou tokenizaci. Předně se odstraní všechny komentáře. Následně se musí vyhodnotit #define konstanty i případně #include, které se mohou objevit v globální sekci. Ty se prochází jen pro hledání definicí konstant aby se nakonec mohlo vyhodnotit příkazy pro podmíněný překlad, tedy #if případně #ifdef. A teprve pak se hledají CPP souboru klíčová slova module a import na základní úrovni.

Protože – v samotné hlavičce modulu by se neměly používat makra. Nesmíte napsat 

#define MODULE_NAME mojemodule
expor module MODULE_NAME; //chyba

#define EXPORT export
#define IMPORT import
#define BUILD_MODULE_NAME(f) module.f
EXPORT IMPORT BUILD_MODULE_NAME(foo); //chyba

Žádný výše uvedená preprocesorová onanie není povolena, takže není třeba provádět plnohodnotný preprocesing. Co ale povolené je, tak to podmíněný překlad. Takže následující příklad bude fungovat

export module cairn.utils.process;
#ifdef _WIN32
export import :win;
#else
export import :posix;
#endif

Skenovací systém v nástroji cairn by měl brát v úvahu právě tyto situace ale nic navíc, makra se tedy neexpandují a hledají se čistě export import a module definice. Výsledek skenování je uložen interně do struktury a lze je případně potřeby exportovat ve formátu YAML

Discovery

Prohledávání potřebných modulů probíhá do šířky. Začíná se souborem přiřazený k danému cíli a prochází se závislosti. Nalezené moduly jsou spojeny se známými zdrojovými soubory. Pokud není mapování nalezeno, rozšíří se hledání podle prefixů v modules.yaml, Do interní databáze se tak přidávají další lokace, které dál mohou prohledávat. Prohledávání se zastaví, jakmile namapovány všechny požadované moduly.

Samotný build  pouze vybírá soubory, které se přímo a nepřímo dotýkají daného cíle. I když je nalezeno víc souborů, k překladu se použijí jen ty co jsou potřeba.

Databáze a pracovní složka

Nástroj cairn operuje nad pracovní složkou ve které udržuje databázi, ale i cache modulů. Databáze je tedy primárně spravována v paměti a na disk se pouze ukládá její serializovaná kopie (v binární formě). V podadresářích jsou pak realizovány cache přeložených BMI nebo objektových souborů .o. Na rozdíl třeba od CMake, tyto cache bývají bez vnitřní struktury, , pouze jeden velký adresář plný souborů.

Tento adresář se primárně jmenuje .build ale parametrem -B lze nastavit jiný název nebo cestu

Jak se vyvíjí v modulech?

Následuje pár postřehů z praktického používání. Na začátek je třeba dodat, že mám k dispozici projekt, který je napsaný plně modulárně: Je to samotný cairn. Jinými slovy, cairn potřebuje sám sebe, aby se přeložil. 

To že výsledkem pokusů s moduly je program napsaný pomocí modulů, který lze přeložit a spustit ukazuje, že používat se to dá. Ale jak dobře?

Integrovaná prostředí (IDE)

Kolik IDE dneska umí moduly? Budou si s moduly vůbec rozumět? Pochopí syntaxi, správně jí obarví, správně budou napovídat? Já vím, že se mezi čtenáři najde spoustu programátorů, kterým k práci stačí vim a základní obarvení klíčových slov. Mně bohužel ne, už jsem si příliš zvykl na asistenty. I když ty s umělou inteligencí tedy zatím používám co nejméně (prodražuje to vývoj), tak i přesto zde nastává regulérní otázka, zda se v module vyzná i umělá inteligence, například copilot.

Já osobně používám dvě IDE. Dlouhodobě píšu v Eclipse CDT, poslední dobou také používám Visual Studio Code. Na úvod je třeba říct, že jsem byl „očekávaně“ zklamán, protože drtivá většina IDE neví která bije. Dokonce poslední Visual Studio 2026 má s moduly problémy.

IDE zpravidla hned podtrhnou klíčové slovo module nebo export, protože jej neznají. Identifikace typů, metod, funkcí, o tom už ani nemluvím. Naštěstí existují workaroundy

Nejprve Eclipse. To lze naštěstí provozovat pouze v režimu „obarvení syntaxe“ bez analýzy kódu, takže je z toho klasický textový editor, co umí poznat základní klíčová slova. Content Assist (napovídač) ale bude nejspíš napovídat genericky a na základě okolního textu. Velmi podobně funguje Microsoft IntelliSense, naštěstí aspoň klíčové slovo module zná. 

Výrazně lepší je situace, pokud nasadíme clangd. Ten jsem byl schopen nasadit hlavně v linuxu. Ve windows mi to „nějak nefungovalo“, ale je možné, že jsem neměl poslední verzi. Osobně jsem zkoušel clangd-18, zejména proto, že verzi 18 považuji za minimum pro překlady modulů v clang++. Je třeba dodat, že kód přeložený clang-20 neumí clangd-18 načíst, zřejmě nerozumí svým pcm souborům z novější verze.

 Nástroj clangd lze integrovat do Eclipse a Visual Studio Code jako extension. Pokud je mu předložen správně zkonstruovaný compile_commands.json je schopen poznat nejen moduly, ale propojit všechny exporty a správně napovědět v každém místě programu.

Generovat soubor compile_commands.json nástroj cairn samozřejmě zvládne přes přepínač -c (například -cbuild/compile_commands.json). Je třeba říct, že k vyplnění všech polí používá skutečné příkazové sekvence z překladu, a proto je dobré projekt překládat v clang++ odpovídající verze. Pokud projekt přeložíte v gcc, pak si nebude vědět rady.

Neznámým prostředím je zde Windows. Příkazy pro překlad v Windows používají cl.exe s jeho specifickou command line, a je otázkou, jak si s tím clangd poradí. Jak píšu výše, mně to nefungovalo, ve windows jsem tedy zůstal u generického IntelliSense

Kdy jaký typ modulu použít

Tohle je asi otázka za zlatého bludišťáka. Na výběr jsou v zásadě tři základní typy: interface, implementation a partition (z toho ještě exportni a neexportní partition).

Pokud jde o drobné moduly nejspíš bude stačit vše psát do jednoho souboru. Třídy dokonce nemusí metody deklarovat 2×, stačí tělo metody vložit přímo do deklarace třídy. Symboly, které chceme exportovat se musí označit klíčovým slovem export. Pokud není symbol označen, nebude v importovaném kódu viditelný.

U větších modulů bych možná volil rozdělení mezi interface a implementation. Vzniknou tak dva soubory, a pracuje se s tím stejně jako u vztahu header-source. Já osobně označuju interface část jako .ifc.cpp. Někde se setkáte s označením .ixx. (Může to být trochu matoucí, protože drtivá část mých modulů jsou interface, měl bych to plné .ixx).  Rozdělení do dvou souborů umožňuje i rozdělit překlad, implementation část se pak překládá paralelně s ostatními moduly, které používají stejný interface. Implementation část není v závislostech. Proto u velkých modulů, kde implementace má velké množství kódu, je dobré ji překládat zvlášť.

Implementační část také může sloužit ke skrytí implementačních detailů, stejně jako doposud, pouze z těch důvodů skrývání zmizel důvod „nezaplevelování namespace“, které moduly řeší právě přes export.

Implemenračních částí může být i víc, tedy mohu mít víc souborů s implementací k jednomu interface (interface může být jen jeden soubor). Tady je třeba si dát pozor na správné discovery. Nástroj cairn musí být schopen všechny implementace odhalit. Typicky stačí je mít ve stejném adresáři, nebo je správně zmínit v modules.yaml, kde by měly být pospolu s interface

Poslední částí jsou partitions. Přiznám se, že ještě nemám představu, jak přesně je použít. Zatím je používám k platformově závislého kódu, kdy si podle platformy vyberu příslušnou partition

#ifdef _WIN32
import :win;
#else
import :posix;
#endif

K tomu je třeba dodat, že cairn neumožňuje podmíněný discovery nad soubory v modules.yaml a tak je schopen objevit a zařadit všechny implementace a partitions které najde. Avšak díky tomu, že partition je třeba importovat, nedochází tady k automatickému „přiložení“ všech implementací, ale jen těch, které se skutečně importují. Takže v tomto případě se program na windows přeloží s win partition, jinak se použije posix partition.

Kdy použít export import <module>

Osobně netuším :) 

Tento příkaz zajistí, že jedno module exportuje obsah celého jiného module – tedy jeho veřejnou část samozřejmě. Z hlediska jazyka to řeší pouze viditelnost. Modul, který reexportuje jiný modul je ekvivalentní situaci, kdy by reexportovaný modul byl prostě vložen do veřejné části modulu, který jej reexportuje. 

Z hlediska buildu pak znamená, že cairn musí všude tam, kde se je reference na module, které reexportuje, přiložit i všechny reexportované moduly – tranzitivně.

Dává mi smysl reexportovat vybranou exportní partition nebo submodule, tak aby kód, který jej importuje se nemusel potýkat s vnitřní strukturou importovaného modulu.

Pozor na past

Klíčové slovo export se píše před template, ne před  class

//špatně
template<typename T>
export class Foo;

//správně
export template<typename T>
class Foo;

Co v modulech nejde

Hodně mě překvapilo, že částečné specializace šablony nejde provádět v jiných modulech.

export module A;
export template<typename Foo> class Bar;

export module B
import A;

template<>
class Bar<std::string> {//chyba, B nedefinuje Bar
...
...
};

Pokud je váš kód postaven na specializaci centrální šablony, nebo specializujete třeba std::format, tak to budete muset přepsat.

Pokud vás zajímá mé řešení, tak se podívejte na implementaci cairn.utils.log, kde používám std::format na zápis patternu pro logování a kde ho rozšiřuju o podporuj invokable funkcí. 

Jiné řešení najdete v cairn.utils.serializer a cairn.utils.serializer.rules. Serializer provádí jednoduchou binární serializaci na základě pravidel, a ty najdete v tom druhém modulu. A protože je to jiný modul, nedá se použít částečná specializace třídy představující pravidla. Proto se to řeší přes koncepty, jako například is_linear_container (vector, string) či is_assoc_container  (mapy)

Hlavičky a zmatky

S importem hlavičkových soubory si člověk užije legrace. Zřejmě za to může vágní definice ve standardu, nebo co já vím, ale všechny překladače trpí nějakými problémy a člověk občas narazí na nutnost organizovat projekt podle překladače

Kolize symbolů

Pokud jednu hlavičku vložíte přes #include a jinou přes import, mohou mít tyto hlavičky společnou část, která je najednou v kolizi. Překladače gcc a clang si budou stěžovat na tuto kolizi, zvlášť pokud kód v hlavičce v různých situacích vede na různá AST. Pak máme k jednomu symbolu několik duplicitních AST a překladač vyhlásí dost nečitelnou chybu, případně spadne s ICE (gcc). U Microsoft jsem zase narazil na konflikt importuju header only knihovny sdílející kusy STL. 

Následující příklad to pěkně demonstruje

module;
#ifdef _WIN32
#include <fkyaml.hpp>
#endif

module cairn.module_resolver;
#ifndef _WIN32
import <fkyaml.hpp>;
#endif

Pokud importuju knihovnu fkyaml.hpp, překlad v msvc skončí touto chybou

 module_resolver_2d411b6e0cf5dfe1.obj : error LNK2019: Nerozpoznaný externí
 symbol "class fkyaml::v0_4_2::detail::basic_str_view<char,struct
std::char_traits<char> > const fkyaml::v0_4_2::detail::
default_secondary_handle_prefix"

Pokud naopak nechám verzi s #include přeložit v clang, bude si clang stěžovat na duplicitní definici .

/usr/bin/include/c++/14/bits/vector.tcc:534:8:error: '_Guard_elts' has
different definitions in different modules; first difference is defined
here found constructor with no body

Což mně naprosto nezajímá, nechť si to překladač nějak vyřeší, je to přece standardní knihovna. 

Microsoft ale obecně má problém s linkerem. Výmluvně vypadá i tato definice 

#ifdef _MSC_VER
module;
#include <filesystem> //msvc requires
#endif

export module cairn.abstract_compiler;

Pokud filesystem chybí jako include, msvc ohlásí chybu

preprocess_2d411b6e0cf5dfe1.obj : error LNK2001: Nerozpoznaný externí symbol
"public: static wchar_t const std::filesystem::path::preferred_separator"
(?preferred_separator@path@filesystem@std@@2_WB

main_2d411b6e0cf5dfe1.obj : error LNK2001: Nerozpoznaný externí symbol
"public: static struct std::strong_ordering const std::strong_ordering::less"
 (?less@strong_ordering@std@@2U12@B)

Zdá se, že Microsoft má problém s korektním exportem BMI z hlaviček a chybí v nich symboly, které běžně patří do OBJ souboru. Problém je, že hlavičky negenerují OBJ soubor

A ještě jedna stížnost na Microsoft a ta se týká transitivními závislostmi. Norma C++ řeší pouze import hlavičky, třeba import <filesystem>, čímž je zahrnuto vše, co tato hlavička obsahuje, včetně třeba <string>. Microsoft z neznámých důvodů bude vyžadovat BMI od <string>, přestože všechny data by měly být k dispozici BMI od <filesystem>, Jinak by to totiž vedlo k tomu, že by si build systém musel poctivě evidovat všechny vnitřní závislosti STL knihovny. 

Bohužel msvc to vyžaduje, navíc vyžaduje přikládat BMI hlaviček importovaných modulů, přestože modul, který se právě překládá, žádnou z hlaviček nevyužívá  ani přímo ani nepřímo. 

Nástroj cairn to řeší tak, že provádí tranzitivní uzávěr všech závislostí a přikládá tak všechna BMI hlaviček, které v tomto uzávěru uvízly.  Tímto mi drtivá většina problémů prošla a tam kde to nestačilo, tam jsem holt dané hlavičky uvedl do import abych si závislost (a přiložení) vynutil. Toto je specialita Windows sestavení, clang ani gcc tím netrpí. Naštěstí uvedení zbytečných importů není chybou.

Moduly a závislosti

Překladače se bohužel ani neshodnout na tom, které moduly je třeba naimportovat aby se uspokojili všechny závislosti vyplývající z kódu. Pokud programátor zvolí jednoduchou strategii která by se dala stručně popsat slovy „doplním importy podle toho, na co si překladač bude stěžovat“ může být bohužel cesta k vlastní záhubě, kdy se programátor stane vazalem překladače a bude bezhlavě doplňovat importy do míst, kde to nedává smysl. Navíc v některých situacích překladače nepomohou. Místo chyby „neznám symbol“ vyskočí chyba „symbol v STL se odkazuje na neúplně definovanou šablonu někde v STL“. Po hlubší analýze člověk zjistí, že to je std::vector s typem, který jsem zapomněl naimportovat

Je zajímavý, že při překládání projektu fungující v msvc jsem narazil na spoustu chybějící importů v clang. Něco bylo oprávněné, něco bylo „na hraně“. 

----------
export module A;
export struct Foo {...}
----------
export module B;
import A;
export Foo bar();
----------
module main;
import B;

void test() {
  auto x = bar();   //musim importovat A?
}

Zeptal jsem se umělé inteligence a neshodnou se. Podle jednoho (grok) zápis export Foo bar() exportuje nejen funkci bar, ale i typ Foo, tím se stane Foo viditelný v modulu main. Podle druhého (chatgpt) naopak vyžaduje export import A v modulu B. 

Prakticky následující export vyžaduje reexport celého modulu <format>, kvůli jedné deklaraci v šabloně – proč mi přijde, že pak moduly jaksi ztrácí smysl?

export struct Log {
//...
    template<typename ... Args>
    static void debug(std::format_string<Convert<Args>...> fmt, Args && ... args) {
        output(Level::debug, fmt.get(), std::forward<Args>(args)...);
    }
};

Je to tím, že std::format_string je součástí exportované funkce, ale patří do <format>. Bez importu tohoto modulu se třída Log nedá používat.

A z toho možná pramení nesoulad a to vede k chaosu v importech

MSVC, clang ..... a kilometr za nimi GCC

Překladač gcc v tomto souboji hraje roli podivného tvora. Začalo to jeho module mapperem (p1184), který připomíná jadernou elektrárnu, vyžaduje instalaci serveru, za použití socketů nebo rour. Naštěstí module mapper lze použít i ve formě manifest souboru (a takhle to používá i cairn)

Pokud není použit module mapper, vytváří gcc modulovou cache v adresáři .gcm-cache. To je naprosto úžasný nápad, proč to nemají jiní? Úplně bych se vyhnul nutnosti referencovat BMI souborů, a jen bych zajistil správné pořadí! Kdyby existoval způsob, jak překladači vnutit vlastní adresářovou cestu . Jenže to jednoduše nejde.  Hold někoho napadlo, že tak, jak je to teď, je to lepší!

GCC je zatím nejvíc ICE překladač. Některé běžné konstrukce nedává a padá na nich. Tuhle jsem řešil chybu ICE při překladu nlohamm jsonu, ten code review  fakt nebudí důvěru.  Moduly jsou postaveny výhradně nad binárním interface, které je v rukou některých programátorů velice křehké a stačí malá nesrovnalost, tu ulítne o bajt a celý se to sesype. 

Tahle chyba v GCC mluví za vše. Ukazuje jaký zmatek má gcc v importovaných headerech

reference to ‘int64_t’ is ambiguous
stdint-intn.h(27, 19): candidates are: ‘typedef __int64_t int64_t’
stdint-intn.h(27, 19): ‘typedef __int64_t int64_t’

(vidí header 2×, jednou jako include a jednou jako import. Jak se to stane? No je includován na více místech)

Jak si vyzkoušet „cairn“?

Tento nástroj najdete v mém github repositáři. Možná někoho napadne, jak nástroj přeložit, když potřebuje sám sebe, aby se přeložil?

U verze pro Windows je k dispozici compile.bat, Ten stačí spustit v rámci Native Tools Developer Command Prompt for Visual Studio Community. Výsledkem by měl být cairn.exe v adresáří  .install

U Linuxu je k dispozici Makefile. Tento soubor je na míru sestaven pro překladač clang++ aspoň verze 18. 

$ make all
$ sudo make install

Pro verzi gcc zatím makefile nechystám. Ona se poslední verze cairn nedá v gcc přeložit (ani ve verzi 15).

 Samotný skript se nedá použít k ničemu jinému, než k počáteční instalaci, je totiž sestaven na míru pro exekuci překladu. Neumí poznat nové závislosti, neumí poznat změny v souborech, každé spuštění udělá nové kompletní sestavení.

Stručně co cairn skutečně provádí

Pokud chcete vidět podrobnější činnost nástroje, použijte přepínač -d (debug)

  1. Detekuje zvolený překladač a vybere profil (nebo použijte -p)
  2. Vytvoří pracovní adresář, pokud neexistuje (./.build, lze změnit -B)
  3. Načte modules.db z pracovního adresáře, pokud existuje
  4. Zkontroluje které soubory se změnily podle databáze a stavu na disku, změněné soubory si vnitřně označí 
  5. Proskenuje změněné soubory a vyhledá všechny moduly. Založí záznamy pro nové hlavičkové soubory
  6. Pokud zjistí, že některé moduly nelze v databázi najít, spustí discovery, začne prohledávat cesty v nalezených modules.yaml.
  7. Nové soubory, které najde při skenování adresářů oskenuje a zařadí do databáze
  8. Smaže z databáze soubory, které fyzicky neexistují
  9. Označí k rekompilaci závislé soubory, udělá transitivní uzávěr
  10. Vytvoří plán sestavení. Do něho zahrne i všechny nalezené implementace referencovaných modulů
  11. Exekuuje plán sestavení (paralelně)
  12. Pokud je to požadováno, aktualizuje compile_commands.json
  13. Uloží modules.db

Organizace pracovního adresáře

  • modules.db – databáze
  • obj - cache s obj soubory
  • pcm - cache s BMI (clang)
  • ifc – cache s BMI (msvc)
  • gcm - cache s BMI (gcc)
  • env_cache.bin - (msvc) serializovaný environment obsahuje hlavně INCLUDE, LIB, PATH, pro danou verzi překladače
  • macros.txt - (msvc) soubor slouží k detekci aktivních maker v cl.exe

Závěr

Na začátku byla výzva překonat počáteční vstupní problémy a dostat se dál, pomoci ostatním programátorům. Na konci je „nějak“ fungující nástroj a taková hořká příchuť v puse. Ale bez zkušenosti bych nemohl soudit

Co se mi líbí

  • Myslím si, že moduly jsou správná cesta, kterou by se měl C++ vydat. Zejména izolace jednotlivých modulů a redukce zaplevelení namespace. 
  • Všechno by dneska mělo být modul, naopak bych zabanoval další vývoj header only knihoven. 
  • Do budoucna by se mi líbil nějaký veřený repozitář po vzoru npm, tak aby člověk pouze zadal název modulu a on by se mu stáhnul do složky „modules“

Co se mi nelíbí

  • V tomto asi není pochyb, že největší problémy jsou kolem překladačů, nejednotnosti nejen na straně rozhraní překladačů ale i chápání jazyka a výkladu normy. 
  • Padání překladačů, padání nástrojů
  • Nejasný je stav vývojových nástrojů z hlediska asistentů, statické syntaxtické analýze. Každá společnost navíc jde vlastní cestou. Clang a jeho compile_commands.json, u microsoftu očekávám opět tlačenku jejich děsivého XML formátu vcxproj.
  • Nejasný výklad ohledně toho, kdy se co musí importovat a co je v kterém modulu viditelné vede na zmatky a na situace, kdy něco projde v jednom překladači a neprojde v jiném

Otázka k diskuzi

Jakou příponu používat pro moduly?

  1. Dál používat .cpp, protože všechno je modul
  2. Používat .cppm pro moduly 
  3. Používat .ixx pro interface

Sdílet