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.

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

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:

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.

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.

Der Unterschied der Einsatzgebiete von Funktionen und Lambdas reduziert sich im Wesentlichen auf zwei Punkte.

  1. Du kannst Lambdas nicht überladen.
  2. 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.

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).

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.

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.

  • 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

()