30.4 Lazy view pipeline és mellékhatások

A ranges pipeline első ránézésre úgy viselkedik, mint egy láncolt adatfeldolgozó: szűrünk, transzformálunk, kiveszünk elemeket. A lényegi különbség az algoritmusokhoz képest az, hogy a view kiértékelése lusta (lazy): a pipeline létrehozása nem számol semmit, legfeljebb egy bejárási szerződést ír le. Ebből két, a gyakorlatban döntő kérdés következik: mikor fut le a tényleges munka, és meddig él mindaz, amire a view hivatkozik. Ez az alfejezet ezt a két kérdést járja körül a views::filter, transform, take, drop és reverse példáján; a ranges algoritmusok, a projection és a sentinel mechanizmusa Ranges algoritmusok, projection és sentinel, a string_view tulajdonlási kérdése std::string, std::string_view és szövegownership anyaga.

30.4.1 View, adaptor és a lustaság szerződése

Egy range olyan típus, amelyre a std::ranges::begin és std::ranges::end értelmezett, és az így kapott iterátorok bejárhatók. Egy view (nézet) olyan range, amely olcsón másolható és mozgatható, és amelynek másolása nem másolja a mögöttes elemeket: a nézet referenciaszemantikával írja le, hol vannak az elemek, nem azt, hogy melyek az elemek. A C++20 std::ranges::view követelményei a szabvány megjelenése óta pontosításon estek át (a P2415R2 javaslat lazította a korábbi, szigorúbb megkötést); a lényeg változatlan: egy view lemásolása olcsó, és a másolat ugyanarra az adatra néz.

Egy range adaptor (tartomány-adaptor) egy customization point object, amely egy range-ből view-t készít. A views::filter(p) tehát nem egy algoritmus, hanem egy adaptor-objektum, amelyet range-re alkalmazva filter_view-t kapunk. A három írásmód ekvivalens:

auto a = std::views::filter(v, pred);   // közvetlen hívás
auto b = std::views::filter(pred)(v);   // adaptor alkalmazása
auto c = v | std::views::filter(pred);  // pipe operátor

A v itt nem feltétlenül ugyanaz a típus a három esetben: a pipe lánc bal oldalán álló kifejezés értékkategóriája és view-volta határozza meg, hogy views::all milyen view-t készít (erről később, a ref_view/owning_view részben).

A lustaság nem optimalizálás, hanem szemantikai döntés. Azt jelenti, hogy a pipeline objektum létrehozása megengedi, de nem kényszeríti ki a bejárást. A szabvány ezt nem úgy fogalmazza meg, hogy „a könyvtár lusta”, hanem úgy, hogy a view műveletei a bejárás során, az iterátorműveletekhez kötve vannak specifikálva. Ebből az következik, hogy a mellékhatásos predikátum vagy transzformáció hívásainak száma és sorrendje nem egy jól definiált „egyszer elemenként” szerződés, hanem a bejárás tényleges menetétől függ.

30.4.1.1 Mikor fut le egy művelet?

A gyakorlati szabály a következő. A pipeline konstrukciója jellemzően nem jár elemenkénti munkával; legfeljebb O(1) előkészítés történik (például egy reverse a közös end-et kéri le, egy drop random access alap esetén kiszámítja a kezdő eltolást). A tényleges hívások a bejárás elemi műveleteihez kötődnek:

Következmény: a ranges nem cache-el. A filter_view nem tárolja el a már megfelelt elemeket, a transform_view nem memorizálja az eredményt. A pipeline egy recept, amelyet minden bejárás újra végrehajt.

30.4.2 Filter/transform/take/drop/reverse: a kidolgozott példa

A következő program teljes, önállóan fordítható. A laborprofilban rögzített C++20-as fordítóval és standard library-vel kell fordítani; a -std=c++20 megadása kötelező, mert a ranges a C++20 része, és a fordító alapértelmezett nyelvi módja verziófüggő.

