42.3 Szeletelő és átalakító view-k: chunk, slide, chunk_by, stride, join_with

A naplófeldolgozás tipikus DevOps-feladata, hogy a sorokban érkező rekordokat ne egyenként, hanem valamilyen szerkezetben vizsgáljuk: azonos szintű bejegyzések blokkjai, egymást követő események ablakai, minden n-edik minta, vagy éppen a blokkok ember által olvasható riporttá fűzése. A C++23 ranges könyvtára pontosan ezekre a műveletekre ad néhány szűk, jól körülhatárolt view-t. Ez a rész ezeket a view-kat tanítja meg úgy, hogy a hangsúly ne az egyes függvényneveken, hanem a szerződéseken legyen: milyen forrásrange-et fogadnak el, mit tárolnak, mit invalidálnak, és mennyibe kerül a bejárásuk.

Egy megjegyzés a bizonyítékokról. A fejezetterv több WG21-javaslatot megnevez (többek között a ranges::to, a zip/adjacent, a fold, valamint a szeletelő view-k forrásaiként), és a fejezethez tartozó forrásjegyzet nem tartalmaz általam olvasott, konkrét szakaszra mutató hivatkozásokat. Ezért ebben a részben nem állítom, hogy egy adott GCC-, Clang-, libstdc++- vagy libc++-verzió implementálja az alábbiakat, és nem állítom azt sem, hogy bármely kód itt lefuttatásra és ellenőrzésre került. A laborprofilban rögzített fordítóval kell feature-test makróval és compile probe-bal ellenőrizni a támogatást; ahol a szabványváltozat és az implementáció eltérhet, azt külön jelzem.

42.3.1 A szeletelő view-k közös szerződése

A fejezet didaktikai felosztásában szeletelő view-k azok, amelyek a forrás elemeit részrange-ek sorozatává alakítják: a chunk, a slide és a chunk_by. Ezek eredménye egy range-of-ranges, amelynek elemei jellemzően subrange típusú, nem tulajdonló nézetek. Ezzel szemben az átalakító view-k (stride, join_with, repeat, as_const, as_rvalue) az elemek sorozatának alakját vagy kategóriáját változtatják meg, de nem képeznek beágyazott range-eket. Ez a felosztás nem szabványos kategorizálás, kizárólag a tananyag eligazodását szolgálja.

Közös szerződésük három pontban foglalható össze. Először, a szeletelő view maga nem másolja az elemeket; hogy a forrást birtokolja-e, az az alap-view típusán múlik (ranges::to és tárolókonstrukció range-ből (42.1)). Lvalue forrásnál ref_view alapra épül, és csak hivatkozik a mögöttes range-re, tehát a Lazy view pipeline és mellékhatások (30.4)-ben tárgyalt élettartamszabályok érvényesek rá. Rvalue, nem view forrásnál owning_view alapra épül, és birtokolja azt. Ha a forrás egy lokális std::vector, akkor az erre lvalue-ként épített view addig használható, amíg a vektor él; a view visszaadása függvényből dangling hivatkozást eredményez. Ezen a borrowed_range tulajdonság sem segít: a std::span például borrowed range, de csak annyit jelent, hogy iterátorai nem a span objektumhoz, hanem a mögöttes tárolóhoz kötöttek. A lokális vektort nem tartja életben, így a span(local) | views::take(2) visszaadása ugyanúgy dangling (ASan alatt heap-use-after-free, lásd a ranges::to és tárolókonstrukció range-ből (42.1) próbáját). Biztonságos visszaadási minta: a tárolót mozgatással a view-ba adni (std::move(local) | views::chunk(3), ami owning_view-t ad), vagy olyan külső tulajdonosra építeni, amely bizonyítottan tovább él. Másodszor, a szeletelő view-k feltételeket támasztanak a forrás kategóriájára, és ez a feltétel nem esztétikai: a slide és a chunk_by csak forward_range felett működik, mert egyszerre több pozíciót kell megjegyezniük. Harmadszor, a méret- és darabszám-számítások csak akkor konstans idejűek, ha a forrás sized_range; ellenkező esetben a distance végiglépked.

42.3.1.1 chunk

A views::chunk(n) a forrást legfeljebb n elemű, nem átfedő, sorrendtartó szeletekre bontja. A szeletek száma sized_range forrás esetén

⌈Nn⌉=⌊N+n−1n⌋,\left\lceil \frac{N}{n} \right\rceil = \left\lfloor \frac{N+n-1}{n} \right\rfloor ,

