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
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++“
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.
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]
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á
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í.
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.
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×.
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
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.
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; }
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.
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.
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.
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]
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.
[1] cppreference: https://en.cppreference.com/w/cpp/language/modules
[2] bugzilla: https://gcc.gnu.org/bugzilla/show_bug.cgi?id=113292
[3] clang modules: https://clang.llvm.org/docs/StandardCPlusPlusModules.htm
[4] P1689R5 https://isocpp.org/files/papers/P1689R5.html
Zkoušel někdo?
https://cmake.org/cmake/help/latest/manual/cmake-cxxmodules.7.html
CMAKE už by nějak moduly mělo umět [s limity]. Na Visual C je začalo umět už docela dávno, ale to bylo nějaké Microsoftem upravené Cmake :) a ve finále se mi celý projekt rozsypal, protože v implementaci modulů mělo VC někde nějaký Bug.
Už jsem zaregistroval, že cmake umí moduly s gcc-14
Že Microsoft má moduly už delší dobu také vím, dokonce si myslím, že hlavní push na moduly jde právě od Microsoftu. Ostatní překladače z velké trojky se k této featuře staví chladněji (subjektivní pocit)
Zajímavé video k jak cmake řeší/hodlá řešit globální závislosti C++ modulů zde
https://youtu.be/_LGR0U5Opdg
V jedné starší přednášce (asi cppcon) snad bylo řečeno, že moduly by měly být ve formě tokenizace(výsledný produkt lexikální analýzy, počítám, že bude provedena i ta syntaktická). Možná budou muset stanardizovat mangling.
Tooling a library/package management je velký dluh, Stroustrup na to upozorňuje roky, podle něho by byl ideální stav, kdy si v IDE napíšete import abc; a stáhne se vám knihova abc. No uvidíme, za jak dlouho to bude, se nejsou schopni už více jak 7 let dohodnout ani na podobě networking knihovny(a exekutorech), jsem zvědav.
Já myslím, že to dobře shrnul jeden vývojář z Bloomberg, který na modulech pracoval, asi takto "Jestli si myslíte, že moduly se budou dát používat tak jak dnesk knihovny, tak na to zapomeňte. Moduly budou dobré hlavně pro mono repa".
Takže, featura, která stála spoustu času a nebude vlastně fungovat asi nikdy, protože C++ není jazyk, který by na to byl připravený a nikdy nebude 100% projektů používat moduly.
Druhá věc je, že sice C++ teď definuje moduly, export, import, ale toto nemá nic společného s tím jak např. exportovat symboly ve sdílené knihovně, protože C++ nic takového nechce řešit...
symboly ve sdílené knihovně můžeš exportovat pomocí defince interface module. Ale budeš ho definovat stejně jako header - tady žádná velká změna oproti headerům není. Akorát ten modul bude muset každý projekt nechat přeložit na binární formát module aby ho mohl importovat.
Myslím si ale, že C++ na tohle myslí u header-modulů, kdy tedy sdílená knihovna dál používá header, ale uživatel si může jeji headery převést na moduly a normálně importovat.
linkování sdílené knihovny řeší dál linker
Intenzivně se zabývám programováním zejména v jazyce C++. Vyvíjím vlastní knihovny, vzory, techniky, používám šablony, to vše proto, aby se mi usnadnil život při návrhu aplikací. Pracoval jsem jako programátor ve společnosti Seznam.cz. Nyní jsem se usadil v jednom startupu, kde vyvíjím serverové komponenty a informační systémy v C++
Přečteno 50 239×
Přečteno 23 536×
Přečteno 22 526×
Přečteno 20 447×
Přečteno 17 495×