C++ Core Guidelines: Übergabe von Funktionsobjekten als Operationen

Ein Interface ist ein Vertrag zwischen einem Anwender und einem Implementierer. Daher sollte es mit großer Sorgfalt entworfen werden. Das gilt auch, wenn eine Operation als Argument zum Einsatz kommt.

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

Ein Interface ist ein Vertrag zwischen einem Anwender und einem Implementierer. Daher sollte es mit großer Sorgfalt entworfen werden. Das gilt auch, wenn eine Operation als Argument zum Einsatz kommt.

Heute beschäftige ich mich nur mit der Regel 40, denn Funktionsobjekte kommen sehr häufig in modernem C++ zum Einsatz.

Zuerst einmal magst du irritiert sein, dass die Regel zwar Lambda-Funktionen verwendet, aber nicht explizit auf sie eingeht. Auf diesen Punkt werde ich noch detaillierter eingehen.

Es gibt viele Möglichkeiten, einen Vektor von Strings zu sortieren:

// functionObjects.cpp

#include <algorithm>
#include <functional>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>

bool lessLength(const std::string& f, const std::string& s){ // (6)
return f.length() < s.length();
}

class GreaterLength{ // (7)
public:
bool operator()(const std::string& f, const std::string& s) const{
return f.length() > s.length();
}
};

int main(){

std::vector<std::string> myStrVec = {"523345", "4336893456", "7234",
"564", "199", "433", "2435345"};

std::cout << "\n";
std::cout << "ascending with function object" << std::endl;
std::sort(myStrVec.begin(), myStrVec.end(), std::less<std::string>()); // (1)
for (const auto& str: myStrVec) std::cout << str << " ";
std::cout << "\n\n";

std::cout << "descending with function object" << std::endl;
std::sort(myStrVec.begin(), myStrVec.end(), std::greater<>()); // (2)
for (const auto& str: myStrVec) std::cout << str << " ";
std::cout << "\n\n";

std::cout << "ascending by length with function" << std::endl;
std::sort(myStrVec.begin(), myStrVec.end(), lessLength); // (3)
for (const auto& str: myStrVec) std::cout << str << " ";
std::cout << "\n\n";

std::cout << "descending by length with function object" << std::endl;
std::sort(myStrVec.begin(), myStrVec.end(), GreaterLength()); // (4)
for (const auto& str: myStrVec) std::cout << str << " ";
std::cout << "\n\n";

std::cout << "ascending by length with lambda function" << std::endl;
std::sort(myStrVec.begin(), myStrVec.end(), // (5)
[](const std::string& f, const std::string& s){
return f.length() < s.length();
});
for (const auto& str: myStrVec) std::cout << str << " ";
std::cout << "\n\n";

}

Das Programm sortiert sowohl lexikografisch als auch basierend auf der Länge der Strings einen Vektor von Strings. In den Zeilen (1) und (2) verwende ich zwei Lambda-Funktionen aus der Standard Template Library. Ein Funktionsobjekt ist eine Instanz einer Klasse, für die der Aufrufeoperator (operater ()) überladen wurde. Diese werden oft fälschlicherweise Funktoren genannt. Ich denke, du bemerkst den Unterschied zwischen dem Aufruf std::sort(myStrVec.begin(), myStrVec.end(), std::less<std::string>()) in Zeile (1) und std::sort(myStrVec.begin(), myStrVec.end(), std::greater<>()) in Zeile (2). Der zweite Ausdruck (std::greater<>()), in dem ich keinen Typ für das Prädikat angebe, ist erst seit C++14 gültig. In den Zeilen (3), (4) und (5) kommen eine Funktion (6), ein Funktionsobjekt (7) und eine Lambda-Funktion (5) zum Einsatz. In diesem Fall war die Länge des Strings das Sortierkriterium.

Nur der Vollständigkeit halber die Ausgabe des Programms:

Die Regel lautet: "Use function objects to pass operations to algorithms".

Meine Argumentation lässt sich auf drei Punkte reduzieren: Performanz, Ausdruckskraft und Zustand. Da Lambda-Funktionen unter der Decke Funktionsobjekte sind, wird meine Argumentation deutlich einfacher.

Performanz

Je mehr Information der Compiler über den Code besitzt, desto optimierteren Code kann er erzeugen. Ein Funktionsobjekt (4) oder eine Lambda-Funktion (5) bietet maximale Transparenz an, da sie direkt an Ort und Stelle erzeugt werden können. Vergleiche dies doch mit einer Funktion, die in einer anderen Übersetzungseinheit definiert wurde. Falls du mir nicht glaubst, wende den Compiler Explorer an und vergleiche die Assembler-Anweisungen. Natürlich musst du den Code optimiert übersetzen.

Ausdruckskraft

