30.5 Borrowed range, dangling és iterator lifetime

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.

30.5.1 Owner, view objektum és iterator: három különböző élettartam

A C++ ranges világában minden elemzési folyamatot először három külön fogalomra kell bontani:

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.

30.5.2 Mit garantál a borrowed_range — és mit nem

A std::ranges::borrowed_range<R> a C++20 standard [range.range] szakasza szerint akkor teljesül, ha:

borrowed_range(R)≡range(R)∧(is_lvalue_reference(R)∨enable_borrowed_range(remove_cvref(R))). \text{borrowed\_range}(R) \equiv \text{range}(R) \land \bigl(\text{is\_lvalue\_reference}(R) \lor \text{enable\_borrowed\_range}(\text{remove\_cvref}(R))\bigr).

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:

  1. 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.
  2. 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.

30.5.3 std::ranges::dangling és biztonságos visszatérés

A range-algoritmusok visszatérési típusa a borrowed_iterator_t<R> alias, amit a standard így definiál:

borrowed_iterator_t(R)={iterator_t(R),ha borrowed_range(R),std::ranges::dangling,egyébként. \text{borrowed\_iterator\_t}(R) = \begin{cases} \text{iterator\_t}(R), & \text{ha } \text{borrowed\_range}(R), \\ \text{std::ranges::dangling}, & \text{egyébként.} \end{cases}

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.cpp

A 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.

30.5.4 View-tól függő iterátorok: filter_view és transform_view

A 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:

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.

30.5.5 Temporális tartományok és a view escape csapda

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();                   // UB

A 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.

30.5.6 Mutáció, újraallokáció és pipeline invalidáció

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.

30.5.7 Gyakori hibák és javításuk

30.5.8 Feladatok

30.5.8.1 1. feladat (azonosítás)

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());
}

30.5.8.2 2. feladat (javítás)

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);
}

30.5.8.3 3. feladat (diagnosztika)

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?

30.5.8.4 4. feladat (tervezés, nehezebb)

Í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.

30.5.9 Megoldások

30.5.9.1 1. feladat megoldása

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.

30.5.9.2 2. feladat megoldása

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>);.

30.5.9.3 3. feladat megoldása

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.

30.5.9.4 4. feladat megoldása

#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.

30.5.10 Összefoglaló

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.