Aller au contenu

Programmation C++ : CM séance 06

13. Fonctions et lambdas

Une fonction lambda, ou encore, une lambda, est une fonction anonyme. Cette appellation fait référence au lambda-calcul, un outil théorique dans lequel on programme tout sous forme d'appel de fonctions.

On a parfois besoin de petites fonctions sans avoir envie de leur donner un nom, ni de les implémenter plus loin. Ce mécanisme est utile par exemple pour les callbacks (dans les interfaces graphiques), ou encore pour réaliser des traitements sur des conteneurs avec un itérateur.

Commençons d'abord par les pointeurs sur fonction.

13.1. Pointeurs sur fonction

Un pointeur sur fonction est une variable qui mémorise l'adresse d'une fonction. Le type du pointeur dépend du type de la fonction, qui prend en compte le type du résultat et le type des paramètres.

Étant donnée une fonction myfunc

type_retour myfunc (type_paramètre, ...);

alors un pointeur pf sur myfunc aura pour type :

type_retour (*pf) (type_paramètre, ...);

Les (*...) autour de pf sont nécessaires pour ne pas le confondre avec une déclaration forward.

Pour affecter le pointeur, on écrit

pf = myfunc;
pf = &myfunc;    // variante

le & devant myfunc est optionnel car une fonction est (déjà) une adresse.

Pour appeler la fonction à partir du pointeur et récupérer le résultat éventuel, il suffit d'écrire :

variable_retour = pf (valeur_paramètre, ...);
variable_retour = (*pf) (valeur_paramètre, ...);  // variante

Le déréférencement est également optionnel.

Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <iostream>

int foo() { return 5; }
int bar() { return 7; }

int main()
{
    std::cout << foo() << " " << bar() << "\n";     // 5 7
    int (*f)() {foo};           // ou encore : int (*f)() = foo;
    std::cout << f() << "\n";   // 5
    f = bar;
    std::cout << f() << "\n";   // 7
}

On peut affecter un pointeur sur fonction à nullptr. L'appel de fonction à partir d'un pointeur sur fonction qui est nullptr ou invalide a un effet indéfini (comprendre, va certainement planter).

13.2. Fonction renvoyant un pointeur sur fonction

Une fonction peut renvoyer un pointeur sur fonction. Étant donnée une fonction myfunc

type_retour myfunc (paramètres_myfunc);

alors une fonction fabric qui retourne un pointeur sur fonction du type de myfunc aura pour prototype :

type_retour (*fabric (paramètres_fabric)) (paramètres_myfunc);

Exemple :

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

int do_sum (int a, int b) { return a+b; }
int do_product (int a, int b) { return a*b; }

int (*get_func_op (char op)) (int, int)
{
    switch (op) {
    case '+' : return do_sum;
    case '*' : return do_product;
    default  : return nullptr;
    }
}

int main()
{
    int (*fp)(int, int) {nullptr};
    fp = get_func_op ('+');
    std::cout << fp (3, 4) << "\n";     // 7
    fp = get_func_op ('*');
    std::cout << fp (3, 4) << "\n";     // 12
}

Il est plus simple de définir un typedef pour le pointeur sur fonction :

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

int do_sum (int a, int b) { return a+b; }
int do_product (int a, int b) { return a*b; }

typedef int (*FuncOp) (int, int); 

FuncOp get_func_op (char op)
{
    switch (op) {
    case '+' : return do_sum;
    case '*' : return do_product;
    default  : return nullptr;
    }
}

int main()
{
    FuncOp fp {nullptr};
    fp = get_func_op ('+');
    std::cout << fp (3, 4) << "\n";     // 7
    fp = get_func_op ('*');
    std::cout << fp (3, 4) << "\n";     // 12
}

Il est également possible d'utiliser des templates ; il faut par contre remplacer typedef par using pour créer un alias de type :

 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
#include <iostream>

