Logo de SAASF

I - 4. Les fonctions

Pour l'instant, sur les trois paradigmes que nous avons présenté au premier cours de C++, seulement un a été étudié : la généricité. Bien que nous ne l'avons pas entièrement étudié, il reste le seul que nous avons commencé à voir. Aujourd'hui, nous allons commencer à voir un autre paradigme : la procéduralité. Ce concept repose sur un autre, très important en informatique : le concept de fonctions.
Contenu

A. Découper son programme

Pour commencer, nous allons voir comment pouvons nous découper notre programme, et dans quel sens du terme nous allons le faire.

a. Où sont situées les instructions ?

Pour la première fois depuis le début de ce cours, nous allons regarder un code assembleur complet. Cependant, nous n'allons pas voir un code utilisant des boucles, conditions... Nous allons regarder un code très simple : le premier code que nous avons vu, simplifié :

#include <iostream>

using namespace std;

int main() {
        int i = 0;
        return 0;
}
Si nous mettons ce code dans un convertisseur assembleur (avec Compiler Explorer), nous obtenons :
main:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 0
        mov     eax, 0
        pop      rbp
        ret
Nous n'allons pas essayer de tout comprendre, mais seulement une petit chose. En effet, toutes ces instructions sont écrites avec un système d'indentation. Pourquoi ? Ici, le mot non-indenté est "main", qui regroupe toutes les instructions suivantes. Ce "main" est une fonction. Une fonction représente un ensemble d'instructions, qui ici (pour "main"), sont toutes celles qui sont indentées. Comme vous pouvez l'avoir remarqué, il s'agit du "main" écrit dans le programme C++. Une instruction (C++ ou assembleur) ne peut pas se retrouver en dehors d'une fonction.

Pour des raisons de santé mentale, je ne vais pas vous arroser d'assembleur pour continuer à vous expliquer les fonctions. Je vais donc vous montrer comment utiliser une fonction en pseudo-code. Voici une façon de faire :

fonction main():
        afficher "Hello world !"
On peut, si l'on veut, rajouter un "FIN FONCTION", de la même façon que l'on pouvait rajouter un "FIN SI" dans le dernier cours. Cependant, comme expliqué au dernier cours, je ne ferai pas comme ça. De plus, nous perfectionnerons cette façon d'écrire dans la suite du cours. Nous verrons à quoi servent les parenthèses plus tard.

Une chose, très importante, qu'il faut comprendre avec les fonctions, est comment les utiliser. En effet, les fonctions ne peuvent pas s'exécuter toutes seules. En effet, ce que nous avons vu plus tôt (en assembleur et en pseudo-code) ne sont que les définitions des fonctions "main", mais pas leur utilisation. Pour les utiliser, et donc exécuter les instructions dans la définition, il faut utiliser une instruction nommée "un appel de fonction". On dit qu'on appelle une fonction. En assembleur, un appel de fonction se fait comme ça :

call    foo()
À noter que "foo" est un nom souvent utilisé pour désigner une fonction. En pseudo-code, c'est assez similaire :
foo()
Il y a une exception : la fonction "main" est appelée automatiquement par le système d'exploitation à l'exécution du programme. Maintenant, nous pouvons définir est appeler des fonctions en pseudo-code.

b. Les paramètres de fonctions

Quand on dit le mot fonction, on peut penser aux fonctions mathématiques. Cependant, une fonction mathématique nécessite une donnée, souvent "x", pour générer un résultat. En informatique, on peut le faire aussi, et passer des données à une fonction pour que son comportement dépende des données. Ces données passées à la fonction sont appelées des "paramètres de fonction". Il s'agit de variables (ou de constantes), exactement comme on les a vu dans le cours des variables. Elles doivent être ajoutées entre les parenthèses, après le nom de la fonction. Voici un exemple d'utilisation d'une fonction avec des paramètres :

fonction afficher_addition(nombre nombre_1, nombre nombre_2):
        afficher nombre_1 + nombre_2

fonction main():
        afficher_addition(254, 65)
C'est à ça que servent les parenthèses.

Cependant, dans certains cas, une valeur de paramètre peut être tellement redondante que devoir la mettre à chaque appel de la fonction peut être ennuyant. Heuresement, vous pouvez spécifier une valeur par défaut à un paramètre. Une valeur par défaut d'un paramètre est utilisé si le paramètre n'a pas de valeur lors de l'appel de la fonction. Pour cela, il suffit simple de rajouter "= valeur" après la déclaration entre parenthèse, comme ça :

