Odpowiedź na pytanie.
Historia pytania
Przed C++23 implementacja statycznego polimorfizmu wymagała Curiously Recurring Template Pattern (CRTP). To podejście zmuszało klasy pochodne do dziedziczenia z szablonu klasy bazowej zainstancjonowanego z typem samej klasy pochodnej. Chociaż funkcjonalne, CRTP generowało rozbudowany kod i skomplikowane hierarchie dziedziczenia, które były trudne do utrzymania.
Problem
Głównym problemem było to, że funkcje członkowskie w bazach CRTP nie mogły wydedukować rzeczywistego typu pochodnego bez jawnych parametrów szablonu. To ograniczenie zmuszało programistów do ręcznego rzutowania this na typ pochodny, co prowadziło do kruchych kodów, które łamały się przy zmianach w łańcuchach dziedziczenia. Dodatkowo, CRTP uniemożliwiało łatwe refaktoryzacje i czyniło interfejsy mniej intuicyjnymi dla użytkowników nieznających metaprogramowania szablonów.
Rozwiązanie
C++23 wprowadził jawny parametr obiektu (wydedukowanie this), pozwalając funkcjom członkowskim deklarować this jako jawny parametr z wydedukowanym typem. Pisząc void func(this auto&& self), funkcja akceptuje dowolny typ obiektu, umożliwiając statyczny polimorfizm poprzez przeciążanie zamiast dziedziczenia. To podejście całkowicie eliminuje CRTP, produkując czystszy kod, który wspiera otwarty polimorfizm.
// Podejście C++23 struct Vector { float x, y; template<typename Self> auto magnitude(this Self&& self) { return std::sqrt(self.x * self.x + self.y * self.y); } }; // Użycie działa bez dziedziczenia Vector v{3.0f, 4.0f}; float len = v.magnitude();
Sytuacja z życia
Zespół silnika gier potrzebował biblioteki wektorów matematycznych wspierającej zarówno ścieżki kompilacji CPU, jak i GPU. Biblioteka wymagała generowanych operacji takich jak magnitude() i normalize(), które działały na typach o precyzji float, double i half, zachowując jednocześnie zerowy narzut abstrakcji.
Pierwszym rozważanym podejściem był CRTP z klasą bazową VectorBase<Derived, T>. Umożliwiało to polimorfizm w czasie kompilacji, ale wprowadzało znaczną złożoność. Każdy nowy typ wektora wymagał dziedziczenia z bazy i przekazania siebie jako parametru szablonu, co powodowało rozbudowany kod i enigmatyczne błędy instancjonowania szablonów podczas refaktoryzacji. Utrzymanie było trudne, ponieważ zmiana interfejsu bazy wymagała aktualizacji wszystkich klas pochodnych.
Drugim rozważanym podejściem było przeciążanie funkcji z użyciem funkcji wolnostojących i dispatching tagów. To unikało dziedziczenia, ale łamało obiektowy projekt preferowany przez zespół graficzny. Wymagało przekazywania instancji wektora jako parametrów zamiast wywoływania metod, co wydawało się nienaturalne dla obiektów matematycznych. Dodatkowo, komplikowało to powierzchnię API i uniemożliwiało łańcuchowanie metod.
Wybrane rozwiązanie to składnia jawnych parametrów obiektów w C++23. Zespół przepisał klasy wektorów, aby używały parametrów auto&& self, umożliwiając statyczny polimorfizm bez dziedziczenia. To podejście zachowało intuicyjną składnię vec.magnitude(), jednocześnie wspierając programowanie ogólne i eliminując rozrost szablonów.
Rezultatem była 40% redukcja błędów kompilacji związanych z szablonami i poprawa wydajności programistów. Kod stał się znacznie bardziej łatwy w utrzymaniu, a łańcuchowanie metod działało płynnie dla wszystkich typów wektorów. Zespół skutecznie wdrożył bibliotekę zarówno na cele CPU, jak i GPU, unikając złożoności CRTP.
Co kandydaci często pomijają
Dlaczego jawna dedukcja parametrów obiektów zawodzi, gdy funkcja członkowska jest zadeklarowana jako const, ale wydedukowany typ nie jest kwalifikowany jako const?
Kandydaci często przeoczyli, że przy użyciu this auto&& self wydedukowany typ zawiera kwalifikatory cv z wyrażenia. Jeśli funkcja jest wywoływana na obiekcie const, typ automatycznie dedukuje się do const T&.
Jednak jeśli kandydat mylnie zadeklaruje parametr jako this T self (przez wartość) na obiekcie const, próbuje dokonać kopiowania. Może to wywołać usunięty konstruktor kopiujący lub kosztowne operacje głębokiego kopiowania.
Kluczowym spostrzeżeniem jest to, że auto&& przestrzega zasad kolapsu referencji i automatycznie zachowuje constness. To sprawia, że jest to preferowana forma dla ogólnych funkcji członkowskich, zapewniając poprawność const bez jawnej kwalifikacji.
Jak jawny parametr obiektu umożliwia rekurencyjne wzorce lambd bez narzutu std::function?
Kandydaci często przeoczają, że jawne parametry obiektów pozwalają lambdom na wezwanie samego siebie bez obcięcia typu std::function. Poprzez zadeklarowanie lambdy z jawnym parametrem auto, który akceptuje samą siebie, może rekurencjonować, używając tego parametru.
Na przykład, auto factorial = [](this auto&& self, int n) -> int { return n <= 1 ? 1 : n * self(n-1); }; tworzy rekurencyjną lambdę bez narzutu. Kompilator zna dokładny typ w czasie kompilacji, co umożliwia pełne inline'owanie i optymalizację.
Bez tej cechy rekurencja wymaga std::function, co wprowadza narzut związany z obcięciem typu i uniemożliwia inline'owanie. Alternatywnie, programiści używali kombinatorów punktów stałych z złożoną składnią, która zaciemniała zamiar.
Jawny parametr obiektu zapewnia bezpośrednią samoreferencję z pełnym zachowaniem typu. Ten wzorzec utrzymuje wydajność, wspierając eleganckie algorytmy rekurencyjne w kodzie ogólnym.
Dlaczego użycie jawnych parametrów obiektów zapobiega tworzeniu tradycyjnych hierarchii klas, jednocześnie umożliwiając zachowanie polimorficzne?
Ten subtelny punkt myli wielu kandydatów. Tradycyjny polimorfizm opiera się na dziedziczeniu i wirtualnych funkcjach, co tworzy silne powiązania między klasami bazowymi i pochodnymi poprzez tabele wirtualne.
Jawne parametry obiektów umożliwiają "otwarty polimorfizm", w którym każdy typ zapewniający wymagany interfejs może używać funkcji. Nie ma potrzeby dziedziczenia z wspólnej klasy bazowej ani wirtualnych destruktorów.
Kluczowa różnica polega na tym, że przy jawnych parametrach obiektów polimorfizm rozwiązuje się w czasie kompilacji poprzez rozwiązywanie przeciążenia. Nie ma typu klasy bazowej, do którego można rzutować, co zapobiega rozdrobnieniu obiektów i eliminuje narzut tabeli wirtualnej.
Jednak oznacza to również, że nie można przechowywać heterogenicznych obiektów w kontenerze wskaźników do klas bazowych bez obcięcia typu. Polimorfizm jest ściśle statyczny, oferując korzyści wydajnościowe, ale różniące się ograniczenia architektoniczne niż polimorfizm dynamiczny.