44.3 Elavult, eltávolított és viselkedést váltó elemek C++23-ban

Az alábbi rész abban segít, hogy a C++20-ról C++23-ra váltás ne látens regresszióként, hanem ellenőrzött migrációként történjen. A C++23 háromféle módon érinti a meglévő kódot: egyes elemeket eltávolít, másokat elavultnak jelöl, és van, ahol a forrás változatlan marad, de a szemantika módosul. Az első kettőt a fordító többnyire kiszűri, a harmadikat nem: azt a build- és tesztmátrixnak kell kimutatnia.

A rész a C++23 laborprofilra épül: a laborprofil rögzíti a fordítót, a libstdc++/libc++ kiadást és a -std=c++23 kapcsolót. Az implicit move és a range-for élettartam részletes nyelvi levezetését itt nem ismétlem meg, csak a migrációs következményeit tárgyalom; a szemantikát a korábbi rész tartalmazza. Minden itt szereplő fordítási parancs a tanuló saját laborjában futtatandó; a szöveg nem állítja, hogy bármely eredményt mértem.

44.3.1 A változás három kategóriája és a velük járó kockázat

Eltávolított (removed) elem: a C++23 szövege már nem tartalmazza a deklarációt. A C++23 módban a név nem létezik, ezért a fordítás hard errorral megáll – ez a legkevésbé kockázatos eset, mert nem lehet figyelmen kívül hagyni. Elavult (deprecated) elem: továbbra is a szabvány része, de a jövőbeli eltávolítás jelöltje. Fontos részlet: az elavulás a szabvány nyelvén fogalmazódik meg, nem feltétlenül jár [[deprecated]] attribútummal, így a könyvtári implementáció dönti el, ad-e diagnosztikát, és ezt makróval ki is kapcsolhatja. Ezért az elavult elemekre nem lehet vakon -Werror-t építeni. Viselkedésváltozás (behavior change): a kód fordul mindkét módban, de mást jelent – más overloadot választ a túlterhelés-feloldás, máskor pusztul el egy temporary, más diagnosztikát ad a preprocesszor. Ez az a kategória, ahol a csendes regresszió él, és ahol a kétmódú összehasonlító teszt a legjobb eszköz.

Végül külön kell kezelni a DR-t (defect report): a visszamenőleg is alkalmazható javítást a fordító a korábbi nyelvi módban is implementálhatja, így a „C++20-as” viselkedés nem garantált. Ezért a migrációs mérés mindig a rögzített laborprofilban, dokumentált fordítóverzióval érvényes.

44.3.2 Eltávolított elemek: a garbage collection támogatás (P2186)

A C++ korábbi kiadásai tartalmaztak egy szemétgyűjtés-támogató felületet a <memory> fejlécben: std::declare_reachable, std::undeclare_reachable, std::declare_no_pointers, std::undeclare_no_pointers, valamint a std::pointer_safety típus és a std::get_pointer_safety() függvény. A P2186 alapján ezek kikerültek a szabványból. A felület gyakorlati problémája az volt, hogy nem lehetett hordozhatóan implementálni, és a valós programok nem használták: a declare_reachable lényegében egy „a gyűjtő ne bántsa ezt a pointert” jelzés volt, ami csak együttműködő, úgynevezett conservative gyűjtő mellett értelmezhető.

A tipikus törő minta, amelyet tanítási célból külön jelölök – ez a példa szándékosan nem fordítható C++23 módban:

// tanítási példa: C++23-ban nem fordul
#include <memory>
void f() {
    int* p = new int(42);
    std::declare_reachable(p);   // C++23: a név nem létezik
    // ... munka a pointerrel ...
    std::undeclare_reachable(p);
    delete p;
}

A javítás nem átírás, hanem törlés: a hívások elhagyhatók, a tulajdonlás pedig marad explicit new/delete, vagy – helyesebben – std::unique_ptr. A keresés CI-ban egyszerű szövegkereséssel is elvégezhető a declare_reachable|declare_no_pointers|pointer_safety mintára, de a döntő kapu a C++23-as build maga.

44.3.3 Elavult elemek: aligned_storage, aligned_union és has_denorm

A P1413 a std::aligned_storage és a std::aligned_union típusokat jelöli elavultnak. Ezek soha nem voltak teljes értékű eszközök: egy nyers, igazított byte-tömböt adtak, amelybe placement new-val kellett objektumot tenni, és a típus-punning körüli szabályok (effective type, laundering) miatt könnyű volt velük nem definiált viselkedést előállítani. A javasolt irány az alignas közvetlen használata, illetve a std::optional<T>, ha a cél egy „esetleg létező T”.

