40.2 Explicit object parameter: deducing this

A C++20-as örökség egyik leggyakoribb tervezési terhe a tagfüggvények objektumhoz való viszonyának sokféle kódolása. Egy egyszerű getterhez négy változat kell, ha meg akarjuk őrizni a const-ságot és az értékkategóriát; a statikus polimorfizmus CRTP-vel gyakran static_cast-olással és sablonparaméterként átadott derived típussal jár; a rekurzív lambda pedig C++20-ban csak körülményesen, std::function-nel vagy külső segédstruktúrával írható meg. A C++23 explicit object parameter — közismertebb nevén deducing this — ezekre ad nyelvi szintű, egységes eszközt. Nem új futásidejű mechanizmus: a tagfüggvény objektumparaméterét teszi láthatóvá és dedukálhatóvá. A mostani rész ezt a nyelvi eszközt tárgyalja; a C++23 laborprofil és a feature-testek általános kezelése a C++23 laborprofil, szabványstátusz és feature-test részben már rögzített alapra épül.

40.2.1 A probléma: overload-négyes, CRTP és rekurzió

A klasszikus C++20-as overload-négyes tipikus példája egy konténer eleméhez való hozzáférés:

struct Param {
    int value;
    int& get() & { return value; }
    const int& get() const& { return value; }
    int&& get() && { return std::move(value); }
    const int&& get() const&& { return std::move(value); }
};

Négy deklaráció, négy visszatérési típus, és a négy változat közötti különbség csak a függvény minősítőiben van. Ugyanez a CRTP-ben:

template<class D>
struct Shape {
    double area() const { return static_cast<const D*>(this)->area_impl(); }
};

struct Circle : Shape<Circle> {
    double r{1.0};
    double area_impl() const { return 3.141592653589793 * r * r; }
};

A Shape csak a D sablonparaméteren keresztül tudja, hogy valójában Circle-ról van szó; a static_cast a derived típusra mutat. A rekurzív lambda C++20-ban jellemzően így néz ki:

#include <functional>

std::function<int(int)> fact = [&](int n) {
    return n <= 1 ? 1 : n * fact(n - 1);
};

Ez működik, de típus-erasure-t és indirekciót hoz be, ráadásul a fact élettartamához kötött. Az explicit object parameter mindhárom mintát egyszerűsíti, de nem mindenható: a virtuális dispatch, a statikus tagfüggvény, a konstruktor/destruktor és a hagyományos tagfüggvény-pointer szemantikája továbbra is határt szab.

40.2.2 Definíció és szintaxis

Az explicit object parameter olyan tagfüggvény-paraméter, amelyet a this specifier előz meg, és amely a paraméterlistában az első helyen áll. A függvény tagfüggvény marad, de nincs implicit this paramétere: az objektumot a hívás az első, explicit paraméteren keresztül adja át. A deducing this elnevezés arra utal, hogy ennek a paraméternek a típusát — jellemzően egy Self sablonparamétert — a hívás objektumargumentumából dedukáljuk.

A leggyakoribb alak:

struct Widget {
    template<class Self>
    void f(this Self&& self) {
        // self a hívó objektum; a this kulcsszó nem használható
    }
};

A this auto&& self ugyanezt jelenti egy implicit, a fordító által bevezetett sablonparaméterrel. A paraméter lehet érték szerinti is:

struct Point {
    int x, y;
    int sum(this Point self) { return self.x + self.y; }
};

Ekkor a hívás objektumból másolat készül; a függvény a saját példányát módosítja, az eredetit nem. Kis, olcsón másolható típusoknál ez kényelmes, nagy típusnál viszont kerülendő. A this Self& self csak lvalue objektumot fogad el, a this Self const& self pedig const hozzáférést ad; a teljes értékkategória- és const-propagáláshoz a this Self&& self a természetes választás.

A fontos feltételek:

40.2.2.1 Levezetés (vázlat)

A this Self&& self paraméter dedukciója ugyanaz, mint egy forwarding reference-é. Ha a hívó objektum típusa C:

Hívás objektuma Self self deklarált típusa
C lvalue C& C&
const C lvalue const C& const C&
C rvalue C C&&
const C rvalue const C const C&&

A referencia-összevonás adja a második oszlopból a harmadikat: Self = C& esetén C& && → C&; Self = C esetén C&&. A const a Self típusába kerül, nem a függvényre. Fontos következmény, hogy a függvényen belül a self név mindig lvalue kifejezés, akármilyen értékkategóriával érkezett az objektum; ezért kell a továbbításhoz std::forward<Self>(self) vagy a tagokra std::forward_like<Self>(...).

40.2.3 Overload-négyes kiváltása forward_like-kal

A négyes egyetlen függvénysablonnal kiváltható:

