40.3 static operator(), többdimenziós indexelés és lambdaváltozások

A C++20-ban egy állapot nélküli függvényobjektumot (funktort) csak körülményesen lehetett igazán „súlytalanná” tenni: a tagfüggvénynek mindig van egy rejtett this paramétere, még akkor is, ha az objektumnak nincs állapota. Ugyanígy a többdimenziós indexelést operator()(i, j) vagy at(i, j) formában kellett megoldani, mert az operator[] csak egy paramétert fogadott el. A C++23 nyelvi változásai közül ebben a részben a hívó- és indexelő operátorok bővítését, valamint a lambdák ehhez kapcsolódó finomításait tárgyaljuk. A cél nem a C++20-as operátor- és lambdaalapok újratanítása — azokat lásd az Operátortúlterhelés és konverziós operátor (23.3) és a Lambda, capture és closure lifetime (30.1) részekben —, hanem annak megértése, mit változtat a C++23, milyen szerződéseket kell ilyenkor kézben tartani, és hogyan lehet a képességet feature-test makróval és compile probe-bal ellenőrizni.

40.3.1 A statikus hívóoperátor

40.3.1.1 Definíció és feltételek

A statikus hívóoperátor (static operator()) C++23-as nyelvi képesség: egy osztály tagfüggvényeként definiált operator() lehet static. A pontos feltételek a következők. A függvénynek tagfüggvénynek kell lennie, és a static kulcsszót a deklarációban kell szerepeltetni. Statikus tagfüggvényként nem lehet const, volatile, & vagy && minősítésű, mert ezek a minősítők a rejtett this paraméterre vonatkoznak, amely itt nem létezik. Nem lehet virtual sem, mert a virtuális dispatchhez objektumpéldány kell. Lehet viszont constexpr, consteval és noexcept, és a hívás szintaxisa a hívó oldalon változatlan: f(args).

A képesség a P1169 paperszámhoz kötődik a témakör tervében. A támogatás tényleges meglétét nem szabad feltételezni: a laborprofilban rögzített GCC vagy Clang verzióval feature-test makróval és egy kis compile probe-bal kell ellenőrizni. A makró neve __cpp_static_call_operator, a C++23-as érték 202207L. A vizsgálat tipikus alakja:

#if defined(__cpp_static_call_operator) && __cpp_static_call_operator >= 202207L
#  define CPP23_STATIC_CALL_OP 1
#else
#  define CPP23_STATIC_CALL_OP 0
#endif

A legfontosabb gyakorlati következmény a capture nélküli lambda esetére vonatkozik. A C++23 szerint az ilyen lambda closure típusának operator()-je implicit módon static: a closure üres típus, a hívás nem igényel this paramétert. Ezt a felhasználó nem írja ki, és a mutable-lel nem kombinálhatja, mert a mutable éppen a nem-const this-t jelentené. A static és a mutable tehát kölcsönösen kizárja egymást.

40.3.1.2 Hívási költség és ABI

A this paraméter elhagyásának van hívási konvencióbeli hatása. Az x86-64 System V ABI-ban egy nem-static tagfüggvény első integer paramétere a this (jellemzően az RDI regiszterben), a többi paraméter pedig eggyel eltolódik. Egy statikus operator() esetén ez a plusz argumentum eltűnik, ami regisztert takaríthat meg, és egyszerűbbé teheti a függvénypointeres átadást. Fontos azonban, hogy ez elvi, nem mért hatás: a fordító az inline-olt, optimalizált kódban gyakran teljesen eltünteti a különbséget. Mérés nélkül nem állítható, hogy a statikus változat gyorsabb; a laborban külön kell megmérni, és a mérési eredményt a fordítóverzióhoz és az optimalizálási szinthez kell kötni.

ABI-szempontból a statikus és a nem-static operator() mangled neve eltér, és a hívási konvenció is más. Ha egy headerben egy meglévő operator()-t static-ká teszünk, a bináris interfész megváltozik: a régi és az új könyvtár nem lesz link-kompatibilis. Ez DevOps-környezetben különösen fontos, ha a függvény egy megosztott könyvtár határán van. Új kódban a statikus változat bevezetése nem jelent ABI-törést, de a meglévő API-t nem szabad csendben átminősíteni.

