30.6 Ranges- és callback-élettartam labor

A labor két, a gyakorlatban gyakran összekapcsolódó szerződést vizsgál: a lazy ranges pipeline és a callable-ek élettartamát. A cél nem az, hogy a lazy view-t automatikusan biztonságosnak vagy cache-eltnek tekintsük, hanem hogy minden példában megnevezzük a tulajdonost, a view élettartamhatárát, az invalidáló műveletet és a hiba megjelenési formáját. Az előfeltételek: a borrowed range és a dangling részletes tárgyalása a Borrowed range, dangling és iterator lifetime (30.5) részben, a callable és type erasure alapjai az invoke, function wrapper és type erasure (30.2) részben, az ASan és társai az ASan, UBSan, MSan, TSan és Valgrind (16.2) részben találhatók. A fejezet a C++20 alapra épít; a C++23-as lehetőségeket külön jelöljük, és a választott compiler/library feature-testjével kell ellenőrizni őket. A kódokra a fejezet nem állítja, hogy lefuttattuk őket: a várt kimenet, a mérési terv és az ASan-lelet a tanuló laborjához tartozik.

30.6.1 Fogalmak és szerződések

A lazy view olyan range, amely nem tárolja az elemeit, hanem a mögöttes range-ből számítja ki őket; a begin() és end() által visszaadott iterátorok az inputtól függenek. A filter_view és a transform_view C++20-ban nem tulajdonolják a mögöttes tartományt, kivéve, ha az egy owning_view. A std::views::all lvalue range-re ref_view-ot, rvalue range-re owning_view-ot ad; a ref_view nem tartja életben a mögöttes objektumot, az owning_view igen.

A borrowed_range olyan range, amelynek iterátorai a range objektum élettartamától függetlenül érvényesek maradhatnak. A std::string_view és a std::span jellemzően borrowed range, a std::vector nem. Ez azonban nem azonos a tulajdonlással: a std::vector nem borrowed range, mégis tulajdonol, ezért a belőle materializált kimenet biztonságos. A dangling olyan pointer, referencia vagy view, amelynek hivatkozott objektuma már nem él; a használata undefined behavior (UB).

A materialization (anyagiasítás) a lazy view elemeinek kiértékelése és egy tulajdonló konténerbe másolása vagy mozgatása. A callable (hívható objektum) std::invoke-bal hívható dolog: lambda closure, függvénypointer, tagfüggvénypointer vagy függvényobjektum. A closure copyable vagy move-only volta a capture-öktől függ; a std::unique_ptr capture move-only closure-t eredményez. A capture (befogás) lehet érték szerinti, referencia szerinti vagy init-capture; referencia szerint nem hosszabbítja meg a temporary élettartamát.

A labor központi szerződése táblázatban:

Objektum Tulajdonol? Élettartam Invalidálja
std::vector<LogRecord> logs igen a logs scope-ja megszűnés, reallokáció
error_view(logs) nem a logs-hoz kötött a logs megszűnése vagy módosítása
materialize_errors(logs) visszatérési értéke igen a visszaadott vector a visszaadott vector megszűnése
std::string_view a logs elemére nem a logs elemhez kötött az elem megszűnése vagy módosítása

A szerződés kimondása önmagában nem véd meg: a [[nodiscard]] és a const nem akadályozza meg a danglinget. A static_assert a std::ranges::borrowed_range concepttel dokumentálhatja, hogy egy visszaadott view nem borrowed range, de a helyes használat továbbra is a hívó felelőssége.

30.6.2 Logrekord pipeline

A példa egy naplófájl-szerű rekordhalmazt szűr és vetít. A LogRecord tulajdonosa a std::vector; a pipeline a vektorra hivatkozik. A filter predikátuma a rekord szintjét vizsgálja, a transform a message mezőre vetít. A vetítés a ranges algoritmusok projection paraméterének mintájára működik: a std::ranges::sort és a std::ranges::find_if is kaphat tagfüggvény-pointert vagy lambdát.

#include <algorithm>
#include <chrono>
#include <iostream>
#include <iterator>
#include <ranges>
#include <string>
#include <vector>

struct LogRecord {
    std::chrono::system_clock::time_point ts;
    std::string level;
    std::string message;
};

std::vector<LogRecord> load_demo_logs() {
    using namespace std::chrono_literals;
    const auto base = std::chrono::system_clock::now();
    return {
        {base + 1s, "INFO",  "service started"},
        {base + 2s, "ERROR", "connection refused"},
        {base + 3s, "WARN",  "retrying"},
        {base + 4s, "ERROR", "timeout"},
    };
}