#include <cstddef>
#include <type_traits>
#include <utility>
#include <vector>

struct Buffer {
    std::vector<int> data;

    template<class Self>
    auto&& at(this Self&& self, std::size_t i) {
        return std::forward_like<Self>(self.data[i]);
    }
};

A std::forward_like<T>(x) a T-ből származtatja azt a const- és értékkategória-információt, amelyet az x-re alkalmazni kell. Itt Self hordozza az objektum const-ságát és értékkategóriáját, miközben self.data[i] önmagában lvalue int kifejezés. A forward_like ezt alakítja int&, const int&, int&& vagy const int&& típusúvá. A std::forward<Self>(self).data[i] ezzel szemben nem elegendő: a std::vector::operator[] nincs ref-minősítve, ezért rvalue vektoron is lvalue referenciát ad vissza; a value category nem terjed át a subscripten. Ez tipikus diagnosztikai hiba, amikor valaki a négyesből csak a const/non-const párt teszteli, az rvalue eset pedig csendben lvalue referenciát ad.

A teljes példa fordítása a laborban rögzített fordítókiadással:

g++ -std=c++23 -Wall -Wextra -pedantic-errors -O2 deducing_this.cpp -o deducing_this
clang++ -std=c++23 -Wall -Wextra -pedantic-errors -O2 deducing_this.cpp -o deducing_this

A támogatás tényét nem feltételezzük; feature-testtel és compile probe-bal ellenőrizzük:

#if !defined(__cpp_explicit_this_parameter) || __cpp_explicit_this_parameter < 202110L
#error "A forditonak C++23 explicit object parametert kell tamogatnia."
#endif

#if !defined(__cpp_lib_forward_like) || __cpp_lib_forward_like < 202207L
#error "A standard konyvtarnak std::forward_like-ot kell nyujtania."
#endif

A nyelvi és a könyvtári támogatás külön ellenőrizendő: a __cpp_explicit_this_parameter a nyelvi képességet, a __cpp_lib_forward_like a könyvtári eszközt jelzi. Ha az utóbbi hiányzik, C++20-as fallbackként a négy overload, vagy kézi static_cast-olás a Self alapján használható; a std::forward önmagában nem elég a fenti operator[]-probléma miatt.

Élettartam szempontjából a forward_like nem varázslat: ha a getter auto&&-t ad vissza, és az objektum temporális, a visszaadott referencia a temporális tagjára mutat, amely a teljes kifejezés végén megszűnik. Ez ugyanaz a dangling-helyzet, mint a ref-minősített gettereknél; a részletes értékkategória- és temporary-szabályokat a Temporary, value category és dangling rész tárgyalta. Ha a hívó nem tudja garantálni az owner élettartamát, adjunk vissza értéket, ne referenciát.

40.2.4 CRTP egyszerűsítése és a statikus polimorfizmus korlátai

A CRTP-s Shape/Circle példa explicit object parameterrel így egyszerűsödik:

struct Shape {
    template<class Self>
    double area(this Self const& self) {
        static_assert(requires { self.area_impl(); },
                      "A Self tipusnak area_impl() tagfuggvenyt kell adnia");
        return self.area_impl();
    }
};

struct Circle : Shape {
    double r{1.0};
    double area_impl() const { return 3.141592653589793 * r * r; }
};

Eltűnt a D sablonparaméter, a static_cast, és a derived osztálynak nem kell önmagát átadnia a base-nek. A Self a hívás statikus típusából dedukálódik, így a Circle c; c.area(); hívásban Self = const Circle& (a const& paraméter miatt), és a self.area_impl() megtalálható. A requires-szel vagy static_assert-tel adott követelmény azonban csak a függvény hívásakor, az adott Self-re ellenőrződik, nem a Circle osztály definiálásakor.

Ez a fő különbség a CRTP-hez képest. A CRTP base már a Shape<Circle> példányosításakor ismeri a Circle-t, ezért ott class-scope-ban is lehet static_assert-et, typedefet vagy egyéb, a derived típusra épülő deklarációt elhelyezni. A deducing this nem tud tagokat „injektálni” a derived osztályba, és nem tud a base-ben class-scope-ban a derived típusra hivatkozni. Ha a statikus polimorfizmus részeként a base referencia mögül is a derived függvényét akarjuk hívni, a deducing this nem elegendő:

Circle c;
Shape const& s = c;
// s.area(); // Self = Shape, a Shape-nek nincs area_impl() tagja

A CRTP-s Shape<Circle> const& ezzel szemben tudja a Circle-t. A deducing this tehát kiváltja a CRTP gyakori static_cast-os mintáját, de nem váltja ki a CRTP teljes statikus polimorfizmusát. Marad továbbá a virtuális dispatch hiánya: nincs vtable, nincs runtime polymorphic interfész, és a Self mindig a kifejezés statikus típusa szerint dedukálódik. A CRTP és a policy design korábbi tárgyalása a Customization point, CRTP és policy design részben található.