template <typename T>
T do_sum (T a, T b) { return a+b; }

template <typename T>
T do_product (T a, T b) { return a*b; }

template <typename T>
using FuncOp = T (*) (T, T);    // typedef non valable pour template

template <typename T>
FuncOp<T> get_func_op (char op)
{
    switch (op) {
    case '+' : return do_sum;
    case '*' : return do_product;
    default  : return nullptr;
    }
}

int main()
{
    FuncOp<int> fp {nullptr};
    fp = get_func_op<int> ('+');
    std::cout << fp (3, 4) << "\n";     // 7
    fp = get_func_op<int> ('*');
    std::cout << fp (3, 4) << "\n";     // 12
}

13.3. Passage de fonction en paramètre

Le passage de fonction en paramètre est le mécanisme fondamental utilisé pour les callbacks dans les interfaces graphiques.

On peut donner une valeur par défaut au paramètre.

Il est plus prudent de tester si le paramètre n'est pas nullptr avant d'appeler la fonction pointée. Dans ce cas, on peut tout simplement ne pas l'appeler, ou encore envoyer une exception. Exemple :

 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
#include <iostream>

int do_sum (int a, int b) { return a+b; }
int do_product (int a, int b) { return a*b; }

using FuncOp = int (*) (int, int);

int compute (int a, int b, FuncOp fp = do_sum)
{
    if (fp == nullptr) {
        throw std::invalid_argument ("function is nullptr");
    }
    return fp (a, b);
}

int main()
{
    std::cout << compute (3, 4) << "\n";                // 7
    std::cout << compute (3, 4, do_sum) << "\n";        // 7
    std::cout << compute (3, 4, do_product) << "\n";    // 12

    try {
        std::cout << compute (3, 4, nullptr) << "\n";
    }
    catch (const std::invalid_argument& e)
    {
        std::cerr << "Error: " << e.what() << '\n';  // Error: function is nullptr
    }
}

13.4. Fonctions lambda

La syntaxe d'une fonction lambda est :

[clause_de_capture] (paramètres) -> type_retour { corps_de_la_lambda };

Comme on le voit il n'y a pas de nom ; ce sont les [...] qui indiquent que c'est une lambda.

La clause_de_capture indique si on veut mémoriser quelque chose en plus de la définition du corps de la fonction (on en reparle ensuite). Lorsque la clause_de_capture est vide on écrit [].

La partie -> type_de_retour (trailing return type) est optionnelle, par défaut le type de retour est déduit (auto).

Note : la syntaxe étrange [](){} est donc une lambda sans capture, sans paramètre et qui ne fait rien.

13.4.1. Lambda sans capture

L'exemple suivant illustre différents usages de lambdas sans capture :

  • ligne 20, on mémorise la lambda dans une variable de type pointeur sur fonction ;
  • ligne 25 on passe une lambda directement en paramètre ( on aurait pu omettre -> int) ;
  • ligne 29 on déclare une lambda et on l'exécute immédiatement.
 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
#include <iostream>

int do_sum (int a, int b) { return a+b; }

using FuncOp = int (*) (int, int);

int compute (int a, int b, FuncOp fp = do_sum)
{
    if (fp == nullptr) {
        throw std::invalid_argument ("function is nullptr");
    }
    return fp (a, b);
}

int main()
{
    std::cout << do_sum (3, 4) << "\n";                     // 7
    std::cout << compute (3, 4, do_sum) << "\n";            // 7

    FuncOp do_product = [] (int a, int b) { return a * b; };
    std::cout << do_product (3, 4) << "\n";                 // 12
    std::cout << compute (3, 4, do_product) << "\n";        // 12

    int r1 = compute (3, 4, 
        [] (int a, int b) -> int { return a - b; }
    );
    std::cout << r1 << "\n";                                // -1

    int r2 = [] (int a, int b) { return a % b; } (12, 7);
    std::cout << r2 << "\n";                                // 5
}

