dimanche 19 avril 2020

C++ : Passage d'arguments par valeur, par référence et par pointeur

Le langage C++ offre des performances incroyables à condition bien sûr de bien comprendre ses subtilités. La façon de passer des arguments lors de l'appel d'une fonction ou d'une méthode peut faire une grande différence. Il en va de même pour le retour des valeurs des méthodes. On peut soit passer les arguments par valeur, par référence ou par pointeur.


Passage par valeur


Lorsqu'on passe un argument par valeur, on se trouve à fournir une copie de l'élément à la fonction ou la méthode.
Exemple :
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

using namespace std;

void printLine(string line);

int main(int argc, char* argv[])
{
    string my_line = "Line original";
    cout << "main function:\t\t" << my_line << endl;
    printLine(my_line);
    cout << "main function:\t\t" << my_line << endl;
    return 0;
}

void printLine(string line)
{
    line = "Line updated";
    cout << "printLine function:\t" << line << endl;
}

Trace de l'exécution

jed@jed-Ubuntu:~/Programming/test$ g++ main.cpp && ./a.out
main function:          Line original
printLine function:     Line updated
main function:          Line original

On peut voir ici que la variable my_line est passée en argument à la fonction printLine mais malgré que la variable line est modifiée à l'intérieur de la fonction, cela n'a aucun effet sur la variable my_line, car à la sortie de la fonction la méthode l'affiche à l'écran et elle a sa valeur originale.

Ce mode de passage devrait être préféré pour les types fondamentaux du C++ tel qu'énuméré ici : https://www.learncpp.com/cpp-tutorial/73-passing-arguments-by-reference/

La plupart de ces types ont une empreinte mémoire plus petite qu'un pointeur (4 octets sur les systèmes x86 et 8 octets pour les systèmes x64) alors il est préférable de les passer par valeur.


Passage par référence (symbole &)

Le passage par référence n'utilise pas de ressource supplémentaire comparativement au passage par valeur qui doit créer une copie de la variable à chaque appel. Dans le cas d'une fonction récursive ou dans le cas où l'argument aurait une empreinte mémoire assez imposante, ça peut vite affecter la performance de l'exécution. Dans le but d'optimiser les ressources utilisées, il est dans la plupart des cas préférable d'utiliser le passage par référence.

Exemple :
#include <iostream>

using namespace std;

void printLine(string &line);

int main(int argc, char* argv[])
{
    string my_line = "Line original";
    cout << "main function:\t\t" << my_line << endl;
    printLine(my_line);
    cout << "main function:\t\t" << my_line << endl;
    return 0;
}

void printLine(string &line)
{
    line = "Line updated";
    cout << "printLine function:\t" << line << endl;
}

Trace de l'exécution

jed@jed-Ubuntu:~/Programming/test$ g++ main.cpp && ./a.out
main function:          Line original
printLine function:     Line updated
main function:          Line updated

On peut voir dans cet exemple que la variable my_line est passée en argument à la fonction printLine et que la modification qui est apportée à l'argument line à l'intérieur de la fonction a aussi eu pour effet de modifier la variable my_line. On le voit bien à la sortie de la fonction lorsqu'on affiche à l'écran le contenu de la variable my_line et qu'elle a la valeur "Line updated". L'argument line était donc une référence qui pointait vers l'espace mémoire de la variable my_line.



Passage par référence constante

Heureusement on peut obtenir le meilleur des deux mondes en passant la variable par référence constante. De cette façon, aucune ressource supplémentaire n'est utilisée, on ne pourra pas modifier l'argument et on s'assure de n'avoir aucun effet de bord.

Si on reprend l'exemple précédent voici la syntaxe pour le passage en référence constante:

void printLine(const string &line);

Si on roule le code ci-dessous, on peut voir que la modification de la valeur de l'argument line est interdite.

void printLine(const string &line)
{
    line = "Line updated";
    cout << "printLine function:\t" << line << endl;
}

Trace de l'exécution

