Les fonctions - Portée d'une variable

fonction.cpp


Il me semble que je vous ai déjà expliqué que le programmeur était paresseux. Et si il y a une chose qu'il n'a pas envie de faire, c'est bien la même chose trente fois dans un programme. Or, il est inévitable que certaines tâches se répètent plusieurs fois. Prenez tout simplement les différentes fonctions mathématiques que nous connaissons : sinus, cosinus, racine carrée (sqrt), et toute la famille. Que serait-il si à chaque fois qu'on voulait une de ces valeurs, il fallait la recalculer directement dans le programme ? On ne s'en sortirait plus. Alors quelqu'un a eu l'idée de faire des bouts de code réutilisables, des fonctions qu'on définit une fois pour toutes, et qu'on peut utiliser après n'importe où à n'importe quel moment.

L'apparition des fonctions en programmation constitue une première distinction entre langages de programmation : c'était le début des langages procéduraux. C'est donc une des caractéristiques fondamentales du C/C++, et il est très important de les connaître, car elles ont tendance à modifier largement l'organisation du programme. Allez, ne trainons pas, car aujourd'hui nous avons du pain sur la planche !

Pas inconnues

En fait, si nous n'en avons pas encore parlé, nous avons déjà rencontré une fonction dans nos programmes : la fonction main(). C'est la fonction principale du programme, et elle est obligatoire. Toutes les instructions que nous avons écrites se trouvaient dans la fonction main(), car on les écrivait à l'intérieur des accolades {}qui délimitaient cette fonction. Les autres fonctions sont pareilles : elles on un nom et sont délimitées par des accolades. Nous verrons un peu plus tard qu'elles ont d'autres caractéristiques, également importantes.

Revenons sur main() :

main()
{

}

Voici plus petite fonction main() qui puisse exister : elle est vide. La première ligne correspond à l'en-tête de la fonction : il indique toutes les caractéristiques de la fonction, et entre autres son nom.

Considérons le code suivant :

#include <iostream.h>

void AfficheBonjour(void)
{

    cout << "Bonjour tout le monde!!!\n";

}

int main(void)
{

    AfficheBonjour();

}

Vous reconnaissez bien sûr la fonction main(), bien qu'il y ait quelques rajouts à son en-tête. Nous y reviendrons. Cette fonction main() ne comporte qu'une seule instruction : vous avez reconnu le nom de la fonction AfficheBonjour(), définie avant la fonction main(). Oublions pour l'instant tous les void et les int que nous avons, et regardons la seule instruction de AfficheBonjour() : il s'agit d'un affichage. Donc, quand on appelle la fonction AfficheBonjour(), il se produit cet affichage. Voici ce que nous obtenons après avoir compilé et lancé ce programme :

Bonjour tout le monde!!!

On constate que même si l'affichage n'était pas défini dans main(), il s'est produit quand même. On peut alors en déduire que lors d'un appel de fonction, tout le contenu de la fonction appelée est exécuté. Mais que se passe-t-il dans la fonction appelante pendant ce temps  ? Etoffons un peu notre fonction main() :

#include <iostream.h>

void AfficheBonjour(void)
{

    cout << "Bonjour tout le monde!!!\n";

}

int main(void)
{

    cout << "Début de main()\n";
    AfficheBonjour();
    cout << "Fin de main()\n";

}

Voici l'affichage produit :

Début de main()
Bonjour tout le monde!!!
Fin de main()

On constate alors que la fonction main() attend la fin de la fonction AfficheBonjour() pour continuer son exécution. MS-DOS fonctionne un petit peu de la même façon : vous devez attendre la fin d'un programme avant d'en lancer un autre.

Les paramètres

Mais la fonction AfficheBonjour() est peu flexible : elle affiche toujours la même chose. Alors qu'on vient de se battre comme des fous pour rendre nos programmes intéractifs, voilà qu'on propose une fonction qui fait tout le temps la même chose. Ne peut-on pas agir un peu sur ce que fait une fonction ?

Il existe un moyen de communiquer avec une fonction : on peut lui donner des paramètres. Voici un exemple pour mieux comprendre :

#include <iostream.h>

void AfficheTemperature(int degres)
{

    cout << "Il fait " << degres << "°C dehors.\n";

}

int main(void)
{

    AfficheTemperature(23);

}

