C++20: Pythonisch mit der Ranges-Bibliothek
"Modernes C++"-Blogger Rainer Grimm wagt ein kleines Experiment, und zwar implementiert er beliebte Funktionen in Python mit der Ranges-Bibliothek.
- Rainer Grimm
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:
- 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.
- 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.
- 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.
()