C++20: Pythonisch mit der Ranges-Bibliothek

Modernes C++  –  43 Kommentare

Heute starte ich ein kleines Experiment. Ich will sehr beliebte Funktionen in Python mithilfe der Ranges-Bibliothek implementieren. Ich bin neugierig, wie leicht mir die Umsetzung von Python in C++ fällt.

Seit 2004 gebe ich Python-Schulungen. Die Sprache besitzt beeindruckende Funktionen. Daher ist Python für mich die Messlatte für eine angenehm zu verwendende Programmiersprache. Heute werde ich die Python-Funktionen range und filter implementieren:

  • range erzeugt eine Liste "containing an arithmetic progression of integers" (Pythons built-in help).
  • filter wendet ein Prädikat auf eine Sequenz an und gibt die Elemente zurück, für die das Prädikat gilt.

Eine Sequenz ist ein Begriff in Python, der für etwas steht, über das iteriert werden kann. Dies kann eine Liste ([1, 2, 3]), ein Tupel ((1, 2, 3)) oder ein String ("123") sein. Anstelle einer Liste kommt in diesem Artikel ein std::vector in C++ zum Einsatz. Die Funktion filter steht für die funktionale Ader in Python.

Bevor ich beginne, möchte ich noch ein paar Bemerkungen loswerden:

  1. Ich verwende in meinen Beispielen Eric Niebers range-v3-Bibliothek, die die Basis für die C++20-Ranges-Bibliothek ist. Im vorherigen Artikel "C++20: Die Ranges-Bibliothek" habe ich gezeigt, wie sich das Beispiel in C++20 übersetzen lässt.
  2. Der Python-Code ist oft kürzer als das C++-Pendant. Dafür gibt es zwei einfache Gründe: Ich speichere die Listen in Python in keiner Variable ab und gebe sie nicht aus.
  3. Ich möchte keinen Glaubenskrieg zu Programmiersprachen starten. Daher werde ich auf entsprechende Kommentare nicht reagieren.

Jetzt geht es aber los mit der range-Funktion. Sie ist das "Schweizer Taschenmesser" in Python für das Erzeugen ganzer Zahlen.

range

Das folgende Beispiel zeige erst das Python-Beispiel in auskommentierter Form und anschließend das C++ Pendant:

// range.cpp

#include <iostream>
#include <range/v3/all.hpp>
#include <vector>

std::vector<int> range(int begin, int end, int stepsize = 1) {
std::vector<int> result{};
if (begin < end) { // (5)
auto boundary = [end](int i){ return i < end; };
for (int i: ranges::views::iota(begin)
| ranges::views::stride(stepsize)
| ranges::views::take_while(boundary)) {
result.push_back(i);
}
}
else { // (6)
begin++;
end++;
stepsize *= -1;
auto boundary = [begin](int i){ return i < begin; };
for (int i: ranges::views::iota(end)
| ranges::views::take_while(boundary)
| ranges::views::reverse
| ranges::views::stride(stepsize)) {
result.push_back(i);
}
}
return result;
}

int main() {

std::cout << std::endl;

// range(1, 50) // (1)
auto res = range(1, 50);
for (auto i: res) std::cout << i << " ";

std::cout << "\n\n";

// range(1, 50, 5) // (2)
res = range(1, 50, 5);
for (auto i: res) std::cout << i << " ";

std::cout << "\n\n";

// range(50, 10, -1) // (3)
res = range(50, 10, -1);
for (auto i: res) std::cout << i << " ";

std::cout << "\n\n";

// range(50, 10, -5) // (4)
res = range(50, 10, -5);
for (auto i: res) std::cout << i << " ";

std::cout << "\n\n";

}

Die Zeilen (1) bis (4) sollten einfach zu lesen sein, wenn du ihre Ausgabe betrachtest:

Die ersten zwei Argumente für den range-Aufruf stehen für den Anfang und das Ende der zu erzeugenden Integrale. Dabei ist der Anfang inklusiv und das Ende exklusiv. Die Schrittweite ist das dritte Argument, das per default 1 ist. Wenn der Intervall [begin, end] absteigend ist, sollte die Schrittweite negativ sein. Falls nicht, erhältst du eine leere Liste oder einen leeren std::vector<int>.

Ich habe in meinem Beispiel ein wenig geschummelt. Die Funktion ranges::views::stride(n), die nicht Bestandteil von C++20 ist, gibt das n-te Element eines Ranges zurück. Schreibe mir, wenn du eine elegante Implementierung basierend auf C++20 kennst.

