A lazy view pipeline sokkal közelebb áll a C callback-mintához, mint
elsőre látszik: egy nézet nem tulajdonolja az elemeket, csak egy
szelekciós transzformációt ír le. Ez a rugalmasság három egymástól
független élettartamot hoz be a kódba, és ha ezeket a program
összemosolja, az iterátor pontos típusa már nem segít — a
begin() hívás sikeresen lefut, csak a kapott érték
másodpercekkel később érvénytelenné válik. Ez az alfejezet azt a három
réteget különíti el, megmutatja, mit garantál pontosan a
std::ranges::borrowed_range, mit jelképez a
std::ranges::dangling, és miért nem oldja meg önmagában a
borrowed_range a filter_view-k körüli tipikus
hibákat.
A C++ ranges világában minden elemzési folyamatot először három külön fogalomra kell bontani:
std::vector, egy std::string, egy
std::array, egy C-tömb, vagy éppen mmap
mögötti memória. Élettartama az owner objektumé.std::string_view, std::span,
filter_view, subrange). Tárolja a referenciát
a backing storage-ra, a szűrőt/projekciót és a határt. Élettartama a
változóé, amibe kötjük, vagy a temporary kifejezés végéig tart.filter_view vagy
transform_view iterátora az aktuális view
objektumra mutató pointert is tartalmaz, mert a filter-állapotot
vagy a projekciós függvényt onnan olvassa. Ilyenkor az iterátor
élettartama a view objektumhoz kötődik, nem a tárolóhoz.A három réteg közti függés táblázatosan:
| Réteg | Ki birtokolja | Mi szünteti meg | Mit visz magával, ha megszűnik |
|---|---|---|---|
| Háttértároló | vector, string, array,
C-tömb, mmap owner |
owner destruktora, deallokáció | minden rá mutató pointer, view, iterator |
| View objektum | a változó, amibe kötöttük | scope vége, temporary vége | minden olyan iterátor, ami a view-ra hivatkozik (pl.
filter, transform) |
| Iterator | a hívó | nem „szüntethető meg” — csak érvénytelenné válhat | ő a függő entitás, nem őt kell életben tartani |
Ez a felosztás magyarázza, miért lehet egy view objektumon
keresztül sok iterátort létrehozni, és miért nem lehet a
view-tól független iterátort továbbéltetni. A Lazy
view pipeline és mellékhatások (30.4) fejezetben felépített
filter-lánc élettartam-kockázata pontosan ebből a három
rétegből ered.
borrowed_range — és mit nemA std::ranges::borrowed_range<R> a C++20 standard
[range.range] szakasza szerint akkor teljesül, ha:
A jelentése egyetlen szemantikai szabály: egy R típusú
range-ből szerzett iterátor érvényessége nem függ annak a
konkrét range objektumnak az élettartamától, amelyből
szereztük. Egy függvény tehát érték szerint (vagy forwarding
referenciával) átvehet egy borrowed range-et, és biztonságosan
visszaadhatja a belőle kapott iterátorokat; az iterátor ettől még
függhet egy másik, tovább élő objektumtól (a mögöttes
tárolótól), és annak élettartamát a hívónak kell biztosítania. Két út
vezet ide:
enable_borrowed_range<T> igaz: a
típus maga ígéri, hogy iterátorai nem a range objektumba, hanem azon
kívülre mutatnak. A std::span és a
std::string_view iterátora a hivatkozott puffert járja be,
ezért a span vagy string_view objektum megszűnése után is érvényes —
amíg a puffer él.R lvalue referencia: ilyenkor a „range
objektum” a hívó által birtokolt, a hívásnál tovább élő objektum. Ha
R például std::vector<int>&, a
ranges::find által visszaadott iterátor a hívás után is
érvényes, mert a vektor a hívó változója — de csak amíg az a vektor él.
Ugyanez igaz egy filter_view&-re: a visszaadott
iterátor addig érvényes, amíg a hívó filter_view objektuma
él.Prvalue std::vector<int> vagy prvalue
filter_view esetén egyik út sem áll fenn: a vektor
iterátora a temporary pufferébe, a filter_view iterátora a
view objektumra (a predikátumra és a begin()-cache-re) is
hivatkozik, ezért a range-algoritmusok ilyenkor
std::ranges::dangling-et adnak vissza.
Néhány konkrét eset:
| Típus | borrowed_range |
Miért |
|---|---|---|
std::vector<int>& |
igaz | lvalue referencia szabálya |
std::vector<int> |
hamis | nincs enable_borrowed_range specializáció |
std::string_view |
igaz | enable_borrowed_range = true |
std::span<int> |
igaz | enable_borrowed_range = true |
std::ranges::subrange<I, S> |
igaz | enable_borrowed_range = true |
std::ranges::filter_view<V, P> |
hamis | nincs specializáció, iterátor view-függő |
std::ranges::filter_view<V, P>& |
igaz | lvalue szabálya, de ez nem védi a view élettartamát |
A borrowed_range tehát nem a backing storage
élettartamát garantálja, hanem csak azt, hogy az iterátorok nem a range
objektum élettartamához kötöttek. Ez egy szűk, de nagyon hasznos
szabály: pont az std::ranges::find(std::vector{1, 2, 3}, 2)
típusú, azonnal danglelő hívásokat fogja meg vele a fordító.
// borrowed_cases.cpp — C++20: mikor ad vissza a ranges::find iterátort, és mikor dangling-et?
#include <algorithm>
#include <ranges>
#include <span>
#include <type_traits>
#include <vector>
std::vector<int> make() { return {1, 2, 3}; }
inline auto odd = [](int x) { return x % 2 != 0; };
int main() {
std::vector<int> v{1, 2, 3};
auto fv = v | std::views::filter(odd);
using std::ranges::dangling;
// span prvalue: az iterátor a v puffere, nem a span objektum függvénye
static_assert(!std::is_same_v<decltype(std::ranges::find(std::span<int>(v), 2)), dangling>);
// vector& (lvalue): az objektum a hívón kívül él tovább
static_assert(!std::is_same_v<decltype(std::ranges::find(v, 2)), dangling>);
// vector prvalue: az iterátor a megszűnő temporary pufferébe mutatna
static_assert(std::is_same_v<decltype(std::ranges::find(make(), 2)), dangling>);
// filter_view prvalue: az iterátor a view-ra (predikátum, cache) is hivatkozik
static_assert(std::is_same_v<decltype(std::ranges::find(v | std::views::filter(odd), 3)), dangling>);
// filter_view& (lvalue): a hívó view-ja tovább él — de csak amíg fv él
static_assert(!std::is_same_v<decltype(std::ranges::find(fv, 3)), dangling>);
}Buildjegyzőkönyv (lab-gcc profil).
g++ -std=c++20 -Wall -Wextra -Wpedantic borrowed_cases.cpp
figyelmeztetés nélkül fordul: a span, a vector& és a
filter_view& esetén a ranges::find
iterátort, a prvalue vektor és a prvalue filter_view esetén
std::ranges::dangling-et ad vissza.
Saját típusra akkor érdemes enable_borrowed_range
specializációt adni, ha az iterátor tényleg független a view
objektumtól:
struct raw_span {
const int* data;
std::size_t size;
const int* begin() const noexcept { return data; }
const int* end() const noexcept { return data + size; }
};
namespace std::ranges {
inline constexpr bool enable_borrowed_range<raw_span> = true;
}Ez akkor helyes, ha belső iterátor-típusunk (const int*)
érvényes marad a raw_span megszűnése után is. Ha bármit is
a raw_span objektum tárol, amire az iterátor hivatkozik,
akkor ez a specializáció hibás — és ezt a standard nem tudja
kikényszeríteni, ez diszciplína kérdése.
std::ranges::dangling és biztonságos visszatérésA range-algoritmusok visszatérési típusa a
borrowed_iterator_t<R> alias, amit a standard így
definiál:
A std::ranges::dangling egy üres, csaknem viselkedés
nélküli marker típus:
namespace std::ranges {
struct dangling {
constexpr dangling() noexcept = default;
template<class... Args>
constexpr dangling(Args&&...) noexcept {}
};
}Mivel nem iterator, nem dereferálható, nem léptethető. Ha tehát a hívó kód megpróbálja használni a visszatérési értéket, a hiba fordítási időben derül ki, nem pedig futásidőben csendes UB formájában.
#include <algorithm>
#include <iostream>
#include <ranges>
#include <string>
#include <string_view>
#include <type_traits>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5};
// Lvalue tartomány: a visszatérés std::vector<int>::iterator
auto it = std::ranges::find(v, 3);
std::cout << "found (lvalue): " << *it << '\n';
// Temporális tartomány: a visszatérés std::ranges::dangling
auto d = std::ranges::find(std::vector{1, 2, 3}, 2);
static_assert(std::is_same_v<decltype(d), std::ranges::dangling>);
// *d; // fordítási hiba
// filter_view lvalue változóban: érvényes, amíg evens él
auto evens = v | std::views::filter([](int x) { return x % 2 == 0; });
for (int e : evens) std::cout << e << ' ';
std::cout << '\n';
// filter_view temporális: a .begin() iterátora danglel
auto bad = (v | std::views::filter([](int x) { return x > 3; })).begin();
(void)bad; // ÉRVÉNYTELEN, ha használnánk: UB
return 0;
}Fordítás (a laborhoz rögzített GCC/Clang és libstdc++/libc++ verzióval):
g++ -std=c++20 -O0 -Wall -Wextra -o dangling_demo dangling_demo.cppA static_assert és a kommentelt dereferencia miatt a
program fordul és lefut, de a 4. eset bad
változója UB, ha használjuk. A program kimenete nem mutatja
meg a hibát — ez a lényeg: a dangling marker csak a
std::ranges algoritmusok egy részére érvényes, a
tag- és member-begin() hívásokon
keresztül vezető útvonalakra nem.
filter_view és transform_viewA filter_view::iterator és a
transform_view::iterator a standardban deklarált módon egy
parent_ pointert tartalmaz az aktuális view objektumra. Ezt
nem cache-ként vagy kényelmi okból tárolja, hanem mert a filter
predikátum és a transform projekció abban a view objektumban
van, ahol az argumentumokat is átadták — a view az owner. Emiatt:
filter_view / transform_view
temporális, a .begin() után azonnal danglel az
iterátor.borrowed_range ezt nem feltétlenül jelzi, ha a hívó
lvalue referenciát ad át.A subrange, span, string_view
más a helyzet: belső iterátoraik csak a backing storage-ra mutatnak,
nincs parent_ pointer. Ezért ezek biztonságosak a view
objektum megszűnése után is, feltéve, hogy a backing storage él. A
ref_view<R> is ebbe a kategóriába tartozik; az
owning_view<R> saját maga az owner, de rvalue
formában enable_borrowed_range<owning_view> nincs
specializálva, mert a storage a view-val együtt megszűnik.
A klasszikus példa string_view-re:
std::string_view sv = std::string{"hello"}; // a temporary string a sor végén megszűnik
std::size_t n = sv.size(); // UBA std::string ideiglenes, a string_view egy
pointert és egy hosszt tárol. A temporary élettartama a teljes
deklarációs utasítás végéig tart, a string_view változó
tovább él — de a mögötte lévő storage felszabadult.
Ugyanez a hiba view-okkal, csak rejtettebb formában:
// HIBÁS: temporális filter_view, .begin() után danglel
auto it = (std::vector<int>{1, 2, 3, 4, 5} | std::views::filter([](int x) { return x > 2; })).begin();A kifejezés típusa
filter_view<owning_view<vector<int>>, lambda>.
A .begin() működése után a temporális view (és benne az
owning_view által birtokolt vector) elpusztul, az iterátor
danglel. Ezért a láncot mindig nevesített változóba
kell kötni, ha az iterátor vagy a view tovább él, mint az utasítás:
// HELYES: a view túléli az iterátor használatát
auto data = std::vector<int>{1, 2, 3, 4, 5};
auto view = data | std::views::filter([](int x) { return x > 2; });
for (int x : view) { /* ... */ }A gyakori counter-tipp a const auto& — de a
filter_view és a transform_view
begin() metódusa nem const, így
const auto& view = ... után view.begin()
fordítási hibát ad, nem pedig élettartam-hosszabbítást. Ezt sokan
félreértik: ilyenkor a view objektum élettartama valóban meghosszabbodik
(mert const& kötés történt), de iterálni nem lehet. A
helyes megoldás a nem-const nevesített változó, vagy a backing storage
és a view külön kezelése.
A backing storage mutációja is danglelő állapotot teremt, akkor is,
ha a view objektum végig él. A std::vector esetén a
standard garantálja, hogy minden olyan művelet, ami
kapacitásváltozással járhat (push_back,
emplace_back, insert, resize,
reserve, clear), érvényteleníti a korábban
szerzett iterátorokat és pointereket. A filter_view
iterátora ezen felül a belső „first-match” pozíciót is eltárolja, így az
újraallokáció után nem csak a memória, hanem a view logikája is
inkonzisztens.
// HIBÁS: iterálás közben növeljük a vektort
std::vector<int> v{1, 2, 3, 4, 5};
auto evens = v | std::views::filter([](int x) { return x % 2 == 0; });
for (int e : evens) {
if (e == 2) {
v.push_back(4); // UB: az iterátor a régi pufferre mutat
}
}A helyes minta: Ha a ciklus közben módosítani kell a tárolót, az
iterációt egy pillanatkép-példányon (std::vector másolatán)
vagy egy for (auto it = ...) kézi cikluson végezzük, ahol a
mutáció előtt megszűnik az iterátorok élettartama. Elem-módosítás
(v[i] = ...) nem érvényteleníti az iterátorokat,
de a filter_view első-match logikájával ütközhet, ha a
módosítás megváltoztatja a predikátum igazságértékét — ezt a
filter_view nem ellenőrzi, mert nincs cache-elt állapota.
Ez a viselkedés a Lazy view pipeline és
mellékhatások (30.4) fejezetben tárgyalt „lazy nem egyenlő
cache-elt” elv közvetlen folyománya.
auto it = (temporary_range | filter(...)).begin(); → a
temporális view és a backing storage is danglel.
Javítás: nevesített változóba kösd a view-t, vagy
owning_view-ként tárold a tárolót.const auto& view = v | std::views::filter(pred); →
nem iterálható, mert a filter_view::begin() nem
const. Javítás:
auto view = ...;.auto it = std::ranges::find(std::vector{1, 2, 3}, 2); →
std::ranges::dangling, fordítási hiba dereferáláskor.
Javítás: nevezd el a vector-t.borrowed_range specializáció olyan típusra, aminek az
iterátora a view-tól függ. Javítás: elhagyni a
specializációt.filter_view-n. Javítás: pillanatkép (v
másolata), vagy a mutációt a cikluson kívül elvégezni.std::ranges::find visszatérési értékét
auto&&-dzsel vagy decltype(auto)-tal
továbbadni — ez dangling esetén sem segít, mert a típus már
std::ranges::dangling.Az alábbi kódban nevezd meg minden érintett entitás (backing storage, view objektum, iterator) élettartamát, és jelöld, hol danglel:
#include <ranges>
#include <string>
#include <string_view>
#include <vector>
int main() {
auto sv = std::string_view{std::string{"temp"}};
auto it = std::ranges::find(std::vector{1, 2, 3}, 2);
return static_cast<int>(sv.size());
}Az alábbi függvény visszaad egy iterátort, de a hívó oldalon danglel.
Javítsd borrowed_iterator_t segítségével úgy, hogy a hibás
hívás fordítási hibát okozzon:
#include <ranges>
#include <vector>
auto find_three(const std::vector<int>& v) {
return std::ranges::find(v, 3);
}Magyarázd meg, miért ad az
std::ranges::filter_view<std::vector<int>&, Pred>
lvalue referenciára borrowed_range = true, miközben az
iterátor a view objektum megszűnése után danglel. Milyen esetekre terjed
ki a borrowed_range védelme?
Írj egy
first_over(std::vector<int> const& v, int threshold)
függvényt, amely a tárolót nem másolja, de az eredményt úgy
adja vissza, hogy a hívó tudja: (a) az iterátor a backing storage-ra
mutat, (b) a hívó felelőssége életben tartani a vector-t. Használd a
std::ranges::borrowed_iterator_t-et a visszatérési
típusban, és egészítsd ki static_assert-tel, ami ellenőrzi
a szerződést.
Az sv mögötti backing storage a
std::string{"temp"} ideiglenes, ami a sor végén megszűnik;
ekkor a std::string_view danglel, sv.size()
már UB. A std::vector{1, 2, 3} temporális, a
std::ranges::find erre hívva
std::ranges::dangling-et ad vissza, így az eredmény nem is
dereferálható. Az it tehát nem iterator, hanem marker; a
program it felhasználása nélkül lefut, de a
sv.size() hívás önmagában UB. Helyes változat:
std::string owner{"temp"}; std::string_view sv = owner;
illetve
std::vector<int> v{1, 2, 3}; auto it = std::ranges::find(v, 2);.
Ellenőrzési feltétel: a
std::is_same_v<decltype(it), std::ranges::dangling>
igazsága a javított változatban hamis.
A hiba forrása, hogy a visszatérési típus
std::vector<int>::const_iterator, de a
find a v referencián dolgozik, aminek az
élettartama a hívótól függ. Ha a hívó egy temporálist ad át, a
v megszűnik, mire a hívó használja az iterátort. A
borrowed_iterator_t megoldás:
#include <algorithm>
#include <ranges>
#include <type_traits>
#include <vector>
template <std::ranges::input_range R>
auto find_three(R&& r) -> std::ranges::borrowed_iterator_t<R> {
return std::ranges::find(std::forward<R>(r), 3);
}A borrowed_iterator_t<R> automatikusan
std::ranges::dangling-re vált, ha R nem
borrowed. Így a find_three(std::vector<int>{1, 2, 3})
hívás dangling-et ad, a * dereferálás
fordítási hiba. Ellenőrzési feltétel:
static_assert(std::is_same_v<decltype(find_three(std::vector<int>{})), std::ranges::dangling>);.
A borrowed_range lvalue-re vonatkozó ága azt a helyzetet
fedi le, amikor a range kifejezés típusa referencia — az iterátor és a
range objektum között nincs kötelező tulajdonosi viszony. Ezt
használja ki a filter_view, amikor lvalue-re hívjuk: az
iterátor a háttértárolóra mutat, de a filter-állapotot a view-ból
olvassa. A borrowed_range csak a rvalue esetre ad
„védelmet” azzal, hogy a std::ranges algoritmusok
dangling-et adnak vissza. A lvalue filter_view
esetén a compiler nem tudja észrevenni, hogy a view később megszűnik —
ezt a programozónak kell életben tartania. A borrowed_range
tehát nem véd az „eltűnt lvalue view” hibaosztály
ellen; csak az „azonnal megszűnő temporális tartomány” hibáját fogja
meg. Ez a hatókör-korlát fontos: ne tekintsük a
borrowed_range-et teljes élettartam-garanciának.
#include <algorithm>
#include <ranges>
#include <type_traits>
#include <vector>
auto first_over(std::vector<int> const& v, int threshold)
-> std::ranges::borrowed_iterator_t<std::vector<int> const&>
{
return std::ranges::find_if(v, [threshold](int x) { return x > threshold; });
}
static_assert(std::is_same_v<
decltype(first_over(std::declval<std::vector<int> const&>(), 0)),
std::vector<int>::const_iterator>);Mivel a const& lvalue, a borrowed_range
igaz, a visszatérés const_iterator. A szerződés a hívó
felé: a visszaadott iterátor a v backing storage-ára mutat,
ezért v-nek élnie kell, amíg az iterátort használjuk. Ezt a
szerződést a dokumentáció rögzítse, és a hívó ne adjon át temporálist.
Ellenőrzési feltétel: a static_assert a
const_iterator-t várja, nem dangling-et. Ha
módosítanánk a függvényt std::vector<int> (nem
referencia) paraméterre, a static_assert átállna
std::ranges::dangling-re — ez jelzi, hogy a szerződés
megsértődött.
A ranges-alapú kód három, egymástól független élettartamot ölel fel:
a backing storage-ot (owner), a view objektumot (nem birtokló leíró), és
az iterator/predikátum kapcsolatot (függő entitás). A
std::ranges::borrowed_range csak a range objektum típusára
tesz állítást, és csak az „ideiglenes tartomány” hibaosztályt fogja meg
— a filter_view és transform_view iterátorai a
view objektumra mutató pointert is tárolnak, ezért ezek lvalue formában
is danglelnek, ha a view megszűnik. A std::ranges::dangling
marker a fordítót használja ki: a nem-borrowed hívások eredményét típus
szinten használhatatlanná teszi. A biztonságos minta: a backing
storage-ot külön nevesített ownerbe tenni, a view-t nem-const nevesített
változóba kötni, és a
filter_view/transform_view iterátorait csak a
view élettartamán belül használni. A borrowed_range nem
fogja meg a view objektum eltűnését — ezért a filter- és
transform-láncok szerződését mindig a tároló → view →
iterator sorrendben kell ellenőrizni.