Odpowiedź na pytanie.
Historia pytania
Przed C++20 model kompilacji C++ opierał się na tekstowym włączaniu za pomocą dyrektyw preprocesora. Kiedy plik nagłówkowy był dołączany, preprocesor dosłownie kopiował tekst tego nagłówka do pliku, który go włączał. Ten mechanizm powodował, że makra zdefiniowane w nagłówkach wyciekały do globalnej przestrzeni nazw każdej jednostki kompilacji, która je włączała, prowadząc do subtelnych błędów i kolizji nazw, które były trudne do zdiagnozowania.
Problem
Wyciek makr tworzył koszmary w utrzymaniu w dużych bazach kodu. Makro zdefiniowane w bibliotece osób trzecich mogło bezszelestnie redefiniować słowa kluczowe lub wspólne identyfikatory w kodzie konsumenta, powodując błędy kompilacji lub błędy czasu wykonywania, które wydawały się niepowiązane z rzeczywistą przyczyną. Tradycyjne obejścia, takie jak osłony #undef, były manualne, podatne na błędy i nie skalowały się w złożonych grafach zależności. Fundamentalnym problemem było to, że preprocesor nie miał pojęcia o granicach zakresu czy interfejsu.
Rozwiązanie
Moduły C++20 wprowadzają semantyczny mechanizm importu, który działa na poziomie języka zamiast na poziomie preprocesora. Kiedy importuje się moduł za pomocą import module_name;, kompilator przetwarza eksportowany interfejs modułu bez wykonywania dyrektyw preprocesora z importującej jednostki kompilacji. Makra zdefiniowane w module pozostają prywatne dla implementacji tego modułu, chyba że zostaną jawnie wyeksportowane. Ta właściwość zapewnia, że makra nie wyciekają przez granice jednostek kompilacji, zapewniając prawdziwą enkapsulację i zapobiegając zanieczyszczeniu nazw.
// mathlib.cpp (Implementacja modułu) module; #define INTERNAL_CALC_FACTOR 3.14 // Prywatne makro, nie wyciekające export module mathlib; export double compute(double x) { return x * INTERNAL_CALC_FACTOR; } // main.cpp (Konsument) import mathlib; // INTERNAL_CALC_FACTOR nie jest tutaj widoczne // #ifdef INTERNAL_CALC_FACTOR byłoby fałszywe int main() { double result = compute(10.0); // Działa dobrze }
Sytuacja z życia
Firma zajmująca się handlem finansowym utrzymywała dużą bazę kodu z milionami linii kodu w setkach modułów. Polegali na starszej bibliotece matematycznej, która definiowała makra takie jak MIN i MAX w swoich publicznych nagłówkach. Te makra często kolidowały z funkcjami standardowej biblioteki oraz z bibliotekami analizy JSON, które używały min i max jako nazw zmiennych lub szablonów funkcji.
Pierwsze podejście, które rozważano, polegało na otoczeniu wszystkich nagłówków osób trzecich osłonami w stylu #pragma once i ręcznym #undef-owaniu problematycznych makr po każdym włączeniu. Wymagało to, aby programiści pamiętali, które nagłówki definiowały które makra i aby sprzątali po każdym dołączeniu. To podejście było kruche, ponieważ brak jednego #undef mógł powodować awarie w niepowiązanych częściach kodu. Ponadto znacznie zwiększało czasy kompilacji z powodu wielokrotnego przetwarzania tego samego tekstu nagłówka przez preprocesor w różnych jednostkach kompilacji.
Drugie rozważane podejście polegało na przekształceniu biblioteki matematycznej, aby używała funkcji inline i szablonów zamiast makr. Chociaż rozwiązało to problem wycieku, wymagało to znacznych modyfikacji starszej biblioteki. Biblioteka matematyczna była używana przez wiele zespołów, a zmiana jej groziła złamaniem istniejących obliczeń, które zależały od konkretnych semantyk oceny makr lub efektów ubocznych. Szacowano, że wysiłek refaktoryzacji zajmie sześć miesięcy i uznano go za zbyt ryzykowny dla platformy handlowej.
Wybrane rozwiązanie polegało na migracji do modułów C++20. Zespół przekształcił bibliotekę matematyczną w moduł, który eksportował funkcje matematyczne, jednocześnie zachowując makra wewnętrzne dla implementacji modułu. Używając import mathlib; zamiast #include <mathlib.h>, jednostki kompilacyjne nie widziały już makr MIN i MAX. To podejście wymagało minimalnych zmian w implementacji biblioteki—było tylko konieczne dodanie instrukcji eksportu i przekształcenie nagłówków w jednostki interfejsu modułu. Migracja zajęła dwa tygodnie zamiast sześciu miesięcy. Rezultatem było wyeliminowanie kolizji nazw związanych z makrami w całej bazie kodu oraz 15% redukcja czasów kompilacji dzięki skompilowanemu interfejsowi modułu.
Co często umyka kandydatom
Jak skompilowany format binarny jednostki interfejsu modułu zapobiega wyciekowi makr w porównaniu do tekstowego dołączenia nagłówka?
Kandydaci często umykają, że moduły C++20 produkują skompilowane jednostki interfejsu modułu (CMI), które są binarnymi reprezentacjami wyeksportowanego interfejsu modułu. W przeciwieństwie do tekstowych nagłówków, które są przetwarzane przez preprocesor i zawierają definicje makr jako tekst, CMI przechowują semantyczne informacje o eksportowanych funkcjach, typach i szablonach. Preprocesor nie przetwarza zawartości importowanego modułu; widzi tylko deklarację importu. Dlatego makra zdefiniowane w implementacji modułu lub nawet w jego jednostce interfejsu nie są widoczne dla importera. To jest zasadniczo różne od #include, które dosłownie kopiuje tekst, w tym dyrektywy #define. Zrozumienie tego wymaga dostrzeżenia, że moduły zmieniają model dołączania tekstu na model semantycznego importu.
Dlaczego makra eksportowane z modułu przy użyciu export import zachowują się inaczej niż makra z dyrektyw #include?
Kandydaci często mylą export import makr z normalnym zachowaniem makr. Chociaż C++20 pozwala na eksportowanie makr za pomocą export import, te makra wpływają tylko na kod, który importuje moduł i nie wyciekają poza ten zakres importu. W przeciwieństwie do #include, gdzie makra utrzymują się w jednostce kompilacji aż do jawnego zdefiniowania ich na nowo lub do końca pliku, eksportowane makra z modułów są ograniczone do ekspozycji jednostki kompilacji na ten moduł. Ponadto, jeśli wiele modułów eksportuje kolidujące makra, konflikt jest wykrywany w czasie importu, a nie powoduje cichych błędów redefinicji podczas późniejszej kompilacji. To zachowanie w zakresie zapewnia higienę, której brakowało w dołączeniu tekstowym.
Jak niezależność modułu od preprocesora wpływa na integrację systemów budowlanych i skanowanie zależności?
Kandydaci często umykają, że moduły C++20 wymagają, aby systemy budowlane rozumiały zależności modułów przed rozpoczęciem kompilacji, w przeciwieństwie do nagłówków, gdzie zależności są odkrywane podczas kompilacji. Ponieważ moduły są jednostkami kompilacyjnymi, a nie plikami tekstowymi, system budowlany musi analizować jednostki interfejsu modułów, aby określić, co eksportują i co importują. Wymaga to procesu budowy w dwóch fazach: najpierw skanowania jednostek interfejsu modułu w celu zbudowania grafu zależności, a następnie kompilacji w kolejności zależności. Niezależność od preprocesora oznacza, że tradycyjne osłony #ifdef dla dołączenia nagłówków są nieistotne, a konfigurowanie interfejsów modułów oparte na makrach jest ograniczone. Systemy budowlane muszą śledzić skompilowane artefakty modułów (BMI - Binary Module Interface) zamiast tylko plików źródłowych, co zasadniczo zmienia sposób, w jaki działa śledzenie zależności i przyrostowe kompilacje.