13.4.2. Algorithmes

Le header algorithms déclare de nombreuses fonctions utilitaires, qui prennent en entrée une paire d'itérateurs, ainsi qu'une fonction de traitement. Cette fonction de traitement est appliquée sur tous les éléments du conteneur ; lorsque la fonction renvoie un booléen, elle est appelée un prédicat.

Les lambdas sont très pratiques pour cet usage.

Dans l'exemple suivant,

  • std::count_if renvoie le nombre d'éléments qui satisfont le prédicat ;
  • std::all_of est vrai si tous les éléments satisfont le prédicat (il existe aussi std::any_of et std::none_of ;
  • std::sort utilise un prédicat qui renvoie vrai si le paramètre 1 doit être rangé avant le paramètre 2 dans le conteneur ;
  • std::for_each applique la fonction de traitement à tous les élements, transmis par référence.
 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
#include <iostream>
#include <array>
#include <algorithm>

int main()
{
    std::array a { 1, 13, 1, 5, 8, 2, 3 };

    int count_div2 = std::count_if (a.begin(), a.end(),
        [] (int e) -> bool { return (e % 2) != 0; }
    );
    std::cout << "Nombre d'entiers impairs : " << count_div2 << "\n";   // 5

    int all_posit = std::all_of (a.begin(), a.end(),
        [] (int e) -> bool { return e >= 0; }
    );
    std::cout << "Tous positifs : " << (all_posit ? "y":"n")  << "\n";  // y

    std::sort (a.begin(), a.end(),
        [] (int u, int v) -> bool { return u < v; }
    );
    std::cout << "Tri croissant : ";
    for (auto e : a) { std::cout << e << " "; }
    std::cout << "\n";                              // 1 1 2 3 5 8 13 

    std::for_each (a.begin(), a.end(),
        [] (int& e) -> void { e *= e; }
    );
    std::cout << "Éléments au carré : ";
    for (auto e : a) { std::cout << e << " "; }
    std::cout << "\n";                              // 1 1 4 9 25 64 169
}

13.4.3. Lambda avec capture

Dans la déclaration d'une lambda,

[clause_de_capture] (paramètres) -> type_retour { corps_de_la_lambda };

le paramètre clause_de_capture est une liste de zéro ou plusieurs éléments séparés par des virgules, qui indique quelles variables extérieures doivent être capturées, c'est-à-dire mémorisées lors de la définition de la lambda.

Les clauses de captures habituelles sont :

  • [&] : capture des variables présentes par référence ;
  • [=] : capture des variables présentes par recopie ;

Dans un objet, (*this) est implicitement capturé, et toujours par référence.

Premier exemple :

1
2
3
4
5
6
7
8
#include <iostream>

int main()
{
    int a = 3;
    auto lambda1 = [] (int b) -> int { return a+b; };  // Erreur : a non capturé
    std::cout << lambda1 (4) << "\n";
}

Cet exemple ne compile pas car dans sa définition, la lambda lambda1 n'a pas mémorisé la variable a utilisée dans son corps.

Il suffit de capturer a :

1
2
3
4
5
6
7
8
#include <iostream>

int main()
{
    int a = 3;
    auto lambda1 = [a] (int b) -> int { return a+b; };  // ou [&] ou [=]
    std::cout << lambda1 (4) << "\n";                   // 7
}

Illustrons la différence entre les captures [=] et [&] :

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

int main()
{
    int a = 3;
    auto lambda1 = [=] (int b) -> int { return a+b; };
    std::cout << lambda1 (4) << "\n";                   // 7
    a = 11;
    std::cout << lambda1 (4) << "\n";                   // 7

    int c = 3;
    auto lambda2 = [&] (int d) -> int { return c+d; };
    std::cout << lambda2 (4) << "\n";                   // 7
    c = 11;
    std::cout << lambda2 (4) << "\n";                   // 15
}

Mais où sont mémorisées les éléments capturés ?

  • la déclaration d'une lambda sans capture renvoie un pointeur sur fonction,
  • tandis que la déclaration d'une lambda avec capture renvoie un foncteur.

13.4.4. Foncteur

Un foncteur (uk : functor, pour function object) est un objet qui peut être exécuté comme une fonction ; pour qu'une classe soit un foncteur il suffit qu'elle surcharge operator().

Voici un exemple de foncteur qui simule une lambda faisant la capture d'un paramètre par référence :

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

class Functor {
    int& m_a;
public:
    Functor (int& a) : m_a {a} {};
    int operator() (int b) { return m_a+b; }
};

int main()
{
    int a = 3;
    Functor fun1 {a};
    std::cout << fun1 (4) << "\n";                   // 7
    a = 11;
    std::cout << fun1 (4) << "\n";                   // 15
}

14. Mémoire, copies et références

14.1. Conversion de types

Le C++ peut être amené à convertir un type en un autre, de manière implicite (automatique) ou explicite (demandé dans le programme) ; cette conversion peut être réalisée ou vérifiée à la compilation ou à l'exécution.

Cette opération s'appelle la conversion de type ou transtypage (uk : cast ).

Exemple de conversion implicite lors d'un passage de paramètres :

void print_as_int    (int k)    { std::cout << k << "\n"; }
void print_as_double (double k) { std::cout << k << "\n"; }

print_as_int (3.14);    // 3
print_as_double (5);    // 5

Exemple de promotion de type dans un calcul (les opérations de même priorité sont effectuées de gauche à droite) :

std::cout << std::fixed << std::setprecision(1);
std::cout << 4 / 5 * 2 << " "              // 0     pas de promotion
          << 4 / 5 * 2.0 << " "            // 0.0   promotion en double
          << 4 / 5.0 * 2 << " "            // 1.6   promotion en double
          << (double) 4 / 5 * 2 << "\n";   // 1.6   cast du C et promotion

Pour convertir explicitement une expression et sa valeur dans un nouveau type, on écrivait dans l'ancienne syntaxe du C pour le cast (C-style cast ) :

(nouveau_type) expression
nouveau_type( expression )  // variante

cette syntaxe est encore valide, mais on lui préfère la forme plus moderne du C++ :

static_cast<nouveau_type>(expression)

Exemples d'utilisation :

double d = -65.2;
std::cout << d << " "                               // -65.2
          << static_cast<int>(d)<< " "              // -65
          << static_cast<unsigned int>(d) << " "    // 4294967231
          << static_cast<char>(-d) << " "           // A
          << static_cast<void*>(&d) << "\n";        // 0x7fffdd414a70

La validité de cette conversion est vérifiée à la compilation :

auto f = [](){};
std::cout << static_cast<void*>(f) << "\n";
// error: invalid static_cast from type ‘main()::<lambda()>’ to type ‘void*’

La conversion static_cast peut être surchargée avec operator().

Il existe d'autres formes de cast :

  • const_cast : ajoute ou supprime const ;
  • dynamic_cast : conversion d'une référence ou pointeur sur un objet, vers une classe mère ou fille ;
  • reinterpret_cast : l'expression prend le nouveau type, sans faire aucune conversion de valeur.

14.2. Allocation dynamique

L'allocation dynamique consiste à demander un bloc mémoire dont la durée de vie n'est pas limitée par la portée dans laquelle il est créé.

Les fonctions habituelles d'allocation mémoire du C sont disponibles :

#include <cstdlib>
void* malloc (std::size_t size);
void* calloc (std::size_t num, std::size_t size);
void* realloc (void* ptr, std::size_t new_size);
void free (void* ptr);
  • malloc alloue un bloc en mémoire de size octets non initialisés, puis retourne un pointeur vers l'adresse de base du bloc (ou nullptr en cas d'erreur).
  • calloc alloue un bloc de num cases de taille size octets et les initialise à 0.
  • realloc modifie la taille d'un bloc mémoire déjà alloué. Si ptr est nul, l'appel a le même effet que malloc.
  • free libère un bloc mémoire ; ne fait rien si ptr est nul.