A l'intérieur des parenthèses de AfficheTemperature(), on a rajouté int degres. Ceci ressemble aux déclarations de variables que nous connaissons, et en quelque sorte, c'est ça. degres est un paramète (d'ailleurs le seul) de la fonction AfficheTemperature(), et sa valeur est décidée au moment de l'appel de AfficheTemperature(), depuis main(). Ainsi on peut déjà agir sur la fonction.

Ce programme produit l'affichage suivant :

Il fait 23°C dehors.

Si il y a plusieurs paramètres pour une fonction, ils seront séparés par une virgule. Pour chaque paramètre, il faut préciser un type. Dans un premier temps, nous dirons que si une fonction est définie avec des paramètres, il est obligatoire de l'appeler avec le même nombre de paramètres.

La mémoire et la portée d'une variable

Avant d'aller plus loin, il est impératif de vous parler de ce qui se passe en mémoire.

Lorsque vous lancez votre programme, le système réserve pour votre programme une zone de mémoire dans laquelle il peut faire ce qu'il veut. C'est cette zone qu'il peut utiliser pour stocker ses données. Dans cette zone, il est libre de faire ce qu'il veut : lecture et écriture à volonté. Si il tente d'aller modifier une parcelle de mémoire qui ne lui est pas réservée, le système le stop illico et le renvoie chez lui : il n'a pas le droit d'aller modifier ce qui se passe chez les autres.

Dans un programme, c'est un peu pareil : chaque fonction à son petit bout de mémoire, et ne peut pas accéder à la mémoire d'une autre fonction. A chaque fois qu'un appel de fonction à lieu, la nouvelle fonction est construite en mémoire, dans un petit bloc qui lui est propre. Dans ce bloc, elle peut faire tout ce qu'elle veut avec ses variables, mais les variables extérieures à ce bloc lui sont inconnues.

Regardez le code suivant :

#include <iostream.h>

void Modifier(int x)
{

    x = 10;

}

int main(void)
{

    int x = 5;
    Modifier(x);
    cout << x << endl;

}

La variable x est définie et initialisée à 5 dans main(). Mais regardez la fonction Modifier() : elle possède aussi une variable x, du même type, qu'elle met à 10. Quelle est alors la valeur affichée à la fin du programme ? Eh! bien c'est 5!!!

La fonction main() crée une variable x, dont la valeur est 5. Ceci se passe dans son bloc de mémoire. La fonction Modifier() a aussi une variable x, qu'elle met à 10. Mais ceci se passe dans un autre bloc de mémoire : ce sont donc deux variables complètement différentes! La valeur de la variable x de main() est recopiée dans la variable x de Modifier(), si bien qu'elles ont la même valeur mais ne sont pas la même variable. Changer la valeur de x de Modifier() ne change donc pas la valeur de x de main().

C'est ce qu'on appelle la portée d'une variable : en dehors d'une certaine zone, une variable n'est plus accessible. C'est pour cela que la variable x de main() n'est pas accessible à Modifier(). La zone en question se délimite comme suit :

  • Une variable n'est visible qu'à partir de la ligne à laquelle elle a été déclarée (valable dans tous les cas).
  • Une variable déclarée en dehors de tout bloc (paire d'accolades { }) est visible dans tout le fichier où elle a été déclarée. Ces variables sont dites globales.
  • Une variable déclarée à l'intérieur d'un bloc est visible dans tout ce bloc, et dans tous les blocs créés à l'intérieur de ce bloc. Elle n'est pas visible en dehors du bloc où elle a été créée. Par exemple, une variable créée au début d'une fonction est visible dans toutes les boucles de cette fonction. Par contre, une variable créée dans un bloc if n'est pas visible à l'extérieur de ce bloc if. Ces variables sont dites locales.
  • Une variable déclarée comme paramètre d'une fonction est considérée comme faisant partie de cette fonction.

Expliquons un peu plus en profondeur les 4 points ci-dessus.

Lorsque vous déclarez une variable, elle n'est visible qu'à partir de la ligne de sa déclaration, pas avant. Le C++ vous permet de déclarer les variables non loin de l'endroit où vous devez l'utiliser, contrairement à d'autres langages, ceci pour ne pas créer de variables inutiles (et donc occuper de l'espace en mémoire). Ceci est valable également pour les fonctions, comme le montre le programme suivant :

#include <iostream.h>

void AfficheBonjour(void);

int main(void)
{

    AfficheBonjour();

}

void AfficheBonjour(void)
{

    cout << "Bonjour tout le monde!!!\n";

}

Ceci est correct, car si la AfficheBonjour() est définie après la fonction main(), elle est déclarée avant (la seconde ligne du programme est le prototype de la fonction AfficheBonjour()). Si vous oubliez le prototype, alors main() ne pourra pas utiliser la fonction AfficheBonjour(). Notez que le prototype se termine par un point-virgule, contrairement à la définition de la fonction elle-même.

Lorsque vous créez une variable en dehors de tout bloc, c'est-à-dire qu'elle n'appartient à aucune fonction en particulier, elle est dite globale, car elle est visible partout, comme le montre l'exemple ci-dessous :

#include <iostream.h>

int x = 5;

void Modifier(void)
{

    x = 10;

}

int main(void)
{

    cout << "Avant: x = " << x << endl;
    Modifier();
    cout << "Après: x = " << x << endl;

}

L'affichage produit est le suivant :

Avant: x = 5
Après: x = 10

En effet, cette fois-ci, x est globale, et c'est donc la même variable qui est manipulée par main() et par Modifier(). Notez bien que Modifier() ne prend plus d'argument.

Bien sûr, rien ne vous empêche de créer dans une fonction une variable qui porte le même nom qu'une variable globale. Dans ce cas, à moins de le préciser, c'est la variable locale qui est prise en compte, comme le montre l'exemple suivant :

#include <iostream.h>

int x = 5;

int main(void)
{

    int x = 10;
    cout << "Le x local: " << x << endl;
    cout << "Le x global: " << ::x << endl;

}

Lorsqu'on écrit x, comme à la seconde ligne de main(), on fait référence au x déclaré à la ligne d'avant. Pour faire référence au x global, il faut le précéder de l'opérateur ::. Je vous avais dit avant qu'un nom de variable devait être unique. Il est donc temps de réctifier un peu cela : il ne peut y avoir deux variables de même portée et ayant le même nom.

D'une manière générale, il est considéré comme une mauvaise pratique d'utiliser des variables globales. Alors que les constantes devraient l'être (du fait qu'elles représentent des valeurs "universelles"), les variables globales rendent le programme moins lisible et moins compréhensible, car il est difficile de savoir où leur valeur est modifiée, et il vaut mieux les éviter au possible. Bien sûr, il peut y avoir des cas où elles sont très utiles, mais ces cas sont extrêmement rares, et le mieux reste de les éviter.

