Mächtigere Lambda-Ausdrücke mit C++20

Modernes C++ Rainer Grimm  –  3 Kommentare

Dank dem C++20-Standard werden Lambda-Ausdrücke mächtiger. Von den vielen Verbesserungen rund um Lambda-Ausdrücke sind Template-Parameter mein Favorit.

Lamba-Ausdrücke (Lambdas) unterstützen mit C++20 Template-Parameter, besitzen einen Default-Konstruktor und einen Copy-Zuweisungsoperator, wenn sie keinen Zustand besitzen und können in nicht evaluierten Kontexten verwendet werden. Zusätzlich stellen sie 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.

Der Artikel beginnt mit Template-Parametern für Lambdas.

Template-Parameter für Lambdas

Zugegeben, die Unterschiede zwischen typisierten Lambdas, generischen Lambdas und Template Lambas (Template-Parameter für Lambdas) sind subtil.

Vier Lambda Variationen

Das folgende Programm verwendet vier Variationen der add Funktion, die mit Lambdas implementiert ist.

// templateLambda.cpp

#include <iostream>
#include <string>
#include <vector>

// only to int convertible types (C++11):
auto sumInt = [](int fir, int sec) { return fir + sec; };
// arbitrary types (C++14):
auto sumGen = [](auto fir, auto sec) { return fir + sec; };
// arbitrary, but convertible types (C++14):
auto sumDec = [](auto fir, decltype(fir) sec) { return fir + sec; };
// arbitrary, but identical types (C++20):
auto sumTem = []<typename T>(T fir, T sec) { return fir + sec; };

int main() {

std::cout << std::endl;
// (1)
std::cout << "sumInt(2000, 11): " << sumInt(2000, 11) << std::endl;
std::cout << "sumGen(2000, 11): " << sumGen(2000, 11) << std::endl;
std::cout << "sumDec(2000, 11): " << sumDec(2000, 11) << std::endl;
std::cout << "sumTem(2000, 11): " << sumTem(2000, 11) << std::endl;

std::cout << std::endl;
// (2)
std::string hello = "Hello ";
std::string world = "world";
// std::cout << "sumInt(hello, world): "
<< sumInt(hello, world) << std::endl; ERROR
std::cout << "sumGen(hello, world): " << sumGen(hello, world) << std::endl;
std::cout << "sumDec(hello, world): " << sumDec(hello, world) << std::endl;
std::cout << "sumTem(hello, world): " << sumTem(hello, world) << std::endl;


std::cout << std::endl;
// (3)
std::cout << "sumInt(true, 2010): " << sumInt(true, 2010) << std::endl;
std::cout << "sumGen(true, 2010): " << sumGen(true, 2010) << std::endl;
std::cout << "sumDec(true, 2010): " << sumDec(true, 2010) << std::endl;
// std::cout << "sumTem(true, 2010): "
<< sumTem(true, 2010) << std::endl; ERROR

std::cout << std::endl;

}

Bevor ich die wohl überraschende Ausgabe des Programms vorstelle, möchte ich die vier Lambdas kurz vergleichen.

sumInt

  • C++11
  • typisierte Lambda
  • nimmt nur nach int konvertierbare Datentypen an

sumGen

  • C++14
  • generische Lambda
  • nimmt alle Datentypen an

sumDec

  • C++14
  • generische Lambda
  • der zweite Datentyp muss sich zum ersten Daten konvertieren lassen

sumTem

  • C++20
  • Template Lambda
  • der erste und der zweite Datentyp müssen identisch sein

Was bedeutet, wenn die Template-Argumente verschiedene Datentypen besitzen? Klar, jedes Lambda nimmt int an (1) und das typisierte Lambda sumInt nimmt keinen std::string an (2).

Der Aufruf der Lambdas mit dem bool true und dem int 2010 birgt einiges Überraschungspotential (3).

  • sumInt gibt 2011 zurück, da true zu int erweitert wird (integral promotion).
  • sumGen gibt 2011 zurück, da true zu int erweitert wird. Es gibt aber einen feinen Unterschied zwischen sumInt und sumGen, dazu unten mehr.
  • sumDec gibt 2 zurück. Warum? Der Datentyp des zweiten Parameters sec erhält den Datentyp des ersten Parameters fir. Dank decltype(fir) sec ermittelt der Compiler den Datentyp von fir und wendet den gleichen Datentyp auf sec an. Daher wird 2010 zu true. In dem Ausdruck fir + sec wird fir zur 1 erweitert und somit ist das Ergebnis 2.
  • sumTem ist nicht gültig.

Dank dem Compiler Explorer und GCC lässt sich das Programm ausführen.

Zwischen den Funktionen sumInt und sumGen besteht ein feiner Unterschied. Die Erweiterungen des true Werts passiert im Falle der sumInt Funktion beim Aufrufenden. Jedoch findet die Erweiterung des true Wertes bei der Funktion sumGen in dem arithmetischen Ausdruck fir + sec statt. Hier ist der entscheidende Teil des Programms nochmals.