std::vector<std::string> materialize_errors(const std::vector<LogRecord>& logs) {
    auto view = logs
        | std::views::filter([](const LogRecord& r) { return r.level == "ERROR"; })
        | std::views::transform([](const LogRecord& r) -> const std::string& {
              return r.message;
          });

    std::vector<std::string> out;
    out.reserve(logs.size());
    std::ranges::copy(view, std::back_inserter(out));
    return out;
}

auto error_view(const std::vector<LogRecord>& logs) {
    return logs
        | std::views::filter([](const LogRecord& r) { return r.level == "ERROR"; })
        | std::views::transform([](const LogRecord& r) -> const std::string& {
              return r.message;
          });
}

int main() {
    auto logs = load_demo_logs();

    for (const auto& msg : materialize_errors(logs)) {
        std::cout << "materialized: " << msg << '\n';
    }

    auto view = error_view(logs);
    for (const std::string& msg : view) {
        std::cout << "lazy: " << msg << '\n';
    }
}

A materialize_errors visszatérési értéke tulajdonol: a std::string elemek másolatai a logs-tól függetlenül élnek. Az error_view ezzel szemben nem tulajdonol: a visszaadott view a logs-ra hivatkozik, ezért a hívónak a view használatáig életben kell tartania a vektort. A std::ranges::copy azért kell, mert a filter_view begin() és end() típusa eltérhet (a sentinel std::default_sentinel_t lehet), a C++20-as std::vector(first, last) pedig azonos iterator típust vár. A C++23-as std::ranges::to és a std::vector range-konstruktora ezt kényelmesebben kezelné, de ezek C++23-as lehetőségek.

A buildparancs a laborban például:

g++ -std=c++20 -O2 -Wall -Wextra -Wpedantic -g loglab.cpp -o loglab
./loglab

A várt kimenet az ERROR szintű üzeneteket tartalmazza, de ezt a fejezet nem mérési eredményként, hanem elvárásként rögzíti. A sort projection használata külön ellenőrizhető:

std::ranges::sort(logs, std::ranges::less{}, &LogRecord::level);
auto it = std::ranges::find_if(
    logs,
    [](const std::string& level) { return level == "ERROR"; },
    &LogRecord::level);

A projection itt a &LogRecord::level; a find_if predikátuma már a vetített értéket kapja.

30.6.3 Temporary- és capture-hibák izolált negatív példaként

A következő két példa szándékosan hibás; a fejezet nem futtatja őket, és a tanuló laborjában is csak izolált, jelölt kísérletként használhatók.

// NEGATÍV PÉLDA: dangling view lokális vectorra. Ne futtasd!
auto bad_error_view() {
    auto logs = load_demo_logs();
    return logs
        | std::views::filter([](const LogRecord& r) { return r.level == "ERROR"; })
        | std::views::transform([](const LogRecord& r) -> const std::string& {
              return r.message;
          });
}

A logs lokális std::vector, a views::all lvalue-ra ref_view-ot ad, a filter_view és a transform_view ezt tárolja. A függvényből való kilépéskor a logs megszűnik; a visszaadott view dangling. A javítás lehet a std::move(logs) használata a pipe-ban, ami C++20-ban owning_view-ot eredményez, vagy a materialization, vagy a vector kívülről való átadása.

// NEGATÍV PÉLDA: temporaryre mutató referencia capture. Ne futtasd!
auto make_level_filter(const std::string& level) {
    return [&level](const LogRecord& r) { return r.level == level; };
}

auto f = make_level_filter("ERROR"); // a temporary a kifejezés végén megszűnik

A "ERROR"-ból ideiglenes std::string keletkezik, a const std::string& ehhez kötődik, a closure pedig referenciát capture-öl. A temporary a teljes kifejezés végén megszűnik, így f hívásakor a level dangling. A helyes változat érték szerint capture-öl:

auto make_level_filter(std::string level) {
    return [level = std::move(level)](const LogRecord& r) {
        return r.level == level;
    };
}

A closure a saját std::string másolatát tartalmazza, ezért a temporary megszűnése után is biztonságos, és a closure copyable marad. A hiba nyelvi oka a referencia élettartam-szerződés megsértése; az ASan a stack-use-after-scope vagy heap-use-after-free leletet adhatja, de a lelet típusa implementáció- és allokációfüggő, és a hiánya nem bizonyíték a helyességre.

30.6.4 Copy/move-only callable tárolása

A C++20-as std::function copy-constructible callable-t vár. Egy std::unique_ptr-t capture-ölő lambda move-only closure, ezért nem tehető std::function-ba:

#include <functional>
#include <memory>

auto make_counting_predicate() {
    auto count = std::make_unique<std::size_t>(0);
    return [count = std::move(count)](const LogRecord& r) {
        ++*count;
        return r.level == "ERROR";
    };
}

// C++20: std::function<bool(const LogRecord&)> f = make_counting_predicate(); // hiba

A std::function példányosítása static_asserttel utasítja el a nem copy-constructible callable-t; a pontos üzenet implementációfüggő. C++20-ban két egyszerű megoldás van. Az egyik a template paraméter, amely nem tárolja el a callable-t, hanem átveszi:

template <class Pred>
std::vector<LogRecord> filter_logs(const std::vector<LogRecord>& logs, Pred pred) {
    std::vector<LogRecord> out;
    for (const auto& r : logs) {
        if (pred(r)) out.push_back(r);
    }
    return out;
}

A hívás filter_logs(logs, std::move(pred)) formában move-only predikátummal is működik, mert a paraméter érték szerinti, és a függvényen belül nem kell másolni. A másik megoldás egy saját, move-only type-erasure wrapper std::unique_ptr<Concept>-tel; ez C++20-ban is megírható, de több kódot igényel. C++23-ban a std::move_only_function erre kész megoldás, használatát azonban külön kell jelölni és a library supporttal ellenőrizni. A ranges adaptorok callable-követelménye adaptoronként és verziónként eltér, ezért nem általánosítható:

A filter-hez ezért másolható predikátum kell. Ha az állapot move-only, a predikátumot egy nevesített, a view-nál tovább élő változóban tartjuk, és std::ref-fel adjuk át; a view így nem birtokolja az állapotot, csak hivatkozik rá:

auto limit = std::make_unique<int>(1);
auto pred = [l = std::move(limit)](int x) { return x > *l; };
auto r = v | std::views::filter(std::ref(pred));   // pred-nek túl kell élnie r-t

Buildjegyzőkönyv. A közvetlen unique_ptr-capture-ös predikátummal a views::filter a lab-gcc profilban (-std=c++20 és -std=c++23) és Clang 22 / libc++ alatt (mindkét módban) is fordítási hibát adott; a std::ref-es változat mindenhol lefordult. A move-only views::transform Clang 22 / libc++ alatt -std=c++23-ban lefordult, a GCC 13.3 libstdc++-a (__cpp_lib_ranges == 202202L) viszont még elutasította, mert a P2494-et nem implementálja.

30.6.5 Materialization versus lazy költség

A lazy pipeline létrehozása nem járja be a tartományt, ezért O(1)O(1); egy bejárás a filter predikátumát minden elemre meghívja, O(n)O(n), a transform a kimeneti elemekre O(k)O(k). A materializálás ugyanezt egyszer elvégzi, majd a kimenetet tárolja: O(n)O(n) idő és O(k)O(k) memória; a későbbi bejárás O(k)O(k). Ha a pipeline-t többször járjuk be, a lazy változat minden alkalommal újraszűr, a materializált nem. A lazy view nem cache-el automatikusan; a „lazy” nem jelent sem biztonságot, sem gyorsítótárazást. A döntés: egyszeri bejárásnál és memóriaérzékeny esetben a lazy előnyös; többszöri bejárásnál vagy stabil kimenetnél a materialization a jobb.

A mérés a laborban történik, azonos inputtal, compilerrel és optimalizálási profillal. Vázlat:

template <class F>
double time_ms(F&& f) {
    const auto t0 = std::chrono::steady_clock::now();
    f();
    const auto t1 = std::chrono::steady_clock::now();
    return std::chrono::duration<double, std::milli>(t1 - t0).count();
}

A mért blokkban a lazy view-t és a materializált vektort ugyanannyiszor kell bejárni, és az eredményt el kell használni (például volatile összegzéssel vagy kiírással), hogy a compiler ne hagyja el a számítást. A mérés eredménye gép-, compiler- és optimalizálásfüggő; a fejezet nem állít konkrét számot.

30.6.6 ASan-lelet és nyelvi ok külön dokumentálása

A negatív példa izolált vizsgálata például:

g++ -std=c++20 -g -O1 -fsanitize=address -fno-omit-frame-pointer bad.cpp -o bad_asan
./bad_asan