Revenons un instant sur la boucle for : celle-ci vient souvent (même très souvent) avec un entier qui fait office de compteur. Mais il est très fréquent que ce compteur ne serve qu'à la boucle for, et qu'il soit inutile dans le reste de la fonction ou du programme. Afin de ne pas traîner derrière soi des variables inutiles, le C++ nous permet de créer une variable dans la boucle, qui disparaît une fois la boucle terminée :

for(int i = 0; i <= 9; i++)

    cout << i << endl;

J'ai trouvé important de vous le dire.

Durée de vie d'une variable

Calmez-vous, on ne va tuer personne. Une variable créée dans une fonction est utilisée dans la fonction, mais une fois que la fonction se termine, la variable n'est plus utilisée. Elle est alors effacée, afin de libérer sa place en mémoire. Ceci est vrai pour les fonctions, mais aussi pour tous les blocs, comme pour la portée. Une variable est détruite lorsque l'exécution sort du bloc où elle a été créée. Les objets globaux, eux, sont créés et initialisés une fois pour toutes, et sont détruites lorsque le programme a terminé son exécution.

Ainsi, si vous déclarez une variable dans une fonction, à chaque fois que la fonction sera appelée, cette variable sera créée et réinitialisée selon le code. Il existe cependant un moyen de faire en sorte qu'une variable ne soit pas détruite puis recréée à chaque appel de fonction : en déclarant la variable avec le mot clé static, vous dites au compilateur que cette variable, visible dans la fonction seulement, ne doit être créée qu'une seule fois. Voici un exemple :

#include <iostream.h>

void Affiche(void)
{

    static int x = 0;
    x++;
    cout << "Appel n°" << x << ".\n";

}

int main(void)
{

    Affiche();
    Affiche();
    Affiche();

}

Voici l'affichage produit :