40.2.5 Rekurzív lambda explicit object parameterrel

A lambda operator()-a is tagfüggvény, ezért kaphat explicit object parametert. Ezzel a closure saját magát névvel érheti el:

#include <cassert>

int main() {
    auto fact = [](this auto&& self, int n) -> int {
        return n <= 1 ? 1 : n * self(n - 1);
    };
    assert(fact(5) == 120);
}

A fact(5) hívásban a fact closure az explicit object paraméter, az 5 pedig az n. A self a closure objektumra kötődik, így self(n - 1) újra a lambda operator()-át hívja. Nem kell std::function, nem kell a lambdát capture-rel önmagára hivatkoztatni, és a rekurzió típusa a closure típusa marad. A this auto self érték szerinti változat is működik, de a closure-t minden rekurzív hívásnál másolja; a this auto&& ezt elkerüli. A closure élettartamára ugyanazok a szabályok vonatkoznak, mint bármely lambdára: ha a closure-t visszaadjuk vagy eltároljuk, a capture-ök élettartamát továbbra is biztosítani kell; erről a Lambda, capture és closure lifetime rész szól.

40.2.6 Korlátok és döntési tábla

Kérdés Explicit object parameterrel
virtual tagfüggvény nem megengedett
static tagfüggvény nem megengedett
konstruktor/destruktor nem megengedett
const/volatile/&/&& a függvényen nem írható; a Self hordozza
implicit this nincs; a this kulcsszó nem használható
lambda operator() megengedett
érték szerinti self megengedett, másol/mozgat; nagy típusnál kerülendő
tagfüggvény-pointer (&C::f) a szabvány külön szabályozza; ABI-érzékeny, nem tekintendő stabil interfésznek
C++20-as fallback négy overload, CRTP, std::function/Y-combinator

A tagfüggvény-pointer kérdése külön óvatosságot érdemel. Az explicit object parameteres függvény címe és hívása nem feltétlenül követi a hagyományos, implicit this-es tagfüggvény-pointer szemantikáját; ezt a konkrét fordítóval és a szabvány vonatkozó szakaszával kell ellenőrizni. Shared library- vagy FFI-határon különösen nem tanácsos ilyen függvény címét stabil ABI-elemként exportálni. A C interfészhez továbbra is extern "C"-s szabad függvény vagy opaque handle való; erről a C++ ABI, C interfész és FFI rész szól.

Mikor nem alkalmazzuk? Ha a kódnak C++20-on is fordulnia kell; ha valódi virtuális interfészre van szükség; ha a CRTP class-scope-s követelményeit vagy taginjekcióját akarjuk; ha a stabil bináris ABI a cél; vagy ha a meglévő kódban az implicit this-re épülő makrók, tagpointerek vannak. Az explicit object parameter erős eszköz, de nem helyettesíti a virtuális polimorfizmust és nem tesz hordozhatóvá egy ABI-határt.

40.2.7 Gyakori hibák

40.2.8 Összefoglaló

Az explicit object parameter a tagfüggvény objektumparaméterét a paraméterlistára hozza, és ezzel dedukálhatóvá teszi a Self típust. Egy template<class Self> auto&& f(this Self&& self) alakkal a const/non-const és lvalue/rvalue overload-négyes egyetlen sablonná válik; a tagok továbbításához std::forward_like<Self> használható, amely a Self-ből viszi át a const- és értékkategória-információt. A CRTP gyakori static_cast-ja eltűnik, de a class-scope-s követelmények, a taginjekció és a base referencián keresztüli statikus polimorfizmus nem váltható ki vele. Rekurzív lambdát tisztán, std::function nélkül írhatunk. A korlátok élesek: nincs virtual, static, konstruktor/destruktor, nincs implicit this, a függvényre nem írható cv/ref minősítő, a tagfüggvény-pointer és az ABI pedig külön ellenőrizendő. A C++20-as fallback továbbra is a négy overload, a CRTP és a std::function/Y-combinator.

40.2.9 Feladatok

40.2.9.1 1. feladat: overload-négyes kiváltása

Adott a következő C++20-as típus:

struct Box {
    int value;
    int& get() & { return value; }
    const int& get() const& { return value; }
    int&& get() && { return std::move(value); }
    const int&& get() const&& { return std::move(value); }
};

Írd át egyetlen explicit object parameteres függvénysablonra, amely mind a négy esetben a megfelelő referenciatípust adja vissza. Ellenőrizd static_assert-tel legalább a nem const lvalue, a const lvalue és a nem const rvalue esetet. Írd le, miért nem elegendő a std::forward<Self>(self).value a tag eléréséhez, ha a tag egy std::vector eleme.