Le résultat est un pointeur vers le début du bloc en mémoire, de type void*. Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <iostream>

int main()
{
    std::size_t n = 5;
    int* p = static_cast<int*>( calloc (n, sizeof(int)) );
    for (std::size_t i = 0; i < n; i++)     // range-for non possible
        p[i] = i*2;
    for (std::size_t i = 0; i < n; i++) 
        std::cout << p[i] << " ";           // 0 2 4 6 8
    std::cout << "\n";
    free (p);
}

Pour éviter une fuite de mémoire, free doit être appelé exactement une fois sur chaque pointeur créé par malloc/calloc/realloc.

Ces fonctions n'appellent pas de constructeur et n'initialisent pas la mémoire (mise à par calloc). Pour allouer des objets il faut utiliser les fonctions new et delete :

T* ptr = new T {paramètres};        // un seul élément
T* ptr = new T[] {paramètres};      // un tableau d'éléments
delete ptr;

Cela fonctionne aussi avec des types simples (on peut utiliser auto) :

int* ga = new int {5};              // ga pointe vers un entier qui vaut 5
double* bu = new double[] {1,2,3};  // bu pointe vers un tableau de 3 doubles
...
delete bu;
delete ga;

Exemple avec une classe :

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

class Foo {
public:
    Foo() { std::cout << "Foo" << "\n"; }
    ~Foo() { std::cout << "~Foo" << "\n"; }
};

