Zum Inhalt springen

Views, Lazy Evaluation & Komposition

Meistere C++20 Views — leichtgewichtige, lazy Range-Adapter, die sich mit dem Pipe-Operator komponieren lassen. Lerne filter, transform, take, drop, split, join, iota und wie man leistungsstarke Datenpipelines ohne Zwischenallokationen baut.

Was sind Views?

Eine View ist eine leichtgewichtige Range, die:

1. Nicht-besitzend ist — sie besitzt nicht die Elemente, auf die sie verweist
2. O(1) Kopieren und Verschieben hat — Views können günstig weitergereicht werden
3. Lazy ausgewertet wird — Elemente werden bei Bedarf während der Iteration berechnet, nicht im Voraus

Views sind die Bausteine von Range-Pipelines. Anstatt bei jedem Schritt Zwischencontainer zu erstellen, komponieren sich Views zu einem einzigen Durchlauf über die Daten:

// Traditionell: erstellt 2 Zwischenvektoren
auto evens = filter(data, is_even);     // Allokation
auto squared = transform(evens, sq);    // Allokation

// Views: null Zwischenallokationen
auto result = data | views::filter(is_even) | views::transform(sq);
// Noch nichts berechnet -- result ist nur ein View-Deskriptor
for (int x : result) { ... }  // Element für Element berechnet

Views befinden sich in std::views:: (ein Namespace-Alias für std::ranges::views::).

filter und transform — Die fundamentalen Views

views::filter behält Elemente, die einem Prädikat entsprechen. views::transform wendet eine Funktion auf jedes Element an. Zusammen ersetzen sie die Mehrheit expliziter Schleifen.

filter_transform.cpp
#include <ranges>
#include <vector>
#include <string>
#include <iostream>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    // Filter even numbers, then square them
    auto even_squares = numbers
        | std::views::filter([](int x) { return x % 2 == 0; })
        | std::views::transform([](int x) { return x * x; });

    // Nothing has been computed yet — even_squares is a view
    for (int x : even_squares) {
        std::cout << x << ' ';  // 4 16 36 64 100
    }
    std::cout << '\n';

    // Views work with any range — including strings
    std::string text = "Hello, World!";
    auto uppercase = text
        | std::views::filter([](char c) { return std::isalpha(c); })
        | std::views::transform([](char c) { return std::toupper(c); });

    for (char c : uppercase) {
        std::cout << c;  // HELLOWORLD
    }
    std::cout << '\n';

    // Collect view results into a vector (C++23: std::ranges::to)
    std::vector<int> collected;
    for (int x : even_squares) {
        collected.push_back(x);
    }
    // C++23: auto collected = even_squares | std::ranges::to<std::vector>();
}

take, drop und Slicing-Views

views::take(n) liefert die ersten n Elemente. views::drop(n) überspringt die ersten n Elemente. Zusammen ermöglichen sie effizientes Slicing ohne Kopieren.

take_drop.cpp
#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    // take — first N elements
    for (int x : data | std::views::take(3)) {
        std::cout << x << ' ';  // 1 2 3
    }
    std::cout << '\n';

    // drop — skip first N elements
    for (int x : data | std::views::drop(7)) {
        std::cout << x << ' ';  // 8 9 10
    }
    std::cout << '\n';

    // Combine: elements 3-7 (0-indexed: drop 3, take 4)
    for (int x : data | std::views::drop(3) | std::views::take(4)) {
        std::cout << x << ' ';  // 4 5 6 7
    }
    std::cout << '\n';

    // take_while / drop_while — predicate-based
    std::vector<int> sorted_data = {1, 3, 5, 7, 2, 4, 6};

    auto ascending_prefix = sorted_data | std::views::take_while(
        [prev = 0](int x) mutable {
            bool ok = x > prev;
            prev = x;
            return ok;
        });
    for (int x : ascending_prefix) {
        std::cout << x << ' ';  // 1 3 5 7
    }
    std::cout << '\n';

    // Pagination example: page 2, page_size = 3
    int page = 2, page_size = 3;
    auto page_view = data
        | std::views::drop((page - 1) * page_size)
        | std::views::take(page_size);
    for (int x : page_view) {
        std::cout << x << ' ';  // 4 5 6
    }
    std::cout << '\n';
}

