Outils d'utilisateurs

Outils du Site


references

Ceci est une ancienne révision du document !


Copies, déplacements et indirections

Dans le chapitre precedent, vous avez vu comment echanger des informations avec une fonction, en utilisant les parametres de fonction et le retour de fonction.

Le type d'echange que vous avez vu s'appelle un passage par valeur. Cela consiste a partir de l'objet qui se trouve dans la fonction appelante, et d'en faire une copie qui sera utilisable dans la fonction appelee.

void f(int i) {
    // i est une nouvelle "variable", accessible uniquement dans la fonction f
    // et qui contient la meme valeur que la variable j de la fonction g.
}
 
void g() {
    const int j { 123 };
    f(j);
}

La valeur est copiee lors de l'appel de la fonction f, ce qui implique que les eventuelles modifications du parametre i ne seront pas repercutees sur la variable j.

Il existe d'autres facon de transmettre une information dans une fonction, il convient donc de detailler d'abord les concepts de copie, de deplacement et d'indirection.

Copie et deplacement

La copie d'objets

La copie consiste donc a creer un nouvel objet, identique a un objet existant. Ces deux objets seront independants, c'est a dire que si l'un des objets est modifie ou detruit, l'autre objet ne sera pas modifie.

main.cpp
#include <iostream>
 
int main() {
    const int i { 123 };
    int j { i };  // j est une copie de i, elle contient la meme valeur
    std::cout << "i=" << i << ", j=" << j << std::endl;
    j = 456;
    std::cout << "i=" << i << ", j=" << j << std::endl;
}

affiche :

i=123, j=123
i=123, j=456

Tous les objets ne sont pas copiables. Les types fondamentaux (int, double, float, etc) sont copiables, ainsi que la tres grande majorite des classes de la bibliotheque standard.

Souvenez vous, cela a ete aborde dans le chapitre Les fonctionnalités de base des collections, la majorite des classes de la bibliotheque standard possedent une semantique de valeur et sont copiables. Un exemple de classe non copiable est std::unique_ptr. Cela sera detaille dans la partie sur la programmation objet.

Le deplacement d'objets

Le deplacement d'objets consiste a deplacer (move) un objet depuis une variable vers une autre. L'objet n'est pas modifie dans cette operation, il est conserve a l'identique. Cette operation peut etre realisee en utilisant la fonction std::move.

main.cpp
#include <iostream>
 
int main() {
    int i { 123 };
    int j { std::move(i) };
    std::cout << "j=" << j << std::endl;
}

Cette notion a aussi ete vu rapidement dans Les fonctionnalités de base des collections et sera detaille dans la partie sur la programmation objet.

Contrairement a la copie qui permet d'obtenir deux objets au final, le deplacement ne modifie pas le nombre d'objets, il y a toujours un seul objet valide apres l'operation. La variable qui contenait l'objet initialement contient ensuite un objet invalide qui ne doit jamais etre utilise (cela produirait un comportement indefini). La variable ne peut etre utilisee que pour lui affecter un nouvel objet.

main.cpp
#include <iostream>
 
int main() {
    int i { 123 };
    int j { std::move(i) };
    std::cout << "j=" << j << std::endl; // interdit d'utiliser i ici
    i = 456;
    std::cout << "i=" << i << ", j=" << j << std::endl; // ok
}

Tous les objets ne sont pas deplacable (movable). La copie et le deplacement sont independants, il est donc possible d'avoir des objets copiables et deplacables, des objets deplacables et non copiables, ou des objets non copiables et non deplacables.

Le deplacement est parfois considere comme une copie, et donc cela n'a pas de sens d'avoir un objet copiable, mais non deplacable. C'est possible syntaxiquement de faire cela, mais cela sera considere comme une erreur de conception dans ce cours.

En pratique, ce qui se passe reelement lors d'un deplacement est un peu complexe. Dans certains cas (par exemple avec les types fondamentaux), une copie sera realisee. Dans d'autres cas (par exemple avec std::string ou std::vector), les donnees ne sont reellement pas copiees et le deplacement est plus performant que la copie. Dans tous les cas, vous pouvez retenir que le deplacement ne sera jamais plus couteux que la copie (au pire, il peut etre equivalent a une copie).