// La valeur par défaut de "exposant" sera de 2
fonction effectuer_racine(nombre nombre, nombre exposant = 2):
        retourner racine exposant de nombre

fonction main():
        afficher(effectuer_racine(9)) // Exposant sera la valeur par défaut (2)
        afficher(effectuer_racine(27, 3)) // Exposant sera la valeur entre parenthèse (3)
Cependant, les paramètres prenant des valeurs par défaut doivent obligatoirement se trouver après ceux n'en prenant pas. De plus, si vous mettez plusieurs paramètres avec des valeurs par défaut dans une même fonction, il vous faut spécier les valeurs de tous les paramètres AVANT le dernier paramètre par défaut auquel vous attribué une valeur (si vous en attribuez une à un quelconque paramètre par défaut, sinon ce n'est pas nécessaire).

La façon dont on passe les variables a des conséquences sur la façon dont elles seront utilisées dans la fonction. En effet, si on passe une variable comme dans l'exemple au-dessus, le contenu de la variable passée est copié dans le paramètre. Donc, la modification du paramètre n'influe pas la variable passée. Par contre, si le paramètre est une référence à la variable passée, alors la valeur de la variable passée change en même que temps celle du paramètre, puisque c'est une référence. Voici un exemple :

fonction effectuer_addition(reference nombre nombre_1, nombre nombre_2):
        mettre nombre_1 à nombre_1 + nombre_2

fonction main():
        mettre nombre_passe à 15
        effectuer_addition(nombre_passe, 65)
Ici, la valeur de nombre_passe dans "main" augmentera de 65.

c. Obtenir une valeur d'une fonction

Si une fonction peut recevoir des données, elle peut aussi en produire, et permettre de l'utiliser dans une autre fonction. Pour les fonctions, les données produites sont appelées les retours de fonction, et l'obstention de ces données est appelé un retour. Cette valeur peut être utilisé via un simple appel à la fonction (elle est, bien évidement, utilisée là où la fonction est appelée), comme n'importe quelle variable classique. Bien que la fonction puisse retourner de plein de façons possibles, on va garder une démarche proche du C++ pour procéder. Comme le C++ utilise un typage statique, nous allons dire qu'un seul type peut être renvoyé par la fonction. Dans notre pseudo-code, nous allons remplacer le mot "fonction" par le nom de ce type, par exemple :

nombre effectuer_addition(nombre nombre_1, nombre nombre_2):
        retourner nombre_1 + nombre_2

nombre main():
        mettre nombre_passe à effectuer_addition(147, 65)
        afficher nombre_passe
        retourner 0
La valeur après l'instruction "retourner" est la valeur retournée par la fonction. D'ailleurs, la fonction "main" est de type "nombre" et retourne 0, et nous verrons pourquoi après. Plusieurs "retourner" peuvent apparaître dans la fonction, même si l'utilisation de conditions est conseillé pour bien utiliser le bon "retourner".

Contenu

B. Les fonctions en C++

Il est temps d'appliquer tout ce que nous avons vu au C++. Effectivement, le C++ sans fonction, c'est comme un ordinateur sans carte mère : inutile.

a. Définir une fonction

La base de tout cela est, bien évidemment, de définir une fonction. J'ai choisi une syntaxe pseudo-code assez proche de ce que propose le C++. En effet, en C++, une fonction se définit comme ça :

#include <iostream>

using namespace std;

type_de_renvoi nom(type_parametre parametre) {
        instructions;
        return variable;
}
Par exemple, pour notre exemple effectuer_addition :
#include <iostream>

using namespace std;

// Fonction "effectuer_addition" renvoyant un "int", et prenant 2 "int" en paramètre
int effectuer_addition(int nombre_1, int nombre_2) {
        return nombre_1 + nombre_2;
}

int main() {
        cout << effectuer_addition(154, 789) << endl; // Affiche 943
        return 0;
}

La valeur retournée sera la valeur situé après "return". Dés que la valeur est retournée, la fonction s'arrête. Cependant, comme en pseudo-code, une fonction peut avoir plusieurs "return" dans sa définition. Dans ce cas là, il est conseillé d'utiliser une condition, comme ça :

#include <iostream>

using namespace std;

// Fonction "effectuer_division" renvoyant un "int", et prenant 2 "int" en paramètre
int effectuer_division(int dividende, int diviseur) {
        if(diviseur == 0) {
                 return 0;
        }
        // N'arrivera pas si diviseur == 0
        return dividende / diviseur;
}