int main()
{
    Foo* foo {nullptr};
    {
        std::cout << "Début bloc" << "\n";
        foo = new Foo;
        std::cout << "Fin bloc" << "\n";
    }
    std::cout << "Après bloc" << "\n";
    delete foo;
}

Affiche :

Début bloc
Foo
Fin bloc
Après bloc
~Foo

L'allocation peut être surchargée au niveau de la classe avec operator new et operator new[].

Chaque pointeur alloué avec un new doit être libéré avec delete exactement une fois. Si un pointeur est perdu, il ne pourra pas être libéré et il y aura une fuite de mémoire.

Exemple :

void do_something ()
{
    throw std::invalid_argument ("Some error");
}
void memory_leak ()
{
    int* tmp = new int;
    do_something ();
    delete tmp;         // on ne passe jamais ici
}

Règle de prudence : Il est très difficile de garantir l'absence de fuites mémoire, c'est pourquoi on évite au maximum les allocations dynamiques : la plupart du temps, l'utilisation de conteneurs évite ces problèmes.

Une autre parade consiste à utiliser des pointeurs intelligents (smart pointers), qui libèrent automatiquement la mémoire selon la situation, par exemple std::unique_ptr ou std::shared_ptr.

Efficacité : les opérations d'allocation et de libération ont un coût (appel système, gestion du tas) ; si on a besoin de beaucoup de blocs, il est beaucoup plus efficace d'utiliser des conteneurs, sans faire de new/delete.

14.3. Sémantique du mouvement

Un des apports important de C++11 a été l'introduction de la sémantique du mouvement (uk : move semantics) de manière à écrire des programmes plus efficaces.

Cette nouvelle syntaxe permet d'écrire du code qui transfère les ressources (telles que des blocs de mémoire) d'un objet vers un autre, au lieu de faire des recopies, plus coûteuses.

14.3.1. Catégories d'expression

On catégorise les expressions d'après les valeurs qu'elles peuvent donner en s'appuyant sur deux propriétés :

  • "a une identité" (has identity) : a un nom et une adresse, il est possible de comparer si l'expression réfère à la même entité qu'une autre expression ;
  • "est déplaçable" (can be moved from) : on peut "piller" ses ressources (pour les placer ailleurs) et laisser l'objet dans un état indéterminé, mais valide.