La fonction std::move ne realise donc pas a proprement parle un deplacement, mais dit au compilateur qu'un objet peut etre deplace. Libre a lui de realiser ou non un deplacement, si c'est possible. Mais dans d'autres cas, le compilateur peut decider par lui meme qu'un objet peut etre deplace et le fera automatiquement. (C'est une optimisation automatique, puisque le deplacement sera potentiellement plus performant que la copie).

int f() { 
    int i { 123 };
    return i;  // i ne sera plus utilise ensuite dans f et peut etre deplace
}

Dans ce code, la variable i ne sera plus utilisable apres le return (puisque la fonction sera terminee) et peut donc etre deplacee vers la fonction appelante.

Les indirections

La concept d'indirection

Avec une copie ou un deplacement, vous avez dans les deux cas une variable locale a la fonction qui contient un objet. Mais il peut etre interessant aussi de pouvoir manipuler un objet qui n'est pas dans la fonction, par exemple pour modifier un objet qui se trouve dans un autre fonction ou acceder a un objet depuis plusieurs endroits du code.

#include <iostream>
 
int f(int i) {
    return i + 456;
}
 
int main() {
    int j { 123 };
    j = f(j);
    std::cout << j << std::endl;
}

affiche :

579

Dans ce code, l'objet initialement dans la variable j est dans un premier temps copie dans la variable ide la fonction f, puis le resultat est deplacer depuis la variable i de la fonction f vers la variable j de la fonction main. Cela fait beaucoup de manipulation d'objets.

Les indirections sont un moyen d'acceder a une variable a distance, sans devoir faire de copie ou de deplacement. Utiliser une indirection revient a utiliser indirectement une autre variable.

Le code precedent peut etre modifie de la facon suivante :

void f(int & i) {  // notez bien l'ajout de & ici
    i += 456;
}
 
int main() {
    int j { 123 };
    f(j);
    std::cout << j << std::endl;
}

affiche :

579

Dans ce code, la variable i dans la fonction f est une indirection (une reference) vers la variable j de la fonction main. Utiliser i revient a utiliser indirectement j, la modification realisee sur i est en fait realisee sur j.

Il n'y a pas de copie ou de deplacement dans ce code. Les indirections seront donc souvent utilisees pour eviter la copie d'objets complexes ou pour modifier un objet dans une fonction. Lorsque la fonction ne doit pas modifier l'objet, la reference sera mis en constante avec le mot-cle const.

void f(int & i) { 
    i += 456;  // ok, la reference n'est pas constante
}
 
void g(int const& i) { 
    i += 456;  // erreur, la reference est constante
}

affiche l'erreur suivante :

main.cpp:6:7: error: cannot assign to variable 'i' with const-qualified type 'const int &'
    i += 456;
    ~ ^
main.cpp:5:19: note: variable 'i' declared const here
void g(int const& i) { 
       ~~~~~~~~~~~^

Pour pouvez egalement trouver les syntaxes equivalentes suivantes pour ecrire une reference constante :

TYPE const & NOM
const TYPE & NOM

N'oubliez pas aussi que l'utilisation des espaces est libre en C++, vous pouvez donc trouver ces syntaxes avec ou sans espace avant et apres la reference &.

La regle est que le mot-cle const s'applique au type qui se trouve a gauche. Et s'il n'y a rien a gauche, il s'applique a droite.

Pour comprendre une syntaxe avec reference, il est souvent plus simple de lire de la droite vers la gauche. Ainsi, la ligne TYPE const & NOM peut se lire : “NOM est une reference sur une constante de type TYPE”.

Classification des indirections

Les references vues precedent ne sont qu'un des nombreux types d'indirections qui existent. C'est le type d'indirection qui est le plus utilise et celui que vous devrez utiliser par defaut.

Il est meme possible de creer des classes qui representent une indirection, il y a donc potentiellement un nombre infini de types differents d'indirections. Le but de ce chapitre n'est donc pas de presenter toutes les indirections, mais uniquement de donner les caracteristiques des indirections du langage C++ et de la bibliotheque standard.

Semantiques de reference et de pointeurs

Pour commencer, il y a deux types principaux d'indirections : les indirections a semantiques de reference et les indirection a semantiques de pointeurs.

Semantiques

Une semantique est le sens qui est donne a un concept, c'est a dire l'ensemble des operations que ce concept permet de faire.

Une “indirection a semantique de reference” est donc une indirection qui n'est pas forcement une reference, mais qui se manipulera comme une reference. Et de meme, une “indirection a semantique de pointeur” est une indirection qui se manipule comme un pointeur, sans forcement etre un pointeur. (Les pointeurs seront vu dans la partie sur la programmation orientee objet).

Par exemple, les iterateurs que vous avez etudie avec les collections sont des indirections a semantique de pointeur, mais qui ne sont pas des pointeurs.

Les references sont des indirections qui permettent d'acceder directement a un objet. La syntaxe pour utiliser l'indirection est exactement la meme que la syntaxe pour utiliser l'objet. Dans certains cas, le compilateur peut meme remplacer l'indirection par un alias de variable, c'est a dire utiliser directement la variable referencee, mais avec un nom different. (Le code genere par le compilateur supprimera completement l'indirection).

int i { 123 };
int & j { i };
 
j += 456; // strictement equivalent a i += 456;

Les pointeurs sont des indirections qui ne permettent pas d'utiliser directement un objet. Pour acceder a l'objet, il faut d'abord appliquer une operation particuliere, appellee “dereferencement”. “Dereferencer un pointeur” consiste donc a acceder a l'objet pointe par un pointeur.

Par exemple, pour utiliser un iterateur, vous avez vu dans le chapitre Les itérateurs qu'il fallait utiliser l'opérateur *, place devant la variable.

main.cpp
#include <iostream>
#include <vector>
 
int main() {
    const std::vector<int> v { 12, 23, 34 };
    const auto it = cbegin(v);
    std::cout << (*it) << std::endl;
}

affiche :

12

Dans ce code, it est un itérateur (une indirection) sur le premier élément de la collection et *it (déréférencement de l'itérateur) correspond a ce premier élément (la valeur entière 12).

Lorsque le nom d'une variable n'a pas d'importance (par exemple dans un code d'exemple ou pour des explications), il est classique de nommer une référence par ref et un pointeur par ptr, p ou q.

// indirection a sémantique de référence
ref = 123;
std::cout << ref << std::endl;
 
// indirection a sémantique de pointeur
(*ptr) = 123;
std::cout << (*it) << std::endl;

Les parenthèses ne sont pas forcement indispensables, l’opérateur de déréférencement * est prioritaire par rapport a l’opérateur d'affectation = et l’opérateur de flux «. C'est a dire que le code *ptr = 123; sera interprété comme équivalent a (*ptr) = 123; (le pointeur est déréférencé PUIS une valeur est affectée a l'objet pointé) et non comme *(ptr = 123); (le pointeur est modifié PUIS il est déréférencé).

La dernière syntaxe est valide, mais produira très probablement un crash. Manipuler les pointeurs de cette façon s'appelle l’arithmétique des pointeurs, c'est quelque chose de très complexe et ne donc être réalisé que dans des cas très particuliers (interfaçage avec le système, le matériel ou d'autres langages).

Dans ce cours, pour éviter les ambiguïtés sur l'ordre d’évaluation des opérateurs ou la confusion avec l’opérateur de multiplication *, les parenthèses seront toujours utilisées.

Validité des indirections

Vous avez vu dans le chapitre sur les iterateurs que ceux-ci pouvait être invalide. Quand c’est le cas, ils prennent une valeur particulière, correspondant a std::end(), il faut donc toujours tester un iterateur avant utilisation.

assert(it != std::end(v));
(*it) = 123;

Vous avez également vu qu'un iterateur pouvait être invalide, mais ne pas être testable, par exemple après que la collection soit modifiée.

std::vector<int> v { 1, 2, 3 };
auto it = std::begin(v);
v.clear();
assert(it != std::end(v));
(*it) = 123;  // erreur

Certaines opérations sur certains types de collections peuvent invalider les iterateurs, et seule la documentation permet de savoir cela. En cas de doute, considérez que les iterateurs sont invalides.

Mais en fait, ce probleme de validité n'est pas spécifique aux iterateurs, mais a toutes les indirections. Certains types d'indirection (comme les références ou les pointeurs intelligents) permettent d'avoir des garanties plus fortes si elles sont correctement utilisées, mais il faut toujours rester vigilants.

L’équivalent de std::end() pour les pointeurs est nullptr (“pointeur nul”), il est donc possible de tester un pointeur avec assert. Mais, comme pour les iterateurs, un pointeur peut être invalide, mais ne pas être testable (“dangling pointer”).

assert(ptr != nullptr);
assert(ptr);  // équivalent

Apres avoir déréférencé une indirection a sémantique de pointeur, vous obtenez encore une indirection, mais a sémantique de référence cette fois ci. Cette nouvelle indirection peut être conservée dans une variable, comme n'importe quelle indirection.

std::vector<int> v { 1, 2, 3 };
auto it = std::begin(v);
assert(it != std::end(v));
int & ref = (*it);
ref = 123;  // ok, equivalent a (*it) = 123

Mais vous voyez dans ce code qu'il est facile d'invalider une référence. Si vous appeler clear après avoir crée la référence, celle si sera invalide, tout comme l'iterateur.

Dans ces conditions, pourquoi une référence est plus sure qu'un pointeur ? La raison est qu'elle n'est pas destinée a être utilisée dans les mêmes conditions qu'un pointeur.

  • un pointeur est modifiable, il peut recevoir une nouvelle valeur (affectation), être nul, faire des opérateurs arithmétiques dessus. Une référence est définie a l'initialisation et ne sera plus modifiable ensuite.
  • un pointeur pourra être transmis entre differentes fonctions et classes et avoir une duree de vie assez longue. Une reference ne sera pas conservee lorsque la duree de vie des objets n'est pas garantie.

La difference de securite entre pointeur et reference tient donc uniquement a leur utilisation. Les references imposent des contraintes plus fortes, mais offrent en retour des garanties plus fortes. Pour ameliorer la securite du code, il faut donc priviliger l'utilisateur des references, c'est a dire de concevoir au maximum son code pour rester dans les contraintes imposees par les references. (C'est pour cette raison que les pointeurs sont vu tres tardivement dans ce cours).

Cette approche est assez classique en C++. Pour résoudre un probleme, il est souvent possible d'utiliser plusieurs approches : des approches plus génériques (moins de contraintes, mais également moins de garanties) ou des approches plus spécialisées (plus de contraintes, donc plus de garanties).

En programmation moderne, la taille des projets est de plus en plus grande (ainsi que la complexité), la qualité du code est donc de plus en plus prioritaire. C'est pour cela que les langages modernes ont des bibliothèques standards de plus en plus importantes et le C++ n’échappe pas a cette règle : le langage C ne propose qu'un seul type d'indirection (les pointeurs), alors que le C++ en propose beaucoup plus (pointeurs du C, références, iterateurs, pointeurs intelligents, etc.).

Cela pourrait laisser penser que les langages modernes sont plus complexes et plus long a apprendre (la norme C tient en 400 pages, la norme C++ en 1500 pages et la documentation du Java doit faire plusieurs milliers de pages), mais ça serait une vision fausse. Si vous souhaitez réaliser une tache en particulier, vous aurez dans le premier cas peu de fonctionnalités utilisables (donc facile a apprendre), mais vous devrez tout faire vous même, et donc avoir un code plus important et plus complexe (et plus de risque d'erreur). Dans le second cas, vous aurez plus d'outils a apprendre, mais ils seront plus puissants pour chaque tache. Le code sera donc plus simple et avec moins d'erreur.

Et en C++, vous devrez étendre cette approche a votre code. Si un outil n'est pas disponible dans le langage ou une bibliothèque, plutôt que d’écrire directement le code pour résoudre cette tache, il faudra faire en sorte de créer ces outils, de les rendre réutilisable et sécurisé, puis de les utiliser pour résoudre votre probleme.

Les references comme parametre de fonction

Quelles sont les conditions pour que les references restent valides lors des appels de fonctions ?

Le cas le plus simple est lorsque la reference est utilisee comme parametre de fonction.

main.cpp
#include <iostream>
 
void f(int & ref) {
    ref = 456;
}
 
int main() {
    int i { 123 };
	f(i);
	std::cout << i << std::endl;
}

affiche :

456

Dans ce cas, vous avez la garantie que la fonction appellee f se terminera avant la fonction appelante main. L'objet provenant de main sera donc forccement valide pendant toute la duree de l'execution de la fonction f et il n'y a aucun risque d'avoir une reference invalide. Et cela serait encore valide si la fonction f appellait une autre fonction, puis une autre fonction et ainsi de suite.

Pour le retour de fonction, la situation est differente.

main.cpp
#include <iostream>
 
int & f() {
    int i { 123 };
    return i;
}
 
int main() {
    int & i { f() };  // probleme
	std::cout << i << std::endl;
}

Dans ce code, la fonction f retourne une reference sur une variable locale a la fonction. Lorsque la fonction se termine (c'est a dire tout de suite apres que la reference soit creee, puisque la reference est le parametre de retour), la variable locale i est detruite et la reference devient invalide.

Ne JAMAIS retourner une reference sur une variable locale !

Notez bien que ce code ne produit pas d'erreur (et affichera peut etre la valeur correcte 123). Une reference etant consideree comme toujours valide, il n'y a pas de verification effectueee. Cela va produire un comportement indeterminee (undefined behavior), c'est de la responsabilite du developpeur de verifier son utilisation des references.

Une reference ne peut etre retournee par une fonction uniquement si elle correspond a un objet dont la duree de vie n'est pas limite par la fonction. Par exemple une reference quis erait passe en parametre.

int & f(int & ref) {
    ref += 123;
    return ref;  //ok
}

Dans tous les cas, faites bien attention quand vos objets sont creees et detruits et quand une indirection est valide ou non.

Copie et deplacement d'indirections

Les indirections sont des types comme les autres et peuvent donc etre utilises comme n'importe quel type. Par exemple, il est posible de creer des variables (comme deja vu) :

int i { 123 };
int & j { i };

La seconde ligne de code peut se lire : j est une variable, de type “reference sur un entier”, qui contient comme valeur une indirection sur la variable i.

Une reference (obtenue directement ou apres deferencement d'un pointeur) peut se convertir implicitement en valeur, par copie. (Il faut donc que le type soit copiable).

int i { 123 };
int & j { i };
int & k { j };
int l { j };

Dans ce code, la variable j est une reference sur un entier (la variable i, qui contient la valeur 123). La variable k est egalement une reference sur la variable i. (Comme une reference peut etre vue comme un alias de variable, elle pourra etre supprimee par le compilateur. Et celui-ci sait tres bien que j est aussi une reference, il n'y a pas de raison que k passe par j pour referencer i).

Note : une consequence directe a cela est qu'il est possible d'avoir autant d'indirections que vous souhaitez sur un meme objet, il n'y a pas de limitation.

Au contraire, la variable l n'est pas une reference, mais contient un entier. C'est donc une copie de la valeur contenu dans la variable i, et comme toujours lorsqu'il y a une copie, les objets sont independants et la modification d'une des deux variables ne sera pas reperctuee sur l'autre.

j++;
std::cout << i << std::endl;
l++;
std::cout << i << std::endl;

affiche :

124
123

A noter qu'un objet complexe peut tout a fait contenir des indirections en interne. Dans ce cas, la copie de cet objet (si elle est autorisee) peut produire differents comportement, selon comment cette classe est concue. Par exemple, les classes std::string et std::shared_ptr (un type de pointeur intelligent) utilisent des pointeurs sur des objets internes. La copie d'un objet de type std::string copie l'integralite du tableau interne et les deux nouveaux objets sont independants (deep copy, copie en profondeur). Au contraire, la copie d'un objet de type std::shared_ptr ne copie pas l'objet interne

Les indirections sont egalement utilisable avec l'inference de type. Dans le chapitre sur l'inference, vous avez vu une difference importante entre auto et decltype : le premier ne conserve pas les modificateurs de types, le second oui. Les references et la constance sont des exemples de modificateurs de type, il faudra donc faire attention

inference de type

  • dans une collection. Il est possible de creer un tableau d'iterateur par exemple. )

## Conversion d'une v

conversion en valeur/copie

inference de type

correspondance arguement et parametre

valeur par defaut

Initialisation et affectation

non affectable: reference

affectable: pointeur, iterateru, reference_wrapper

Les indirections sont un type d'objet et peuvent donc etre manipule comme un objet : copie, deplacmeent.

Proprietes des objets

ownership: shared/unique

sans ownership: reference, iterteur

Compatiblite

par defaut, arguement accepte

auto, declrtype

references.1468064567.txt.gz · Dernière modification: 2016/07/09 13:42 par gbdivers