int main() {
        cout << effectuer_division(154, 0) << endl; // Affiche 0
        return 0;
}
On peut aussi l'utiliser sans condition, mais il faut faire attention à bien savoir ce que l'on fait, pour éviter les comportements imprévus.

Pour attribuer des valeurs par défaut aux paramètres la syntaxe est la même qu'en pseudo-code. Voici un très simple exemple :

#include <iostream>
#include <string>

using namespace std;

// Fonction "indicatif_telephone" renvoyant un "int", et prenant 1 "string" en paramètre, de valeur par défaut "France"
int indicatif_telephone(string pays = "France") {
        if(pays == "France") {
                 return 33;
        }
        else if(pays == "Russie") {
                 return 7;
        }
        else if(pays == "Turquie") {
                 return 90;
        }
        return 0;
}

int main() {
        cout << indicatif_telephone() << endl; // Affiche 33
        cout << indicatif_telephone("Russie") << endl; // Affiche 7
        return 0;
}

Si une fonction qui doit retourner un type ne retourne rien, des comportements inattendus peuvent apparaître, de l'ignorance de la valeur avec le compiler MSVC, au crash avec le compiler GCC. Heuresement, il existe un type pour les fonctions ne retournant rien : void. Dans ce cas, le "return" peut être utilisé pour sortir de la fonction, mais aucune valeur n'est obstensible via cette dernière. Cependant, vous pouvez aussi ne pas mettre de "return" du tout, le compiler s'en chargera. Voici un exemple inspiré des exemples précédents :

#include <iostream>
#include <string>

using namespace std;

// Fonction "indicatif_telephone" ne renvoyant rien, et prenant 1 "string" en paramètre, de valeur par défaut "France"
void afficher_indicatif_telephone(string pays = "France") {
        if(pays == "France") {
                 cout << 33 << endl;
        }
        else if(pays == "Russie") {
                 cout << 7 << endl;
        }
        else if(pays == "Turquie") {
                 cout << 90 << endl;
        }
        else {
                cout << "Inconnu" << endl;
        }
}

int main() {
        afficher_indicatif_telephone(); // Affiche 33
        afficher_indicatif_telephone("Russie"); // Affiche 7
        return 0;
}

b. Utiliser proprement une fonction

Savoir écrire des fonctions, c'est bien. Savoir les utiliser, c'est encore mieux. En C++, il existe pas mal de petits conseils pour pouvoir rendre ses fonctions les plus performantes possibles.

L'avantage de séparer le code en fonction est de pouvoir facilement modifier ce code après, sans grandes difficultés. On peut donc optimiser le code autant que l'on veut, facilement. Une opération pouvant prendre beaucoup de temps lors de l'appel d'une fonction est la copie des paramètres nécessaires. En effet, si nous passons les paramètres comme vu en haut, ils seront juste copiés. D'ailleurs, pour mesurer l'optimisation d'un algorithme, on doit utiliser sa complexité. Cependant, vous pouvez aussi utiliser en paramètre une référence de variable, pour éviter la phase de copie. Dans ce cas là, la variable ne sera pas copiée, mais référencée, ce qui peut faire gagner beaucoup de temps pour des grosses variables. Voici un exemple pour la fonction vue juste au dessus :

#include <iostream>
#include <string>

using namespace std;

// Fonction "indicatif_telephone" renvoyant un "int", et prenant 1 "string" en paramètre, de valeur par défaut "France"
int indicatif_telephone(string& pays) {
        if(pays == "France") {
                 return 33;
        }
        else if(pays == "Russie") {
                 return 7;
        }
        else if(pays == "Turquie") {
                 return 90;
        }
        return 0;
}

int main() {
        string pays = "France";
        cout << indicatif_telephone(pays) << endl; // Affiche 33
        pays = "Russie";
        cout << indicatif_telephone(pays) << endl; // Affiche 7
        return 0;
}
Dans ce cas là, seule des variables seront utilisables, et pas des valeurs littérales. De plus, la variable référencée peut être modifiée par la fonction. Si vous voulez à tout prix éviter ça, vous devez déclarer la référence comme constante :
#include <iostream>
#include <string>

using namespace std;

// Fonction "indicatif_telephone" renvoyant un "int", et prenant 1 "string" en paramètre, de valeur par défaut "France"
int indicatif_telephone(const string& pays) {
        if(pays == "France") {
                 return 33;
        }
        else if(pays == "Russie") {
                 return 7;
        }
        else if(pays == "Turquie") {
                 return 90;
        }
        return 0;
}

