Remédiation en C++

C++: les fonctions, la surcharge de fonctions et d’opérateurs

Université de Bordeaux – Licence Ingénierie Mathématique

Généralités sur les fonctions

On a vu avec les boucles une première façon de ne pas avoir à recopier du code. Seulement, l’utilisation des boucles est quand même assez particulière. On les appelle à un moment précis, elles ont des critères d’arrêt…

Il y a des parties de code dont on ne sait pas nécessairement quand ni combien de fois on va les utiliser, mais qu’on ne va pas recopier pour autant.

Par exemple, si vous écrivez un code qui résoudra des problèmes incluant de la géométrie ou de l’algèbre linéaire, vous aurez très certainement besoin de calculer des produits scalaires entre vecteurs. Il y a donc tout intérêt à écrire quelque chose qui calculera directement ce produit, sans qu’on ait à recopier à chaque fois la formule du produit scalaire.

De telles parties de code sont mises sous forme de fonctions. Les fonctions sont des groupes d’instructions indépendants. Elles prennent en entrée des arguments (ou non, c’est optionnel), effectuent les instructions qu’elles contiennent, et retournent un résultat (ou non, c’est aussi optionnel).

Le prototype d’une fonction

En C++, il est nécessaire de déclarer une variable pour pouvoir l’utiliser. Pour les fonctions, c’est pareil. Le compilateur doit savoir qu’une fonction existe pour pouvoir être appellée par la suite.

Une fonction se déclare en énonçant son prototype, qui s’écrit de cette manière:

type nomFonction (type1 <argument1>, type2 <argument2>, type3 <argument3>, ...);
  • Le type indiqué en premier est le type du résultat que retourne la fonction (int, double, std::vector<double>, etc). Si la fonction ne retourne rien, on l’indique en remplaçant type par le mot-clé void. Si une fonction retourne un résultat, il faut pour sortir de la fonction utiliser l’instruction return, suivie du résultat à retourner. Il peut y avoir plusieurs return écrits dans une fonction, mais seul le premier rencontré sera effectif.
  • On donne ensuite le nom de la fonction. Les règles des noms des fonctions sont les mêmes que ceux des variables.
  • Enfin, si il y en a besoin, on donne la liste des arguments. Pour chaque argument, il faut spécifier le type de la variable. On peut aussi donner un nom aux arguments dans le prototype, c’est optionnel, mais ça aide en général à savoir à quoi réfère l’argument en question.

Le prototype d’une fonction

Pour reprendre l’exemple du produit scalaire dans \(\mathbb{R}^n\), le prototype de la fonction serait:

double prodScal(std::vector<double> u, std::vector<double> v);

Pour appeler cette fonction depuis un autre endroit du code:

std::vector<double> v1 = { 1., -3.5, 10., 2., -4.2};
std::vector<double> v2 = {-1.,   2.,  2., 0., -3.1};
double p = prodScal(v1,v2);

Notez que les noms des arguments au moment de l’appel de la fonction et dans le prototype sont différents. Les noms donnés aux arguments dans le prototype sont temporaires, et serviront dans la définition de la fonction.

Déclaration vs définition d’une fonction.

Je viens de parler de définition d’une fonction. Il ne faut pas confondre définition et déclaration d’une fonction.

  • Pour déclarer une fonction, il suffit d’écrire son prototype (suivi d’un point-virgule).
  • Pour définir une fonction, on réécrit le prototype, et on donne les instructions que va effectuer la fonction.

La déclaration ou la définition d’une fonction se font en dehors de toute autre fonction, y compris la fonction main.

// Declaration d'une fonction prodScal
double prodScal(std::vector<double> u, std::vector<double v>);

// Definition de la fonction prodScal
double prodScal(std::vector<double> u, std::vector<double v) {
   double res = 0.e0;
   if (u.size()!=v.size())
     std::cerr << "Avertissement: dans la fonction prodScal, les tableaux sont de tailles differentes: " << std::endl
               << u.size() << " et " << v.size() << ". Le produit sera calcule sur la plus petite taille." << std::endl;
                  
   for (unsigned int i=0; i<std::min(u.size(),v.size()); i++)
     res += u[i]*v[i];
   
   return res;
}

Déclaration vs définition d’une fonction.

Tout à l’heure, je disais que le compilateur doit savoir qu’une fonction existe avant son appel. La déclaration suffit à indiquer qu’une fonction existe, le compilateur n’a pas besoin de connaître les instructions réalisées par la fonction pour continuer.

