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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 aussistd::any_of
etstd::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 |
|
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 |
|
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 |
|
Illustrons la différence entre les captures [=]
et [&]
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
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 |
|
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 supprimeconst
;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 desize
octets non initialisés, puis retourne un pointeur vers l'adresse de base du bloc (ounullptr
en cas d'erreur).calloc
alloue un bloc denum
cases de taillesize
octets et les initialise à 0.realloc
modifie la taille d'un bloc mémoire déjà alloué. Siptr
est nul, l'appel a le même effet quemalloc
.free
libère un bloc mémoire ; ne fait rien siptr
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 |
|
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 |
|
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 unelvalue
ou unexvalue
: 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 uneprvalue
ou unexvalue
: 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 deslvalues
, ou des référencesconst
sur desrvalues
; - dorénavant, avec la nouvelle notation
&&
, on peut également déclarer une référence (nonconst
ouconst
) sur desrvalues
(=xvalue
ouprvalue
).
La syntaxe est :
T& e = une_lvalue;
const T& e = une_rvalue;
T&& e = une_rvalue;
Exemple :
1 2 3 4 5 6 |
|
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 |
|
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 |
|
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
ouT a {b}
, oùb
est de typeT
; - au passage d'argument d'une fonction :
f(T a)
; - au retour de fonction :
T f(...) { ... return a; }
oùa
est de typeT
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 |
|
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)
ouT a {std::move(b)}
, oùb
est de typeT
; - au passage d'argument d'une fonction :
f(T std::move(a))
; - au retour de fonction :
T f(...) { ... return a; }
oùa
est de typeT
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 |
|
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 |
|
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 |
|
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.