Démystifions les lambda expressions de C++ avec cppinsights

Les lambdas expressions sont apparues en C++11 et sont une des features qui font vraiment la différence entre le C++ à l'ancienne et le C++ moderne. Il y a des tonnes de bons articles sur internet qui expliquent leurs syntaxes et donnent des bonnes pratiques pour les utiliser. La référence reste cppreference même si ce n'est pas forcément la ressource la plus simple pour découvrir les lambdas expressions.

L'objectif de cet article est de comprendre ce que le compilateur fait quand on utilise des lambdas expressions et ainsi démystifier ce qui ressemble de prime abord à de la magie (mais comme vous le savez tous, il n'y pas de magie dans la vraie vie).

C'est aussi l'occasion de vous présenter cppinsights, un copain de Compiler Explorer dont je vous ai parlé en vidéo en décembre. Quand je dis qu'ils sont copains, je n'invente rien : chacun possède un bouton pour ouvrir le code dans l'autre.

cppinsights

Le principe de cppinsights est très semblable à celui de Compiler Explorer : à gauche vous taper du code et à droite il vous montre des trucs. Ces trucs, c'est votre code "modifié" pour vous montrer ce qu'il fait vraiment. Voici un exemple avec un range-based for loop :

#include <array>
#include <iostream>

int main()
{
    std::array array{1, 2, 3, 4, 5};

    for (const auto &e : array)
    {
        std::cout << e << '\n';
    }
}

On obtient :

#include <array>
#include <iostream>

int main()
{
  std::array<int, 5> array = {{1, 2, 3, 4, 5}};
  {
    std::array<int, 5> & __range1 = array;
    int * __begin1 = __range1.begin();
    int * __end1 = __range1.end();
    for(; __begin1 != __end1; ++__begin1)
    {
      const int & e = *__begin1;
      std::operator<<(std::cout.operator<<(e), '\n');
    }
  }
}

On voit la déduction automatique du type réel de l'objet array, on voit que la boucle a été remplacée par un simple parcours d'itérateur, on voit que le chaînage des << a été remplacé par des appels de fonctions imbriqués.

C'est ça cppinsights : sans aller à l'assembleur, mieux comprendre ce que fait votre code.

Le but d'une lambda expression

Reprenons la définition d'une lambda expression donnée par cppreference :

Constructs a closure): an unnamed function object capable of capturing variables in scope.

En suivant le lien Wikipédia closure, on lit cette phrase intéressante dans l'introduction :

Operationally, a closure is a record storing a function together with an environment.

Une lambda expression va donc générer une fonction (qu'on pourra appeler, c'est le but) en stockant un environnement (c'est la notion de capture).

Quand on réfléchit instinctivement à comment on ferait si on n'avait pas de support des lambdas dans le langage, on se dit qu'il faudrait réussir à :

  1. créer un objet avec un opérateur operator() (qu'on appelle function call operator) et qui serait donc un function object.
  2. stocker les données à capturer (si données il y a).

En fait, c'est exactement comme ça que ça marche.

Les lambdas dans la moulinette de cppinsights

Passer du code avec des lambdas dans cppinsights nous permettra de voir le code que le compilateur génère pour faire fonctionner tout ça.

Il y a 2 points qu'on peut traiter séparément :

  • la génération du function call operator
  • la capture du contexte

Appel à la lambda

Pour simplifier, on fera ici des lambdas qui ne capturent rien.

Pas de paramètres

On est dans le cas le plus simple d'une lambda expression : on ne capture rien et on l'appelle sans lui passer de paramètre.

#include <iostream>

int main()
{
    auto print = []() {
        std::cout << 42 << '\n';
    };
    print();
}

Résultat avec cppinsights :

#include <iostream>

int main()
{

  class __lambda_5_14
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      std::operator<<(std::cout.operator<<(42), '\n');
    }

    using retType_5_14 = void (*)();
    inline /*constexpr */ operator retType_5_14 () const noexcept
    {
      return __invoke;
    };

    private: 
    static inline void __invoke()
    {
      std::operator<<(std::cout.operator<<(42), '\n');
    }


  };

  __lambda_5_14 print = __lambda_5_14{};
  print.operator()();
}

C'est en fait tout simple : la lambda génère une classe avec un function call operator sans paramètre, en crée une instance et appelle l'opérateur sur cette instance.