// pipeline.cpp
#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> data{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    auto even_sq = data
        | std::views::filter([](int i) { return i % 2 == 0; })
        | std::views::transform([](int i) { return i * i; })
        | std::views::drop(1)
        | std::views::take(3);

    for (int x : even_sq) {
        std::cout << x << ' ';
    }
    std::cout << '\n';
}
g++ -std=c++20 -O2 -Wall -Wextra -Wpedantic pipeline.cpp -o pipeline
# vagy
clang++ -std=c++20 -O2 -Wall -Wextra -Wpedantic pipeline.cpp -o pipeline
./pipeline

A szerződés alapján várható kimenet 16 36 64. Ez nem mért eredmény: a számítás menete a következő. A data elemei 1..10; a szűrő az első szakaszban a párosakat engedi át: 2, 4, 6, 8, 10. A transzformáció ezeket négyzetre emeli: 4, 16, 36, 64, 100. A drop(1) az első elemet elhagyja: 16, 36, 64, 100. A take(3) legfeljebb három elemet enged át: 16, 36, 64. Fontos, hogy a take(3) miatt a 100 elemen a transzformáció már nem feltétlenül fut le, hiszen a bejárás a harmadik elem után megállhat; ez a lustaság egyik gyakorlati haszna, és egyben a mellékhatásokkal kapcsolatos csapda is.

A lustaságot a legjobban egy nyomkövető (trace) transzformációval lehet megfigyelni. A következő programban a pipeline létrehozása után kiíratunk egy jelzést, majd kétszer bejárjuk ugyanazt a view-t:

// lazy_trace.cpp
#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> v{10, 21, 32, 43, 54};

    auto pipe = v
        | std::views::filter([](int x) { return x % 2 == 0; })
        | std::views::transform([](int x) {
              std::cout << "[t" << x << "]";
              return x / 2;
          });

    std::cout << "pipeline letrejott\n";
    for (int x : pipe) std::cout << x << ' ';
    std::cout << "\nmasodik bejaras: ";
    for (int x : pipe) std::cout << x << ' ';
    std::cout << '\n';
}

A konstrukció után előbb a pipeline letrejott szöveg jelenik meg, mert a view létrehozása nem iterál. Az első bejárás a transzformációt csak a szűrőn átjutó elemeken hívja meg (a [t...] jelzések száma kevesebb, mint a bemenet hossza). A második bejárás újra végrehajtja ugyanazokat a transzformációkat: a jelzések újra megjelennek, mert nincs cache. Ez a viselkedés nem hiba, hanem a nézet szerződése; a mellékhatásos transzformáció viszont így ismételt hatást fejt ki, ami naplózásnál vagy számlálásnál már önmagában is probléma.

30.4.3 Capture, predikátumállapot és újraiterálás

A lambda closure objektum a view-ba bemásolva kerül: a filter_view a predikátumát tagként tárolja. Ez két dolgot jelent. Egyrészt a [&] capture esetén a view csak egy referenciát tárol a hivatkozott objektumra, tehát a hivatkozott objektumnak tovább kell élnie, mint a view-nak és minden olyan iterátornak, amelyet a view-ból készítettünk. Másrészt a mutable lambda belső állapota a view példányában él, így a view másolása az állapotot is másolja, és a view ismételt bejárása ugyanazt az állapotot folytatja.

// stateful_pred.cpp
#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> v{5, 1, 4, 2, 3};
    int calls = 0;   // a view ezt referenciaként latja

    auto big = v | std::views::filter([&calls](int x) { ++calls; return x > 2; });

    for (int x : big) std::cout << x << ' ';
    std::cout << "\nhivasok az elso bejaras utan: " << calls << '\n';

    for (int x : big) std::cout << x << ' ';
    std::cout << "\nhivasok a masodik bejaras utan: " << calls << '\n';
}