split, join und String-Verarbeitungs-Views

views::split teilt eine Range durch ein Trennzeichen. views::join flacht verschachtelte Ranges ab. Diese sind leistungsstark für String- und Datenverarbeitung.

split_join.cpp
#include <ranges>
#include <string>
#include <string_view>
#include <vector>
#include <iostream>

int main() {
    // split — divide a string by delimiter
    std::string csv = "Alice,30,Engineer,95000";

    for (auto field : csv | std::views::split(',')) {
        // Each 'field' is a subrange — convert to string_view
        std::string_view sv(field.begin(), field.end());
        std::cout << '[' << sv << "] ";
    }
    std::cout << '\n';  // [Alice] [30] [Engineer] [95000]

    // join — flatten nested ranges
    std::vector<std::vector<int>> matrix = {
        {1, 2, 3},
        {4, 5, 6},
        {7, 8, 9}
    };
    for (int x : matrix | std::views::join) {
        std::cout << x << ' ';  // 1 2 3 4 5 6 7 8 9
    }
    std::cout << '\n';

    // Combine: split lines, then process each line
    std::string multiline = "hello world\nfoo bar\nbaz qux";
    int line_num = 1;
    for (auto line : multiline | std::views::split('\n')) {
        std::string_view line_sv(line.begin(), line.end());
        std::cout << line_num++ << ": " << line_sv << '\n';
    }

    // Enumerate (C++23)
    // for (auto [i, val] : data | std::views::enumerate) { ... }
}

views::iota — Ranges generieren

views::iota generiert eine Folge aufsteigender Werte. Es kann begrenzt (iota(0, 10) für 0..9) oder unbegrenzt (iota(0) für 0, 1, 2, ...) sein. Kombiniert mit take ersetzt es viele Zählschleifen.

views_iota.cpp
#include <ranges>
#include <iostream>
#include <vector>
#include <numeric>

int main() {
    // Bounded iota — replaces for(int i = 0; i < 10; ++i)
    for (int x : std::views::iota(0, 10)) {
        std::cout << x << ' ';  // 0 1 2 3 4 5 6 7 8 9
    }
    std::cout << '\n';

    // Unbounded iota + take — first 5 squares
    auto first_5_squares = std::views::iota(1)
        | std::views::transform([](int x) { return x * x; })
        | std::views::take(5);
    for (int x : first_5_squares) {
        std::cout << x << ' ';  // 1 4 9 16 25
    }
    std::cout << '\n';

    // Generate Fibonacci-like sequence with views
    // (iota as index generator)
    auto indices = std::views::iota(0, 20);
    auto even_indices = indices | std::views::filter([](int i) { return i % 2 == 0; });
    for (int i : even_indices | std::views::take(5)) {
        std::cout << i << ' ';  // 0 2 4 6 8
    }
    std::cout << '\n';

    // Use iota to create a vector of sequential values
    auto range_vec = std::views::iota(1, 11);  // 1 to 10
    int sum = 0;
    for (int x : range_vec) sum += x;
    std::cout << "Sum 1..10: " << sum << '\n';  // 55
}

Vollständige Datenpipelines bauen

Die wahre Stärke von Views zeigt sich, wenn du mehrere Adapter zu einer einzigen Pipeline komponierst. Die gesamte Pipeline wertet lazy in einem einzigen Durchlauf aus.

data_pipeline.cpp
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
#include <algorithm>

struct LogEntry {
    std::string level;   // "INFO", "WARN", "ERROR"
    std::string message;
    int timestamp;
};