ahol NN a forrás elemszáma és n>0n>0 egész. A levezetés egyszerű: írjuk fel N=qn+rN = qn + r alakban, ahol 0≤r<n0 \le r < n. Ha r=0r=0, akkor N+n−1=qn+n−1N+n-1 = qn+n-1, amit nn-nel egész osztva q+n−1nq+\frac{n-1}{n}, azaz qq adódik, ami ⌈N/n⌉\lceil N/n \rceil. Ha r>0r>0, akkor N+n−1=qn+(r+n−1)N+n-1 = qn + (r+n-1), ahol n≤r+n−1≤2n−2n \le r+n-1 \le 2n-2, tehát az egész osztás eredménye q+1q+1, ami szintén a felső egészrész. Az utolsó szelet tehát rövidebb lehet, és ez nem hiba, hanem a szerződés része: aki egyenlő méretű szeleteket vár, annak magának kell ellenőriznie a hosszt.

Az n > 0 előfeltétel megsértése nem definiált viselkedés, illetve a könyvtár implementációjától függően assert. A chunk állapota konstans méretű: csak az aktuális szelet elejét és a hátralévő darabszámot követi, ezért a bejárás költsége lineáris, extra allokáció nélkül. A szeletek típusa subrange, amelynek kategóriája a forrás kategóriáját követi.

Itt van a rész legkényesebb pontja, amit a fejezetterv külön kér. Ha a forrás forward_range, a chunk eredménye is forward range, a szeletek többször bejárhatók, és sized_range forrás esetén a szelethossz is ismert. Ha a forrás csak input_range, akkor a keletkező view is csak input range marad: a szeleteket nem lehet kétszer bejárni, és nem lehet rájuk támaszkodni úgy, hogy „előbb megszámolom, aztán feldolgozom”. Történelmi okból a korai chunk_view tervezet szigorúbban, csak forward range-re volt értelmezve; a későbbi változat engedte meg az input range-et azzal a kompromisszummal, hogy a szeletek kategóriája lesüllyed. Mivel ezt nem tudom ellenőrzött, konkrét implementációhoz kötni, a gyakorlati szabály ez: ha a kódod feltételezi, hogy egy chunk kétszer bejárható vagy size()-ja konstans idejű, azt statikusan is ki kell mondanod, például static_assert(std::ranges::forward_range<decltype(chunkView)>); formában, és a laborban ellenőrizni kell, hogy a választott könyvtár ezt teljesíti-e.

42.3.1.2 slide

A views::slide(n) minden pozícióra egy n hosszú, átfedő ablakot ad: az első ablak az első n elemet tartalmazza, a következő a másodikat a harmadiktól kezdve, és így tovább. A darabszám N >= n esetén N−n+1N-n+1, egyébként nulla. A slide előfeltétele n>0, és a forrásnak forward_range-nek kell lennie, mert a view n-1 iterátort tárol, hogy az ablakot meg tudja jeleníteni. Ez egyben a fő költségjellemző is: a slide memoriaigénye O(n)O(n), nem O(1)O(1) — ellentétben a chunk-kal.

