Odpowiedź na pytanie.
Historia pytania
Przed C++20, model kompilacji C++ polegał na tekstowym dołączaniu przez dyrektywy preprocesora. Kiedy plik nagłówkowy był dołączany, preprocesor dosłownie kopiował tekst tego nagłówka do pliku dołączającego. Ten mechanizm powodował, że makra zdefiniowane w nagłówkach przenikały do globalnej przestrzeni nazw każdej jednostki tłumaczenia, która je dołączała, co prowadziło do subtelnych błędów i konfliktów nazw, które były trudne do zdiagnozowania.
Problem
Przenikanie makr powodowało koszmary związane z utrzymaniem w dużych bazach kodu. Makro zdefiniowane w bibliotece zewnętrznej mogło cicho redefiniować słowa kluczowe lub powszechne identyfikatory w kodzie klienta, powodując błędy kompilacji lub błędy w czasie wykonywania, które wydawały się niepowiązane z rzeczywistą przyczyną. Tradycyjne obejścia, takie jak strażnicy #undef, były ręczne, 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 zakresie ani granicach interfejsu.
Rozwiązanie
Moduły C++20 wprowadzają mechanizm importu semantycznego, który działa na poziomie języka, a nie na poziomie preprocesora. Kiedy importujesz moduł za pomocą import module_name;, kompilator przetwarza wyeksportowany interfejs modułu, nie wykonując dyrektyw preprocesora z jednostki tłumaczącej, która dokonuje importu. Makra zdefiniowane w module pozostają prywatne dla implementacji tego modułu, chyba że zostaną wyraźnie wyeksportowane. Ta właściwość zapewnia, że makra nie przenikają poza granice jednostek tłumaczenia, co zapewnia prawdziwe kapsułkowanie i zapobiega zanieczyszczeniu nazw.
// mathlib.cpp (Implementacja modułu) module; #define INTERNAL_CALC_FACTOR 3.14 // Prywatne makro, nie przenikające export module mathlib; export double compute(double x) { return x * INTERNAL_CALC_FACTOR; } // main.cpp (Konsument) import mathlib; // INTERNAL_CALC_FACTOR NIE jest widoczne tutaj // #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 starej 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 i zewnętrznymi bibliotekami parsowania JSON, które używały min i max jako nazw zmiennych lub szablonów funkcji.
Pierwszym rozważanym podejściem było otoczenie wszystkich nagłówków zewnętrznych strażnikami stylu #pragma once i ręczne #undef problematycznych makr po każdym dołączeniu. Wymagało to od deweloperów pamiętania, które nagłówki definiowały które makra i sprzątania po każdym dołączeniu. Podejście to było kruchy, ponieważ pominięcie pojedynczego #undef mogło spowodować błędy w niepowiązanych częściach bazy kodu. Znacząco zwiększyło to także czasy kompilacji z powodu przetwarzania przez preprocesor tego samego tekstu nagłówka wielokrotnie w jednostkach tłumaczenia.
Drugim rozważanym podejściem było przekształcenie biblioteki matematycznej na funkcje inline i szablony zamiast makr. Choć to rozwiązało problem przenikania, wymagało to znacznych modyfikacji w starej bibliotece. Biblioteka matematyczna była używana przez wiele zespołów, a jej zmiana ryzykowała złamanie istniejących obliczeń, które polegały na specyficznych semantykach lub efektach ubocznych oceny makr. Szacowano, że wysiłek refaktoryzacji zajmie sześć miesięcy i uznano go za zbyt ryzykowny dla platformy handlowej.
Wybrane rozwiązanie to migracja 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. Korzystając z import mathlib; zamiast #include <mathlib.h>, jednostki tłumaczące nie widziały już makr MIN i MAX. To podejście wymagało minimalnych zmian w implementacji biblioteki — jedynie dodania instrukcji eksportu i przekształcenia nagłówków w jednostki interfejsu modułu. Migracja zajęła dwa tygodnie zamiast sześciu miesięcy. Wynikiem była eliminacja konfliktów nazw związanych z makrem w całej bazie kodu oraz 15% redukcja czasów kompilacji dzięki skompilowanemu interfejsowi modułu.
Co często umykają kandydatom
Jak skompilowany format binarny jednostki interfejsu modułu zapobiega przenikaniu makr w porównaniu z tekstowym dołączaniem nagłówków?
Kandydaci często pomijają to, ż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ą informacje semantyczne o wyeksportowanych 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 zasadniczo różni się od #include, które dosłownie kopiuje tekst, w tym dyrektyw #define.
Zrozumienie tego wymaga uświadomienia sobie, że moduły przekształcają się z modelu tekstowego do modelu semantycznego importu. Format binarny zapewnia, że tylko wyraźnie wyeksportowane encje są widoczne, a makra nie są częścią wyeksportowanego interfejsu, chyba że są specjalnie eksportowane przy użyciu dyrektyw makr.
Dlaczego makra eksportowane z modułu przy użyciu eksportu importu 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 przy użyciu export import, te makra wpływają tylko na kod, który importuje moduł i nie przenikają poza ten zakres importu.
W przeciwieństwie do #include, gdzie makra utrzymują się w jednostce tłumaczenia, aż zostaną wyraźnie zdefiniowane na nowo lub do końca pliku, wyeksportowane makra z modułów są ograniczone do wprowadzenia jednostki tłumaczącej w stosunku do tego modułu. Preprocesor traktuje importowane makra tak, jakby były zdefiniowane w punkcie importu, ale nie wpływają na kolejne importy ani na globalny stan preprocesora w ten sam sposób, jak dołączenie tekstowe.
Ponadto, jeśli wiele modułów eksportuje kolidujące makra, konflikt wykrywany jest w czasie importu, a nie powoduje cichych błędów redefinicji później w kompilacji. To zachowanie ograniczające zapewnia higienę, której brakuje w dołączaniu tekstowym, zapewniając, że makra zachowują się bardziej jak poprawnie zasięgowe encje przestrzeni nazw.
Jak niezależność modułu od preprocesora wpływa na integrację systemu budowania i skanowanie zależności?
Kandydaci często pomijają, że moduły C++20 wymagają od systemów budowania zrozumienia zależności modułów przed rozpoczęciem kompilacji, w przeciwieństwie do nagłówków, gdzie zależności odkrywane są w trakcie kompilacji. Ponieważ moduły są jednostkami skompilowanymi, a nie plikami tekstowymi, system budowania musi analizować jednostki interfejsu modułu, aby określić, co one eksportują i co importują.
Wymaga to dwufazowego procesu budowy: najpierw skanowania jednostek interfejsu modułu, aby zbudować graf zależności, a następnie kompilacji w porządku zależności. Niezależność od preprocesora oznacza, że tradycyjne strażniki #ifdef dla dołączania nagłówków są nieistotne, a konfiguracja interfejsów modułu oparta na makrach jest ograniczona. Systemy budowania muszą śledzić skompilowane artefakty modułów (BMI - Binary Module Interface) zamiast tylko plików źródłowych.
To fundamentalnie zmienia sposób, w jaki funkcjonuje śledzenie zależności i inkrementalne budowanie. System budowania musi teraz zarządzać plikami BMI jako pośrednimi artefaktami z własnymi łańcuchami zależności, co wymaga aktualizacji narzędzi budowlanych, takich jak CMake lub Bazel, aby wspierać wykresy kompilacji świadome modułów.