Verbesserte Iteratoren mit Ranges

Es gibt noch mehr Gründe, die Ranges-Bibliothek der klassischen STL vorzuziehen. Sie bieten einheitliche Lookup-Regeln und zusätzliche Sicherheitsgarantien.

Lesezeit: 4 Min.
In Pocket speichern
vorlesen Druckansicht Kommentare lesen
Von
  • Rainer Grimm

Es gibt noch mehr Gründe, die Ranges-Bibliothek der klassischen Standard Template Library vorzuziehen. Die Ranges-Iteratoren unterstützen einheitliche Lookup-Regeln und bieten zusätzliche Sicherheitsgarantien.

Angenommen, du möchtest eine generische Funktion implementieren, die begin auf einem Container aufruft. Die Frage ist, ob die Funktion, die begin auf dem Container aufruft, eine freie begin-Funktion oder eine Memberfunktion begin annehmen soll?

// begin.cpp

#include <cstddef>
#include <iostream>
#include <ranges>

struct ContainerFree {                                        // (1)
    ContainerFree(std::size_t len): len_(len), data_(new int[len]){}
    size_t len_;
    int* data_;
};
int* begin(const ContainerFree& conFree) {                   // (2)
    return conFree.data_;
}

struct ContainerMember {                                     // (3)
    ContainerMember(std::size_t len): len_(len), data_(new int[len]){}
    int* begin() const {                                     // (4)
        return data_;
    }
    size_t len_;
    int* data_;
};

void callBeginFree(const auto& cont) {                        // (5)
    begin(cont);
}

void callBeginMember(const auto& cont) {                      // (6)
    cont.begin();
}
 
int main() {

    const ContainerFree contFree(2020);
    const ContainerMember contMemb(2023);

    callBeginFree(contFree);                                 
    callBeginMember(contMemb);

    callBeginFree(contMemb);                                  // (7)
    callBeginMember(contFree);                                // (8)
   
}

ContainerFree (Zeile 1) besitzt eine freie Funktion begin (Zeile 2), und ContainerMember (Zeile 3) hat eine Memberfunktion begin (Zeile 4). Dementsprechend kann contFree die generische Funktion callBeginFree mit dem freien Funktionsaufruf begin(cont)(Zeile 5) und contMemb kann die generische Funktion callBeginMember mit dem Member-Funktionsaufruf cont.begin (Zeile 6) verwenden. Wenn ich callBeginFree und callBeginMember mit den ungeeigneten Containern in Zeile (7) und (8) aufrufe, schlägt die Kompilierung fehl.

Dieses Problem lässt sich lösen, indem ich zwei verschiedene begin-Implementierungen bereitstelle: klassisch und auf Range basierend.

// beginSolved.cpp

#include <cstddef>
#include <iostream>
#include <ranges>

struct ContainerFree {
    ContainerFree(std::size_t len): len_(len), data_(new int[len]){}
    size_t len_;
    int* data_;
};
int* begin(const ContainerFree& conFree) {
    return conFree.data_;
}

struct ContainerMember {
    ContainerMember(std::size_t len): len_(len), data_(new int[len]){}
    int* begin() const {
        return data_;
    }
    size_t len_;
    int* data_;
};

void callBeginClassical(const auto& cont) {
    using std::begin;                          // (1)
    begin(cont);
}

void callBeginRanges(const auto& cont) {
    std::ranges::begin(cont);                  // (2)
}
 
int main() {

    const ContainerFree contFree(2020);
    const ContainerMember contMemb(2023);

    callBeginClassical(contFree);
    callBeginRanges(contMemb);

    callBeginClassical(contMemb);
    callBeginRanges(contFree);
   
}

Der klassische Weg, dieses Problem zu lösen, besteht darin, std::begin mit einer sogenannten using-Deklaration (Zeile 1) in den Geltungsbereich zu bringen. Dank ranges kannst aber auch direkt std::ranges::begin verwenden (Zeile 2). std::ranges::begin berücksichtigt beide Implementierungen von begin: die freie Version und die Memberfunktion.

Abschließend möchte ich noch etwas zur Sicherheit schreiben.

Beginnen wir mit Iteratoren.

Die Ranges-Bibliothek bietet die erwarteten Operationen für den Zugriff auf den Bereich.

Wenn du diese Operationen für den Zugriff auf den zugrunde liegenden Bereich verwendest, gibt es einen großen Unterschied. Die Kompilierung schlägt fehl, wenn du die Variante von std::ranges verwendest und das Argument ein rvalue ist. Im Gegensatz dazu stellt die Verwendung der klassischen std-Variante undefiniertes Verhalten dar.

// rangesAccess.cpp

#include <iterator>
#include <ranges>
#include <vector>

int main() {

    auto beginIt1 = std::begin(std::vector<int>{1, 2, 3});
    auto beginIt2 = std::ranges::begin(std::vector<int>{1, 2, 3});

}

std::ranges::begin bietet nur Überladungen für lvalues an. Der temporäre Vektor std::vector{1, 2, 3} ist aber ein rvalue. Folglich schlägt die Kompilierung des Programms fehl.

Die Abkürzungen lvalue und rvalue stehen für locatable value und readable value.

  • lvalue (locatable value): Ein locatable Value (auffindbarer Wert) ist ein Objekt, das einen Ort im Speicher hat und dessen Adresse du daher bestimmen kannst. Ein lvalue besitzt eine Identität.
  • rvalue (readable value): Ein rvalue ist ein Wert, von dem du nur lesen kannst. Er stellt kein Objekt im Speicher dar, und du kannst seine Adresse nicht bestimmen.

Ich muss zugeben, dass meine kurzen Erklärungen zu lvalues und rvalues eine Vereinfachung darstellen. Wenn du mehr Details über Wertkategorien wissen willst, lies den Beitrag "Value Categories".

Übrigens: Nicht nur Iteratoren, sondern auch Views bieten diese zusätzlichen Sicherheitsgarantien.

Views besitzen keine Daten. Deshalb verlängern Views die Lebensdauer ihrer Daten nicht und können nur auf lvalues verwendet werden. Die Kompilierung schlägt fehl, wenn du eine View auf einen temporären Range erstellst.

// temporaryRange.cpp

#include <initializer_list>
#include <ranges>


int main() {

    const auto numbers = {1, 2, 3, 4, 5};

    auto firstThree = numbers | std::views::drop(3);             // (1)
    // auto firstThree = {1, 2, 3, 4, 5} | std::views::drop(3);  // (2)

    std::ranges::drop_view firstFour{numbers, 4};                // (3)
   // std::ranges::drop_view firstFour{{1, 2, 3, 4, 5}, 4};      // (4)
   
}

Wenn die Zeilen 1 und 3 mit dem lvalues numbers verwendet werden, ist alles in Ordnung. Im Gegensatz dazu führt die Verwendung der auskommentierten Zeilen 2 und 4 für den rvalue std::initializer_list<int> {1, 2, 3, 4, 5} zu einer wortreichen Beschwerde des GCC-Compilers:

In meinem nächsten Artikel werfe ich einen ersten Blick in die Zukunft: auf C++23. Vor allem die Ranges-Bibliothek wird viele Verbesserungen erhalten. So gibt es mit std::ranges::to eine bequeme Möglichkeit, Container aus Ranges zu konstruieren. Außerdem werden wir fast zwanzig neue, zusätzliche Algorithmen bekommen. Hier sind einige von ihnen: std::views::chunk_by, std::views::slide, std::views::join_with, std::views::zip_transform und std::views::adjacent_transform. (map)