"Explicit is better than implicit." Diese Metaregel von Python lässt sich auch auf C++ anwenden. Sie steht dafür, dass dein Sourcecode seine Intention explizit ausdrücken soll. Dies gilt vor allem für Lambda-Funktionen wie in der Zeile (5). Im Gegensatz dazu steht die Funktion lessLength in Zeile (6), die in der Zeile (3) zum Einsatz kommt. Stelle dir vor, dein Mitarbeiter gab der Funktion den Name foo. Natürlich hast du jetzt keine Idee, was die Aufgabe der Funktion ist. Das heißt, du musst ihre Anwendung wie in den folgenden Zeilen dokumentieren:

// sorts the vector ascending, based on the length of its strings 
std::sort(myStrVec.begin(), myStrVec.end(), foo);

Darüber hinaus kannst du nur hoffen, dass dein Kollege das Prädikat richtig implementiert hat. Falls du skeptisch bist, hilft dir nur die Implementierung weiter. Dies ist aber nicht möglich, wenn lediglich die Deklaration der Funktion zur Verfügung steht. Mit einer Lambda-Funktion kann dies nicht passieren. Der Sourcecode ist die Wahrheit. Lass es mich provokativer formulieren: Dein Code soll so ausdruckreich sein, dass er keine Dokumentation benötigt.

Zustand

Im Gegensatz zu einer Funktion kann ein Funktionsobjekt Zustand besitzen. Das Codebeispiel zeigt genau diesen Punkt:

// sumUp.cpp

#include <algorithm>
#include <iostream>
#include <vector>

class SumMe{
int sum{0};
public:
SumMe() = default;

void operator()(int x){
sum += x;
}

int getSum(){
return sum;
}
};

int main(){

std::vector<int> intVec{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

SumMe sumMe= std::for_each(intVec.begin(), intVec.end(), SumMe());

std::cout << "\n";
std::cout << "Sum of intVec= " << sumMe.getSum() << std::endl;
std::cout << "\n";

}

Der std::for_each-Aufruf in Zeile (1) ist der entscheidende. std::for_each ist ein spezieller Algorithmus aus der Standard Template Library, denn er kann seine aufrufbare Einheit zurückgeben. Ich rufe std::for_each mit dem Funktionsobjekt SumMe auf und kann daher das Ergebnis des Aufrufs direkt in dem Funktionsobjekt speichern. In der Zeile (2) frage ich das Ergebnis der Summation ab. Dies ist der Zustand des Funktionsobjekts.

Nur der Vollständigkeit halber: Lamda-Funktion können auch Zustand besitzen. Du kannst eine Lambda-Funktion verwenden, um Werte aufzusummieren.

// sumUpLambda.cpp

#include <algorithm>
#include <iostream>
#include <vector>

int main(){

std::cout << std::endl;

std::vector<int> intVec{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

std::for_each(intVec.begin(), intVec.end(),
[sum = 0](int i) mutable {
sum += i;
std::cout << sum << std::endl;
});

std::cout << "\n";

}

Nicht dass ich das schön finde. Zuerst einmal repräsentiert die Variable sum den Zustand der Lambda-Funktion. Mit C++14 unterstützen Lambda-Funktion die sogenannte Initialisation Capture. sum = 0 erklärt und initalisiert eine Variable vom Datentyp int, die nur im Bereich der Lambda-Funktion gültig ist. Lambda-Funktionen sind per default konstant. Indem ich sie als mutable erklären, kann ich sukzessive die Zahlen auf sum addieren

Ich habe behauptet, das Lambda-Funktionen unter der Decke Funktionsobjekte sind. C++ Insights machen meine Argumentation zu einem Kinderspiel.

Eine Lambda-Funktion ist lediglich Syntactic Sugar für ein Funktionsobjekt, das an Ort und Stelle instanziiert wird. C++ Insights bringen ans Tageslicht, welche Transformation der Compiler auf eine Lambda-Funktion anwendet.

Hier ist ein einfaches Beispiel. Wenn ich das folgende Codebeispiel in C++ Insights ausführe,

gibt mir das Werkzeug den Unsugared Syntactic Sugar:

Der Compiler erzeugt das Funktionsobjekt object __lamda_2_16 (Zeilen 4 bis 11), instanziiert es in Zeile 13 und wendet es in Zeile 14 an. Das war bereits die ganze Magie hinter Lambda-Funktionen.

Das nächste Beispiel ist ein wenig anspruchsvoller. Nun fügt die Lambda-Funktion addTo die Summe zur Variable c hinzu, die per Copy gebunden wird:

In diesem Fall erhält das automatisch erzeugte Funktionsobjekt ein Attribut c und einen Konstruktor. Dies ist der erzeugte Sourcecode von C++ Insights:

T.40: Use function objects to pass operations to algorithms war die erste Regel zu Interfaces von Templates. Mein nächster Artikel knüpft an diese Regeln an. ()