Mehr Lambda-Features mit C++20
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.
- Rainer Grimm
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.cpp
ist 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::se
t 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.
()