Die if-Bedingung (begin < end) der range-Funktion in Zeile (1) sollte einfach zu verdauen sein. Erzeuge alle Integrale, die mit begin starten (ranges::views::iota(begin)), entnehme jedes n-te Element (ranges::views::stride(stepsize)) und fahre so lange fort, bis die Stopp-Bedingung eintritt (ranges::views::take_while(boundary)). Schiebe die Elemente zum Abschluss auf den std::vector<int>.

Im else-Fall (Zeile 2) musste ich einen Trick anwenden. Der Algorithmus erzeugt die Integrale [end++, begin++], schreitet voran, bis die Stopp-Bedingung erreicht ist, kehrt ihre Reihenfolge um (ranges::views::reverse) und entnimmt jedes n-te Element.

Das Beipiel verwendet die gierige Version von filter und map (dazu mehr im nächsten Artikel). Mit Python 3 wird filter und map lazy. map und lazy geben in diesem Fall nur Generatoren zurück. Um das gierige Verhalten von Python 2 zu erzwingen, muss in Python 3 daher ein list-Aufruf um die filter- und map-Aufrufe gesetzt werden.

filter(lambda i: (i % 2) == 1 , range(1, 10))       # Python 2   

list(filter(lambda i: (i % 2) == 1 , range(1, 10))) # Python 3

Beide Aufrufe erzeugen dieselbe Liste: [1, 3, 5, 7, 9].

Nun geht es mit der Funktion filter weiter, da sie einfacher als die Funktion map zu implementieren ist.

filter

// filter.cpp

#include "range.hpp" // (1)

#include <fstream>
#include <iostream>
#include <range/v3/all.hpp>
#include <sstream>
#include <string>
#include <vector>
#include <utility>

template <typename Func, typename Seq> // (2)
auto filter(Func func, Seq seq) {

typedef typename Seq::value_type value_type;

std::vector<value_type> result{};
for (auto i : seq | ranges::views::filter(func)) result.push_back(i);

return result;
}


int main() {

std::cout << std::endl;

// filter(lambda i: (i % 3) == 0 , range(20, 50)) // (3)
auto res = filter([](int i){ return (i % 3) == 0; }, range(20, 50) );
for (auto v: res) std::cout << v << " ";

// (4)
// filter(lambda word: word[0].isupper(), ["Only", "for", "testing", "purpose"])
std::vector<std::string> myStrings{"Only", "for", "testing", "purpose"};
auto res2 = filter([](const std::string& s){ return static_cast<bool>(std::isupper(s[0])); }, myStrings);

std::cout << "\n\n";

for (auto word: res2) std::cout << word << std::endl;

std::cout << std::endl;

// (5)
// len(filter(lambda line: line[0] == "#", open("/etc/services").readlines()))
std::ifstream file("/etc/services", std::ios::in);
std::vector lines;
std::string line;
while(std::getline(file, line)){
lines.push_back(line);
}
std::vector<std::string> commentLines = filter([](const std::string& s){ return s[0] == '#'; }, lines);

std::cout << "Comment lines: " << commentLines.size() << "\n\n";

}

Bevor ich das Programm erläutere, möchte ich seine Ausgabe vorstellen:

In diesem Beispiel wird die range-Implementierung nur inkludiert (Zeile 1). Die filter-Funktion in Zeile 2 sollte einfach zu lesen sein. Sie wendet lediglich die aufrufbare Einheit func auf jedes Element der Sequenz an und materialisiert sie in dem std::vector. Die Zeile 3 erzeugt alle Zahlen von 20 bis 50, für die (i % 3) == 0 gilt. Nur die Strings, die mit einem Großbuchstaben beginnen, überwinden den Filter in Zeile (4). Die Zeile (5) zählt, wie viele Zeilen in der Datei "/etc/services" Kommentare sind. Kommentare sind Zeilen, die mit "'#" beginnen.

Wenn du die verschiedenen Arten, Lambdas in Python und C++ zu erzeugen, ignorierst, sind die filter Aufrufe sehr ähnlich.

Wie geht's weiter?

map war deutlich schwieriger, als filter in C++ zu implementieren. Einerseits kann map den Typ der Inputsequenz ändern. Andererseits verursachte meine map-Implemtierung einen GCC-Bugreport. Danach kombiniere ich die Funktionen map und filter in einer Funktion und erhalte ... . Die Details gibt es in meinem nächsten Artikel.