Lambdas T() et std::function<const T&>, un mélange dangereux

Le chat de Dvp est le lieu de rendez-vous quotidien des devs C++ et de nombreuses discussions techniques sur le C++ soulèvent des interrogations sur des points particuliers du langage.

Il nous est apparu qu’une faille dangereuse et non détectée par le compilateur résultait de l’utilisation conjointe de la déduction automatique de type retour des lambdas et des std::function<const A&()> qui retournent une référence constante.

Le code suivant compile parfaitement sans aucun warning sous g++4.8 et clang++3.2 avec les flags -pedantic -Wall -Wextra.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <functional>

struct A
{
    A(){ std::cout << "A()" << std::endl; }
    ~A(){ std::cout << "~A()" << std::endl; }

    int i = 32;//variable témoin
};
 
int main()
{
    A a;
    std::function<const A&()> f = [&a]{return a;};
    const A& aref = f();
    std::cout << "checkpoint_1" << std::endl;
    std::cout << aref.i << std::endl;
}

Or le résultat obtenu en commentant cette ligne est :

1
2
3
4
5
A()                                            `
~A()
checkpoint_1
1
~A()

Pour les rapides, oui aref est totalement corrompue, et son utilisation résulte en un crash sauvage et sans pitié du programme lorsqu’on a de la chance. Pour les autres, voici l’explication.

Savez-vous quel est le type de retour implicite de la lambda ci-dessous ?

1
2
A a;
auto lm = [&a]{return a;};

Si comme moi, à première vue vous répondez A&, vous vous en mordrez les doigts.
La réponse est A.

En effet, comment se déduit automatiquement le type de retour d'une lambda ? Eh bien de la même façon que decltype. C'est à dire tel que la variable a été déclarée. Ici, a étant déclaré comme A, c'est donc A qui est le type du retour de la lambda.

Si l'on capture cette fois une référence vers A :

1
2
3
A a;
A& refTo_a(a);
auto lm = [&refTo_a]{return refTo_a;};

Ici refTo_a est déclarée comme une A&, c'est donc A& qui sera déduit.

Reprenons notre cas initial. Bon, à priori ce n'est pas une grande perte, on fait une simple copie.

Ainsi, le code suivant est parfaitement sain si on reconnait la légitimité de la copie :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <functional>

static int ID = 0;

struct A
{
    A(){ id = ID++; std::cout << "A() " << id << std::endl;}
    A(const A&){ id = ID++; std::cout << "A(const A&) " << id << std::endl; }
    ~A(){ std::cout << "~A() " << id << std::endl;}

    int i = 32;//variable témoin
    int id = 0;
};
 
int main()
{
    A a;
    auto lm = [&a]{return a;};
    const A& aref = lm();
    std::cout << "checkpoint_1" << std::endl;
    std::cout << aref.i << std::endl;
}
1
2
3
4
5
6
A() 0                                            `
A(const A&) 1
checkpoint_1
32
~A() 1
~A() 0

La prise par référence constante allonge bien la durée de vie de la temporaire retournée par la lambda jusqu'à la fin de la vie de la référence, ainsi que la norme le demande.

Oui mais…

Que se passe-t-il lorsque notre lambda est encapsulée dans une std::function<const A&()> ?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <functional>

static int ID = 0;

struct A
{
    A(){ id = ID++; std::cout << "A() " << id << std::endl;}
    A(const A&){ id = ID++; std::cout << "A(const A&) " << id << std::endl; }
    ~A(){ std::cout << "~A() " << id << std::endl;}

    int i = 32;//variable témoin
    int id = 0;
};
 
int main()
{
    A a;
    std::function<const A&()> f = [&a]{return a;};
    const A& aref = f();
    std::cout << "checkpoint_1" << std::endl;
}

Ainsi que cela a été révélé plus haut, mais en ajoutant l'indication du constructeur de copie et un id aux objets la sortie est :

1
2
3
4
5
A() 0                                            `
A(const A&) 1
~A() 1
checkpoint_1
~A() 0

Lors de l'appel à f(), une copie est donc faite, conformément à ce qu'on a vu précédemment avec la déduction automatique du type de la lambda. Mais elle est détruite immédiatement !

Pourtant on récupère bien une const A&, pourquoi ne fait-elle pas prolonger de la copie temporaire comme précédemment ?

Un petit code montrant le mécanisme du avec std::function<const A&()> en détail :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static int ID = 0;

struct A
{
    A(){ id = ID++; std::cout << "A() " << id << std::endl;}
    A(const A&){ id = ID++; std::cout << "A(const A&) " << id << std::endl; }
    ~A(){ std::cout << "~A() " << id << std::endl;}

    int i = 32;//variable témoin
    int id = 0;
};

A lambda()
{
    A a;
    return a;
}

const A& function()
{
    const A& aret = lambda();

    std::cout << "checkpoint_1";

    return aret;
}

int main()
{
    const A &aref = function();

    std::cout << "checkpoint_2";
}

On note à nouveau un silence total du compilateur. Le résultat est :

1
2
3
4
A() 0                                            `
checkpoint_1
~A() 0
checkpoint_2

Là où ça devient tordu c'est que l'extension de durée de vie d'une temporaire par référence constante est limitée à une seule fois.

En effet, une fois récupérée dans une const A&, la copie temporaire n'a plus du tout le statut de temporaire : aussi on ne peut donc plus étendre sa durée de vie de cette façon !

Voici donc le pourquoi du comment. En conclusion il est donc dangereux soit de jouer avec les std::function<const A&()> soit de laisser faire la déduction automatique du type retour des lambdas. En l'absence d'arguments forts pour pencher la balance, la vigilance est donc de mise lorsque l'on joue avec les deux règles responsables du problème ici :

  1. Un type de retour automatiquement déduit est équivalent à un decltype(expression_de_return), soit le type de l’expression telle qu’elle a été déclarée, la capture par référence n’y fait rien, ça reste le type de la variable avant sa capture.
  2. Si une lambda a vocation à être stockée dans une std::function, alors il faut veiller à ce qu’elle ne renvoie pas une temporaire et que le type de la std::function corresponde au comportement souhaité.

Un grand merci à Flob90 qui a pris le temps de faire beaucoup de tests que vous pouvez consulter ici : lien pastebin. Merci à gbdivers pour sa relecture attentive.

Vous pouvez commenter ce billet de blog sur le forum.