A predikátumhívások pontos száma nem egy „egyszer elemenként” szerződés: a begin() előreszkennelése, a léptetés és az esetleges korai megállás miatt a szám implementáció- és bejárásfüggő. A predikátum szerződése három külön kérdés. (1) Szemantika: a filter predikátumának std::indirect_unary_predicate-et kell modelleznie, ami regular_invocable-t jelent: a hívás egyenlőségőrző (azonos bemenetre azonos eredmény, közbenső módosítás nélkül), és nem módosíthatja sem az argumentumot, sem a saját állapotát. Nincs viszont semmilyen intervallum- vagy ekvivalenciafeltétel a szomszédos elemekre: a {1, 2, 3, 4, 5} páratlan elemeinek szűrése {1, 3, 5}-öt ad, és ez teljesen szabályos. (2) Megismételhetőség: mivel a hívások száma nem specifikált, a predikátum eredménye nem függhet attól, hányszor hívták. (3) Élettartam: a view a predikátum egy példányát saját tagként tárolja, ezért a predikátumnak a view élettartamáig hívhatónak kell maradnia — a referenciával rögzített állapotnak (mint itt a calls) is. A fenti számláló nem változtat a predikátum eredményén, ezért nem sérti a szerződést, de a kiírt szám csak megfigyelés, nem garancia; tanítási példa arra, hogy a predikátum a view-ban él; termelési kódban ez a minta könnyen nem reprodukálható viselkedést okoz.

Külön élettartam-kérdés, hogy a view-ból kapott iterátor meddig érvényes. Két független invalidáló tényező van: az alap tároló módosítása (például push_back egy vector-ra, ami minden iterátort és referenciát invalidál), és magának a view objektumnak a megszűnése. Utóbbi azért kritikus, mert a filter_view iterátora a view belső predikátumállapotára hivatkozhat; ha a view egy blokkon belül jön létre, az iterátort nem lehet kivinni a blokkból.

30.4.4 ref_view, owning_view és az rvalue range-ek

Amikor egy adaptort egy range-re alkalmazunk, a könyvtár először views::all-t hív. Ennek eredménye dönti el, hogy a pipeline birtokol-e valamit:

A tipikus hiba tehát nem az, hogy „a view lusta”, hanem hogy a pipeline élettartama nem fedi le a mögöttes adat élettartamát:

// HIBA: dangling, nem futtatando
#include <ranges>
#include <vector>

auto make_view() {
    std::vector<int> v{1, 2, 3, 4};
    return v | std::views::filter([](int x) { return x > 1; });  // ref_view v-re!
}

Itt v lvalue, ezért ref_view készül, és a függvény visszatérésekor v megszűnik: a visszaadott view és minden belőle készített iterátor dangling. A javítás lehet a tároló visszaadása (a hívó birtokolja), vagy szándékos birtoklás: return std::vector<int>{1, 2, 3, 4} | std::views::filter(...);, ahol az ideiglenes vektor owning_view-ba kerül. A két változat nem ekvivalens: az első értéket ad vissza, a második nézetet, amely birtokol.

Érdemes a pipeline élettartamát táblázatban rögzíteni, mert ez a review legfontosabb ellenőrzőlistája:

Entitás Birtokol? Élettartam Invalidáló művelet
std::vector<int> data igen a deklarációtól a blokk végéig push_back, clear, újabb allokáció
ref_view (data \| views::filter) nem legfeljebb data élettartama data megszűnése vagy módosítása
owning_view (ideiglenes vektor) igen a view élettartama a view mozgatása/újraértékelése
view-ból kapott iterátor nem legfeljebb a view és az adat élettartama a view vagy az adat megszűnése
[&] capture a predikátumban nem legfeljebb a hivatkozott objektum élettartama a hivatkozott objektum megszűnése

30.4.5 Nem garantált tulajdonságok: sized, common, borrowed

Egy view nem feltétlenül sized_range (van O(1) size()), nem feltétlenül common_range (a begin() és end() azonos típusú), és nem feltétlenül contiguous_range. Ezek a tulajdonságok a pipeline mentén változhatnak:

