lambda - behind the scenes

Wie sehen lambda-Funktionen eigentlich unter der Haube aus? Das Video geht etwas theoretischer darauf ein, was der Standard für dieses Sprachmittel so anbietet und wie man das unter der Haube umsetzen könnte.

Video

Quellcode

Achtung: das kompiliert nicht! Der Quellcode ist nur zur Illustration!

Der einfache Fall: eine lambda-Funktion mit Default Capture Copy:

std::string w = "Welt";

auto f = [=]() { std::cerr << "Hallo " << w << std::endl; };

Und nun die mögliche Umsetzung als explizites Funktionsobjekt

std::string w = "Welt";

struct LambdaCopy
{
  std::string local_w;
  
  LambdaCopy(std::string w_)
    : local_w(w_)
    {
    }
  
  inline void operator()()
  {
    std::cerr << "Hallo " << local_w << std::endl;
  }
};

auto f = LambdaCopy(w);

Erklärung

Die Umsetzung der Lambda-Funktion mit Copy-Capture ist relativ naheliegend: es wird ein (im Original namenloser) Typ für ein Funktionsobjekt erzeugt, der für jede eingefangene Variable ein Member vom gleichen Typ erhält. Der operator() bekommt die gleiche Signatur, wie die betreffende lambda-Funktion. Als Ergebnis der lambda-Expression wird der zugehörige Typ instanziiert, wobei die eingefangenen Variablen als Konstruktorparameter dienen und in die passenden Klassenmember kopiert werden. Ab da sind die Klassenmember und die ursprünglichen Variablen voneinander getrennt und beeinflussen sich nicht mehr.

Wohlgemerkt: das ist eine mögliche Umsetzung. Der Standard gibt hier nur das Verhalten vor. Die Implementierung des Konstruktors ist bspw. naheliegend, aber nicht zwingend.

Für Referenz-Capture ist der Standard noch freizügiger: da ist es der Implementierung überlassen, ob sie entsprechende Klassenmember als Referenzen verwendet oder das ganze anders löst. Eine naheliegende Lösung könnte unter der Haube auch direkt auf die entsprechenden Variablen zugreifen, ohne vorher Referenzen im betreffenden Funktionstyp zu initialisieren.

Quellcode 2

Diesmal die lambda-Funktion ohne Capture-List

auto f = [](std::string w){ std::cerr << "Hallo " << w << std::endl; };

Die Umsetzung sieht etwas komplexer aus:

struct LambdaNoCapture
{  
  static void fn(std::string w)
  {
    std::cerr << "Hallo " << w << std::endl;
  }

  inline void operator()(std::string w)
  {
    fn(w);
  }

  typedef void (*fptr)(std::string);
  
  inline operator fptr() const {
    return fn;
  }
};

auto f = LambdaNoCapture();

Erklärung 2

Nochmal deutlich vornweg: das ist eine mögliche Umsetzung. Kann auch anders gelöst sein, solange es sich passend verhält!

Der interessanteste Teil an lambda-Funktionen ohne Capture ist die Tatsache, dass sie automatisch in einen Funktionspointer gleicher Signatur umgewandelt werden können. Der resultierende Typ der lambda-Expression muss also einen Konvertierungsoperator bereitstellen, der bei Bedarf das Objekt umwandelt. Dieser soll laut Standard einen Zeiger auf eine Funktion passender Signatur zurückliefern, deren Aufruf die gleiche Wirkung hat, wie der Aufruf des operator() des Funktionstyps. Ich hab das hier relativ simpel gelöst: eine static-Methode, deren Funktionszeiger wir zurückgeben können, dient als Implementierung der lambda-Funktion. Der operator() ruft diese Methode nur auf und stellt damit einheitliches Verhalten sicher. Der operator fptr() liefert wiederum einen Zeiger auf die Methode fn zurück, womit auch die automatische Konvertierung sichergestellt ist.

Wozu der Aufwand mit der Konvertierung? In bestehenden Bibliotheken werden oft Funktionspointer für Callbacks und ähnliches verwendet. Es gab ja früher keine Lambda-Funktionen und Funktionsobjekte sind doch meist etwas umfangreicher zu schreiben. Daher hat man sich bei der Standardisierung gedacht: wenn eine Lambda-Funktion sich auch als Funktionszeiger ausdrücken lassen würde (sprich: sie benötigt eigentlich kein umgebendes Objekt, da sie keinen Kontext einfängt), dann soll diese sich auch an Stellen verwenden lassen, wo Funktionszeiger gefragt sind. Wir brauchen zwar immernoch einen C++11-fähigen Compiler, um die Funktion übersetzen zu können, aber zumindest der Aufruf über ältere Schnittstellen funktioniert somit.