int main() {
        string pays = "France";
        cout << indicatif_telephone(pays) << endl; // Affiche 33
        pays = "Russie";
        cout << indicatif_telephone(pays) << endl; // Affiche 7
        return 0;
}

Vous pouvez aussi utiliser des fonctions en elles même comme une boucle. Ce procédé s'appelle la récursion. Il s'agit d'une fonction qui s'appelle elle même. Il faut cependant faire extrêmement attention à ce que la fonction ne s'appelle pas à l'infini, sinon le programme freeze. Le meilleur moyen est d'utiliser une condition où la fonction n'est plus appelée, nommée une condition de sortie. Voici un exemple d'utilisation avec la fonction effectuer_multiplication :

#include <iostream>

using namespace std;

// Fonction "effectuer_multiplication" renvoyant un "int", et prenant 2 "unsigned int" en paramètre
unsigned int effectuer_multiplication(unsigned int facteur_1, unsigned int facteur_2) {
        // Condition de sortie
        if(facteur_2 == 0) {
                 return 0;
        }
        // On retourne la somme du premier facteur et de la multiplication du premier facteur par le deuxième facteur - 1
        return facteur_1 + effectuer_multiplication(facteur_1, facteur_2 - 1);
}

int main() {
        cout << effectuer_multiplication(154, 8) << endl; // Affiche 1232
        return 0;
}

Pour en finir avec tout ça, nous allons parler d'une fonction que nous avons déjà vu : la fonction "main". Pour rappel, il s'agit de la fonction appelée automatiquement par le système d'exploitation lors du lancement du programme. Elle est de type "int", donc nombre entier signé. En effet, le retour de la fonction "main" est le retour du programme entier. En général, 0 veut dire "exécution normale". Ce nombre retourné est le nombre affiché à la fin de la console du programme exécuté. De plus, la fonction "main" peut aussi prendre deux paramètres, passés, encore une fois, par le système d'exploitation. Ces deux paramètres sont un "int", nommé par convention "argc", et un tableau de "char*", nommé par convention "argv". Nous verrons comment utiliser les tableaux dans les prochains cours. Ces deux paramètres représentent les arguments passés au programme par le système d'exploitation. Ces arguments représentent les mêmes que les arguments passés lors de l'exécution du programme via la ligne de commande, c'est pour ça qu'ils sont en format "char*" (texte bas niveau). À noter que quel que soit la façon de lancer le programme, il y aura au moins un argument : le chemin d'accés du programme exécuté.

c. Les méthodes

Pour finir les fonctions, nous allons voir un type de fonction extrêmement important à connaître, bien que nous n'avons pas encore les outils pour les comprendre totalement : les méthodes.

Quand vous créez une variable, quel quelle soit, elle obéit à ce que son type lui permet de faire, comme nous l'avons vu au premier cours. Certains types permettent d'utiliser des fonctions directement sur la variable. Ces fonctions sont nommés des méthodes. C'est un moyen de se passer de l'utilisation de seulement des références pour modifier une variable via une fonction. En général, elles sont définies en même temps que le type, donc pas besoin de le faire nous même. Pour les utiliser, il faut utiliser un opérateur spécial avec la variable : l'opérateur d'accès aux membres de la variables, qui s'écrit ".". Nous définirons le mot "membre" dans un prochaine cours. Malheuresement, les types fondamentaux ne peuvent pas être utilisés via des membres. Heuresement, les autres, comme les "string", peuvent. Voici un exemple d'utilisation de l'opérateur, avec un "string" et une de ses méthodes "size()" :

#include <iostream>
#include <string>

using namespace std;

int main() {
        string pays = "France";
        cout << pays.size() << endl; // Affiche 6
        return 0;
}
La méthode "size()" utilisé sur "pays" retourne la taille du texte contenu dans "pays", qui fait 6 caractères de long. Certaines méthodes retournent des valeurs, d'autres modifient la variable, via des paramètres que vous devez passer, ou sans... Les possibilités sont infinies.

En général, toutes les méthodes utilisables dans un type sont présentées dans un texte nommé "la documentation du type". Par exemple, toutes les méthodes utilisables dans "string" sont présentes sur ce site web. Les opérateurs peuvent aussi être considérés comme des méthodes. Comme nous n'avons pas vu les classes pour le moment, certains termes peuvent rester abstraits. Essayez de vous concentrer sur ce que vous savez déjà, pour pouvoir faire de grandes choses !