A borrowed_range fogalma azt fejezi ki, hogy a range-ből kapott iterátorok érvényessége nem függ magának a range objektumnak az élettartamától (tipikusan lvalue referenciára vagy enable_borrowed_range-ra épül). Adaptorokon keresztül ez öröklődhet, de nem szabad rá hagyatkozni: ha a view belső állapotára (predikátumra, cache-re) hivatkozó iterátort ad, a view megszűnése után az iterátor dangling. A laborban a static_assert(std::ranges::borrowed_range<decltype(pipe)>) és a sized_range/common_range állítások adják a leggyorsabb visszajelzést.

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

  1. Azt hisszük, hogy a pipeline kiszámolta az eredményt. A views::transform alkalmazása után nincs új tároló; ha értékre van szükség, azt materializálni kell (C++20-ban std::ranges::copy back_inserter-rel, C++23-ban std::ranges::to — ez utóbbi külön jelölendő, mert C++23-as képesség).
  2. A view túlél az adatnál. A make_view() fenti példája; javítás: a tároló visszaadása, vagy szándékos owning_view.
  3. A [&] capture a view-ba kerül, de a hivatkozott objektum rövidebb életű. Javítás: érték szerinti capture, ha az állapot kicsi és másolható.
  4. Állapotfüggő predikátum. A mutable számláló miatt a második bejárás más eredményt ad; javítás: tiszta predikátum, a számlálást a bejárás mellé tenni.
  5. size()-ra építés filter után. A filter nem sized; a distance O(n), és nem is hívható meg minden esetben O(1)-ként.
  6. Mellékhatásos transzformáció. Naplózás vagy számlálás a transform-ban a lustaság miatt kiszámíthatatlan számban futhat le, és a take miatt el is maradhat.

30.4.7 Összefoglaló

A ranges view leírás, nem eredmény: a filter/transform/take/drop lánc konstrukciója nem iterál, a munka a begin(), ++, *it és az összehasonlítások során történik. Nincs cache, ezért minden bejárás újrafuttatja a predikátumot és a transzformációt; a mellékhatásos callable-ek száma nem szerződés. A lambdák closure-je a view-ba másolva kerül, így a [&] capture és a mutable állapot élettartama a view élettartamához kötődik. A views::all lvalue range-re ref_view-t, ideiglenes nem view range-re owning_view-ot készít (verziófüggő), ideiglenes view-ra viszont csak a view-t másolja — ez a leggyakoribb dangling-forrás. A sized, common, borrowed tulajdonságok adaptoronként változnak, ezért ezeket a laborban static_assert-tel kell ellenőrizni, nem feltételezni.

30.4.8 Feladatok

1. feladat (magyarázó). Az alábbi kódrészletben hányszor hívódik meg a predikátum és a transzformáció, ha a for ciklus végigmegy? Indokolj, és mondd meg, mi változik, ha a take(2) helyett take(10) áll.

std::vector<int> v{1, 2, 3, 4};
auto pipe = v
    | std::views::filter([](int x) { return x % 2 == 0; })
    | std::views::transform([](int x) { return x + 1; })
    | std::views::take(2);
for (int x : pipe) { /* ... */ }

2. feladat (kódolási). Írj olyan függvényt, amely egy const std::vector<int>& bemenet páratlan elemeinek négyzetösszegét adja vissza ranges pipeline-nal, és garantáltan nem hivatkozik dangling adatra. Add meg a buildparancsot és az ellenőrzési feltételt.

3. feladat (diagnosztikai). Az alábbi függvényt egy kolléga írta. Azonosítsd a hibát, javítsd két különböző szerződésű változattal (értékvisszaadás és birtokló nézet), és mondd meg, milyen eszközzel lehetne a hibát detektálni.

auto positive(const std::vector<int>& src) {
    return src | std::views::filter([](int x) { return x > 0; });
}

4. feladat (elmélkedő). Miért nem lehet a std::views::reverse-et általában egy take után alkalmazni, és miért nem sized_range a filter eredménye?

30.4.9 Megoldások