C'est le cas le plus simple et pourtant il est déjà super instructif puisqu'on voit la technique utilisée : créer une classe à la volée. Une closure est donc une classe.

On voit aussi pourquoi on dit souvent que "seul le compilateur connait le type réel de la lambda". Effectivement, le compilateur génère un nom que vous ne pouvez pas vraiment deviner. C'est pour ça qu'on utilise toujours auto pour stocker le résultat d'une lambda expression.

Il y aussi un opérateur pour convertir la closure en pointeur sur fonction (qui n'est pas utilisé ici). Ce type de conversion n'est possible qu'avec les lambdas non capturantes. Vous verrez dans les sections suivantes que cet opérateur n'apparait plus. On peut lire sur cppreference :

ClosureType::operator ret(*)(params)()

This user-defined conversion function is only defined if the capture list of the lambda-expression is empty. It is a public, constexpr (since C++17), non-virtual, non-explicit, const noexcept (since C++14) member function of the closure object.

Avec des paramètres

Ajoutons simplement un paramètre à l'appel de la lambda :

#include <iostream>

int main()
{
    auto print = [](int value) {
        std::cout << value << '\n';
    };
    print(42);
}

Résultat avec cppinsights :

#include <iostream>

int main()
{

  class __lambda_5_18
  {
    public: 
    inline /*constexpr */ void operator()(int value) const
    {
      std::operator<<(std::cout.operator<<(value), '\n');
    }

    using retType_5_18 = void (*)(int);
    inline /*constexpr */ operator retType_5_18 () const noexcept
    {
      return __invoke;
    };

    private: 
    static inline void __invoke(int value)
    {
      std::operator<<(std::cout.operator<<(value), '\n');
    }


  };

  __lambda_5_18 print = __lambda_5_18{};
  print.operator()(42);
}

Rien de bien incroyable ici. Le function call operator a maintenant un paramètre et l'opérateur de conversion en pointeur sur fonction a été adapté en conséquence.

Avec une valeur de retour

Je ne pense pas avoir vraiment besoin de vous montrez ça mais puisque je suis lancé :

int main()
{
    auto square = [](int a) {
        return a * a;
    };

    return square(42);
}

Résultat avec cppinsights :

int main()
{

  class __lambda_3_19
  {
    public: 
    inline /*constexpr */ int operator()(int a) const
    {
      return a * a;
    }

    using retType_3_19 = int (*)(int);
    inline /*constexpr */ operator retType_3_19 () const noexcept
    {
      return __invoke;
    };

    private: 
    static inline int __invoke(int a)
    {
      return a * a;
    }


  };

  __lambda_3_19 square = __lambda_3_19{};
  return square.operator()(42);
}

Dans le mille : l'opérateur operator() a cette fois un type de retour et fait un return.

Capture de contexte

On peut faire plein de choses intéressantes avec les lambdas non capturantes mais la puissance s'exprime pleinement quand on capture du contexte. Il y a 2 techniques de capture : par copie ou par référence. Une lambda peut utiliser les 2 techniques à la fois pour capturer son contexte de manière précise.

Prenons le même bout de code, avec une lambda qui capture une variable, et voyons les conséquences de la méthode de capture. Essayons d'abord par copie puis par référence.

Capture par copie

#include <iostream>

int main()
{
    int value = 42;

    auto print = [value]() {
        std::cout << value << '\n';
    };
    print();
}

Résultat avec cppinsights :

#include <iostream>

int main()
{
  int value = 42;

  class __lambda_7_18
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      std::operator<<(std::cout.operator<<(value), '\n');
    }

    private: 
    int value;

    public:
    __lambda_7_18(int _value)
    : value{_value}
    {}

  };

  __lambda_7_18 print = __lambda_7_18{value};
  print.operator()();
}

On commence à voir des choses intéressantes, des choses qui démystifient les lambdas et leur capture de contexte. La classe générée a un constructeur et des champs pour stocker tout ce qui est capturé. C'est aussi simple que ça.

On constate bien qu'il n'y a pas d'opérateur de conversion en pointeur sur cette fonction cette fois.

Capture par référence

Vous vous doutez très logiquement de ce qui va se passer lorsqu'on va capturer par référence plutôt que par copie :

#include <iostream>

int main()
{
    int value = 42;

    auto print = [&value]() {
        std::cout << value << '\n';
    };
    print();
}

Résultat avec cppinsights :

#include <iostream>