A std::numeric_limits<T>::has_denorm elavulása ugyanebbe a családba tartozik: olyan tulajdonságot kérdezett, amely a gyakorlatban vagy mindig jelen volt, vagy a lekérdezés nem adott cselekvésre váltható információt, és a válasz erősen implementációfüggő. Aki mégis a denormális viselkedésre akar építeni, az a denorm_min() és a min() viszonyát vizsgálja, illetve a célplatform dokumentált lebegőpontos modelljére támaszkodik; ezt a laborprofilban kell rögzíteni, mert a szabványos lebegőpontos szemantika nem írja elő a denormálisok hardveres támogatását.

Az aligned_storage kiváltására egy önállóan fordítható példa:

// build: g++ -std=c++23 -Wall -Wextra -Wdeprecated -pedantic -o storage storage.cpp
#include <cstddef>
#include <cstdio>
#include <new>
#include <type_traits>
#include <utility>

template <class T>
class InlineStorage {
public:
    // A sablon ne legyen jelölt egyetlen saját típusú argumentumra: különben
    // egy nem const lvalue-ból a törölt másoló helyett ez nyerne.
    template <class... Args>
        requires (!(sizeof...(Args) == 1 &&
                    (std::is_same_v<std::remove_cvref_t<Args>, InlineStorage> && ...)))
              && std::is_constructible_v<T, Args...>
    explicit InlineStorage(Args&&... args) {
        ::new (static_cast<void*>(buf_)) T(std::forward<Args>(args)...);
    }
    ~InlineStorage() { get()->~T(); }

    // Az implicit másolás/mozgatás a buf_ BÁJTJAIT másolná, nem a T objektumot:
    // a két példány ugyanazt az erőforrást birtokolná (std::string esetén
    // kétszeres felszabadítás). A minimális owner ezért nem másolható.
    InlineStorage(const InlineStorage&) = delete;
    InlineStorage& operator=(const InlineStorage&) = delete;
    T* get() { return std::launder(reinterpret_cast<T*>(buf_)); }
private:
    alignas(T) std::byte buf_[sizeof(T)];
};

struct Point { int x, y; };

int main() {
    InlineStorage<Point> s(1, 2);
    std::printf("%d %d\n", s.get()->x, s.get()->y);
}

Az owner itt az InlineStorage objektum, a tároló a buf_ tomb, a get() nem tulajdonló hozzáférés, az invalidáló művelet pedig a destruktor (utána a pointer dangling). A std::launder azért kell, mert a reinterpret_cast által adott pointer eredetileg std::byte-ra mutatott. Ha nem kell kézi élettartam-kezelés, a std::optional<Point> rövidebb és biztonságosabb.

A sablon szerződése nem csak a triviális Point-ra kell, hogy helyes legyen. Az implicit másoló és mozgató művelet a buf_ tömböt bájtonként másolná, nem a benne élő T objektumot konstruálná. InlineStorage<std::string> esetén a két példány ugyanazt a heap-puffert birtokolná, és a második destruktor kétszeres felszabadítást okozna. A minimális owner ezért nem másolható és nem mozgatható: a másoló műveletek törölve vannak, a mozgatók pedig így nincsenek deklarálva, és a mozgatás is a törölt másolóra esik. (A másik lehetőség a T valódi másoló/mozgató konstrukciója a célpufferben, a megfelelő kivételgaranciával; ez már egy std::optional-szerű típus újraírása.) A variadikus konstruktor-sablont korlátozni kell, különben egy nem const lvalue-ból történő direkt inicializálásnál (InlineStorage<std::string> b(a);) jobb jelölt lenne a törölt másolónál.

Buildjegyzőkönyv (lab-gcc profil, g++ -std=c++23 -Wall -Wextra -Wdeprecated -pedantic). A program figyelmeztetés nélkül fordult, kimenete 1 2. Az InlineStorage<std::string> b = a; és a b(a) alak, std::any elemtípussal is, use of deleted function hibát ad. Egy std::string-et tároló példány és egy élő objektumokat számláló típus ASan/UBSan alatt hibátlanul fut, a számláló a végén 0. A javítás előtti változatban az InlineStorage<std::string> másolata ASan alatt attempting double-free hibát adott.

44.3.4 Viselkedést váltó elemek: implicit move és range-for

Az implicit move C++23-as egyszerűsítése (P2266, a Érték, élettartam és kifejezések: auto(x), implicit move és range-for (40.5) részben tárgyalt módon) migrációs szempontból elsősorban fordíthatósági változás: a return utasításban megnevezett implicit mozgatható entitás xvalue lesz, ezért az int&& fwd(int&& x) { return x; } C++23-ban fordul, a lokálisra lvalue-referenciát visszaadó kód pedig nem. Az érték szerinti paraméter visszaadása viszont nem változik: az implicit move C++11 óta kiterjed rá. Ezt is érdemes kétmódú teszttel rögzíteni, mert a „C++23-ban más konstruktor fut” feltételezés gyakori tévedés:

// passthrough.cpp — build: g++ -std=c++20 ... ; majd ugyanez -std=c++23 ...
#include <cstdio>
struct S {
    int copies = 0, moves = 0;
    S() = default;
    S(const S& o) : copies(o.copies + 1), moves(o.moves) {}
    S(S&& o) : copies(o.copies), moves(o.moves + 1) {}
};
S passthrough(S s) { return s; }   // érték szerinti paraméter: implicit move (C++11 óta)
int main() {
    S b = passthrough(S{});        // az argumentum inicializálása elided
    std::printf("copies=%d moves=%d\n", b.copies, b.moves);
}

Buildjegyzőkönyv (lab-gcc profil). A kimenet -std=c++11, -std=c++17, -std=c++20 és -std=c++23 módban egyaránt copies=0 moves=1: a paraméter mindegyik módban mozog, másolás egyikben sincs. A mérési eljárás általános: azonos forrás, két -std mód; ha a kimenet eltér, a különbséget dokumentáljuk. A valódi C++23-as törést (fordul / nem fordul) a Érték, élettartam és kifejezések: auto(x), implicit move és range-for (40.5) mutatja be.

A range-for élettartam-szigorítása (C++23) a for (auto e : kif().tag()) alakot érinti: korábban csak a legfelső temporary élettartama hosszabbodott meg, a köztes temporary a deklaráció végén elpusztult, és a ciklusmag dangling referencián futott – azaz nem definiált viselkedés. C++23-ban a range-init minden temporaryje a ciklus végéig él. Csendes változás: a korábban UB-t produkáló kód most definiáltan működik, és a destruktorok sorrendje is megváltozik. Migrációs szabály: a korábbi „véletlenül működött” kódot nem szabad megtartani mintaértékűnek; a helyes kód tulajdonost és élettartamot nevez meg, és nem épít a temporary pusztulásának pillanatára.

44.3.5 Vegyes szélességű string literal összefűzés

A C++23 szigorítása miatt a különböző típusú string literalek összefűzése – például L"id: " "42" – ill-formed lett; korábban feltételesen támogatott, implementációfüggő jelentésű konstrukció volt. Ez jó hír: fordítási hiba, nem csendes viselkedésváltozás. A tipikus örökség a _T()/TCHAR makróvilág és a kézzel vegyített char/wchar_t üzenetek. A javítás: egységes literalprefix (a forrás és a feldolgozó lánc egy kódolásán), vagy explicit konverziós függvény a határon; a u8 prefixet használó kódnál figyelni kell, hogy a u8 literal típusa char8_t tömb, nem char.

44.3.6 Migrációs ellenőrzőlista

Ellenőrzés Keresési minta Eszköz Következmény, ha talál
GC-felület declare_reachable, declare_no_pointers, pointer_safety C++23 build, grep hard error; hívások törlése
Igazított tároló aligned_storage, aligned_union C++23 build + -Wdeprecated elavulási diagnosztika; alignas/optional
Denormális lekérdezés has_denorm, denorm_style grep + -Wdeprecated elavulás; denorm_min()/platformdok.
Visszaadott paraméter return + by-value paraméter kétmódú teszt másolás→mozgatás; dokumentált eltérés
Range-init temporary for (... : kif().tag()) kétmódú teszt, TSan UB megszűnése, destruktorsorrend
Literál-összefűzés L"/u"/u8" + " C++23 build hard error; egységes prefix

A CI-átállítás fokozatokban történjen. 1. fokozat: minden target fordul -std=c++20 és -std=c++23 módban is, de a C++20-as build marad a kötelező kapu, a C++23-as csak jelző (warning, nem hiba). 2. fokozat: az új kódra és az új targetekre -Werror=deprecated-declarations; az örökölt kód külön targeten, target-szintű target_compile_options-szal kapja a szigorítást, hogy a figyelmeztetés ne globális -Wno-...-tal tűnjön el. 3. fokozat: a kétmódú tesztek kimenet-összehasonlítása, eltérés esetén dokumentált döntés; majd a C++23 lesz az alapértelmezett, a C++20 pedig ideiglenes visszaállási út. Az elavulási figyelmeztetést soha ne kapcsold ki projekt-szinten: a lokális, indokolt elnyomás (egy blokkra szűkített diagnosztika) megőrzi a jelzést a következő migrációs hullámra.