Ce qui veut dire que vous devez placer au moins la déclaration d’une fonction avant son appel. Cependant vous pouvez placer la définition d’une fonction avant, dans ce cas l’énoncé du prototype seul est inutile.

// Definition de la fonction placée avant le main
int maFonction() {
  //...
  return 1;
}

int main() {
  int a = maFonction();
  return 0;
}
// Declaration de la fonction placée avant le main
int maFonction();

int main() {
  int a = maFonction();
  return 0;
}

// Definition de la fonction placée après le main
int maFonction() {
  // ...
  return 1;
}

Déroulement de l’appel d’une fonction

On va maintenant voir ce qu’il se passe quand une fonction est appelée pendant l’exécution du programme.

Testez l’appel d’une fonction qui ajoute 1 à ses arguments, avec le programme suivant:

#include<iostream>

void addOne(int i1, int i2) {
  i1++;
  i2++;
}

int main() {
  int a = 10;
  int b = 2;
  std::cout << "Avant l'appel, a: " << a << " b: " << b << std::endl; 
  addOne(a,b);
  std::cout << "Après l'appel, a: " << a << " b: " << b << std::endl; 
  return 0;
}

Déroulement de l’appel d’une fonction

Vous devriez voir que les valeurs de a et de b n’ont pas été modifiées. C’est dommage, parce que dans ce cas la fonction ne sert à rien. :/

Regardons de plus près ce qu’il se passe à l’appel d’une fonction.

Comme je le disais précédemment, les fonctions sont des blocs d’instructions indépendants. Cela est aussi vrai au moment de la compilation, les instructions en binaire (dans les fichiers objets) ne sont pas dupliquées pour chaque appel de la fonction.

Il faut alors transmettre à ces blocs les valeurs des variables passées en argument. En C++, les arguments sont passés par copie.

Voyons cela pas à pas avec le programme précédent.

Déroulement de l’appel d’une fonction

Au début, on se trouve dans le main, et on a initialisé deux variables a et b.

#include<iostream>

void addOne(int i1, int i2) {
  i1++;
  i2++;
}

int main() {
  int a = 10;
  int b = 2;
  std::cout << "Avant l'appel, a: " << a << " b: " << b << std::endl; 
  
  // Ici =>

  addOne(a,b);
  std::cout << "Après l'appel, a: " << a << " b: " << b << std::endl; 
  return 0;
}

Déroulement de l’appel d’une fonction

Au moment de l’appel à la fonction addOne(a,b) , les expressions a et b sont évaluées, et c’est addOne(10,2) qui est appelée.

Ensuite, un nouvel «environnement» est créé et ajouté à la pile d’appels des fonctions, avec deux entiers i1 et i2 qui sont les arguments. Ces entiers sont initialisés avec les valeurs données à l’appel: 10 et 2.

#include<iostream>

void addOne(int i1, int i2) {
  
  // Ici => 
  
  i1++;
  i2++;
}

int main() {
  int a = 10;
  int b = 2;
  std::cout << "Avant l'appel, a: " << a << " b: " << b << std::endl; 
  
  addOne(a,b);
  std::cout << "Après l'appel, a: " << a << " b: " << b << std::endl; 
  return 0;
}

Déroulement de l’appel d’une fonction

Les instructions de la fonction sont ensuite exécutées, à savoir modifier les valeurs des variables i1 et i2.

#include<iostream>

void addOne(int i1, int i2) {
  i1++;
  i2++;

    // Ici => 
  
}

int main() {
  int a = 10;
  int b = 2;
  std::cout << "Avant l'appel, a: " << a << " b: " << b << std::endl; 
  
  addOne(a,b);
  std::cout << "Après l'appel, a: " << a << " b: " << b << std::endl; 
  return 0;
}

Déroulement de l’appel d’une fonction

On arrive ensuite à la fin du bloc d’instructions de la fonction. De la même manière que pour la portée des variables, ce qui a été ajouté à la pile d’appel est détruit à la sortie du bloc. Les variables i1 et i2 n’existent plus, et il ne reste plus que les variables a et b, qui n’ont pas été modifiées.

#include<iostream>

void addOne(int i1, int i2) {
  i1++;
  i2++;

    // Ici => 
  
}

int main() {
  int a = 10;
  int b = 2;
  std::cout << "Avant l'appel, a: " << a << " b: " << b << std::endl; 
  
  addOne(a,b);
  std::cout << "Après l'appel, a: " << a << " b: " << b << std::endl; 
  return 0;
}

