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).
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:
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.Pour reprendre l’exemple du produit scalaire dans \(\mathbb{R}^n\), le prototype de la fonction serait:
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.
Je viens de parler de définition d’une fonction. Il ne faut pas confondre définition et déclaration d’une 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;
}
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.
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:
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.
Au début, on se trouve dans le main
, et on a initialisé
deux variables a
et b
.
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.
Les instructions de la fonction sont ensuite exécutées, à savoir
modifier les valeurs des variables i1
et
i2
.
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 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:
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++
.
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.
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.
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
}
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.
Quelques règles pour les arguments par défaut.
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:
Quand on écrit 1./2
, le compilateur cherche une fonction
compatible. Pour la division c’est
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 !
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
On peut déclarer une fonction à n’importe quel endroit du code.
On peut déclarer une fonction à n’importe quel endroit du code.
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.
Lorsque j’appelle la fonction prodscal
avec
prodscal(a,b)
, avec pour prototype
a
est copié
b
est copié
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é.
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);
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.