19.4 auto, decltype és típuslevezetés

Ez az alfejezet a C-ből hozott deklarációs rutinra épül: ott a típust általában mi írtuk ki, itt viszont a fordító is megállapíthatja. Ez nem kényelmi „var”-szerűség, hanem két, pontosan definiált levezetési szabályrendszer: az auto a sablonargumentum-levezetést használja, a decltype pedig a deklarált típust vagy a kifejezés értékkategóriáját. A különbségtétel nem akadémikus: a rosszul megválasztott alak néma másolatokhoz, elveszett const-hoz vagy érvénytelen élettartamú referenciához vezet. A referenciák és a const C++-beli szemantikáját itt előfeltételként használjuk, részletesen lásd Reference, pointer és const C++-ban; a deklarációs szintaxis C-s alapjait Deklarációk, typedef és típusminősítők.

19.4.1 Az auto levezetése

Definíció. Az auto helyőrzővel írt deklarációban a fordító a deklarált típusból egy „mintát” képez, és a kezdőértékből a sablonargumentum-levezetés szabályai szerint tölti ki. A minta alakja dönti el, hogy a kezdőérték referenciája és minősítői megmaradnak-e: ha a mintában szerepel &, && vagy const, akkor az adott tulajdonság megjelenik a levezetett típusban, ha nem, akkor elveszik. Ez a „template deduction for dummies” elv: auto-t helyettesítve egy template<class T> void f(T) paraméterébe ugyanazt kapjuk.

Következmény, amit a legtöbb kezdő elront: a csupasz auto soha nem lesz referencia, és a kezdőérték top-level const/volatile minősítője elvész. A referencia és a const megőrzéséhez a deklarációban kell jelezni.

deklaráció kezdőérték levezetett típus
auto a = i; int i int
auto b = ci; const int ci int
auto c = ri; int& ri int
auto& d = ci; const int ci const int&
auto&& e = ci; const int ci const int&
auto&& f = 42; prvalue int int&&
const auto& g = i; int i const int&
auto* h = &i; int i int*

Az auto&& alak külön figyelmet érdemel: lvalue kezdőértéknél lvalue referenciát, rvalue-nál rvalue referenciát ad (forwarding reference), ezért gyűjtőnévként használható ott, ahol nem akarunk dönteni a két eset között.

Kidolgozott példa. A következő program minden állítását static_assert-tel ellenőrzi, így a levezetett típus nem „szemre”, hanem fordítási időben ellenőrizhető. A kód C++20-at igényel, és a <type_traits> fejlécet használja.

#include <type_traits>

int main() {
    int i = 0;
    const int ci = 1;
    int& ri = i;
    const int& cri = ci;

    auto a  = ci;        // int: a const elvész
    auto b  = ri;        // int: referencia -> érték, másolat
    auto& c = ri;        // int&: a tényleges i-re hivatkozik
    auto& d = ci;        // const int&: const megmarad
    auto* p = &ci;       // const int*
    auto&& e = ci;       // const int&

    static_assert(std::is_same_v<decltype(a), int>);
    static_assert(std::is_same_v<decltype(b), int>);
    static_assert(std::is_same_v<decltype(c), int&>);
    static_assert(std::is_same_v<decltype(d), const int&>);
    static_assert(std::is_same_v<decltype(p), const int*>);
    static_assert(std::is_same_v<decltype(e), const int&>);
    static_assert(std::is_same_v<decltype(cri), const int&>);
}

Fordítás és ellenőrzés (a tanuló saját laborjában; itt nem futtattuk):

g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o deduce deduce.cpp && ./deduce
# vagy
clang++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o deduce deduce.cpp && ./deduce

Ha bármelyik static_assert hamis, a program nem fordul le: ez erősebb ellenőrzés annál, mintha a futáskor nyomtatnánk típusneveket. Megjegyzendő korlát: a decltype itt deklarált típusra kérdez rá, ezért a static_assert a deklaráció helyességét igazolja, nem azt, hogy a mögöttes futásidejű érték mit tartalmaz.

Két további, gyakran előforduló eset. Az auto x = {1, 2, 3}; alakban a kezdőérték std::initializer_list<int>, nem int és nem tömb; ez a auto kivételes szabálya. Függvény visszatérési típusaként az auto minden return utasításból ugyanazt a típust köteles levezetni, és braced-init-listből nem lehet visszatérési típust levezetni.

19.4.2 decltype: név és kifejezés