1. megoldás. A bemenet 1, 2, 3, 4; a szűrő a 2-t és a 4-et engedi át. A take(2) pontosan két elemet kér, ezért a bejárás a második egyezés után megállhat: a transzformáció kétszer fut le (a 2-n és a 4-en). A predikátumhívások száma legalább annyi, ahány elemet meg kell vizsgálni az első két egyezés megtalálásához, azaz négy (a 2 és a 4 között a 3 is megvizsgálandó) — de a pontos szám a begin() előreszkennelésének és a léptetésnek a függvénye, ezért csak alsó korlátként kezelendő, nem egzakt szerződésként. take(10) esetén a bejárás a range végéig tart, így mind a négy elemre lefut a predikátum, a transzformáció pedig a két párosra; a válasz attól is függ, hogy a filter common_range-e az adott könyvtárban, mert az end() összehasonlítás további munkát jelenthet. Ellenőrzési feltétel: a viselkedést static_assert-tel és nyomkövető transzformációval kell igazolni a rögzített fordítón.

2. megoldás. Az owner a hívó vector-ja, a pipeline csak ref_view-t készít, és a függvény visszatéréséig él:

// sum_odd_sq.cpp
#include <cstddef>
#include <ranges>
#include <vector>

std::size_t sum_odd_squares(const std::vector<int>& src) {
    std::size_t sum = 0;
    auto pipe = src
        | std::views::filter([](int x) { return x % 2 != 0; })
        | std::views::transform([](int x) { return static_cast<std::size_t>(x) * static_cast<std::size_t>(x); });
    for (auto x : pipe) sum += x;
    return sum;
}
g++ -std=c++20 -O2 -Wall -Wextra -Wpedantic sum_odd_sq.cpp -o sum_odd_sq

Indoklás: a src lvalue, ezért ref_view készül, és a view élettartama a függvényre korlátozódik, nem kerül ki a hívóhoz. Ha a függvény view-t adna vissza, az dangling lenne. Ellenőrzési feltétel: sum_odd_squares({1, 2, 3}) == 10, és a függvény ne adjon vissza referenciát vagy view-t. A négyzet kiszámításánál a std::size_t-re konvertálás megelőzi az int túlcsordulást nagyobb bemenetnél; ez a szerződés része.

3. megoldás. A hiba: src const lvalue referencia, ezért views::all ref_view-t készít, a visszaadott view pedig a hívóhoz tartozó vector-ra hivatkozik. Ha a hívó az ideiglenessel hívja (positive({1, 2, 3})), a visszaadott view dangling. Értékvisszaadó változat:

std::vector<int> positive_value(const std::vector<int>& src) {
    std::vector<int> out;
    for (int x : src | std::views::filter([](int x) { return x > 0; }))
        out.push_back(x);
    return out;
}

Birtokló nézet változat (a bemenetet átveszi, nem referenciát tart):

auto positive_owned(std::vector<int> src) {
    return std::move(src) | std::views::filter([](int x) { return x > 0; });
}

A második változat csak akkor biztonságos, ha a views::all a mozgatott rvalue vektort owning_view-ba teszi; ezt a használt standard library verzióján ellenőrizni kell. Detektálás: AddressSanitizer (-fsanitize=address), amely stack-használat-utáni-return esethez külön kapcsolóval (detect_stack_use_after_return) is futtatható, valamint a fordító statikus figyelmeztetései; a tool viselkedése verziófüggő, ezért a laborban rögzíteni kell.

4. megoldás. A filter eredménye nem sized_range, mert a szűrt elemek száma csak a teljes bejárással derülne ki; egy O(1) size() szerződést nem lehet tartani. A views::reverse bidirectional_range-et igényel, és a C++20 szövege common_range alapot vár; a take eredménye nem sized alapon tipikusan nem common_range (az end() egy sentinel), ezért a reverse rákötése jellemzően fordítási hibát ad. A megoldás: előbb materializálni a szűrt/szeletelt eredményt egy tárolóba, és azt megfordítani, vagy olyan sorrendet választani, ahol a reverse közvetlenül egy kétirányú alapra kerül.