Il y a 5 catégories d'expressions :

  • une lvalue (left value) a une identité et n'est pas déplaçable ;
  • une xvalue (expiring value) a une identité et est déplaçable ;
  • une glvalue (generalized lvalue) est une lvalue ou une xvalue : elle a une identité, peut être déplaçable ou non ;
  • une prvalue (pure rvalue) n'a pas d'identité et est déplaçable ;
  • une rvalue (right value) est une prvalue ou une xvalue : elle est déplaçable, peut avoir une identité ou non.

Les catégories principales sont lvalue, prvalue et xvalue ; les deux autres, glvalue et rvalue sont des catégories combinées.

On peut résumer cette catégorisation dans ce tableau :

est déplaçable : rvalue non déplaçable
a une identité : glvalue xvalue lvalue
pas d'identité prvalue inutilisé

L'idée générale est qu'une lvalue peut être placée à gauche d'une affectation, tandis qu'une prvalue ne peut être placée qu'à droite. Exemple :

int k;
k = 3+4;    // k est une lvalue car on peut écrire k = ...
            // 3+4 est une prvalue car on ne peut écrire 3+4 = ...

Une lvalue peut être autre chose qu'une variable :

int k;  // globale
int& foo() { return k; }
foo() = 5;              // foo() est une lvalue

La nouveauté de C++11 est la notion de xvalue : c'est une expression temporaire, sur le point d'être détruite (expiring value), dont on peut éventuellement "piller" les ressources.

Exemple : une expression pour accéder à un membre non statique d'un objet rvalue est une xvalue :

struct A { int m_foo = 5; };
int bar = A{}.m_foo;        // l'objet temporaire A{} est une rvalue car 
                            // on ne peut écrire A{} = ...
                            // donc A{}.m_foo est une xvalue.

14.3.2. Références

Le standard C++11 élargit la notion de référence :

  • auparavant, avec le symbole &, on ne pouvait déclarer de référence que sur des lvalues, ou des références const sur des rvalues ;
  • dorénavant, avec la nouvelle notation &&, on peut également déclarer une référence (non const ou const) sur des rvalues (= xvalue ou prvalue).

La syntaxe est :

      T&  e = une_lvalue;
const T&  e = une_rvalue; 
      T&& e = une_rvalue;

Exemple :

1
2
3
4
5
6
int a = 3;
int& b = a;     // ok car a est une lvalue
int& c = 3;     // erreur, 3 n'est pas une lvalue
int&& d = a;    // erreur, a n'est pas une rvalue
int&& e = 3;    // ok car 3 est une rvalue
e = 4;          // ok car une référence nommée sur rvalue est une lvalue

Il est intéressant de noter que ligne 1, la rvalue temporaire 3 est détruite dès que le ; est atteint, alors que ligne 5, la durée de vie de la rvalue 3 est prolongée à la durée de vie de la référence e.

L'exemple suivant montre avec des objets la prolongation de la durée de vie de rvalues lorsqu'elles sont référencées par & ou && :

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

class Foo {
public:
    int m_bar;
    Foo (int bar) : m_bar{bar} { std::cout << "Foo " << m_bar << "\n"; }
    ~Foo() { std::cout << "~Foo " << m_bar << "\n"; }
};

int main()
{
    //Foo& hoo = Foo{1};        // erreur, ref & non const sur rvalue 
    const Foo& hoo = Foo{2};    // ok, ref & const sur rvalue permise
    Foo&& goo = Foo{3};         // ok, ref && sur rvalue (prvalue)
    goo.m_bar = 4;              // ok, durée de vie de Foo{3} prolongée
    int&& baz = Foo{5}.m_bar;   // ok, ref && sur rvalue (xvalue)
    baz = 6;                    // ok, durée de vie de Foo{5} prolongée
    std::cout << "Fin de la portée\n";
}