40.3.1.3 Lambdák C++23-ban

A lambdákra vonatkozó C++23-as változások közül kettő kapcsolódik ide. Az első a capture nélküli lambda implicit statikus operator()-je, amelyet az előző pontban tárgyaltunk. A második a lambda-deklarátor egyszerűsítése: a paraméterlista elhagyható, ha a lambda nem vesz át paramétert, és nincs olyan specifikátor, amely a paraméterlistát igényelné. A []{} és a [](){} alak tehát egyenértékű, de a [] mutable {} nem érvényes, mert a mutable a paraméterlistához kötött. Hasonlóképpen a noexcept, a constexpr, a consteval és a static specifikátorok bizonyos kombinációi igénylik a paraméterlista jelenlétét.

A lambda-attribútumok terén a C++23 lehetővé teszi, hogy attribútumok a lambda-introducer után is megjelenjenek, például [] [[nodiscard]] () { return 1; } alakban. A pontos elfogadott szintaxist és a paraméterlista elhagyhatóságának eseteit azonban nem szabad fejből feltételezni: ezek verzió- és compilerfüggő részletek, amelyeket compile probe-bal kell ellenőrizni. A __cpp_lambdas makró C++23-as értéke a rendelkezésre álló jegyzet szerint 202207L, de ez is ellenőrizendő.

40.3.2 Többdimenziós indexelés

40.3.2.1 A vesszőoperátor öröksége

A többdimenziós indexelő operátor (P2128) lehetővé teszi, hogy az operator[] tagfüggvénynek egynél több paramétere legyen. A szintaxis változatlanul a[i, j], de a vessző jelentése megváltozik: ha az osztálynak van többparaméteres operator[]-ja, akkor a vessző paraméterelválasztó, nem vesszőoperátor. Ha nincs ilyen, akkor a C++20-ig megszokott módon a vesszőoperátor lép életbe.

Ez a különbség könnyen félreértéshez vezet. A beépített tömbindexelésnél az operator[] továbbra is egyoperandusú, ezért

int a[10];
a[1, 2] = 5;   // a[2] = 5: a vesszőoperátor kiértékeli 1-et, eldobja, majd 2-t ad

C++23-ban is ezt jelenti, mert a beépített [] nem többparaméteres. Saját osztálynál viszont a m[1, 2] attól függ, van-e többparaméteres operator[]. Ha van, két paraméteres hívás; ha nincs, a vesszőoperátor eredménye kerül az egyparaméteres []-ba. Ha az osztálynak nincs egyparaméteres operator[]-ja, a m[1, 2] fordítási hiba lesz. A vesszőoperátor túlterhelése ezen nem változtat: a beépített [] esetén továbbra is a operator, hívódik, ha azt az osztály túlterhelte.

40.3.2.2 Mátrixtípus operator indexeléssel

A következő példa egy egyszerű, sorfolytonos mátrixtípust mutat, amely C++23-ban m[i, j] alakban indexelhető. A kód önállóan fordítható, de a C++23-as operator[] miatt a laborprofilban rögzített, a képességet támogató compilerverzió szükséges hozzá.

#include <cstddef>
#include <limits>
#include <stdexcept>
#include <vector>

class Matrix {
public:
    Matrix(std::size_t rows, std::size_t cols)
        : rows_(rows), cols_(cols) {
        if (cols_ != 0 && rows_ > std::numeric_limits<std::size_t>::max() / cols_) {
            throw std::length_error("Matrix: meret tulcsordulas");
        }
        data_.resize(rows_ * cols_);
    }

    int& operator[](std::size_t i, std::size_t j) noexcept {
        return data_[i * cols_ + j];
    }

    int operator[](std::size_t i, std::size_t j) const noexcept {
        return data_[i * cols_ + j];
    }

    int& at(std::size_t i, std::size_t j) {
        if (i >= rows_ || j >= cols_) {
            throw std::out_of_range("Matrix: index hataron kivul");
        }
        return (*this)[i, j];
    }