int main() {
    std::vector<LogEntry> logs = {
        {"INFO",  "Server started",      1000},
        {"ERROR", "Connection refused",  1001},
        {"INFO",  "Request received",    1002},
        {"WARN",  "Slow query detected", 1003},
        {"ERROR", "Timeout expired",     1004},
        {"INFO",  "Request completed",   1005},
        {"ERROR", "Disk full",           1006},
        {"WARN",  "Memory usage high",   1007},
    };

    // Pipeline: get recent error messages, formatted
    auto recent_errors = logs
        | std::views::filter([](const LogEntry& e) {
              return e.level == "ERROR";
          })
        | std::views::drop(1)            // skip first error
        | std::views::transform([](const LogEntry& e) {
              return "[" + std::to_string(e.timestamp) + "] " + e.message;
          })
        | std::views::take(5);           // at most 5 results

    std::cout << "Recent errors (after first):\n";
    for (const auto& msg : recent_errors) {
        std::cout << "  " << msg << '\n';
    }
    // [1004] Timeout expired
    // [1006] Disk full

    // Reverse view — iterate backward without copying
    auto reversed = logs | std::views::reverse | std::views::take(3);
    std::cout << "\nLast 3 entries (reversed):\n";
    for (const auto& e : reversed) {
        std::cout << "  [" << e.level << "] " << e.message << '\n';
    }
}
Stolperfalle

Views sind nicht-besitzend — sie referenzieren die zugrunde liegende Range. Wenn die Quell-Range zerstört wird, wird die View zu einer Dangling-Referenz:

auto make_view() {
    std::vector<int> local = {1, 2, 3};
    return local | std::views::filter([](int x) { return x > 1; });
    // BUG: local wird zerstört, View dangled!
}

Achte auch auf Views über temporäre Objekte, die mitten in der Pipeline erstellt werden:

// FALSCH: Der String ist ein temporäres Objekt, das zerstört wird
auto words = std::string("hello world")
    | std::views::split(' ');  // dangling!

Stelle immer sicher, dass die Quelldaten alle Views überleben, die auf sie verweisen. Speichere die Daten in einer Variable mit ausreichender Lebensdauer, bevor du Views darüber erstellst.

Best Practice

Views sind lazy — keine Arbeit geschieht, bis iteriert wird. Das bedeutet:

1. Eine Pipeline aufzubauen ist im Wesentlichen kostenlos (speichert nur Funktionszeiger und Parameter)
2. Du kannst Views über sehr große oder sogar unendliche Ranges erstellen
3. Jedes Element durchläuft die gesamte Pipeline, bevor das nächste Element verarbeitet wird
4. Kurzschluss-Views wie take(n) stoppen die gesamte Pipeline nach n Elementen

Um View-Ergebnisse in einen Container zu materialisieren, iteriere und sammle:

std::vector<int> results;
for (int x : some_view) results.push_back(x);

// C++23 fügt std::ranges::to für sauberere Materialisierung hinzu:
auto results = some_view | std::ranges::to<std::vector>();

Bevorzuge es, Daten so lange wie möglich als Views zu halten — materialisiere nur, wenn du Direktzugriff, Persistenz brauchst oder APIs bedienen musst, die Container erfordern.

Wichtigste Erkenntnisse
  • Views sind nicht-besitzende, O(1)-kopierbare, lazy ausgewertete Range-Adapter
  • views::filter und views::transform sind die fundamentalen Bausteine für Datenpipelines
  • views::take(n) und views::drop(n) bieten Slicing ohne Kopieren
  • views::iota generiert Folgen — begrenzt oder unbegrenzt — und ersetzt Zählschleifen
  • Der Pipe-Operator | komponiert Views zu lesbaren Links-nach-rechts-Pipelines mit null Zwischenallokationen
  • Views dürfen ihre Quelldaten nicht überleben — stelle immer sicher, dass die zugrunde liegende Range eine ausreichende Lebensdauer hat

Quiz — Teste dein Wissen

(20 XP)

1. Wann berechnet eine C++20 View ihre Elemente?

2. Was ist das Ergebnis von `std::views::iota(1) | std::views::take(3)`?

3. Warum ist es gefährlich, eine View über eine lokale Variable zu erstellen und sie von einer Funktion zurückzugeben?