Historia: Przed C++20, programiści polegali na makrach preprocesora, takich jak __FILE__ i __LINE__, aby uchwycić metadane kodu źródłowego do logowania i debugowania. Te makra cierpiały na problemy z kontekstem rozszerzania, zanieczyszczenie przestrzeni nazw oraz niemożność propagacji przez warstwy abstrakcji bez sztuczek z generowaniem kodu. Standard C++20 wprowadził std::source_location, aby zapewnić bezpieczną typowo, zgodną z constexpr alternatywę, która automatycznie uchwyci informacje o lokalizacji wywołania.
Problem: Podczas opakowywania funkcjonalności logowania w funkcjach pomocniczych, podejścia oparte na makrach uchwyciły lokalizację definicji opakowania, a nie rzeczywistej lokalizacji wywołania, co czyniło je bezużytecznymi do wskazywania błędów w głębokich stosach wywołań. Dodatkowo manualna propagacja metadanych źródłowych przez każdy podpis funkcji generowała inwazyjne zmiany API i obciążenia związane z utrzymaniem. Istniała potrzeba mechanizmu, który uchwyciłby nazwę pliku, numer linii, kolumnę i nazwę funkcji w momencie wywołania bez explicitego przekazywania parametrów.
Rozwiązanie: std::source_location to struktura, której można kopiować, z prywatnym konstruktorem, który może być instancjonowany przez kompilator tylko poprzez jego statyczną funkcję członku current(). Gdy jest używany jako domyślny argument dla parametru funkcji, std::source_location::current() jest oceniane w miejscu wywołania, a nie w miejscu definicji, wykorzystując intryzyjności kompilatora do uzupełnienia swoich pól dokładnymi współrzędnymi źródłowymi. Ten projekt zapobiega ręcznej konstrukcji dowolnych lokalizacji źródłowych, zapewniając integralność diagnostyki, jednocześnie umożliwiając bezproblemową propagację przez instancje szablonów i łańcuchy zwrotnych wywołań.
#include <source_location> #include <iostream> #include <string> class Logger { public: static void log(const std::string& message, std::source_location loc = std::source_location::current()) { std::cout << loc.file_name() << ":" << loc.line() << " [" << loc.function_name() << "] " << message << std::endl; } }; void process_data(int value) { if (value < 0) { Logger::log("Otrzymano nieprawidłową wartość"); // Uchwyt tej linii, a nie definicji Logger::log } }
Kontekst: System handlu o wysokiej częstotliwości wymagał rozproszonego logowania, gdzie raporty błędów musiały wskazywać dokładne miejsce pochodzenia w milionach linii kodu, w tym przez algorytmy szablonowe i wywołania lambda. Istniejąca baza kodu używała makra LOG_ERROR(), które rozszerzało __FILE__ i __LINE__, ale to przestało działać, gdy programiści wprowadzili funkcje pomocnicze, takie jak validate_input(), które wewnętrznie wywoływały logger, powodując, że wszystkie błędy zgłaszały wewnętrznie linię funkcji pomocniczej, a nie lokalizację logiki biznesowej.
Problem: Ekspansja makr uchwyciła lokalizację, w której wywołanie logowania zostało fizycznie zapisane w źródle, a nie logiczną lokalizację błędu. Kiedy validate_input() było wywoływane z 500 różnych miejsc, wszystkie 500 błędów zgłaszało ten sam plik i linię wewnątrz funkcji walidującej. To uniemożliwiło praktycznie debugowanie w produkcji podczas dochodzeń w przypadku warunków wyścigu.
Rozważane rozwiązania:
Opcja 1: Propagacja makr z explicytnymi parametrami. Rozważaliśmy wymuszenie na każdej funkcji przyjmowania const char* file, int line przez makro wielowariantowe, które wstrzykiwało je w każdym miejscu wywołania. Zalety: Utrzymuje dokładne informacje lokalizacyjne przez dowolne głębokości wywołań. Wady: Ogromne zanieczyszczenie API, łamie interfejsy bibliotek zewnętrznych, znacznie zwiększa czasy kompilacji i uniemożliwia użycie w kontekstach constexpr, gdzie makra są zabronione.
Opcja 2: Odwracanie stosu w czasie wykonywania z symbolami debugowania. Wdrożenie przechwytywania stosu w czasie wykonywania za pomocą specyficznych dla platformy API, takich jak backtrace() w POSIX lub CaptureStackBackTrace w Windows, a następnie rozwiązanie adresów do numerów linii za pomocą symboli debugowania. Zalety: Nieinwazyjne dla API, uchwyca pełny stos wywołań. Wady: Ekstremalne obciążenie czasem wykonywania (nieskuteczne dla ścieżek o wysokiej częstotliwości), wymaga dostarczenia symboli debugowania do produkcji oraz rozwiązanie jest asynchroniczne i niepewne w warunkach awarii.
Opcja 3: std::source_location z domyślnymi argumentami. Zastąpienie makra funkcją przyjmującą std::source_location loc = std::source_location::current() jako ostatni parametr. Zalety: Brak obciążenia w czasie wykonywania (budowa constexpr), automatyczna propagacja przez szablony, uchwycenie informacji o kolumnie do precyzyjnej diagnostyki i szanowanie przestrzeni nazw bez zanieczyszczenia. Wady: Wymaga wsparcia kompilatora C++20, a programiści muszą pamiętać o umieszczeniu go jako domyślnego argumentu (nie wewnątrz ciała funkcji, gdzie uchwyciłoby wewnętrzną lokalizację funkcji).
Wybrane rozwiązanie i rezultat: Wybraliśmy Opcję 3, ponieważ system transakcyjny przechodził na C++20 i możliwości constexpr std::source_location pozwoliły na weryfikację łańcuchów formatu logów w czasie kompilacji, jednocześnie utrzymując wymagania dotyczące wydajności na poziomie nanosekund. Po implementacji raporty błędów zawierały dokładne numery linii, takie jak trading_engine.cpp:847 [auto execute_order(const Order&)::(lambda)], co umożliwiło nam zidentyfikowanie krytycznego warunku wyścigu w dwie godziny zamiast dwóch dni. Ograniczenie, że std::source_location nie może być ręcznie skonstruowany, zapobiegło przypadkowemu przekazywaniu przez młodszych programistów sfałszowanych lokalizacji podczas testowania, zapewniając, że logi produkcyjne pozostały forensycznie wiarygodne.
Dlaczego std::source_location::current() jest specjalne, gdy jest używane jako domyślny argument, a co się dzieje, jeśli wywołasz to wewnątrz ciała funkcji zamiast tego?
Kiedy std::source_location::current() pojawia się jako domyślny argument, standard C++20 nakazuje, aby kompilator ocenił to w miejscu wywołania, zastępując linię, w której funkcja jest wywoływana. Jeśli umieszczono ją wewnątrz ciała funkcji, to ocenia się na lokalizację tej konkretnej linii wewnątrz definicji funkcji, co czyni ją bezużyteczną dla atrybucji lokalizacji wywołania. To zachowanie jest specjalnym przypadkiem w specyfikacji języka dla tej konkretnej funkcji; regularne argumenty domyślne są oceniane w miejscu definicji, ale std::source_location otrzymuje to wyjątkowe traktowanie, aby umożliwić automatyczne logowanie. Początkujący często umieszczają auto loc = std::source_location::current(); jako pierwszą linię swojej funkcji logującej, a następnie zastanawiają się, dlaczego każdy wpis logu wskazuje na tę samą wewnętrzną linię.
Czy możesz ręcznie skonstruować std::source_location z dowolnymi numerami plików i linii, a dlaczego standard temu zapobiega?
Nie, nie możesz ręcznie skonstruować ważnego std::source_location, ponieważ jego konstruktory są prywatne i dostępne tylko dla implementacji. Standard egzekwuje to ograniczenie, aby utrzymać integralność informacji diagnostycznych, zapobiegając programistom w fałszowaniu lub zmyślaniu lokalizacji źródłowych w systemach logowania krytycznych dla bezpieczeństwa. Choć możesz chcieć symulować lokalizacje na potrzeby testów jednostkowych logów, komitet standardowy priorytetowo traktował wiarygodność forensyczną w porównaniu do elastyczności testowej. Jedynym sposobem uzyskania instancji jest użycie current(), która jest implementowana jako intryzyjność kompilatora, populującą prywatne pola struktury rzeczywistą reprezentacją jednostki translacyjnej.
Czy std::source_location działa poprawnie wewnątrz wyrażeń lambda, instancji szablonów i funkcji inlined, a jakie konkretnie metadane uchwyca?
Tak, std::source_location działa poprawnie we wszystkich tych kontekstach, ale kandydaci często nie dostrzegają niuansów. Dla lambd, function_name() zwraca nazwę zdefiniowaną przez implementację (często coś w stylu operator() lub wewnętrzny symbol lambdy), podczas gdy file_name() i line() wskazują na miejsce definicji lambdy w źródle. W instancjach szablonów każda osobna instancja generuje swoją lokalizację źródłową wskazującą na konkretne użyte argumenty szablonu. Struktura uchwyca cztery elementy metadanych: file_name() (const char*), line() (uint_least32_t), column() (uint_least32_t, często niedoceniane, ale kluczowe dla kodu opartego na makrach) oraz function_name() (const char*). Wiele kandydatów nie jest świadomych column(), które rozróżnia między wieloma wywołaniami makr na tej samej fizycznej linii, lub zakładają, że function_name() zwraca demanglowane symbole (w rzeczywistości zwraca surowy podpis funkcji implementacji).