Affiche :

Foo 2
Foo 3
Foo 5
Fin de la portée
~Foo 6                          // Les instances sont bien restées en vie
~Foo 4                          // jusqu'à la fin de la portée des références.
~Foo 2

On peut faire afficher au compilateur la nature d'une référence en surchargeant des fonctions :

 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
#include <iostream>

void print_ref_nat (int& k)
{
    std::cout << "ref sur Lvalue, e = " << k <<  "\n";
}

void print_ref_nat (int&& k)
{
    std::cout << "ref sur Rvalue, e = " << k <<  "\n";
}

struct Foo { int m_bar = 5; };

int main()
{
    int a = 3;
    int& b = a;
    int&& c = 4;
    print_ref_nat (a);      // ref sur Lvalue, e = 3
    print_ref_nat (b);      // ref sur Lvalue, e = 3
    print_ref_nat (c);      // ref sur Lvalue, e = 4
    print_ref_nat (5);      // ref sur Rvalue, e = 5

    Foo foo;
    print_ref_nat (foo.m_bar);      // ref sur Lvalue, e = 5
    print_ref_nat (Foo{}.m_bar);    // ref sur Rvalue, e = 5
}

Un autre point important pour la sémantique du mouvement est l'introduction de la fonction std::move, qui transforme une référence sur lvalue en une référence sur rvalue ; cette transformation est faite avec un static_cast, donc sans aucun coût :

std::move(ref_sur_lvalue) -> ref_sur_rvalue

En reprenant l'exemple précédent, on obtient :

int a = 3;
print_ref_nat (a);              // ref sur Lvalue, e = 3
print_ref_nat (std::move(a));   // ref sur Rvalue, e = 3

14.3.3. Constructeur de copie

Chaque classe possède un constructeur de copie, qui est appelé notamment :

  • à l'initialisation par copie : T a = b ou T a {b}, où b est de type T ;
  • au passage d'argument d'une fonction : f(T a) ;
  • au retour de fonction : T f(...) { ... return a; }a est de type T et n'a pas de constructeur de mouvement.

Le compilateur synthétise automatiquement un constructeur de copie (implicitely defined copy constructor) si la classe n'en déclare pas un.

Le constructeur de copie synthétisé effectue une copie légère (shallow copy) des membres, c'est-à-dire que les références et pointeurs sont recopiés tels quels. Si une copie profonde est souhaitée (deep copy) alors il faudra écrire un constructeur de copie qui effectue des opérations de duplication des membres (allocation dynamique, etc).

La forme d'un constructeur de copie pour un type T est :

class T {

    T (T& other, autres_paramètres)
        : liste_initialisations_membres         // copie légère
    {
        // corps                                // copie profonde : new, ...
    }

};

Exemple :

 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>

class Foo {
    int m_bar;
public:
    Foo (int bar)
        : m_bar{bar} 
    {
        std::cout << "Foo " << m_bar << "\n"; 
    }    
    Foo (Foo& other)                // constructeur de copie
        : m_bar{other.m_bar+1}      // +1 pour différencier les traces
    {
        std::cout << "Foo& " << m_bar << "\n";
    }
    ~Foo() { std::cout << "~Foo " << m_bar << "\n"; }
};

int main()
{
    Foo foo1 {5};
    Foo foo2 {foo1};    // ou (foo1) ou = foo1
}

Affiche :

Foo 5       // constructeur appelé
Foo& 6      // constructeur de copie appelé
~Foo 6
~Foo 5

Si on supprime la ligne 12, foo2.m_bar vaudra 0.

14.3.4. Constructeur de mouvement

Chaque classe possède également un constructeur de mouvement, qui est appelé lorsqu'un objet est initialisé à partir d'une rvalue (xvalue ou prvalue) du même type, notamment :

  • à l'initialisation : T a = std::move(b) ou T a {std::move(b)}, où b est de type T ;
  • au passage d'argument d'une fonction : f(T std::move(a)) ;
  • au retour de fonction : T f(...) { ... return a; }a est de type T et possède un constructeur de mouvement.