    int at(std::size_t i, std::size_t j) const {
        if (i >= rows_ || j >= cols_) {
            throw std::out_of_range("Matrix: index hataron kivul");
        }
        return (*this)[i, j];
    }

    std::size_t rows() const noexcept { return rows_; }
    std::size_t cols() const noexcept { return cols_; }

private:
    std::size_t rows_;
    std::size_t cols_;
    std::vector<int> data_;
};

A (*this)[i, j] itt már a többparaméteres operator[] hívása. A const változat értéket ad vissza, nem referenciát; ezzel elkerüljük, hogy egy temporary Matrix-on kapott referenciát a hívó tovább éltessen. A nem-const változat int&-t ad, ami a data_ elemére hivatkozik, és a Matrix élettartamához kötött.

A build parancs a laborprofilban rögzített compilerrel:

g++ -std=c++23 -O2 -Wall -Wextra -Wpedantic matrix.cpp -o matrix
clang++ -std=c++23 -O2 -Wall -Wextra -Wpedantic matrix.cpp -o matrix

Ez a parancs a szándékot rögzíti, nem annak bizonyítéka, hogy a laborban telepített compiler támogatja a képességet; a tényleges támogatást a feature-test makró és a compile probe dönti el.

40.3.2.3 Bounds-szerződés és overflow

Az operator[] előfeltétele, hogy i < rows_ és j < cols_ teljesüljön. Ezt a függvény nem ellenőrzi, mert a gyorsított indexelés éppen ettől gyors; a szerződés megsértése nem definiált viselkedés, nem kivétel. A noexcept ezért indokolt: a std::vector::operator[] nem dob, és a saját operator[] sem végez ellenőrzést. A at() ezzel szemben ellenőriz, és std::out_of_range-t dob, ezért nem noexcept.

Az indexszámításnál az i * cols_ + j alak túlcsordulhatna, ha i és cols_ nagyon nagy. A konstruktor azonban ellenőrzi, hogy rows_ * cols_ ne csorduljon túl. Ebből következik, hogy a szerződés betartása esetén az indexszámítás sem csordul túl:

i⋅cols_+j≤(rows_−1)⋅cols_+(cols_−1)=rows_⋅cols_−1,i \cdot cols\_ + j \le (rows\_-1)\cdot cols\_ + (cols\_-1) = rows\_\cdot cols\_ - 1,

és mivel rows_⋅cols_≤𝚂𝙸𝚉𝙴_𝙼𝙰𝚇rows\_\cdot cols\_ \le \texttt{SIZE\_MAX}, az i⋅cols_+ji \cdot cols\_ + j legfeljebb 𝚂𝙸𝚉𝙴_𝙼𝙰𝚇−1\texttt{SIZE\_MAX}-1. Ez a vázlat arra, hogy a bounds-szerződés és az overflow-ellenőrzés ugyanannak a méretszerződésnek a két oldala: a konstruktor a teljes méretet, az operator[] a két indexet feltételezi.

Az std::mdspan C++23-as operator[]-ja szintén több indexet fogad, és ugyanezt a mintát követi: a méreteket és a layoutot az mdspan példány hordozza, az indexelés pedig nem végez bounds check-et. Az mdspan részleteivel a Multidimenziós view és numeric utility térkép (31.5) foglalkozik; itt csak a kapcsolatot rögzítjük.

40.3.3 Feature-test makrók és fallback

A többdimenziós operator[] feature-test makrója __cpp_multidimensional_subscript, C++23-as értéke a jegyzet szerint 202110L. A statikus operator() makrója __cpp_static_call_operator (202207L). A statikus operator[] (P2589) önálló makróját a rendelkezésre álló jegyzet nem nevezi meg, ezért itt compile probe dönt: egy static int operator[](int) deklarációt tartalmazó kis fordítási egységgel kell ellenőrizni, hogy a compiler elfogadja-e.

A C++20-as fallback a következő. Statikus operator() helyett használhatunk statikus tagfüggvényt más néven, például static bool apply(int), vagy névtelen névtérben szabad függvényt. Többdimenziós operator[] helyett operator()(i, j) vagy at(i, j) áll rendelkezésre. A kódot feltételes fordítással lehet mindkét világban fordítani:

#if defined(__cpp_multidimensional_subscript) && __cpp_multidimensional_subscript >= 202110L
#  define CPP23_MULTIDIM_SUBSCRIPT 1
#else
#  define CPP23_MULTIDIM_SUBSCRIPT 0
#endif

class Grid {
public:
    int& at(std::size_t i, std::size_t j) { return data_[i * cols_ + j]; }
#if CPP23_MULTIDIM_SUBSCRIPT
    int& operator[](std::size_t i, std::size_t j) noexcept { return data_[i * cols_ + j]; }
#else
    int& operator()(std::size_t i, std::size_t j) noexcept { return data_[i * cols_ + j]; }
#endif
private:
    std::size_t cols_ = 0;
    std::vector<int> data_;
};

Ez a minta a lényeg: a hívó oldalnak a fallback ágban g(i, j), a C++23-as ágban g[i, j] alakot kell használnia, ezért a választást vagy makróval, vagy külön wrapperrel kell egységesíteni. A #if ágakban a két operator szignatúra nem azonos, ezért a kód más részein is figyelni kell a hívási szintaxisra.

40.3.4 Tipikus hibák

Az első és leggyakoribb hiba a static és a const együttes használata: static int operator()(int) const nem érvényes, mert a statikus tagfüggvénynek nincs this paramétere, amit a const minősíthetne. A második a m[1, 2] félreértelmezése: C++20-as kódban ez a vesszőoperátor miatt m[2]-t jelenthet, C++23-ban viszont két paraméteres hívást, ha van ilyen operator[]; a kettő közötti váltás csendben megváltoztathatja a program jelentését. A harmadik a bounds check elhagyása és az overflow figyelmen kívül hagyása: az operator[] nem ellenőriz, a méretösszeszorzást a konstruktornak kell védenie. A negyedik a const operator[] által visszaadott referencia élettartamának félreértése: egy temporary Matrix-on kapott const int& danglinggé válik, amint a temporary megszűnik. Az ötödik a feature-test makró elhagyása: a kód látszólag fordul, de egy régebbi compileren nem a várt ágat választja.

40.3.5 Feladatok

  1. Kódolási feladat. Definiálj egy IsEven funktort static constexpr bool operator()(int) noexcept hívóoperátorral, és használd std::count_if-fel egy std::vector<int>-en. Ellenőrizd static_assert-tel, hogy a hívás konstans kifejezésben is működik. Indokold, miért lesz a funktor típusa üres, és miért nem igényel a hívás this paramétert.

  2. Tervezési feladat. Egészítsd ki a Matrix osztályt rows() és cols() tagfüggvényekkel, valamint egy at(i, j) const változattal. Biztosítsd, hogy a m[1, 2] és a m.at(1, 2) ugyanazt az elemet adja, és hogy a m.at(3, 0) std::out_of_range-t dobjon. Fogalmazd meg az operator[] előfeltételét és a konstruktor méretellenőrzésének szerepét.

  3. Diagnosztikai feladat. Adott egy C++20-as osztály, amely m(i, j) alakban indexel. Írd át C++23-as m[i, j] alakra, és készíts olyan feltételes fordítású változatot, amely C++20-on a régi m(i, j) szintaxist használja. Mutasd meg, melyik feature-test makró dönt, és hogyan ellenőriznéd a statikus operator[] támogatását.

40.3.6 Megoldások

40.3.6.1 Az IsEven functor

#include <algorithm>
#include <cassert>
#include <vector>

struct IsEven {
    static constexpr bool operator()(int x) noexcept { return x % 2 == 0; }
};

static_assert(IsEven{}(4));
static_assert(!IsEven{}(3));

int main() {
    std::vector<int> v{1, 2, 3, 4, 6};
    assert(std::count_if(v.begin(), v.end(), IsEven{}) == 3);
}

A static miatt a hívás nem igényel this paramétert, a funktor típusa üres, és a static_assert azt igazolja, hogy a hívás konstans kifejezésben is érvényes. Ellenőrzési feltétel: a program -std=c++23-mal forduljon, a static_assert-ek menjenek át, és a count_if eredménye 3 legyen. C++20-on a static elhagyásával a kód továbbra is fordul, de a hívás ekkor this-t igényel.