A slide tipikus felhasználása az átmenetdetektálás: „ mely esemény következett közvetlenül egy másik után”. Erre a C++23 egy másik eszköze, az adjacent is használható, amelyet a [Kombináló view-k: zip, adjacent, enumerate, cartesian_product (42.2)](#s219) tárgyal; a választás köztük jellemzően azon múlik, hogy rögzített 2-es vagy 3-as ablaka van-e a kódnak, vagy futásidejű n-re van szükség.

42.3.1.3 chunk_by predikátummal

A views::chunk_by(pred) a szomszédos elemeket addig vonja össze egy szeletbe, amíg a predikátum igazat nem ad. A predikátumot a könyvtár pred(*előző, *aktuális) sorrendben hívja meg, és a szeletek soha nem üresek. A forrásnak itt is forward_range-nek kell lennie, mert a view a szelet határát visszafelé is meg kell hogy tudja állapítani.

A legfontosabb szemantikai pontosítás: a chunk_by nem ekvivalenciaosztályokat képez, hanem a szomszédos párokra támaszkodó maximális láncokat. Ha a predikátum nem tranzitív, akkor előfordulhat, hogy AA és BB egy csoportba kerül, BB és CC is, miközben AA és CC között a predikátum hamis. Például a |a-b| <= 1 predikátummal az {1,2,3} bemenet egyetlen csoport lesz, pedig az 1 és a 3 különbsége 2. Ez nem hiba, hanem a definíció következménye; aki ekvivalenciaosztályokat akar, annak a csoportosítás előtt normalizálnia kell a kulcsot, vagy asszociatív tárolót kell használnia.

42.3.2 Átalakító view-k: stride, join_with, repeat

A views::stride(n) minden n-edik elemet adja vissza: a 0,n,2n,…0, n, 2n, \dots indexűeket. Előfeltétele n>0, és mivel csak előrefelé lépked, input_range forrás is elegendő. sized_range forrás esetén az eredmény hossza ⌈N/n⌉\lceil N/n \rceil. A stride nem pufferel, állapota konstans méretű, viszont a forrás iterátorát n-szer lépteti minden egyes elemnél; gyorsítótárazott, szekvenciális bejárásnál ez jól viselkedik, véletlen hozzáférésű, de nagy kihagyásokkal járó bejárásnál viszont minden lépés cache-vonalat ugorhat.

A views::join_with(pattern) egy range-of-ranges elemeit fűzi össze, a belső range-ek közé a pattern-t szúrva. A pattern lehet egyetlen érték vagy maga is egy range. A típuskompatibilitás itt valódi követelmény: a belső range-ek value type-jának és a pattern value type-jának közös típusra kell leképezhetőnek lennie. Ezért működik a std::vector<std::string> range-eknél a join_with(std::string(" | ")), de nem feltétlenül a join_with('|'), mert ott a pattern value type-ja char, a belső range-eké std::string. A join_with input range-et is elfogad, és a belső range-eket is csak egyszer járja be, ami fontos a lusta pipeline-oknál.

A views::repeat(v) végtelen range-et ad, amely minden iterációnál v-t szolgáltatja; a views::repeat(v, n) pontosan n elemet ad. A végtelen változat csak korlátozó view-val (take, take_while) együtt használható; distance vagy size hívása rajta nem terminál. A repeat értékszemantikájú, a v-t másolja, tehát nagy objektum esetén érdemes ref-fel vagy views::repeat(std::ref(x)) formában használni — utóbbi esetben viszont a hivatkozott objektum élettartama a felhasználó felelőssége.

42.3.3 as_const és as_rvalue

A views::as_const a forrás elemeihez konstans hozzáférést biztosító view-t ad, ami akkor hasznos, ha egy amúgy nem konstans tárolón csak olvasó pipeline-t akarunk futtatni, és szeretnénk, hogy a fordító ezt kikényszerítse. A views::as_rvalue ezzel szemben minden elemet rvalue referenciaként ad tovább, ami azt teszi lehetővé, hogy egy transform vagy join_with move-oljon a másolás helyett. Mindkettő csak akkor viselkedik „ingyen” módon, ha a forrás kategóriája engedi; a pontos const- és value category szabályokat a szabvány rögzíti, és ezek implementációtól függő részletei (például a const iterátorok pontos típusa) verziófüggők lehetnek.

42.3.4 Kidolgozott példa: naplórekordok csoportosítása

A példa egy rövid naplót dolgoz fel: szint szerint csoportosít, átmeneteket keres, mintavételez, majd riporttá fűz. A kód önállóan fordítható, de — hangsúlyozom — itt nem állítom, hogy bármely konkrét fordítóval lefutott; a laborban kell ellenőrizni.

// log_slice.cpp
// Feltételezett build (a laborprofilban rögzített verziókkal):
//   g++     -std=c++23 -O2 -Wall -Wextra -pedantic log_slice.cpp -o log_slice
//   clang++ -std=c++23 -O2 -Wall -Wextra -pedantic log_slice.cpp -o log_slice
#include <iostream>
#include <ranges>
#include <string>
#include <string_view>
#include <vector>

namespace rv = std::views;
namespace rs = std::ranges;

struct entry {
    std::string_view level;
    std::string_view message;
};

static entry parse(std::string_view line)
{
    const auto sp = line.find(' ');
    if (sp == std::string_view::npos)
        return {line, {}};
    return {line.substr(0, sp), line.substr(sp + 1)};
}

int main()
{
    const std::vector<std::string> lines = {
        "INFO start", "INFO warmup", "WARN slow", "WARN retry",
        "ERROR fail", "INFO done"
    };

    std::vector<entry> entries;
    entries.reserve(lines.size());
    for (const auto& l : lines)
        entries.push_back(parse(l));

    // 1) Szomszédos, azonos szintű rekordok csoportosítása.
    auto groups = entries
                | rv::chunk_by([](const entry& a, const entry& b) {
                      return a.level == b.level;
                  });

    for (auto g : groups) {
        std::cout << g.front().level << " x" << rs::distance(g) << '\n';
    }

    // 2) Csúszóablak: WARN -> ERROR átmenetek.
    for (auto w : entries | rv::slide(2)) {
        if (w[0].level == "WARN" && w[1].level == "ERROR")
            std::cout << "atmenet: " << w[0].message
                      << " -> " << w[1].message << '\n';
    }

    // 3) Mintavételezés minden harmadik rekordból.
    for (const auto& e : entries | rv::stride(3))
        std::cout << "minta: " << e.level << '\n';

    // 4) Riport: csoportok szöveggé, elválasztóval összefűzve.
    auto parts = groups
               | rv::transform([](auto g) {
                     return std::string(g.front().level) + " x"
                          + std::to_string(rs::distance(g));
                 })
               | rv::join_with(std::string(" | "));

    for (const auto& p : parts)
        std::cout << p;
    std::cout << '\n';

    // 5) Végtelen forrás korlátozása.
    for (char c : rv::repeat('-') | rv::take(20))
        std::cout << c;
    std::cout << '\n';
}

A példa élettartam-térképe egyszerű, de kimondandó: az entries vektor a main teljes ideje alatt él; a groups, parts és a slide/stride view-k mind erre hivatkoznak; az entry::level és entry::message string_view-k a lines vektor elemeire mutatnak, tehát a lines élettartama szintén kritikus. Ha bármelyik view-t visszaadnánk a main-ből, a lines és az entries megszűnésével dangling nézetet kapnánk. Az as_const itt azért lenne hasznos, hogy a 4. pontbeli transform véletlenül se módosíthassa a csoportokat.

A rs::distance(g) hívás a chunk szeletén sized_range forrás esetén konstans idejű; a vector ilyen, tehát a példa lineáris marad. Ha a forrás egy generátor vagy egy istream_view lenne, a distance végiglépkedne a szeleten, és a 4. pontban ez négyzetes viselkedést okozhatna.

42.3.5 Pipeline-teljesítmény és cache-elés

A lusta pipeline költsége három forrásból áll össze. Az első a bejárási lépések száma: a chunk_by minden új elemnél legfeljebb egy predikátumhívást végez (a szelet határán), a stride minden kimeneti elemhez n iterátorlépést, a join_with minden elemhez egy belső iterátorlépést és a határokon a pattern kiírását. A második a tárolt állapot: a chunk, a stride és a join_with konstans méretű, a slide O(n)O(n), a chunk_by konstans méretű, de a predikátum objektumot tárolja, amely lambdák esetén capture-öket is tartalmazhat. A harmadik a cache-viselkedés: a stride nagy n-nél minden egyes kimeneti elemhez n egységet ugrik, ami szekvenciális forráson is cache-vonal-ugrálást okoz; a slide ezzel szemben szomszédos ablakokat ad, tehát locality szempontjából kedvező, de az ablakok között az elemeket újra és újra feldolgozza, ami a felhasználói kódban könnyen O(N⋅n)O(N \cdot n) munkához vezet.

Cache-elés szempontjából a chunk a legbarátságosabb: a szeletek nem fednek át, minden elemet egyszer ad át a feldolgozónak. A slide és a chunk_by esetében érdemes megmérni, hogy a feldolgozó lépés idempotens-e, illetve hogy az átfedés miatti ismételt munka mennyi; a [Profilozás, benchmark és cost model (37.4)](#s189)-ben tárgyalt mérési fegyelem itt is érvényes: a várt és a mért költség nem ugyanaz a kategória.

42.3.6 Tipikus hibák

A leggyakoribb hiba a n = 0 átadása a chunk, slide vagy stride view-nak. Ez előfeltétel-sértés; a helyes védekezés a bemenet ellenőrzése a view létrehozása előtt, például if (n == 0) throw std::invalid_argument("n");, nem pedig a view „megjavítása”.

A második a forward range feltétel figyelmen kívül hagyása: slide-t vagy chunk_by-t input range-re próbálni nem fordul le, és ez nem korlátozás, hanem a szerződés. Ha a forrás csak input range, a chunk marad, de ott is számolni kell a szeletek egyszeri bejárhatóságával.

A harmadik a dangling predicate: a chunk_by lambdája referenciát capture-el egy lokális változóra, majd a view tovább él, mint a captured objektum. A javítás a capture élettartamának összehangolása vagy érték szerinti capture.

A negyedik a join_with típuseltérése: karaktert adunk patternként stringek range-éhez, vagy std::string_view-t std::string-hez; a fordítási hibaüzenet hosszú és nehezen olvasható, ezért érdemes a pattern típusát expliciten a belső range value type-jához igazítani.

Az ötödik a végtelen repeat korlátozás nélkül: distance vagy size hívása végtelen ciklust eredményez, és a take elfelejtése is könnyen megesik.

42.3.7 Összefoglaló

A C++23 szeletelő view-k közül a chunk nem átfedő, a slide átfedő ablakokat ad, a chunk_by pedig predikátum alapján, szomszédos párokra támaszkodva csoportosít. A chunk, a stride és a join_with konstans állapotú, a slide O(n)O(n) iterátortárolást igényel, a chunk_by pedig a predikátum objektumot tartja életben. A slide és a chunk_by forward range-et követel; a chunk input range-en is elfogadott, de ilyenkor a szeletek kategóriája lesüllyed, és a többszöri bejárhatóságra nem lehet építeni. A stride és a join_with a sorozat alakját változtatja, a repeat végtelen forrást ad, az as_const és az as_rvalue pedig a hozzáférés módját rögzíti. Mindegyik view nem tulajdonló, ezért a forrás élettartama a felhasználó felelőssége.

42.3.8 Feladatok

1. feladat (alap). Adott a std::vector<int> v{1,2,3,4,5,6,7};. Készíts v | rv::chunk(3) pipeline-t, és írd ki az egyes szeletek elemeit, valamint a szeletek számát. Hány elemű az utolsó szelet, és miért?

2. feladat (közepes). Csoportosítsd a {1,2,3,10,11,20} sorozatot chunk_by-val a |a-b| <= 1 predikátummal. Magyarázd el, miért kerül az 1 és a 3 ugyanabba a csoportba, és mutass egy olyan bemenetet, ahol a „láncolt” viselkedés meglepő eredményt ad.

3. feladat (diagnosztikai). Írj egy make_stride(std::size_t n) függvényt, amely n == 0 esetén std::invalid_argument-t dob, egyébként visszaadja a rv::stride(n) view-t egy paraméterként kapott tartományra. Magyarázd el, miért nem védhető a stride view belülről, és hogyan ellenőriznéd a viselkedést a laborban.

42.3.9 Megoldások

1. megoldás. A chunk(3) három szeletet ad: [1,2,3], [4,5,6], [7]. A darabszám a felső egészrész képlettel ⌈7/3⌉=3\lceil 7/3 \rceil = 3, ami a ⌊(7+3−1)/3⌋=⌊9/3⌋=3\lfloor (7+3-1)/3 \rfloor = \lfloor 9/3 \rfloor = 3 alakkal is számítható. Az utolsó szelet azért 1 elemű, mert 7=2⋅3+17 = 2\cdot 3 + 1, tehát a maradék nem nulla; a chunk szerződése szerint az utolsó szelet rövidebb lehet. Ellenőrzési feltétel: a külső view std::ranges::distance-ja 3, az utolsó szelet std::ranges::distance-ja 1, és a szeletek egyesítése kiadja az eredeti sorozatot.

2. megoldás. Az eredmény [1,2,3], [10,11], [20]. Az 1 és a 3 azért kerül egy csoportba, mert a chunk_by csak a szomszédos párokra kérdez rá: pred(1,2) igaz, ezért 2 még a csoportban marad; pred(2,3) igaz, ezért 3 is; az 1 és a 3 közötti viszonyt a view soha nem vizsgálja. Meglepő eset: {5,6,7,8,20} esetén [5,6,7,8] egyetlen csoport lesz, pedig az 5 és a 8 különbsége 3. Ez nem hiba, hanem a szomszédoslánc-szemantika; ha ekvivalenciaosztályokra van szükség, a kulcsot normalizálni kell (például egész osztással), vagy asszociatív tárolót kell használni. Ellenőrzési feltétel: a csoportok száma és sorrendje megfelel a szomszédos határoknak, és a csoportok összefűzése az eredeti sorozatot adja.

3. megoldás. A stride view előfeltétele a konstruktorban rögzített n > 0; a view-nak nincs olyan tagfüggvénye, amely ezt futásidőben ellenőrizné és helyreállítaná, mert a ranges view-k szerződése a hívó felelőssége. A védőburkoló tehát a view létrehozása előtt ellenőriz:

[[nodiscard]] auto make_stride(std::size_t n)
{
    if (n == 0)
        throw std::invalid_argument("stride: n must be positive");
    return std::views::stride(n);
}

Mivel a visszaadott view a n-et érték szerint tárolja, a burkoló élettartama nem probléma; a forrásrange-re viszont ugyanaz a dangling-szabály vonatkozik, mint bármely más view-ra. A laborban a helyes ellenőrzés: (a) feature-test makróval igazolni, hogy a stride elérhető; (b) n = 0-ra a burkoló dob, és ezt negatív tesztként rögzíteni; (c) a stride(0) közvetlen hívását nem szabad „tesztelni”, mert viselkedése nem definiált, és az eredmény nem hordozható megfigyelés.