W całym C++98 metody członkowskie uzyskiwały dostęp do implicitnego obiektu przez ukryty wskaźnik this, co wymagało odrębnych przeciążeń do obsługi kontekstów const i non-const, podczas gdy C++11 wprowadził kwalifikatory referencji do rozróżnienia obiektów lvalue i rvalue. To potencjalnie wymagało czterech przeciążeń na funkcję, aby pokryć wszystkie kombinacje cv-ref, tworząc znaczne duplikaty kodu i obciążenia konserwacyjne dla bibliotek ogólnych.
Podstawowy problem pojawia się, gdy metoda członkowska musi zwrócić obiekt z taką samą kategorią wartości i kwalifikacją cv jak wywołujący, aby umożliwić efektywną semantykę przenoszenia lub zapobiec wiszącym referencjom. Bez wnioskowania o typie obiektu programiści pisali obszerne zestawy przeciążeń lub godziły się na semantykę kopiowania, prowadząc do nieefektywnego zarządzania rvalue lub subtelnych błędów powiązanych z czasem życia w ogólnym kodzie, który propagował referencje obiektów.
C++23 wprowadza eksploracyjne parametry obiektów, pozwalając na składnię void foo(this auto&& self). Tutaj self staje się parametrem wnioskowanym, uchwycającym kategorię wartości obiektu i kwalifikatory cv, eliminując potrzebę osobnych przeciążeń & i &&, ponieważ std::forward<decltype(self)>(self) propaguje poprawną kategorię. Jednakże statyczne funkcje członkowskie nie mają implicitnego obiektu, więc zastosowanie tej składni do nich narusza fundamentalny wymóg posiadania obiektu, do którego można by przypisać self, co czyni program źle sformułowanym zgodnie ze standardem.
// Przed C++23: Cztery przeciążenia potrzebne class Builder { public: Builder& setName(...) & { /* ... */ return *this; } Builder const& setName(...) const& { /* ... */ return *this; } Builder&& setName(...) && { /* ... */ return std::move(*this); } Builder const&& setName(...) const&& { /* ... */ return std::move(*this); } }; // C++23: Jedno przeciążenie class Builder { public: template<typename Self> auto setName(this Self&& self, ...) -> Self&& { // ... return std::forward<Self>(self); } };
Nasz zespół opracował wysokowydajną bibliotekę JSON, w której węzły DOM wspierały łańcuchowanie metod dla budowy drzewa, wymagając, aby klasa Node udostępniała metody addChild() z odrębnymi semantykami zwracania. Te metody musiały zwracać rodzica przez referencję, gdy rodzic był lvalue, aby umożliwić dalszą mutację, ale przez wartość, gdy rodzic był rvalue, aby umożliwić eliminację przenoszenia i zapobiec przypadkowemu modyfikowaniu wygasłych obiektów.
Początkowa implementacja używała tradycyjnych przeciążeń z kwalifikatorami referencji. Utrzymywaliśmy cztery wersje addChild: jedna zwracająca Node& dla lvalues, jedna zwracająca Node const& dla const lvalues, jedna zwracająca Node&& dla rvalues, i jedna zwracająca Node const&& dla const rvalues. To podejście zaspokajało wymagania wydajnościowe, ale poczwórnie zwiększyło nasze pole testowe, a krytyczny błąd pojawił się, gdy przeciążenie const&& błędnie zwracało wiszącą referencję z powodu błędu kopiuj-wklej z przeciążenia &.
Rozważaliśmy całkowite porzucenie kwalifikatorów referencji i zawsze zwracanie przez wartość, polegając na RVO w celu optymalizacji kopiowania, ale to zmuszało do niepotrzebnych ruchów na nazwanych obiektach i łamało kompatybilność API z istniejącym kodem, który przechowywał referencje do zwracanego węzła. Ocenialiśmy również CRTP z szablonem klasy bazowej wnioskowującym pochodny typ, ale to ujawniało szczegóły implementacji użytkownikom i komplikowało hierarchię dziedziczenia, nie rozwiązując w pełni problemu propagującego kategorii wartości.
Przyjęcie eksploracyjnych parametrów obiektów C++23 pozwoliło nam skonsolidować zestaw przeciążeń w jedną metodę szablonową: template<typename Self> auto addChild(this Self&& self, ...) -> Self. To uchwyciło dokładnie potrzebną kategorię wartości, umożliwiło idealne przekazywanie bez redundancji std::move czy std::forward w implementacji, a także zmniejszyło złożoność cyklomatyczną metody do jednej ścieżki. Wynikiem była redukcja kodu szablonowego o 75% oraz eliminacja błędów związanych z różnicą przeciążeń.
Dlaczego użycie składni eksploatacyjnych parametrów obiektów uniemożliwia dodawanie tradycyjnych kwalifikatorów cv-kwalifikacji lub kwalifikatorów referencji po liście parametrów?
Tradycyjne metody członkowskie umieszczają kwalifikatory cv i kwalifikatory referencji po liście parametrów, aby zmodyfikować typ wskaźnika implicitnego this. Przy eksploracyjnych parametrach obiektów this Self&& self już encoduje kwalifikację cv i kategorię referencji w dedukcji typu Self. Dodawanie dodatkowych kwalifikatorów, takich jak const czy &, po liście parametrów próbowałoby kwalifikować nieistniejący implicitny obiekt, tworząc sprzeczność w systemie typów. Standard wyraźnie zabrania tej kombinacji, ponieważ eksploracyjny parametr zastępuje rolę zarówno parametru, jak i kwalifikatorów, a pozwolenie na obie opcje stworzyłoby niejasność co do tego, które semantyki rządzą wywołaniem.
Jak różni się wyszukiwanie nazw w ciele funkcji, gdy używa się eksploracyjnych parametrów obiektów w porównaniu z tradycyjnymi metodami członkowskimi?
W tradycyjnych metodach członkowskich, niekwalifikowane wyszukiwanie nazw automatycznie przeszukuje zakres klasy, jakby this-> było dodane na początku. Przy eksploracyjnych parametrach obiektów nie ma implicitnego wskaźnika this; parametr self musi być używany jawnie, aby uzyskać dostęp do członków. Kandydaci często zakładają, że member wewnątrz void foo(this auto& self) automatycznie rozwiązuje się do this->member, ale w rzeczywistości wymaga kwalifikacji self. lub jawnej kwalifikacji klasy, takiej jak ClassName::member. To zmienia fundamentalne zasady wyszukiwania i wymaga adaptacji przy migrowaniu kodu, szczególnie w przypadku uzyskiwania dostępu do członków chronionych z klas pochodnych, gdzie self. jawnie wywołuje sprawdzenie dostępu zgodnie z dedukowanym typem, a nie typem statycznej klasy.
Czy eksploracyjne parametry obiektów mogą uczestniczyć w nadpisywaniu funkcji wirtualnych i jakie ograniczenia obowiązują w relacji nadpisania?
Eksploracyjne parametry obiektów mogą pojawiać się w funkcjach wirtualnych, ale zasadniczo zmieniają zasady dopasowywania nadpisania. Klasa bazowa deklarująca virtual void bar(this Base& self) nie może być nadpisywana przez klasę pochodną deklarującą void bar(this Derived& self), nawet jeśli tradycyjne nadpisania pozwalają na kowariantne typy zwracane. Eksploracyjny parametr obiektu staje się częścią sygnatury funkcji do celów dopasowywania nadpisania. Ponieważ Base& i Derived& to różne typy, nie stanowi to ważnego nadpisania. To uniemożliwia powszechny wzór używania eksploracyjnych parametrów obiektów do osiągnięcia "sfinae-friendly" funkcji wirtualnych lub łańcuchowania metod zachowującego typ w hierarchiach polimorficznych. Aby nadpisać, funkcja pochodna musi dokładnie pasować do typu parametru jawnego klasy bazowej, co neguje korzyści z wnioskowania dla tego parametru w kontekście nadpisania.