40.3.6.2 A mátrix bounds-szerződéssel

#include <cassert>
#include <stdexcept>

int main() {
    Matrix m(3, 4);
    m[1, 2] = 42;
    assert((m[1, 2] == 42));   // dupla zárójel: a vessző ne makróargumentum-elválasztó legyen
    assert(m.at(1, 2) == 42);

    bool dobott = false;
    try {
        (void)m.at(3, 0);
    } catch (const std::out_of_range&) {
        dobott = true;
    }
    assert(dobott);
}

Az assert makró, és a preprocesszor a C++ nyelvtannál korábban dolgozik: a makróhívás argumentumait a legfelső szintű, zárójelbe nem foglalt vesszők választják el, és a szögletes zárójel ebből a szempontból nem számít. Az assert(m[1, 2] == 42) ezért két makróargumentumot kapna (m[1 és 2] == 42), és a fordítás macro "assert" passed 2 arguments, but takes just 1 hibával leáll. A külön zárójelpár egyetlen argumentummá fogja össze a kifejezést; ugyanez kell minden más függvényszerű makrónál is, ha az argumentumban a C++23-as többargumentumos operator[] szerepel.

Buildjegyzőkönyv (lab-gcc profil). A fenti Matrix-szal kiegészítve a program g++ -std=c++23 -Wall -Wextra -Wpedantic beállítással figyelmeztetés nélkül fordult és futott, ASan/UBSan builddel is. A zárójel nélküli assert a fenti makróhibát adta. C++20-ban a kétparaméteres operator[] deklarációja maga hibás (must have exactly one argument), ezért a fallback szükséges.

Az operator[] előfeltétele i < rows_ && j < cols_; megsértése nem definiált viselkedés, ezért nem dob. Az at() ellenőriz, és std::out_of_range-t dob. A konstruktor a rows_ * cols_ szorzatot ellenőrzi, mert e nélkül a későbbi i * cols_ + j túlcsordulhatna. Ellenőrzési feltétel: a m[1, 2] és a m.at(1, 2) értéke egyezzen, a határon kívüli at() dobjon, és a program szaniterrel (például -fsanitize=address,undefined) is futási hibát ne jelezzen.

40.3.6.3 A C++20-as fallback

A feltételes fordfítás kulcsa a __cpp_multidimensional_subscript makró. Ha a makró értéke legalább 202110L, a C++23-as operator[] ág fordul; különben a C++20-as operator() ág. A hívó oldalt ugyanígy kell elágaztatni, vagy egy vékony wrapperrel egységesíteni:

#if CPP23_MULTIDIM_SUBSCRIPT
    m[i, j] = 7;
#else
    m(i, j) = 7;
#endif

A statikus operator[] támogatását külön compile probe-bal kell ellenőrizni, mert a jegyzet nem nevez meg rá önálló makrót. Ellenőrzési feltétel: a C++23-as ág a támogató compileren forduljon és futáskor a helyes elemet írja; a C++20-as ág a régi compileren forduljon; a makró nélküli, véletlenül rossz ágat választó buildet a CI-ben egy negatív teszt fogja meg.

40.3.7 Összefoglaló

A C++23 két, látszólag kicsi operátorbővítése ugyanazt a célt szolgálja: a hívási és indexelési szintaxis közelebb kerüljön ahhoz, amit a programozó gondol. A static operator() eltünteti a felesleges this paramétert az állapot nélküli callable-ökből, és a capture nélküli lambdák operator()-jét is statikussá teszi. A többdimenziós operator[] természetes m[i, j] szintaxist ad, de megváltoztatja a vessző jelentését, ezért a régi kóddal való keveredés külön figyelmet igényel. Mindkét képességnél a bounds-szerződés, a méret-overflow és az ABI-határ a lényeg; a támogatást feature-test makróval és compile probe-bal kell ellenőrizni, a C++20-as fallbacket pedig tudatosan kell megtervezni.