Mais dans ce cas, comment peut-on modifier des arguments ? On va utiliser une fonctionnalité du C++: les références.

Les références

Les références permettent de créer des alias pour des variables existantes. Elles désignent les adresses mémoire de ces variables.

Une référence se déclare comme une variable, avec deux différences:

  • on précède le nom de la variable du symbole “&”,
  • une référence doit être obligatoirement initialisée

  int a = 10;
  int &r = a;
  
  // r designe l'emplacement memoire de la variable a
  
  std::cout << a << " " << &a << " " << r << std::endl;
  
  r++;
  
  std::cout << a << " " << &a << " " << r << std::endl;

Si vous essayez ces lignes ci-dessus, vous verrez que la valeur de a a bien été modifiée, même si cela a été fait par l’instruction r++.

Références et fonctions

Revenons à notre histoire de passage d’arguments. Plutôt que de faire une copie de la valeur stockée dans une variable, on va en quelque sorte faire une copie de l’adresse de cette variable.

Observez les arguments, on y indique maintenant une référence vers des entiers, au lieu de simples entiers.

#include<iostream>

void addOne(int& i1, int& i2) {
  i1++;
  i2++;
}

int main() {
  int a = 10;
  int b = 2;
  std::cout << "Avant l'appel, a: " << a << " b: " << b << std::endl; 
  
  addOne(a,b);
  std::cout << "Après l'appel, a: " << a << " b: " << b << std::endl; 
  return 0;
}

Quand on fait maintenant i1++ et i2++, on accède aux cases mémoire référencées, et on vient modifier ces valeurs là. En sortant de la fonction, les valeurs initiales auront été modifiées.

Références et fonctions

Un autre avantage du passage par référence et qu’il évite de faire la copie des arguments quand ceux-ci sont de très grande taille. Imaginez que vous passez un vecteur d’un million de réels en arguments. À chaque appel de la fonction, il faudra copier toutes ces valeurs pour les détruire immédiatement après.

Passer par référence permet d’accèder aux éléments du vecteur sans perdre du temps à tous les copier!

En bref, pour modifier la valeur d’un argument ou quand celui-ci est trop grand en mémoire, il faut le passer par référence, en ajoutant “&” après le type de l’argument.

Surcharger les fonctions

Contrairement aux variables, il est possible d’avoir plusieurs fonctions qui ont le même nom en même temps. On dit qu’une fonction homonyme en surcharge une autre. Cependant il faut qu’elles aient des arguments différents, par leur nombre et/ou par leurs types.

C’est au moment de l’appel d’une fonction que le compilateur va déterminer quelle est la bonne fonction à appeler, en fonction des arguments qu’il trouve entre les parenthèses. C’est la résolution de la surcharge.

void ditCoucou() {
  std::cout << "Hello " << std::endl;
}
void ditCoucou(std::string s) {
  std::cout << "Hello " << s << std::endl;
}

int main() {
  ditCoucou(); // Affiche "Hello", on a appele la premiere version de la fonction
  ditCoucou("there"); // Affiche "Hello there", on a appele la seconde version
}

Les arguments par défaut

On n’est pas obligé de préciser la valeur de tous les arguments au moment de l’appel d’une fonction. Par exemple:

// retourne le nombre d'elements pairs dans le vecteur,
// affiche les elements pairs si le second argument est vrai
int comptePairs(std::vector<int>& data, bool affiche=false);

int comptePairs(std::vector<int>& data, bool affiche) {
  int total=0;
  for (unsigned int i=0;i<data.size(); i++)
    if (data[i]%2 == 0) {
      total++;
      if (affiche)
        std::cout << data[i] << std::endl;
    }
}

Ici, dans la déclaration de la fonction, on a précisé une valeur par défaut pour le second argument affiche. On peut appeler cette fonction en faisant int t = comptePairs(v);. Dans ce cas, le compilateur va compléter la liste des arguments à l’appel avec la valeur par défaut pour le booléen. Je peux aussi appeler int t = comptePairs(v,true) pour faire l’affichage.

Les arguments par défaut