Az ASan lelet lehet heap-use-after-free vagy stack-use-after-return; a riport a hibás hozzáférést és az érintett allokációt mutatja. A nyelvi ok ettől külön dokumentálandó: a bad_error_view egy ref_view-ot tartalmaz, amely a lokális logs-ra mutat; a logs élettartama a függvény végén véget ér, a view nem tulajdonol, ezért a használat UB. Az ASan egy implementációs diagnosztikai eszköz, nem a szabvány; nem jelez minden UB-t, és a „nincs lelet” nem bizonyítja a helyességet. A laborjegyzőkönyvben ezért külön rubrika legyen a leletre és külön a nyelvi élettartam-szerződésre.

30.6.7 Tipikus hibák

30.6.8 Feladatok

30.6.8.1 Materializáló függvény

Adott a következő paraméter:

const std::vector<LogRecord>& logs

Írj függvényt, amely a WARN szintű rekordok üzeneteit std::vector<std::string> típusú értékként adja vissza. Magyarázd el, ki tulajdonol, és meddig él a belső view.

30.6.8.2 Érték szerinti capture

Írj make_level_filter függvényt, amely egy szintet capture-öl, és visszaad egy closure-t. Készíts hozzá egy negatív változatot is referencia szerinti capture-rel, és indokold a különbséget.

30.6.8.3 Move-only predikátum

Készíts olyan predikátumot, amely egy std::unique_ptr<std::size_t> számlálót tart. Tárold/Add át egy szűrőfüggvénynek C++20-ban. Miért nem megy std::function-nal, és mi a C++23-as lehetőség?

30.6.8.4 ASan és nyelvi ok

A bad_error_view példához írj egy rövid diagnosztikai jegyzőkönyvet: mi az ASan lelet, mi a nyelvi ok, és mi a javítás? Készíts olyan javított változatot, amely nem támaszkodik a lokális vektorra.

30.6.9 Megoldások

30.6.9.1 Materializáló függvény

A függvény a logs-ra hivatkozó filter_view-ot és transform_view-ot lokálisan használja, majd std::ranges::copy-val másol a kimeneti vektorba. A kimeneti vektor tulajdonol; a belső view élettartama a függvényhívásra korlátozódik, a logs a hívóé. Ellenőrzés: a visszaadott vektor elemei a WARN üzenetek, és a logs módosítása után a kimenet változatlan.

30.6.9.2 Érték szerinti capture

A helyes változat std::string paramétert és [level = std::move(level)] capture-t használ. A closure a saját másolatát birtokolja, ezért a hívási kifejezés temporaryje megszűnése után is jó. A negatív változat const std::string& paramétert és [&level] capture-t használ; a temporary a teljes kifejezés végén megszűnik, a closure dangling. Ellenőrzés: a helyes closure "ERROR" temporaryvel létrehozva is helyes eredményt ad; a negatív változat ASan alatt leletet adhat, de a nyelvi ok önmagában is UB.

30.6.9.3 Move-only predikátum

A std::function C++20-ban copy-constructible callable-t követel, a unique_ptr-t capture-ölő lambda nem az. A template paraméteres filter_logs(logs, std::move(pred)) megoldás move-only predikátumot is átvesz, mert a paraméter érték szerinti és nem kell másolni. C++23-ban a std::move_only_function használható, de ezt jelölni kell és feature-testtel ellenőrizni. Ellenőrzés: a template-es változat fordul -std=c++20-szal; a std::function-ös változat fordítási hibát ad; a számláló a várt számot mutatja.

30.6.9.4 ASan és nyelvi ok

Az ASan lelet jellemzően heap-use-after-free vagy stack-use-after-return a visszaadott view bejárásakor. A nyelvi ok: a ref_view a lokális logs-ra mutat, a logs a függvény végén megszűnik, a view nem owning, ezért a hozzáférés UB. A javítás lehet a std::move(logs) a pipe-ban (owning_view), a materialization, vagy a vector kívülről való átadása. Ellenőrzés: a javított változat ASan alatt nem jelez; a következő static_assert dokumentálja, hogy a visszaadott view nem borrowed range.

static_assert(!std::ranges::borrowed_range<
    decltype(error_view(std::declval<const std::vector<LogRecord>&>()))>);

30.6.10 Összefoglaló

A lazy ranges pipeline nem tulajdonol, kivéve az owning_view-ot; a visszaadott view szerződése mindig kimondandó. A materialization tulajdonló kimenetet ad, és a többszöri bejárásnál cache-ként viselkedik, a lazy view nem. A capture élettartama a capture módjától függ; a referencia capture nem hosszabbítja meg a temporary élettartamát. A std::function C++20-ban copyable callable-t vár; move-only callable-hez template, saját wrapper vagy C++23-as std::move_only_function kell. Az ASan lelet és a nyelvi élettartam-ok külön dokumentálandó; a diagnosztika hiánya nem bizonyíték a helyességre.