Appel n°1.
Appel n°2.
Appel n°3.

La ligne static int x = 0; n'est exécutée que lors du premier appel à la fonction Affiche(). Ensuite, la fonction "se rappelle" de la valeur de x et l'incrémente à chaque fois.

Valeur de renvoi

Nous savons maintenant comment faire pour donner une valeur (ou plus) à une fonction, mais nous ne savons pas encore recevoir de valeur d'une fonction. Or, vous vous en doutez, une fonction doit pouvoir retourner une valeur, comme résultat d'un calcul, par exemple. Prenons la fonction factorielle, qui, en mathématiques se note n! et qu'on appellera Fact(int n). La fonction factorielle se calcule comme ceci :

6! = 6 * 5 * 4 * 3 * 2 * 1 = 720

On veut pouvoir écrire quelque chose comme : int x = Fact(6); pour assigner à x le résultat du calcul. On se rend compte alors que Fact(6) doit renvoyer une valeur de type int, sinon il y a une erreur de type dans notre programme. On doit donc également spécifier le type de la valeur de renvoi. Voici comme tout ceci se fait :

#include <iostream.h>

int Fact(int x)
{

    int resultat = 1;
    for(int i = 1; i <= x; i++)

      resultat *= i;

    return resultat;

}

int main(void)
{

    cout << "6! = " << Fact(6) << endl;

}

L'instruction magique, c'est le return. C'est lui qui met fin à la fonction et qui lui donne sa valeur, afin que cout << Fact(6) affiche 720. La fonction Fact() en elle-même n'est pas très difficile à comprendre, ce qui nous intéresse plus, c'est l'en-tête de cette fonction : elle dit int Fact(...), autrement dit, elle sert à préciser que la valeur de retour de la fonction Fact() est de type int. Il est important alors que resultat soit également de type int, puisque c'est lui qu'on renvoie.

Mais voilà qui est étrange : la fonction main() est également de type int. Pourtant, personne ne l'appelle, et il n'y a pas d'instruction return? Puisqu'on a décidé que main() était du type int, on s'attend à renvoyer une valeur. Mais à qui? Eh! bien au système, pardi! C'est le système qui appelle la fonction main(), et c'est lui qui récupère la valeur de renvoi du programme. En général, un programme renvoie 0 si tout c'est bien passé, et une autre valeur autrement. Mais ceci ne nous intéresse pas vraiment, car ça n'a pas de conséquence sur la programmation. Sachez juste que dorénavant, la fonction main() sera de type int.

Par contre, je suis sûr qu'une autre question vous titille déjà la langue : que signifie le void ? Il signifie tout simplement rien. Si vous mettez void comme argument, vous indiquez que vous ne voulez pas d'argument, et si vous mettez void comme type de renvoi, vous indiquez que vous ne renvoyez pas de valeur.

Pour sortir d'une fonction non-void, il suffit, à n'importe quel moment de rencontrer l'instruction return valeur;. Mais comment alors sortir d'une fonction void ? Pour cela, l'instruction return; seule suffit (sans valeur). Seule, cette instruction retourne à la fonction appelante, mais sans renvoyer de valeur.

Le programme du jour

Aujourd'hui, le programme du jour est trop volumineux pour être inclus dans cette page. Il contient plusieurs fonctions, afin d'illustrer un peu l'utilisation de celles-ci.

Cinq fonctions sont mises en jeu : Fact(), AfficheResultat(), Somme(), Double() et Moitie(). La première, Fact(), calcule tout simplement le factoriel de n et renvoie sa valeur. La seconde, AfficheResultat(), illustre la possibilité de traiter une fonction de la même façon qu'on traite une constante : on peut par exemple afficher sa valeur directement (par contre, il paraît absurde de vouloir assigner une valeur à une fonction.) Somme() est juste là pour illustrer l'utilisation de plusieurs paramètres.