auto sumInt = [](int fir, int sec) { return fir + sec; };            
auto sumGen = [](auto fir, auto sec) { return fir + sec; };

int main() {

sumInt(true, 2010);
sumGen(true, 2010);

}

Wenn ich den Code-Schnipsel in C++ Insights verwende, lässt sich der Unterschied genau studieren. Ich stelle in dem folgenden Code nur den entscheidenden Teil des vom Compiler erzeugten Codes dar.

class __lambda_1_15
{
public:
inline /*constexpr */ int operator()(int fir, int sec) const
{
return fir + sec;
}

};

__lambda_1_15 sumInt = __lambda_1_15{};


class __lambda_2_15
{
public:
template<class type_parameter_0_0, class type_parameter_0_1>
inline /*constexpr */ auto operator()(type_parameter_0_0 fir,
type_parameter_0_1 sec) const
{
return fir + sec;
}

#ifdef INSIGHTS_USE_TEMPLATE
template<>
inline /*constexpr */ int operator()(bool fir, int sec) const
{
return static_cast<int>(fir) + sec; // (2)
}
#endif

};

__lambda_2_15 sumGen = __lambda_2_15{};


int main()
{
sumInt.operator()(static_cast<int>(true), 2010); // (1)
sumGen.operator()(true, 2010);
}

Vermutlich ist bekannt, dass der Compiler automatisch ein Funktionsobjekt aus einem Lambda-Ausdruck erzeugt. Falls nicht, möchte ich auf Andreas Fertigs Artikel zu seinem Werkzeug C++ Insights auf meinem Blog verweisen. Ein Artikel beschäftigt sich explizit mit Lambdas: C++ Insights Artikel.

Sorgfältiges Studieren des Codeschnipsels enthüllt den Unterschied: sumInt führt die Erweiterung zu int beim Aufruf der Funktion (1) aus. Hingegen findet die Erweiterung auf sumGen in dem arithmetischen Ausdruck statt (2).

Die Beispiele dieses Abschnitts zu Lambdas haben das eine oder andere sehr überraschende Detail zur Konvertierungen von Datentypen vorgestellt. Ein typischer Einsatz von Template Lambdas besteht in der Verwendung von Containern in Lambas.

Template-Parameter für Container

Das folgenden Programm stellt Lambdas vor, die einen Container annehmen. Jede Lambda gibt die Länge des Containers zurück.

// templateLambdaVector.cpp

#include <concepts>
#include <deque>
#include <iostream>
#include <string>
#include <vector>

auto lambdaGeneric = [](const auto& container) { return container.size(); };
auto lambdaVector = []<typename T>(const std::vector<T>& vec)
{ return vec.size(); };
auto lambdaVectorIntegral = []<std::integral T>(const std::vector<T>& vec)
{ return vec.size(); };

int main() {


std::cout << std::endl;

std::deque deq{1, 2, 3}; // (1)
std::vector vecDouble{1.1, 2.2, 3.3, 4.4}; // (1)
std::vector vecInt{1, 2, 3, 4, 5}; // (1)

std::cout << "lambdaGeneric(deq): " << lambdaGeneric(deq) << std::endl;
// std::cout << "lambdaVector(deq): " << lambdaVector(deq)
<< std::endl; ERROR
// std::cout << "lambdaVectorIntegral(deq): "
<< lambdaVectorIntegral(deq) << std::endl; ERROR

std::cout << std::endl;

std::cout << "lambdaGeneric(vecDouble): "
<< lambdaGeneric(vecDouble) << std::endl;
std::cout << "lambdaVector(vecDouble): "
<< lambdaVector(vecDouble) << std::endl;
// std::cout << "lambdaVectorIntegral(vecDouble): "
<< lambdaVectorIntegral(vecDouble) << std::endl;

std::cout << std::endl;

std::cout << "lambdaGeneric(vecInt): " << lambdaGeneric(vecInt)
<< std::endl;
std::cout << "lambdaVector(vecInt): " << lambdaVector(vecInt)
<< std::endl;
std::cout << "lambdaVectorIntegral(vecInt): "
<< lambdaVectorIntegral(vecInt) << std::endl;

std::cout << std::endl;

}

lambdaGeneric lässt sich mit jedem Datentyp aufrufen, der die Methode size() unterstützt. lambdaVector ist hingegen spezifischer: Sie nimmt nur einen std::vector an. lambdaVectorIntegral verwendet das C++20 Concept std::integral. Damit lässt sich nur ein std::vector mit integralen Datentypen wie int verwenden. Um es einzusetzen, muss die Headerdatei <concepts> inkludiert werden. Ich denke, das kleine Programm erklärt sich selbst.

Das Programm templateLambdaVector.cpp enthält ein leicht zu übersehendes Feature: Seit C++17 kann der Compiler den Datentyp eines Klassen-Templates von seinen Funktionsargumenten ableiten (1). Daher kann ich statt einem wortreichen std::vector<int> myVec{1, 2, 3} einfach std::vector myVec{1, 2, 3} schreiben.

Wie geht's weiter?

In meinem nächsten Artikel geht es um weitere C++20 Verbesserung rund um Lambdas.