Definíció. A decltype(e) a zárójelben álló kifejezés típusát adja vissza, de a kifejezést nem értékeli ki (unevaluated operand). A szabály kétágú:

  1. Ha e zárójel nélküli azonosító-kifejezés (változó, paraméter, structured binding tag), akkor az eredmény a deklarált típus, a referencia és a const megtartásával.
  2. Minden más esetben az értékkategória dönt: lvalue → T&, xvalue → T&&, prvalue → T.

A zárójel tehát nem kozmetika. decltype(i) az int, decltype((i)) viszont int&, mert a zárójelezett alak nem azonosító-kifejezés, hanem lvalue kifejezés.

int i = 0;
const int ci = 1;

decltype(i)      a = i;    // int
decltype((i))    b = i;    // int&   <- a plusz zárójel számít
decltype(ci)     c = ci;   // const int
decltype((ci))   d = ci;   // const int&
decltype(i + 1)  e = 0;    // int (prvalue)
decltype(i = 1)  f = i;    // int&  (az értékadás lvalue)

Ez a szabály teszi lehetővé, hogy generikus kódban a visszatérési típus tökéletesen kövesse a paramétert, ugyanakkor könnyű vele meglepni magunkat: egy makróba tett decltype((x)) hirtelen referenciát ad, ahol értéket vártunk.

19.4.3 decltype(auto) és a visszatérés csapdái

Definíció. A decltype(auto) helyőrző a kezdőértékre (vagy a return operandusára) a decltype szabályait alkalmazza az auto szabályai helyett. Célja a tökéletes visszaadás: referenciát adó kifejezésből referenciát, értékből értéket csinál.

A veszély pontosan itt van: a decltype(auto) nem nyújtja meg a lokális változó élettartamát, csak a típust követi.

// TANÍTÁSI PÉLDA – nem definiált viselkedés, nincs garantált kimenet
decltype(auto) bad() {
    int local = 42;
    return (local);        // (local) lvalue -> int&, a local kilépéskor megszűnik
}

// Helyes változat: érték visszaadása
int good() {
    int local = 42;
    return local;          // int, másolat
}

// decltype(auto)-val is helyes, mert itt név áll zárójel nélkül
decltype(auto) also_good() {
    int local = 42;
    return local;          // decltype(local) == int
}

A bad() eredményét használó hívó kód érvénytelen élettartamú objektumra hivatkozik; ez a dangling esete, és UB, nem „véletlen szemét”, amit meg lehet figyelni. A javítás mindig szerződés kérdése: ha a függvény referenciát ad vissza, annak a hívó élettartamához kell kötnie, nem a függvény lokálisához.

19.4.4 Structured binding és a rejtett backing object

Definíció. Az auto [a, b] = expr; deklaráció egy rejtett változót (e, a backing object) hoz létre expr-ből, majd az a, b neveket az e részeihez köti (tömb esetén elemekhez, std::tuple-szerű típusnál get<> hívás eredményéhez, osztálynál nyilvános tagokhoz). Az a, b nem önálló objektumok, hanem az e alterületeit jelölő nevek; típusukat az e típusa és a hozzáférés módja határozza meg.

Ebből következik a gyakorlati szabály, két külön kérdéssel. (1) Mi az e? Ha auto-t írunk, az e a forrás másolata; ha auto& vagy const auto& áll, az e referencia, és a nevek az eredeti objektumot aliasolják. (2) Mit tartalmaz az e? A másolat csak a forrás értéktagjait választja le. Ha a forrás referenciát vagy pointert tárol — std::tie(a, b) eredménye, std::tuple<int&>, pointertag —, akkor a másolat is ugyanarra az objektumra hivatkozik, és a neveken át végzett írás a külső objektumot módosítja. Az auto tehát csak akkor „választja le a forrást”, ha annak minden tagja érték, mint a lenti std::pair<const std::string, int> esetén.

// binding_alias.cpp — C++20: auto [..] másolata is aliasolhat
#include <cassert>
#include <tuple>

