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.
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átorA 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.
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:
begin(): a filter itt szkennel előre az
első olyan elemig, amelyre a predikátum igaz; a drop itt
lépteti át az első n elemet; a take itt csak az
alap begin()-jét kéri.++it: a következő elem megkeresése; filter
esetén további predikátumhívások.*it: a transform függvényének meghívása az
aktuális elemen; a filter nem hív predikátumot
dereferáláskor, mert a szűrést már a léptetés elvégezte.end() és az it != end összehasonlítás:
take esetén nem sized_range alapon ez további
iterálást jelenthet, mert a végpontot ki kell számítani.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.
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
./pipelineA 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.
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.
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:
std::vector<int> v) esetén az eredmény egy
ref_view: nem birtokol, csak hivatkozik v-re,
ezért v-nek túl kell élnie a view-t és az iterátorait;make_vector() hívás eredménye) esetén a modern C++20
könyvtárak owning_view-ot készítenek, amely mozgatással
birtokolja a tárolót. Ennek a viselkedésnek a pontos bevezetése
verziófüggő (a P2415R2 pontosítás nyomán), ezért a konkrét fordítón és
standard library-n ellenőrizni kell;std::string_view, std::span vagy saját view)
esetén a views::all a view-t másolja. A másolat ugyanarra a
külső adatra néz, tehát ha a külső adat megszűnt, a pipeline dangling
marad — az owning_view itt nem segít, mert nincs mit
birtokolni.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 |
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:
filter után a range jellemzően nem
sized_range, mert a méret csak bejárással derülne ki; ezért
a std::ranges::distance itt O(n) lesz;transform megőrzi a sized és
common tulajdonságot, ha az alap rendelkezik vele, de a
contiguous-ot nem;take nem sized alapon a végpontot
iterálással számolja, és tipikusan nem common_range (ezért
a views::reverse rá gyakran nem is alkalmazható);drop random access alapon O(1)
begin()-et ad, láncolt alapon O(n)-t;views::reverse bidirectional_range-et
igényel, és a C++20 szövege common_range alapot vár; a
pontos megkötés a használt standard és library verziójától függ, ezért
érdemes static_assert-tel ellenőrizni.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.
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).make_view()
fenti példája; javítás: a tároló visszaadása, vagy szándékos
owning_view.[&] 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ó.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.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.transform-ban a lustaság miatt kiszámíthatatlan
számban futhat le, és a take miatt el is maradhat.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.
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?
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_sqIndoklá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.