Mehr Lambda-Features mit C++20

Modernes C++ Rainer Grimm  –  3 Kommentare

Wenn Lambda-Ausdrücke zustandslos sind, besitzen sie einen Default-Konstruktor und einen Copy-Zuweisungsoperator. Darüber hinaus können sie in C++20 in nicht evaluierten Kontexten verwendet werden, und der C++20-Compiler stellt fest, wenn der this-Zeiger implizit kopiert wird. Das heißt, dass eine häufige Ursache von undefinierten Verhalten mit Lambdas der Vergangenheit angehört.

Mit dem letzten Feature der Einleitung möchte ich heute beginnen. Der Compiler stellt das undefinierte Verhalten fest, wenn der this-Zeiger implizit kopiert wird. Doch was heißt undefiniertes Verhalten? Mit undefiniertem Verhalten gibt es keine Einschränkungen zum möglichen Verhalten des Programms. Damit gibt es auch keine Garantie dafür, was passieren kann.

In meinen Schulungen sage ich gerne: Wenn das Programm undefiniertes Verhalten hat, besitzt das Programm catch-fire Semantik. Das bedeutet, selbst dein Rechner kann in Rauch aufgehen. In früheren Jahren wurde undefiniertes Verhalten noch radikaler beschrieben: Wenn das Programm undefiniertes Verhalten besitzt, kann es eine Cruise-Missile (launch a cruise missile) starten. Eigentlich ist es unerheblich: Wenn das Programm undefiniertes Verhalten besitzt, besteht die einzige mögliche Aktion darin, das undefinierte Verhalten zu beseitigen.

Im nächsten Abschnitt werde ich bewusst undefiniertes Verhalten provozieren.

Impliziertes Kopieren des this-Zeigers

Das folgende Programm bindet den this-Zeiger implizit per Copy.

// lambdaCaptureThis.cpp

#include <iostream>
#include <string>

struct Lambda {
auto foo() const {
return [=] { std::cout << s << std::endl; }; // (1)
}
std::string s = "lambda";
~Lambda() {
std::cout << "Goodbye" << std::endl;
}
};

auto makeLambda() {
Lambda lambda; // (2)
return lambda.foo();
} // (3)


int main() {

std::cout << std::endl;

auto lam = makeLambda();
lam(); // (4)

std::cout << std::endl;

}

Das Kompilieren des Programms funktioniert erwartungsgemäß, aber nicht dessen Ausführen.

Der Fehler im Programm lambdaCaptureThis.cppist folgender: Die Methode foo (1) gibt den Lambda-Ausdruck [=] { std::cout << s << std::endl; } zurück, der implizit eine Kopie des this-Zeigers anlegt. Die implizite Kopie ist in (2) noch kein Problem, aber sie wird ein Problem am Ende des Gültigkeitsbereichs der lokalen Variable lambda (3). Konsequenterweise verursacht der Aufruf lam() (4) das undefinierte Verhalten.

Der C++20 Compiler muss in diesem Fall eine Warnung ausgeben. Hier ist auch schon die Ausgabe des GCC mit dem Compiler Explorer.

Die zwei noch fehlenden Lambda-Features in C++20 klingen nicht so aufregend. Lambdas lassen sich in C++20 default-konstruieren und sie unterstützen die Kopiezuweisungen, wenn sie zustandslos sind. Lambdas lassen sich auch in nicht-evaluierten (unevaluated) Kontexten verwenden. Bevor ich beide Feature zusammen vorstelle, steht noch ein kleiner Umweg an. Was ist ein nicht-evaluierter Kontext?

Nicht-evaluierter Kontext

Der folgende Codeschnipsel besitzt eine Funktionsdeklaration und eine Funktionsdefinition.

int add1(int, int);                       // declaration
int add2(int a, int b) { return a + b; } // definition

add1 ist eine Funktionsdeklaration, während add2 eine Funktionsdefinition ist. Wenn add1 in einem evaluierten Kontext wie einem Funktionsaufruf verwende wird, ist das Ergebnis ein Linking-Fehler. Daher lässt sich add1 nur in einem nicht-evaluierten Kontext wie typeid oder decltype verwenden. Beide Operatoren nehmen nicht-evaluierte Operanden an.

// unevaluatedContext.cpp

#include <iostream>
#include <typeinfo> // typeid

int add1(int, int); // declaration
int add2(int a, int b) { return a + b; } // definition