int main() {
    int a = 1, b = 2;
    auto [x, y] = std::tie(a, b);   // e: tuple<int&, int&> másolat
    x = 10;                         // a másolt tuple referenciáján át a-t írja
    assert(a == 10);

    std::tuple<int&> t{b};
    auto [r] = t;                   // e: tuple<int&> másolat, r mégis b-re hivatkozik
    r = 20;
    assert(b == 20);

    struct P { int v; int* p; };
    P src{1, &a};
    auto [v, p] = src;              // e: P másolat; v független, p ugyanoda mutat
    v = 5; *p = 30;
    assert(src.v == 1 && a == 30);
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic binding_alias.cpp figyelmeztetés nélkül fordul, és a futás egyik assert-et sem sérti: az x, r, *p írása a külső a, b, a objektumot változtatta, a v írása nem hatott a src.v-re.

#include <map>
#include <string>

void demo(std::map<std::string, int>& m) {
    auto [k, v] = *m.begin();       // e: másolat a pair-ről
    v = 99;                         // csak a másolatot módosítja, m változatlan

    auto& [k2, v2] = *m.begin();    // e: referencia a map elemére
    v2 = 99;                        // most már a tároló eleme változik

    for (const auto& [key, value] : m) {
        (void)key; (void)value;     // olvasás, másolás nélkül
    }
}

A háttérben álló objektum élettartama a deklaráció hatóköre: ha expr ideiglenes volt, és referenciát kötünk hozzá a deklarációban, a nyelv megnyújtja az e élettartamát; ha viszont az expr egy függvényhívás eredményeként referenciát ad vissza egy már megszűnt objektumra, a binding ezt nem tudja megjavítani.

19.4.5 Range-for: másolat és referencia

A range-for hátterében a szabvány egy auto&& kötést készít a tartományra, majd iterátorokkal járja be. A ciklusváltozó deklarációja ezért ugyanazt a döntést kéri, mint az auto:

#include <string>
#include <vector>

struct Metric { std::string name; long value; };

void bump(std::vector<Metric>& v) {
    for (auto m : v) { ++m.value; }        // HIBA: a másolatot növeli
}

void bump_fixed(std::vector<Metric>& v) {
    for (auto& m : v) { ++m.value; }       // helyes: a tényleges elem
}

long total(const std::vector<Metric>& v) {
    long sum = 0;
    for (const auto& m : v) { sum += m.value; }  // olvasás, másolás nélkül
    return sum;
}

A for (auto m : v) alak helyes, ha csak olvasunk és az elem kicsi, de minden iterációnál másol; nagy std::string-et tartalmazó struktúránál ez mérhető költség. A for (auto& m : v) nem másol, viszont konstans tartományon nem fordul le, ezért olvasáshoz a const auto& a helyes választás. Élettartam szempontjából a ciklus saját maga által létrehozott ideiglenes tartományát a nyelv a ciklus végéig életben tartja (C++20-ban így definiált); a C++23 ezt a szabályt tovább pontosítja a range-init kifejezésben keletkező ideiglenesekre, ennek részletei verziófüggők, és itt nem tárgyaljuk őket. Az viszont nem megengedett következtetés, hogy egy függvényből visszakapott referencia automatikusan megnyúlik: const auto& r = makeVector()[0]; után r érvénytelen, mert az ideiglenes vektor a teljes kifejezés végén megszűnik.

19.4.6 Alias using és olvasható típusjelölések

A using kulcsszóval típusálneveket vezethetünk be; ez a typedef modern megfelelője, és sablonoknál az egyetlen olvasható alak. A using nem hoz létre új típust, csak nevet ad egy létezőnek, ezért a levezetés szempontjából átlátszó.

#include <cstddef>
#include <vector>

using Size = std::size_t;
using Callback = void (*)(int);                  // függvénypointer alias
template<class T> using Vec = std::vector<T>;    // sablon-alias

Size count(const Vec<int>& v);                   // olvashatóbb, mint std::size_t

A using és az auto együtt különösen hasznos hosszú visszatérési típusoknál, illetve ott, ahol a típusnév a fejlécben zajt jelent. A fejlécben továbbra is érdemes kiírni a típust vagy aliast használni, mert az auto a deklaráció helyén derül ki, és a fejlécet olvasó számára nem látszik.

19.4.7 Tipikus hibák és javításuk

19.4.8 Összefoglaló

Az auto a sablonargumentum-levezetés szabályait követi: csupasz alakban elveszti a referenciát és a top-level const-ot, ezért a szándékot a mintában (&, const&, &&) kell kifejezni. A decltype zárójel nélküli névre a deklarált típust adja, zárójelezett kifejezésre az értékkategóriából következtet, és nem értékeli ki az operandust. A decltype(auto) a decltype szabályait viszi a visszatérési típusba, ami hasznos forwardingnál, de nem nyújtja meg a lokális élettartamát. A structured binding mögött rejtett backing object áll, a range-for ciklusváltozója pedig ugyanazt a másolat-referencia döntést igényli. A pontos típus ellenőrzésének olcsó eszköze a static_assert(std::is_same_v<...>).

A szabvány pontos paragrafusaira ebben az egységben nem hivatkozunk ellenőrzött forrásként: a megadott forrásjegyzet ehhez a részhez nem tartalmaz tételt, ezért a fenti állítások a bemutatott kód és a levezetési szabályok szintjén értelmezendők, nem hivatalos szöveghely-igazolással.

19.4.9 Feladatok

  1. Típusmegállapítás (fogalmi). Adott: int i = 0; const int ci = 1; int& ri = i; const int& cri = ci;. Határozza meg a következő deklarációk levezetett típusát, és írjon mindegyikhez static_assert-et: auto a = ci;, auto& b = ci;, auto&& c = ci;, auto&& d = 42;, decltype(ri), decltype((ri)), decltype(ci + 1).
  2. Kódolási. A bump és total függvényeket tartalmazó példát alakítsa át úgy, hogy sehol ne keletkezzen felesleges másolat, a módosító változat tényleg módosítson, és a const tartományon is forduljon le. Indokolja minden auto-alak választását.
  3. Diagnosztikai. Az alábbi függvény fordító által elfogadott, de hibás. Mutassa meg, miért, adja meg a javított változatot, és mondja meg, milyen eszközzel lehetne a hibát futás előtt elkapni:
decltype(auto) firstPositive(const std::vector<int>& v) {
    for (const auto& x : v)
        if (x > 0) return (x);
    return 0;
}
  1. Tervezési (alias). Vezessen be using aliasokat egy olyan fejlécben, amely std::map<std::string, std::vector<int>> típust használ paraméterként és visszatérési típusként, és indokolja, miért nem auto-t ír a fejlécbe.

19.4.10 Megoldások

1. megoldás. a típusa int (a const elvész, mert a mintában nincs const). b típusa const int& (a auto& megőrzi a const-ot). c típusa const int&: az auto&& lvalue kezdőértéknél lvalue referenciát ad, a const megmarad. d típusa int&&, mert prvalue-ból rvalue referencia lesz. decltype(ri) az int& (deklarált típus), decltype((ri)) szintén int&, mert a zárójelezett név lvalue; a két alak itt egybeesik, de nem minden esetben. decltype(ci + 1) int, mert az összeg prvalue. Ellenőrzési feltétel: a static_assert-ek forduljanak le -std=c++20 módban, és a program ne tartalmazzon futásidejű tesztet, mert a kérdés tisztán típusszintű.

2. megoldás. A módosító változat for (auto& m : v), mert a ciklusváltozónak a tároló elemét kell aliasolnia. Az összesítő változat for (const auto& m : v), mert csak olvasunk, így nincs másolat és a const tartomány is elfogadott. Ha a Metric nagyon kicsi és olcsón másolható (például két int), a for (auto m : v) is védhető, de ezt mérés vagy dokumentált költségmodell nélkül nem állítjuk. Ellenőrzési feltétel: a módosító függvény után a tároló elemei tényleg megváltoztak, az összesítő pedig const std::vector<Metric>& paraméterrel is fordul.

3. megoldás. A return (x); zárójelezett lvalue kifejezés, ezért a decltype(auto) visszatérési típusa const int&. A x viszont a ciklusváltozó, amelynek élettartama a ciklus végén lejár, tehát a visszaadott referencia dangling; a hívó oldali használat UB. A return 0; ág ezzel szemben int-et adna, ami a két ág eltérő visszatérési típusa miatt önmagában is problémás. Javítás: adjuk vissza az értéket, például std::optional<int> firstPositive(const std::vector<int>& v) alakban, amely a „nincs találat” esetet is kifejezi, vagy int visszatéréssel és külön találati logikával. Fordítási időben a hiba nehezen fogható meg, mert a típus legális; a gyakorlati védelem a -Wall -Wextra figyelmeztetés, a code review és a statikus elemzés, futásidőben pedig az AddressSanitizer jelzi a stack-use-after-scope hibát. Ez utóbbi eszköz viselkedése verzió- és beállításfüggő, ezért konkrét kimenetet itt nem állítunk.

4. megoldás. Például:

#include <map>
#include <string>
#include <vector>

using IntVec = std::vector<int>;
using Table = std::map<std::string, IntVec>;

Table load(const Table& input);

A fejlécben azért nem auto, mert a visszatérési típus a deklaráció helyén derül ki, a fejlécet olvasónak viszont a szerződést kell látnia, és a fordítónak is stabil szignatúrára van szüksége a külön fordított egységekhez. Az alias nem új típus, ezért az IntVec és a std::vector<int> felcserélhető, a levezetést nem befolyásolja. Ellenőrzési feltétel: a fejléc önállóan include-olható, és a fordítása -std=c++20 módban figyelmeztetés nélkül sikerül.