Swift rozwija makra w fazie analizy semantycznej kompilacji, a konkretnie po analizie składniowej, ale przed sprawdzaniem typów ostatecznego Drzewa Abstrakcyjnej Składni (AST). Ten czas jest kluczowy, ponieważ umożliwia rozwinięcie makr, które musi jeszcze przejść pełne sprawdzanie typów i walidację semantyczną. Działając na tym etapie, Swift zapewnia, że rozwinięty kod nie narusza gwarancji bezpieczeństwa typów języka ani nie omija modyfikatorów kontroli dostępu.
Problem polega na tym, że makra przekształcają kod źródłowy, generując nowe węzły składni, co potencjalnie może wprowadzać identyfikatory, które kolidują z istniejącymi zmiennymi w otaczającym zakresie leksykalnym. Jeśli makro po prostu wstrzyknęłoby stałe nazwy zmiennych, mogłoby przypadkowo przechwycić lub zacieniować zmienne z kontekstu wywołania. Może to prowadzić do subtelnych błędów lub luk w zabezpieczeniach, w których generowany kod ingeruje w logikę wywołującego.
Aby rozwiązać ten problem, Swift stosuje higieniczny system makr, który używa unikalnych identyfikatorów wewnętrznych dla wszystkich syntetyzowanych powiązań. Kompilator dołącza metadane do węzłów składni, które śledzą ich oryginalny kontekst leksykalny, zapewniając, że generowane identyfikatory są traktowane jako odrębne od kodu napisanego przez użytkownika, chyba że zostaną jawnie rozpakowane. Mechanizm ten pozwala makrom bezpiecznie wprowadzać zmienne tymczasowe bez ryzyka kolizji nazw, jednocześnie umożliwiając zamierzony przechwytywanie nazw przez jawne przekazywanie parametrów, gdy jest to pożądane.
Nasz zespół tworzył pakiet Swift do wstrzykiwania zależności, który używał dołączonego makra o nazwie @Injectable, aby automatycznie generować kod inicjalizatora dla złożonych klas serwisowych. Makro musiało stworzyć zmienne tymczasowe do przechowywania pośrednich zależności podczas budowy, ale stanęliśmy przed ryzykiem, że powszechne nazwy zmiennych, takie jak container lub service, mogą już istnieć w zakresie docelowej klasy. Stworzyło to dylemat: jak wygenerować bezpieczny kod inicjalizacyjny, nie ryzykując kolizji nazw, które mogłyby złamać kod klienta lub wprowadzić subtelne błędy ponownego przypisania?
Początkowo rozważyliśmy wdrożenie naiwnego podejścia do generowania kodu opartego na tekście przy użyciu prostych szablonów tekstowych do produkcji implementacji inicjalizatora. Główną zaletą była prostota wdrożenia, ponieważ mogliśmy natychmiast zobaczyć wygenerowany kod Swift i debugować go bezpośrednio. Jednak krytyczną wadą był brak gwarancji higieny; nie było mechanizmu zapewniającego, że tymczasowe nazwy zmiennych nie będą kolidować z istniejącymi właściwościami w docelowej klasie, co mogło prowadzić do błędów kompilacji lub cichych błędów logicznych.
Następnie oceniliśmy użycie Sourcery, dojrzałego narzędzia do generowania kodu trzeciej strony, które działa jako krok przed kompilacją zewnętrzny do kompilatora Swift. Zalety obejmowały obszerną dokumentację, elastyczne szablony i możliwość generowania całych plików, a nie tylko kodu inline. Niestety, wady obejmowały skomplikowaną integrację narzędzi budowlanych wymagającą dodatkowych faz Run Script w Xcode, znacznie wolniejsze czasy budowy z powodu narzutów związanych z procesem zewnętrznym oraz brak analizy semantycznej w czasie rzeczywistym, co oznaczało, że błędy typów w generowanym kodzie ujawniały się tylko w czasie kompilacji bez wyraźnego mapowania źródłowego do oryginalnego wywołania makra.
Ostatecznie wybraliśmy natywny system makr Swift, wprowadzony w Swift 5.9, wykorzystując peers macro dołączone do deklaracji klasy serwisowej. To rozwiązanie zostało wybrane, ponieważ bezpośrednio integruje się z potokiem kompilatora, zapewniając czas kompilacji sprawdzania typów rozwiniętego kodu oraz wbudowaną higienę dla generowanych identyfikatorów dzięki bibliotece SwiftSyntax. Rezultatem był solidny framework wstrzykiwania zależności, w którym makro @Injectable mogło bezpiecznie generować złożoną logikę inicjalizacji bez obaw o zacienienie nazw, redukując ilość kodu boilerplate o około 70%, przy zachowaniu pełnych gwarancji bezpieczeństwa czasu kompilacji i jasnych komunikatów o błędach, które wskazywały bezpośrednio na miejsce użycia makra.
Ostateczna implementacja wyeliminowała całą kategorię błędów związanych z nazwami, które dręczyły nasze wcześniejsze ręczne wstrzykiwanie zależności. Czas budowy poprawił się o 40% w porównaniu do podejścia Sourcery, a deweloperzy mogli refaktoryzować klasy serwisowe z pewnością, wiedząc, że generowane przez makra inicjalizatory automatycznie dostosują się do nowych zależności bez ręcznej synchronizacji.
Dlaczego makra w Swift nie mogą modyfikować istniejącego kodu w miejscu, a jakie alternatywne wzorce osiągają podobną semantykę?
W przeciwieństwie do makr proceduralnych Lisp lub Rust, które mogą przekształcać istniejące węzły składni w miejscu, makra Swift są czysto dodane—mogą tylko generować nowy kod, nigdy nie mutując oryginalnego źródła. To ograniczenie istnieje, ponieważ model kompilacji Swift wymaga, aby oryginalne źródło pozostało nienaruszone do celów debugowania, mapowania źródłowego i inkrementalnej kompilacji. Aby osiągnąć semantykę „modyfikacji”, deweloperzy muszą używać makr równoległych, które generują dodatkowe przeciążenia lub typy opakowujące, połączone z adnotacjami o deprecjacji na oryginalnych deklaracjach, aby kierować migracją w stronę wygenerowanych alternatyw.
Jak rozwinięcie makra obsługuje wnioskowanie typów dla generowanych wyrażeń, a co się dzieje, gdy wnioskowanie nie powiedzie się?
Gdy makro rozwija się w kod, który zawiera wyrażenia bez jawnych adnotacji typów, Swift przeprowadza wnioskowanie typów na wygenerowanym AST w trakcie standardowej fazy sprawdzania typów, która występuje po rozwinięciu makra. Jeśli wnioskowanie nie powiedzie się, kompilator generuje komunikaty diagnostyczne, które mapują miejsca błędów z powrotem do miejsca wywołania makra, korzystając z metadanych dotyczących lokalizacji źródła dołączonych podczas rozwinięcia. Kandydaci często pomijają to, że makra mogą jawnie generować literały #file i #line lub używać dyrektywy #sourceLocation, aby kontrolować, jak diagnostyka pojawia się dla użytkownika, zapewniając, że błędy wskazują na sensowne lokalizacje, a nie wewnętrzne szczegóły implementacji makra.
Jaka jest różnica między makrami wolnostojącymi a dołączonymi pod względem ich kontekstu rozwinięcia i dostępnych informacji semantycznych?
Makra wolnostojące (prefiksowane #) rozwijają się na poziomie wyrażeń lub instrukcji i mają ograniczony dostęp do otaczających informacji o typach, otrzymując tylko składnię swoich argumentów. W przeciwieństwie do tego makra dołączone (prefiksowane @) działają na deklaracjach i otrzymują bogate informacje semantyczne, w tym składnię dołączonej deklaracji, modyfikatory dostępu i relacje dziedziczenia poprzez parametr kontekstu deklaracji macro. Początkujący często mylą te granice, próbując używać makr wolnostojących tam, gdzie wymagane są makra równoległe lub członkowskie, aby uzyskać dostęp do członków typu lub generować zagnieżdżone deklaracje w określonych zakresach typów.