Quelques règles pour les arguments par défaut.

  • S’il y a des arguments par défaut, il faut tous les placer après les arguments «obligatoires»
  • Si il y a plusieurs arguments par défaut, dans l’appel à la fonction, il faut spécifier tous les arguments jusqu’au dernier à spécifier. Par exemple, si la fonction a trois arguments par défaut, mais que je ne veux donner que le deuxième à l’appel, il faut quand même donner le premier et le deuxième.
  int maFonction(int arg1 = 0, int arg2 = 10, int arg3 = 50);
  // ...
  int a = maFonction(20); //< arg1 sera mis a 20, arg2 a 10 et arg3 a 30
  int b = maFonction(20,0); //< arg1 sera mis a 20, arg2 a 0, arg 3 a 30

Surcharger les opérateurs

Les opérateurs qu’on a vu au tout début du tuto (+,-,*, etc) sont en fait des fonctions. Ils prennent un ou deux arguments, et retournent un résultat.

En interne, quand le compilateur lit “a+b”, avec des entiers, il appelle une fonction qui s’appelle operator+, avec a et b comme argument : operator+(a,b). Tous les opérateurs correspondent à ce genre de fonction.

Maintenant, souvenez-vous: le comportement d’un opérateur n’est pas le même en fonction des types des opérandes. Reprenons le cas fameux de la division.

1/2 appelle en fait operator/(1,2), avec 1 et 2 entiers. C’est la fonction:

int operator/(int, int);

Quand on écrit 1./2, le compilateur cherche une fonction compatible. Pour la division c’est

float operator/(float, float);

qui retourne un résultat réel.

Et bien, de la même manière qu’on peut surcharger les fonctions, on peut surcharger les opérateurs !

Surcharger les opérateurs

Par exemple, je veux pouvoir additionner facilement le contenu de deux vecteurs de réels. Je surcharge alors l’opérateur +:

std::vector<double> operator+(std::vector<double>& v1, std::vector<double>& v2) {
  unsigned int s = std::min(v1.size(),v2.size());
  std::vector<double> res (s,0);
  for (unsigned int i=0u; i<s; i++)
    res[i] = v1[i] + v2[i];
  return res;
}

Et je peux écrire ailleurs dans le code

std::vector<double> v1 = {1,2,3};
std::vector<double> v2 = {0.5,-0.5,-1};
std::vector<double> v3 = v1+v2;

QCM

On peut déclarer une fonction à n’importe quel endroit du code.

Vrai
 
Faux


QCM

On peut déclarer une fonction à n’importe quel endroit du code.

Vrai
 
Faux


On ne peut déclarer et définir des fonctions telles qu’on les a vues qu’en dehors de toute autre fonction. Il faut cependant qu’une fonction soit au moins déclarée avant tout appel.

QCM

Lorsque j’appelle la fonction prodscal avec prodscal(a,b), avec pour prototype

double prodScal(std::vector<double>& u, std::vector<double>& v);
le vecteur a est copié

le vecteur b est copié

aucun vecteur n’est copié

les deux vecteurs sont copiés


QCM

Lorsque j’appelle la fonction prodscal avec prodscal(a,b), avec pour prototype

double prodScal(std::vector<double>& u, std::vector<double>& v);
le vecteur a est copié

le vecteur b est copié

aucun vecteur n’est copié

les deux vecteurs sont copiés


Lorsqu’on passe un argument par référence (avec le symbole & après le type de l’argument), seule l’adresse de la variable en argument est transmise à la fonction, ce qui permet de modifier directement sa valeur, mais aussi d’éviter de la recopier, ce qui est utile quand elle représente une grande quantité de données. Ici, les deux vecteurs sont passés par référence, donc aucun ne sera copié.

QCM

Je veux écrire une fonction qui utilise les \(n\) premiers éléments d’un grand vecteur pour écrire un fichier. Le prototype de la fonction sera:

void (std::vector<int> v, int n);

void (std::vector<int>& v, int n);

std::vector<int> (std::vector<int> v, int n);

std::vector<int> (std::vector<int>& v, int n);


QCM

Je veux écrire une fonction qui utilise les \(n\) premiers éléments d’un grand vecteur pour écrire un fichier. Le prototype de la fonction sera:

void (std::vector<int> v, int n);

void (std::vector<int>& v, int n);

std::vector<int> (std::vector<int> v, int n);

std::vector<int> (std::vector<int>& v, int n);


Cette fonction écrit un fichier, elle ne retourne rien au reste du programme. Donc son type de retour, placé au début du prototype, est void. Ensuite, j’ai précisé que le vecteur est grand, donc il vaut mieux le passer par référence, avec le symbole “&” après le type. Cela évite la recopie des arguments qui est effectuée à chaque appel de la fonction.

Retour au cours Moodle