int main() {

std::cout << std::endl;

std::cout << "typeid(add1).name(): "
<< typeid(add1).name() << std::endl; // (1)

decltype(*add1) add = add2; // (2)

std::cout << "add(2000, 20): " << add(2000, 20) << std::endl;

std::cout << std::endl;

}

typeid.(add1).name() (1) gibt eine String-Repräsentierung seines Arguments zurück und decltype (2) deduziert den Datentyp seines Arguments.

Zustandslose Lambdas besitzen einen Default-Konstruktor und einen Kopiezuweisungsoperator

Lambdas lassen sich in nicht-evaluierten Kontexten verwenden

Zugegeben, das ist ein recht langer Titel. Für einige Entwickler mag der Begriff zustandlose Lambda zudem neu sein. Ein zustandsloses Lambda bindet nichts aus seinem Definitionskontext. Anders ausgedrückt: Ein zustandsloses Lambda besitzt leere initialen eckige Klammern  . Zum Beispiel ist der folgende Lambda-Ausdruck zustandlos: auto add =  (int a, int b) { return a + b; };

Aus der Kombination beider Features resultieren praktische Lambda-Ausdrücke.

Bevor ich das Beispiel zeige, möchte ich ein paar Anmerkungen machen. std::set wie alle anderen geordneten assoziativen Container der Standard Template Library (std::map, std::multiset und std::multimap) verwenden per-Default std::less zum Sortieren ihrer Schlüssel. Dank std::less werden alle Schlüssel der geordneten assoziativen Container lexikografisch aufsteigend sortiert. Die Deklaration von std::set auf cppreference.com zeigt schön das Ordnungsverhalten.

template<
class Key,
class Compare = std::less<Key>,
class Allocator = std::allocator<Key>
> class set;

Nun möchte ich in dem folgenden Beispiel ein wenig das Ordnungsverhalten variieren.

// lambdaUnevaluatedContext.cpp

#include <cmath>
#include <iostream>
#include <memory>
#include <set>
#include <string>

template <typename Cont>
void printContainer(const Cont& cont) {
for (const auto& c: cont) std::cout << c << " ";
std::cout << "\n";
}

int main() {

std::cout << std::endl;

std::set<std::string> set1 = {"scott", "Bjarne", "Herb",
"Dave", "michael"};
printContainer(set1);

using SetDecreasing = std::set<std::string,
decltype([](const auto& l,
const auto& r)
{ return l > r; })
>; // (1)
SetDecreasing set2 = {"scott", "Bjarne", "Herb", "Dave", "michael"};
printContainer(set2); // (2)

using SetLength = std::set<std::string,
decltype([](const auto& l,
const auto& r)
{ return l.size() < r.size(); })
>; // (1)
SetLength set3 = {"scott", "Bjarne", "Herb", "Dave", "michael"};
printContainer(set3); // (2)

std::cout << std::endl;

std::set<int> set4 = {-10, 5, 3, 100, 0, -25};
printContainer(set4);

using setAbsolute = std::set<int ,
decltype([](const auto& l,
const auto& r)
{ return std::abs(l)< std::abs(r); })
>; // (1)
setAbsolute set5 = {-10, 5, 3, 100, 0, -25};
printContainer(set5); // (2)

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

}

set1 und set4 sortieren ihre Schlüssel in aufsteigenden Reihenfolge. set2, set3 und set5 wenden eine Lambda in einem nicht-evaluierten Kontext an. Das using Schlüsselwort (1) deklariert einen Typ-Alias, der in der folgenden Zeile eingesetzt wird (2) um ein Set zu definieren. Das Erzeugen des Sets verursacht den Aufruf des Default-Konstruktors der zustandslosen Lambda.

Dank dem Compiler Explorer und dem GCC kann ich die Ausgabe des Programms vorstellen.

Das sorgfältie Studieren der Ausgabe birgt eine Überraschung. Das spezielle Set, das den Lambda-Ausdruck [](const auto& l, const auto& r){ return l.size() < r.size(); } als Prädikat verwendet, ignorierten den Name "Dave". Der Grund ist einfach. "Dave" besitzen dieselbe Länge wie "Herb", das zuerst zu dem Set hinzugefügt wurde. Die Schlüssel eines std::set können nur einmal vorkommen. Mit einem std::multiset sind mehrere gleiche Schlüssel möglich.

Wie geht's weiter?

Nur noch wenige Feature in der C++ Kernsprache habe ich noch nicht vorgestellt. Zu diesen kleinen Featuren gehören die neuen Attribute [[likely]] und [[unlikely]]. Dazu wird die Semantik von volatile deutlich eingeschränkt.