jed@jed-Ubuntu:~/Programming/test$ g++ main.cpp && ./a.out
main.cpp: In function void printLine(const string&):
main.cpp:18:12: error: passing const string {aka const std::__cxx11::basic_string<char>} as this argument discards qualifiers [-fpermissive]
     line = "Line updated";
            ^~~~~~~~~~~~~~

Le mode de passage par référence (constant ou non) devrait être utilisé pour tous les types personnalisés (classes et struct) qui auront toujours une valeur (non nulle).



Passage par pointeur

Il est toujours possible d'utiliser la bonne vieille méthode de passage par pointeur mais celle-ci est un peu plus complexe. Nous en reparlerons plus loin. Voyons pour l'instant comment utiliser notre exemple vu plus haut, mais en passage par pointeur.

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

using namespace std;

void printLine(string *line); 

int main(int argc, char * argv[]) 
{
    string my_line = "Line original";
    cout << "main function:\t\t" << my_line << endl;
    printLine(&my_line);
    cout << "main function:\t\t" << my_line << endl;
    return 0;
}

void printLine(string *line)
{
    *line = "Line updated";
    cout << "printLine function:\t" << *line << endl;
}

Trace de l'exécution

jed@jed-MS-7593:~/Programming/test$ g++ src/main.cpp && ./a.out
main function:          Line original
printLine function:     Line updated
main function:          Line updated

On obtient exactement le même résultat que le passage par référence. Aucune copie de valeur n'a été effectuée, en fait ce que nous avons passé en argument c'est une copie du pointeur de la variable my_line qui continent l'adresse mémoire d'où la valeur "Line original" est stockée.

Remarquer à la ligne 18 que l'on doit utiliser l'opérateur de déréférencement * pour assigner "Line updated" dans l'espace mémoire pointé par le pointeur. Même principe à la ligne 19 pour récupérer les données de l'adresse mémoire en question.

Voici ce qui survient si l'on n'utilise pas l'opérateur de déréférencement * à la ligne 19 :

jed@jed-MS-7593:~/Programming/test$ g++ src/main.cpp && ./a.out
main function:          Line original
printLine function:     0x7ffc1d2856d0
main function:          Line updated

C'est l'adresse mémoire contenue par le pointeur qui est affiché au lieu de la valeur de ce qui est contenu à cette adresse.



Passage par pointeur constant

Il y a deux façons de faire:

  • En utilisant la notation const string * line on s'assure que le contenu du pointeur ne pourra pas être changé.
  • En utilisant la notation string * const line on s'assure que l'adresse du pointeur ne pourra pas être changée.
1
2
3
4
5
void printLine(const string * line)
{
    *line = "Line updated"; //Erreur on ne peut pas changer la valeur contenue par le pointeur
    cout << "printLine function:\t" << *line << endl;
}

1
2
3
4
5
6
void printLine(string * const line)
{
    string lineUpdated = "Line updated";
    line = &lineUpdated; //Erreur on ne peut pas changer l'adresse du pointeur
    cout << "printLine function:\t" << *line << endl;
}

On peut aussi utiliser la combinaison des deux méthodes afin d'avoir la totale : const string * const line.

1
2
3
4
5
6
7
void printLine(const string * const line)
{
    string lineUpdated = "Line updated";
    line = &lineUpdated; //Erreur on ne peut pas changer l'adresse du pointeur
    *line = lineUpdated; //Erreur on ne peut pas changer la valeur contenue par le pointeur
    cout << "printLine function:\t" << *line << endl;
}

Le mode de passage par pointeur (constant ou non) devrait être utilisé pour tous les types personnalisés (classes et struct) lorsqu'ils peuvent être nul. Si par exemple on veut passer une instance d'une classe que nous allons appeler Employé mais qu'il est possible que l'instance soit nulle alors on doit utiliser un passage par pointeur. Ce mode peut aussi être très pratique lorsqu'on veut modifier le pointeur rȩcu pour le faire pointer sur un autre objet.



Conclusion

Tout au long du développement d'une application on peut avoir à travailler avec les 3 modes, il est donc important de connaître l'utilité de chacun des modes et quand on devrait les utiliser.