40.2.9.2 2. feladat: diagnosztika

Az alábbi kódot egy kolléga azzal a céllal írta, hogy a Buffer::at a négyes overloadot váltsa ki:

struct Buffer {
    std::vector<int> data;
    template<class Self>
    auto&& at(this Self&& self, std::size_t i) {
        return std::forward<Self>(self).data[i];
    }
};

Mondd meg, fordul-e, és ha igen, miért nem adja vissza az rvalue esetben int&&-t. Javítsd a kódot. Milyen futásidejű veszélyt jelent, ha a javított gettert temporális Buffer-en hívjuk meg és a visszaadott referenciát eltároljuk?

40.2.9.3 3. feladat: CRTP kiváltása

Adott a CRTP-s alak:

template<class D>
struct Base {
    void interface() const { static_cast<const D*>(this)->impl(); }
};
struct Derived : Base<Derived> {
    void impl() const {}
};

Írd át explicit object parameterrel. Ezután válaszolj: mi történik, ha Base const& b = d; b.interface(); formában hívjuk meg? Miért viselkedik másképp a CRTP-s változat? Sorolj fel legalább két olyan feladatot, amelyre a CRTP továbbra is jobb választás.

40.2.10 Megoldások

40.2.10.1 1. feladat megoldása

#include <type_traits>
#include <utility>

struct Box {
    int value;

    template<class Self>
    auto&& get(this Self&& self) {
        return std::forward_like<Self>(self.value);
    }
};

int main() {
    Box b{1};
    const Box cb{2};
    static_assert(std::is_same_v<decltype(b.get()), int&>);
    static_assert(std::is_same_v<decltype(cb.get()), const int&>);
    static_assert(std::is_same_v<decltype(Box{3}.get()), int&&>);
    static_assert(std::is_same_v<decltype(std::move(cb).get()), const int&&>);
}

A Self a hívó objektum const- és értékkategóriáját hordozza; a forward_like ezt viszi át a value tagra. A std::forward<Self>(self).value közvetlen tagelérésnél működne, mert a tagelérés megőrzi az értékkategóriát, de egy std::vector operator[]-je nem ref-minősített: rvalue vektoron is int&-t ad, ezért ott a forward_like szükséges. Ellenőrzési feltétel: a négy static_assert forduljon le, és a const rvalue esetben az eredmény const int&& legyen.

40.2.10.2 2. feladat megoldása

A kód lefordul, de a visszatérési típus rvalue Buffer esetén is int& lesz. Az ok a

std::forward<Self>(self).data[i]

kifejezésben van: a data[i] hívás a nem ref-minősített std::vector::operator[]-t használja, amely lvalue referenciát ad. A value category nem „szivárog át” a subscripten. Javítás:

template<class Self>
auto&& at(this Self&& self, std::size_t i) {
    return std::forward_like<Self>(self.data[i]);
}

A forward_like a Self alapján castolja az int& kifejezést int&&-re, illetve const esetben const int&&-re. Ha a gettert temporális Buffer-en hívjuk, és a visszaadott referenciát eltároljuk, a temporális a teljes kifejezés végén megszűnik; a referencia dangling lesz, és a későbbi használat nem definiált viselkedés. Ellenőrzési feltétel: static_assert(std::is_same_v<decltype(Buffer{{1}}.at(0)), int&&>) teljesüljön, és a temporálisra visszaadott referenciát ne tároljuk el.

40.2.10.3 3. feladat megoldása

struct Base {
    template<class Self>
    void interface(this Self const& self) {
        self.impl();
    }
};

struct Derived : Base {
    void impl() const {}
};

A Derived d; d.interface(); hívásban Self = Derived, és a self.impl() megtalálható. A Base const& b = d; b.interface(); hívásban viszont a statikus típus Base, ezért Self = Base, és a Base-nek nincs impl() tagja; a requires/static_assert itt hibát jelez. A CRTP-s Base<Derived> azért működik, mert a base már a példányosításkor ismeri a Derived típust, és a static_cast a dinamikus objektumra mutat. A deducing this a statikus típusból dedukál, nem a dinamikusból.

A CRTP továbbra is jobb, ha (1) a base-nek class-scope-ban kell a derived típusra hivatkoznia, typedefet/tagot kell örökítenie vagy példányosításkor kell követelményt ellenőriznie; (2) a base referencia mögül is a derived statikus interfészét akarjuk hívni; (3) olyan statikus polimorf kódot írunk, ahol a derived típus a base típusparamétere, és erre a template-metaprogramozásban is szükség van. Ellenőrzési feltétel: a Derived d; d.interface(); hívás forduljon, a Base const&-es hívás pedig adjon érthető diagnosztikát, ne csendben a Base-re dedukáljon.