Le constructeur de mouvement transfère les ressources mémorisées dans les membre, au lieu d'effectuer des copies. Il laisse l'objet "pillé" dans un état indéterminé mais valide (il peut encore être détruit).

Le compilateur synthétise automatiquement un constructeur de mouvement (implicitely defined move constructor) si la classe n'en déclare pas un.

La forme d'un constructeur de mouvement pour un type T est :

class T {

    T (T&& other, autres_paramètres)
        : liste_initialisations_membres         // copie légère
    {
        // corps                                // transferts de ressources
    }

};

Exemple :

 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
#include <iostream>

class Foo {
    int m_bar;
public:
    Foo (int bar)
        : m_bar{bar} 
    {
        std::cout << "Foo " << m_bar << "\n"; 
    }    
    Foo (Foo& other)                // constructeur de copie
        : m_bar{other.m_bar+1}      // +1 pour différencier les traces
    {
        std::cout << "Foo& " << m_bar << "\n";
    }
    Foo (Foo&& other)               // constructeur de mouvement
        : m_bar{other.m_bar+2}      // +2 pour différencier les traces
    {
        std::cout << "Foo&& " << m_bar << "\n";
    }
    ~Foo() { std::cout << "~Foo " << m_bar << "\n"; }
};

int main()
{
    Foo foo1 {5};
    Foo foo2 {foo1};                // ou (foo1) ou = foo1
    Foo foo3 {std::move(foo1)};     // ou = std::move(foo1)
}

Affiche :

Foo 5       // constructeur appelé
Foo& 6      // constructeur de copie appelé
Foo&& 7     // constructeur de mouvement appelé
~Foo 7
~Foo 6
~Foo 5

Tout l'intérêt réside dans la capacité à transférer les ressources, comme dans l' exemple suivant : ligne 22 on recopie le pointeur puis on le met à nullptr dans other de manière à ce que le destructeur de other ne fasse rien.

 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
34
35
36
37
#include <iostream>

class Foo {
    int m_bar;
    int *m_ham;
public:
    Foo (int bar)
        : m_bar{bar}, m_ham{nullptr}
    {
        m_ham = new int;
        std::cout << " Foo   " << m_bar << " " << m_ham << "\n"; 
    }    
    Foo (Foo& other)                // constructeur de copie
        : m_bar{other.m_bar},
          m_ham{other.m_ham}
    {
        std::cout << " Foo&  " << m_bar << " " << m_ham << "\n";
    }
    Foo (Foo&& other)               // constructeur de mouvement
        : m_bar{other.m_bar}
    {
        m_ham = other.m_ham ; other.m_ham = nullptr;
        std::cout << " Foo&& " << m_bar << " " << m_ham << "\n";
    }
    ~Foo() { std::cout << "~Foo   " << m_bar << " " << m_ham << "\n"; }
};

Foo get_a_foo (int a)
{
    Foo tmp {a};
    return std::move(tmp);
}

int main()
{
    Foo foo = get_a_foo(5);
}

Affiche :

 Foo   5 0x55d4d75dceb0
 Foo&& 5 0x55d4d75dceb0
~Foo   5 0
~Foo   5 0x55d4d75dceb0

14.3.5. Élision de copie garantie

Il est à noter que dans l'exemple précédent, si on supprime std::move ligne 31  :

28
29
30
31
32
Foo get_a_foo (int a)
{
    Foo tmp {a};
    return tmp;
}

alors on obtient la trace :

 Foo   5 0x557906c16eb0
~Foo   5 0x557906c16eb0

Dans cet exemple, les constructeurs de copie et de mouvement ne sont même pas appelés : c'est l'élision de copie. Elle est garantie depuis C++17 sur les prvalues dans certaines circonstances.