44.3.7 Tipikus hibák

44.3.8 Feladatok

  1. Osztályozó feladat. Az alábbi négy állítás mindegyikéről döntsd el, hogy eltávolított, elavult vagy viselkedésváltozó kategória-e, és indokold a CI-következményt: (a) std::declare_reachable, (b) std::aligned_storage, (c) return param; egy by-value paraméternél, (d) L"a" "b".
  2. Kódolási feladat. Írj egy Slot<T> osztályt, amely aligned_storage nélkül, alignas(T) std::byte pufferen tárol egy opcionálisan létező T-t. Készíts emplace, has_value, reset műveletet, és dokumentáld az owner–tároló–nézet viszonyt, valamint az invalidáló műveleteket.
  3. Diagnosztikai feladat. Készíts kétmódú tesztet a passthrough(S{}) példára, és egészítsd ki a CI-t úgy, hogy az eltérés ne bukás, hanem dokumentált riport legyen. Add meg az ellenőrzési feltételt is.

44.3.9 Megoldások

1. megoldás. (a) eltávolított – a C++23-as build hard errorral jelzi, ezért a legelső fokozatban javítandó; (b) elavult – a kód továbbra is fordul, de a diagnosztika implementációfüggő, ezért feature-/compile-probe és -Wdeprecated kell, nem elég a build; (c) viselkedésváltozó – mindkét módban fordul, csak a választott konstruktor más, ezért kétmódú futásidejű összehasonlítás szükséges; (d) ill-formed lett – azaz az eltávolításhoz hasonlóan hard error, de nem könyvtári, hanem nyelvi szigorítás; a keresés a vegyes literalprefixekre irányul. Indoklás: a kategória határozza meg a kapu típusát (build, diagnosztika, futásidejű diff), és ezt a CI-fokozatokhoz kell rendelni.

2. megoldás. A váz:

// build: g++ -std=c++23 -Wall -Wextra -pedantic -o slot slot.cpp
#include <cstddef>
#include <new>
#include <utility>

template <class T>
class Slot {
public:
    Slot() = default;
    ~Slot() { reset(); }
    Slot(const Slot&) = delete;
    Slot& operator=(const Slot&) = delete;

    template <class... Args>
    T& emplace(Args&&... args) {
        reset();
        ::new (static_cast<void*>(buf_)) T(std::forward<Args>(args)...);
        live_ = true;
        return *get();
    }
    bool has_value() const noexcept { return live_; }
    void reset() noexcept {
        if (live_) { get()->~T(); live_ = false; }
    }
    T* get() { return std::launder(reinterpret_cast<T*>(buf_)); }
private:
    alignas(T) std::byte buf_[sizeof(T)];
    bool live_ = false;
};

Owner a Slot, tároló a buf_ és a live_ jelző, nézet a get() pointer. Invalidál: reset() és a destruktor; emplace() előbb resetel, ezért a korábbi pointer érvénytelenné válik. Ellenőrzési feltétel: nem hívunk destruktort nem létező objektumra, és get() csak has_value() mellett dereferálható. Megjegyzés: a T legyen legalább move-konstruálható, és a Slot maga nem másolható, mert a belső pointer nem vihető át triviálisan.

3. megoldás. A teszt a fenti passthrough program két buildje; a CI-ben két CTest-tesz, probe_cxx20 és probe_cxx23, mindkettő saját elvárt kimenettel, és egy harmadik, probe_diff, amely a kettő eltérését riportfájlba írja, de nem bukik el. Ellenőrzési feltétel: a riport tartalmazza a fordítóverziót, a -std kapcsolót és a két kimenetet; a kapu csak akkor szigorodik hibára, ha a laborprofilban dokumentáltuk, hogy a vizsgált elemre nincs visszamenőleges DR-alkalmazás. Így a migráció akkor is nyomon követhető, ha a fordító frissítése megváltoztatja a viselkedést.

44.3.10 Összefoglaló

A C++23 migráció három különböző kaput igényel: a hard errorokat a C++23-as build adja, az elavulásokat csak compile probe és implementációfüggő diagnosztika fogja meg, a viselkedésváltozásokat pedig kizárólag kétmódú, összehasonlító teszt. A garbage collection felület eltűnt, az aligned_storage/aligned_union és a has_denorm elavult, az implicit move és a range-for temporary élettartama csendesen változik, a vegyes literal-összefűzés pedig fordítási hibává vált. A fokozatos CI-átállítás kulcsa, hogy a figyelmeztetés ne tűnjön el globálisan, és hogy minden eltérés dokumentált, verzióhoz kötött megfigyelés legyen – ne feltevés.