Les deux dernières sont plus sournoises. Vous remarquez qu'elles sont d'abord déclarées en début de fichier. En effet, leur définition est donnée à la fin, et il faut que main() puisse les appeler, d'où la nécessité de placer leurs prototypes avant leur utilisation). L'une renvoie le double de la valeur qu'on lui a passé en paramètre, l'autre renvoie la moitié (c'est-à-dire le résultat entier de la division euclidienne de n par 2), mais toutes les deux provoquent un affichage. Exécutez le programme, et vous verrez que l'affichage peut vous sembler un peu surprenant si vous avez regardé le programme. Cela vient du fait qu'avant d'afficher quoi que se soit, le compilateur lance d'abord les fonctions, pour ensuite les remplacer par leurs valeurs respectives dans l'expression qui les utilise (ici, un cout). D'où l'ordre apparamment inversé des affichages.

Si vous regardez bien, la ligne 29 se termine sur la ligne 30 : ceci vous illustre que vous pouvez aller de temps à autres à la ligne si votre cout devient trop long, sans pour autant réécrire cout à chaque ligne. Par contre, l'instruction se termine avec le point-virgule ligne 30.

Ce cours fut un peu plus long que d'habitude, mais nous venons de faire un gros morceau (et en plus, vous avez eu le droit à tout un speach sur la mémoire!). Revoyons les points importants, un peu nombreux cette fois-ci :

  • Une fonction se compose d'un en-tête (indiquant son type de renvoi et ses paramètres) et d'un corps, délimité par une paire d'accolades.
  • Le prototype d'une fonction sert à déclarer une fonction afin de pouvoir l'utiliser si la fonction n'est pas définie avant d'être utilisée. Le prototype doit être indentique (au niveau des types seulement) à l'en-tête de la fonction.
  • Lors d'un appel de fonction, l'exécution commence par la première instruction de la fonction et s'arrête soit à la fin de la fonction, soit lors d'une instruction return.
  • L'instruction return permet de renvoyer une valeur. Ceci n'est possible que si la fonction n'est pas déclarée de type void.
  • Lors d'un appel de fonction, l'exécution entre dans la fonction, en resort et continue à la l'instruction qui suit l'appel.
  • Une fonction peut recevoir des paramètres, décrits (type et nom) entre parenthèses après le nom de la fonction dans le prototype ou dans l'en-tête. Si il y a plusieurs paramètres, ils sont séparés par des virgules.
  • Une fonctions définie avec une certain nombre de paramètres (y compris aucun, ou void) doit être appelée avec le même nombre de paramètres exactement.
  • Lorsqu'une expression met en jeu des appels de fonctions, ceux-ci sont effectués dans un premier temps et l'expression est ensuite évaluée avec le résultat de ces appels de fonctions.
  • Une variable n'est visible que dans le bloc dans laquelle elle a été créée et dans tous les blocs qui y sont contenus. Elle n'est pas visible dans les blocs contenants.
  • Une variable n'est visible qu'après la ligne où elle a été créée.
  • Une variable est détruite à chaque fois que l'exécution sort du bloc où elle a été créée, à moins qu'elle soit globale ou qu'elle soit déclarée static.
  • Lorsqu'une variable locale porte le même nom qu'une variable globale, on peut accéder à la globale en utilisant l'opérateur :: (ex: ::x).
  • Une boucle for peut créer une variable pour elle-même et la détruire ensuite.

Eh! bien avec tout ça, ça nous fait pas mal de boulot... non, je vais être gentil :

  • Que vous dit votre compilateur lorsque :
    • vous effacez les prototypes de Double() et Moitie() au début du fichier ?
    • vous donnez à la fonction Somme() un type de renvoi int alors que l'un de ses paramètres est de type double ?
    • vous donnez à la fonction main() le type de renvoi void ou double ?
    • vous effectuez une appel à Somme() avec seulement deux paramètres ?
  • Replacez toutes les fonctions après la fonction main() dans le programme.
  • Ecrivez une fonction RacineCarree(int n), qui renvoie le produit de n par lui-même. Quel est son type ?
  • Avec l'opérateur modulo %, écrivez une fonction Chiffre(int n) qui renvoie le dernier chiffre de n.

Ouf, ça va mieux. C'est fini pour aujourd'hui. Ce fut long et intense, mais c'est toujours ça de fait. Si vous n'avez pas tout compris encore, ce n'est pas grave, vous aurez tout le programme de ce site pour comprendre les fonctions, étant donné qu'on ne va faire plus que ça dans nos programmes. Avec tout ça, je pense que vous allez bien dormir... ou jouer à Doom pour les moins sérieux. La semaine prochaine, nous allons voir un petit point amusant des fonctions, qui permet de résoudre certains problèmes de manière très élégante : j'ai nommé la récursivité.


Voir aussi : Les boucles : de 1 à 10