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.
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.
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
ahol a forrás elemszáma és egész. A levezetés egyszerű: írjuk fel alakban, ahol . Ha , akkor , amit -nel egész osztva , azaz adódik, ami . Ha , akkor , ahol , tehát az egész osztás eredménye , 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.
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
,
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
,
nem
— 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.
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
és
egy csoportba kerül,
és
is, miközben
és
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.
A views::stride(n) minden n-edik elemet
adja vissza: a
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
.
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.
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.
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.
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
,
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
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.
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.
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
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.
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.
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
,
ami a
alakkal is számítható. Az utolsó szelet azért 1 elemű, mert
,
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.