int main()
{
  int value = 42;

  class __lambda_7_18
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      std::operator<<(std::cout.operator<<(value), '\n');
    }

    private: 
    int & value;

    public:
    __lambda_7_18(int & _value)
    : value{_value}
    {}

  };

  __lambda_7_18 print = __lambda_7_18{value};
  print.operator()();
}

Le constructeur prend cette fois en paramètre une référence et le champ privé permet de converser cette référence.

Capture par référence constante

Puisqu'on y est, parlons de la capture par référence constante. Ben oui vous avez vu que la référence dans le code "généré" ci-dessus n'est pas const alors qu'on ne modifie pas l'objet référencé.

Ce n'est pas possible avec le simple ajout du mot const dans la liste de capture. Depuis C++17, il y a une astuce assez simple :

#include <iostream>
#include <utility>

int main()
{
    int value = 42;

    auto print = [&value = std::as_const(value)]() {
        std::cout << value << '\n';
    };
    print();
}

Résultat avec cppinsights :

#include <iostream>
#include <utility>

int main()
{
  int value = 42;

  class __lambda_8_18
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      std::operator<<(std::cout.operator<<(value), '\n');
    }

    private: 
    const int & value;

    public:
    __lambda_8_18(const int & _value)
    : value{_value}
    {}

  };

  __lambda_8_18 print = __lambda_8_18{std::as_const(value)};
  print.operator()();
}

Et voilà ! Impeccable !

Retourner une lambda depuis une fonction

Il est possible de renvoyer une closure depuis une fonction. On rentre alors dans le vaste sujet des std::functions et de leur gestion en mémoire, et on sort ainsi du cadre de cet article. Vous pouvez lire cette discussion stackoverflow sur le sujet pour en savoir un peu plus.

Ce qu'il est très important de savoir est qu'il y a un risque de dangling reference :

If a non-reference entity is captured by reference, implicitly or explicitly, and the function call operator of the closure object is invoked after the entity's lifetime has ended, undefined behavior occurs. The C++ closures do not extend the lifetimes of the captured references.

Same applies to the lifetime of the object pointed to by the captured this pointer.

Ce qui a été expliqué plus haut vous permet de comprendre pourquoi : quand on capture par référence, la closure référence des éléments qui ont potentiellement été créés sur la pile de la fonction où la lambda expression a été faite. Quand cette fonction va se terminer en retournant la closure, la pile va changer et la closure pointera alors sur une zone mémoire qui est devenue invalide. C'est le principe même d'une dangling reference.

Le problème n’apparaît bien sûr pas quand on copie le contexte. La closure est alors "autonome".

Pour en finir

Maintenant, vous cernez normalement mieux comment une lambda expression permet de générer une closure, cette classe qui stocke les éléments capturés et a un opérateur operator(), ce qui permet "d'appeler la lambda".

En creux, vous aurez sans doute deviné pourquoi on dit de ne pas utiliser les modes de capture par défaut [&] et [=]. La classe générée possède alors un champ par variable visible dans le scope courant, potentiellement en faisant des copies de tout le monde. Même si ça semble pratique, ce n'est peut-être pas du tout ce que vous voulez : ça peut impacter les performances, ça peut ne pas compiler si l'une des classes n'est pas copiable, ça peut ne plus compiler quand on modifie une des classes pour la rendre non copiable (alors que ça se passait très bien avant), en plus de capturer des choses dont vous n'avez pas besoin et que vous pourriez modifier par erreur (puisque, par défaut, les références ne sont pas constantes).

Les lambda expressions sont vraiment un outil puissant pour écrire du C++ moderne. Prenez le temps d'expérimenter, de comprendre comment les utiliser, et votre code n'en sera que meilleur !

Pierre

Que la vie de Pierre, expert embarqué Younup, serait terne sans les variadic templates et les fold expressions de C++17. Heureusement pour lui, Python a tué l'éternel débat sur l’emplacement de l’accolade : "alors, en fin de la ligne courante ou en début de la ligne suivante ?"

Homme de terrain, il est aussi à l’aise au guidon de son VTT à sillonner les chemins de forêt, dans une salle de concert de black metal ou les mains dans les soudures de sa carte électronique quand il doit déboguer du code (bon ça, il aime moins quand même !)

Son vœu pieux ? Il hésite encore... Faire disparaitre le C embarqué au profit du C++ embarqué ? Ou stopper la génération sans fin d'entropie de son bureau ?

Retours aux publications