C++ Core Guidelines: Funktionsobjekte und Lambdas
Modernes C++ ohne Lambda-Ausdrücke? Kaum vorstellbar! Daher ist es um so verwunderlicher, dass sich die Regeln dazu an zwei Händen abzählen lassen. In diesem Artikel geht es um vier strategische Regeln zum richtigen Einsatz, die weitreichende Konsequenzen besitzen.
- Rainer Grimm
Modernes C++ ohne Lambda-Ausdrücke? Kaum vorstellbar! Daher ist es um so verwunderlicher, dass sich die Regeln dazu an zwei Händen abzählen lassen. In diesem Artikel geht es um vier strategische Regeln zum richtigen Einsatz, die weitreichende Konsequenzen besitzen.
Hier sind die vier Regeln zu Lambda-AusdrĂĽcken (kurz Lambdas) kurz und kompakt:
- F.50: Use a lambda when a function won’t do (to capture local variables, or to write a local function)
- F.52: Prefer capturing by reference in lambdas that will be used locally, including passed to algorithms
- F.53: Avoid capturing by reference in lambdas that will be used nonlocally, including returned, stored on the heap, or passed to another thread
- ES.28: Use lambdas for complex initialization, especially of const variables
Im Vorspann erwähnte ich, dass ich über Lamdas schreiben will. Daher ist es vermutlich überraschend, dass der Titel diese Blogeintrags "Funktionsobjekte und Lambas" lautet. Falls du weißt, dass Lambdas lediglich Funktionsobjekte sind, die der Compiler auf Bedarf automatisch erzeugt, dann hält sich deine Verwunderung in Grenzen. Falls nicht, stelle ich im nächsten Abschnitt kurz und bündig die wichtigsten Fakten vor. Ein Blick unter die Haube hilft sehr, Lambda-Ausdrücke besser zu verstehen.
Hier sind die wichtigsten Fakten.
Lambda-AusdrĂĽcke: Ein Blick unter die Haube
Zuerst einmal ist ein Funktionsobjekt eine Instanz einer Klasse, für die der Aufrufoperator (operator() ) überladen ist. Das bedeutet, dass ein Funktionsobjekt ein Objekt ist, das sich wie eine Funktion verhält. Der Hauptunterschied zwischen einer Funktion und einem Funktionsobjekt ist, dass ein Funktionsobjekt einen Zustand besitzen kann.
Hier ist ein einfaches Beispiel fĂĽr ein Funktionsobjekt:
int addFunc(int a, int b){ return a + b; }
int main(){
struct AddObj{
int operator()(int a, int b) const { return a + b; }
};
AddObj addObj;
addObj(3, 4) == addFunc(3, 4);
}
Instanzen der Struktur AddObj und die Funktion addFunc sind beide aufrufbare Einheiten (engl. callable). Ich habe die Struktur AddObj genau an der Stelle definiert, an der ich sie benötige. Genau diesen Schritt wendet der C++-Compiler automatisch an, wenn ich einen Lambda-Ausdruck verwende.
Hier ist der äquivalente Lambda-Ausdruck:
int addFunc(int a, int b){ return a + b; }
int main(){
auto addObj = [](int a, int b){ return a + b; };
addObj(3, 4) == addFunc(3, 4);
}
Das ist alles! Falls der Lambda-Ausdruck seine Umgebung bindet und damit einen Zustand besitzt, erhält die entsprechende Struktur AddObj einen Konstruktor für das Initialisieren ihrer Attribute. Falls sie ihre Argumente per Referenz bindet, dann tut dies auch der Konstruktor.
C++14 kennt Generic Lambdas. Das bedeutet, du kannst einen Lambda-Ausdruck der Form [](auto a, auto b){ return a + b; }; definieren. Was heißt das für den Aufrufoperator von AddObj? Ich denke, du ahnst es bereits – der Aufrufoperator wird zum Template. Das will ich gerne nochmals explizit betonen: Eine Generic Lambda ist ein Template.
Ich hoffe, dieser Abschnitt war nicht zu kompakt. Jetzt geht es weiter mit den Regeln zu Lambda-AusdrĂĽcken.
F.50: Use a lambda when a function won’t do (to capture local variables, or to write a local function)
Der Unterschied der Einsatzgebiete von Funktionen und Lambdas reduziert sich im Wesentlichen auf zwei Punkte.
- Du kannst Lambdas nicht ĂĽberladen.
- Eine Lambda kann lokale Variablen binden.
Hier ist ein konstruiertes Beispiel zum zweiten Punkt:
#include <functional>
std::function<int(int)> makeLambda(int a){ // (1)
return [a](int b){ return a + b; };
}
int main(){
auto add5 = makeLambda(5); // (2)
auto add10 = makeLambda(10); // (3)
add5(10) == add10(5); // (4)
}
Die Funktion makeLambda gibt einen Lambda-Ausdruck zurĂĽck. Er nimmt ein int an und gibt ein int zurĂĽck. Dies ist der Typ des polymorphen Funktions-Wrappers std::function: std::function<int(int)> (1). Der Aufruf makeLambda(5) (2) erzeugt einen Lambda-Ausdruck, der a kopiert. a besitzt in diesem Fall den Wert 5. Dieselbe Argumentation gilt natĂĽrlich auch fĂĽr den Ausdruck makeLambda(10) (3). Daher ergeben die Aufrufe add5(10) und add10(5) beide 15 (4).
Die nächsten zwei Regeln beschäftigen sich explizit mit dem Fall, wenn ein Lambda-Ausdruck seine Argumente per Referenz bindet. Beide Regeln sind sehr ähnlich, daher kann ich sie zusammen beschreiben.
F.52: Prefer capturing by reference in lambdas that will be used locally, including passed to algorithms, F.53: Avoid capturing by reference in lambdas that will be used nonlocally, including returned, stored on the heap, or passed to another thread
Aus Effizienz- und Korrektheitsgründen sollte dein Lambda-Ausdruck seine Argumente per Referenz binden, falls er nur lokal verwendet wird. Entsprechend gilt natürlich, dass du deine Argumente nicht per Referenz binden sondern kopieren solltest, wenn du deinen Lambda-Ausdruck nicht lokal verwendest. Tust du das doch, erhältst du undefiniertes Verhalten.
Hier ist ein Beispiel zu undefiniertem Verhalten mit Lambda-AusdrĂĽcken.
// lambdaCaptureReference.cpp
#include <functional>
#include <iostream>
std::function<int(int)> makeLambda(int a){
int local = 2 * a;
auto lam = [&local](int b){ return local + b; }; // 1
std::cout << "lam(5): "<< lam(5) << std::endl; // 2
return lam;
}
int main(){
std::cout << std::endl;
int local = 10;
auto addLocal = [&local](int b){ return local + b; }; // 3
auto add10 = makeLambda(5);
std::cout << "addLocal(5): " << addLocal(5) << std::endl; // 4
std::cout << "add10(5): " << add10(5) << std::endl; // 5
std::cout << std::endl;
}
Die Definiton der Lambda addLocal (3) und ihre Anwendung (4) ist wohldefiniert. Das gleiche gilt fĂĽr die Definition des Lambda-Ausdrucks lam (1) und seine Anwendung (2) innerhalb der Funktion. Das undefinierte Verhalten tritt dann auf, wenn die Funktion makeLambda den Lambda-Ausdruck zurĂĽckgibt, der eine Referenz auf die lokale Variable local besitzt.
Hast du eine Vermutung, welchen Wert der Aufruf add10(5) in Ausdruck (5) besitzen wird? Hier ist die Ausgabe des Programms:
Jede AusfĂĽhrung des Programms ergibt ein anderes Ergebnis fĂĽr den Ausdruck (5).
ES.28: Use lambdas for complex initialization, especially of const variables
Um ehrlich zu sein, ich mag diese Regel. Sie macht deinen Code einfacher robuster.
Warum bezeichnen die Guidelines das folgende Programm schlecht?
widget x; // should be const, but:
for (auto i = 2; i <= N; ++i) { // this could be some
x += some_obj.do_something_with(i); // arbitrarily long code
} // needed to initialize x
// from here, x should be const, but we can't say so in code in this style
Die Idee des Programms ist es, das widget x zu initialisieren. Sobald die Initialisierung fertig ist, soll die Variable konstant bleiben. Das ist eine Idee, die sich nicht direkt in C++ umsetzen lässt. Daher müsstest du zum Beispiel den Zugriff auf das widget x in einem Multithreading-Programm aufwendig synchronisieren.
Letzteres wäre nicht notwendig, wenn das widget x konstant ist. Hier kommt das gute Pendant mit Lambda-Ausdruck zu dem Codeschnipsel:
const widget x = [&]{
widget val; // assume that widget has a
// default constructor
for (auto i = 2; i <= N; ++i) { // this could be some
val += some_obj.do_something_with(i); // arbitrarily long code
} // needed to initialize x
return val;
}();
Dank des an Ort und Stelle ausgeführten Lambda-Ausdrucks kann widget x als Konstante definiert werden. Da du diesen Wert nicht mehr ändern kannst, kannst du die Variable von nun an gänzlich ohne Synchronisation in Multithreading-Programmen verwenden.
Wie geht's weiter?
Eines der Wesensmerkmale der objektorientierten Programmierung ist Vererbung. Die C++ Core Guidelines bieten gut 25 Regeln für Klassenhierarchien an. Im nächsten Artikel gehe ich auf die Konzepte Interfaces und Implementierung in Klassenhierarchien ein.
Weitere Informationen
- Lambda-Funktionen, ein erste Vorstellung: Die Elf spielt auf (freier Artikel fĂĽr das Linux-Magazin)
- Lambda-Funktionen, mehr Details: Kurz und knackig (freier Artikel fĂĽr das Linux-Magazin)
- Den Source Code zu den Beispielen gibt es hier: GitHub