Tous droits réservés

Découpons du code — Les fonctions

Nos programmes deviennent de plus en plus complets, nous sommes en mesure de faire de plus en plus de choses. Mais, chaque fois que nous avons besoin de réutiliser un morceau de code, comme dans le cadre de la protection des entrées, nous nous rendons comptes que nous devons dupliquer notre code. Autant dire que c’est contraignant, ça alourdit le code et c’est une source d’erreurs potentielles.

Le moment est donc venu pour vous de découvrir les fonctions, à la base de nombreux codes et la solution à notre problème.

Les éléments de base d'une fonction

Une fonction, c’est quoi ?

Une fonction est un ensemble d’instructions pour réaliser une tâche précise. Cet ensemble d’instructions est isolé dans une partie spécifique qui porte un nom, appelé identificateur. Enfin, une fonction peut ou non prendre des informations en entrée, comme elle peut ou non fournir des informations en sortie.

Depuis le début de ce cours, nous manipulons des fonctions inclues dans la bibliothèque standard. Revenons un peu sur différents exemples qui vont illustrer la définition théorique vue ci-dessus.

Cas le plus simple

Nous avons vu des fonctions simples qui ne renvoyaient ni ne prenaient de valeur. Ce genre de fonction n’a pas besoin qu’on lui fournisse des données en entrées, et elle n’en fournit pas en retour.

std:string chaine { "Salut toi" };
// On supprime le dernier caractère.
chaine.pop_back();
// On supprime la chaîne.
chaine.clear();

std::list<int> liste { 4, -9, 45, 3 };
// On trie la liste.
liste.sort();

Fonction renvoyant une valeur

Certaines fonctions nous fournissent des informations que nous pouvons, au choix, récupérer ou ignorer. Le chapitre sur les tableaux nous en fournit plusieurs exemples.

std::vector<int> const tableau_de_int { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// Récupérer le premier élément.
int premier { tableau_de_int.front() };
// Récupérer le dernier élément.
int dernier { tableau_de_int.back() };

Nous ne sommes pas obligés de stocker la valeur qu’une fonction renvoie. Par exemple, std::getline peut s’utiliser tel quel, sans se soucier de ce que la fonction a renvoyé.

Fonction attendant une valeur

D’autres fois, certaines fonctions ont besoin d’informations extérieures pour travailler. C’est le cas notamment des algorithmes que nous avons vus, mais aussi d’autres fonctions.

std::string texte { "Du texte." };
// Ajouter une lettre à la fin.
texte.push_back('!');

std::vector<char> tableau {};
// On remplit le tableau avec cinq fois la valeur 'C'.
tableau.assign(5, 'C');
Point de vocabulaire

Dans le cas où une fonction ne retourne aucune valeur, on parle dans la littérature informatique de procédure. Cette appellation est valable, peu importe si la fonction attend ou non des données.

Fusion !

Enfin, dernière possibilité, il y a les fonctions qui attendent des informations en entrée et qui en fournissent en sortie. Là encore, nous en avons vu dans le cours.

std::vector<int> tableau { 1, 2, 3, 4 };
// Renvoie un itérateur sur le premier élément.
auto it = std::begin(tableau);
// On compte le nombre d'éléments valant deux.
std::count(std::begin(tableau), std::end(tableau), 2);

std::string texte { "" };
// std::getline attend qu'on lui donne le flux à lire et la chaîne qui servira à stocker le résultat.
// Elle renvoie ce flux en gise de retour.
std::getline(std::cin, texte);
// Attend un conteneur en entrée et renvoie sa taille en sortie.
auto taille { std::size(texte) };

Une fonction bien connue

Nous avons un exemple de fonction sous nos yeux depuis le premier code que vous avez écrit : la fonction main. Celle-ci est le point d’entrée de tous les programmes que nous codons. Je la remets ci-dessous.

int main()
{
    // Instructions diverses.
    return 0;
}
  • D’abord, main est le nom de la fonction, son identificateur.
  • Ensuite, le int situé juste avant définit le type de retour. Dans notre cas, la fonction renvoie une valeur entière, qui est le 0 que nous voyons dans return 0; et qui indique au système d’exploitation que tout s’est bien passé.
  • Après, nous voyons une paire de parenthèses vides, ce qui signifie que la fonction n’attend aucune information en entrée.
  • Enfin, entre les accolades {}, nous avons le corps de la fonction, les instructions qui la composent.

Les composants d’une fonction

Schéma d’une fonction

En écrivant en pseudo-code, voici à quoi ressemble une fonction en C++.

type_de_retour identificateur(paramètres)
{
    instructions
}

Votre identité, s’il vous plaît

Le point le plus important est de donner un nom à notre fonction. Les règles sont les mêmes que pour nommer nos variables. Je les réécris ci-dessous.

  • L’identificateur doit commencer par une lettre. Il ne peut pas commencer par un chiffre, c’est interdit. Il ne doit pas commencer non plus par underscore _, car les fonctions commençant par ce caractère obéissent à des règles spéciales et sont souvent réservées à l’usage du compilateur.
  • Les espaces et les signes de ponctuation sont interdits (', ?, etc).
  • On ne peut pas utiliser un mot-clef du langage comme identificateur. Ainsi, il est interdit de déclarer une fonction s’appelant int ou return, par exemple.

Je vous rappelle également l’importance de donner un nom clair à vos fonctions, qui définit clairement ce qu’elles font. Évitez donc les noms trop vagues, les abréviations obscures, etc.

Mais de quel type es-tu ?

Comme vous le savez, une fonction peut ou non renvoyer une valeur de retour, comme nous l’avons vu à l’instant. Si elle doit renvoyer une valeur quelconque, alors il suffit d’écrire le type de cette valeur.

À l’inverse, si votre fonction ne doit rien renvoyer, alors on utilise le mot-clef void, qui signifie « vide » et qui, dans ce contexte, indique que la fonction renvoie du vide, c’est-à-dire rien.

Pour renvoier une valeur, on utilise le mot-clef return, exactement comme dans la fonction main. Il peut y en avoir plusieurs dans une fonction, par exemple deux return, un si un if est vrai, l’autre s’il est faux. Par contre, lorsqu’une instruction return est exécutée, on sort de la fonction en cours et tout le code restant n’est pas exécuté. On dit qu’un retour de fonction coupe le flot d’exécution de la fonction. Oui, comme break dans une boucle. Vous suivez, c’est très bien. :)

Paramétrage en cours…

Maintenant, parlons des paramètres. Grâce aux paramètres, une fonction montre au reste du monde ce qu’elle attend pour travailler. Par exemple, std::sort attend qu’on lui donne un itérateur de début et un itérateur de fin. Quand nous ajoutons un élément à un tableau à l’aide de push_back, il faut indiquer à cette dernière la valeur que nous ajoutons.

Comment les déclarer ? C’est très simple, il faut que chaque paramètre ait un type et un identificateur. S’il y en a plusieurs, ils seront séparés par des virgules.

Exemples

Voici un exemple de fonctions que j’ai créé en reprenant les codes que nous avions écrits il y a plusieurs chapitres.

int pgcd(int a, int b)
{
    int r { a % b };
    while (r != 0)
    {
        a = b;
        b = r;
        r = a % b;
    }

    // On peut tout à fait renvoyer la valeur d'un paramètre.
    return b;
}

int somme(int n)
{
    // On peut renvoyer le résultat d'un calcul ou d'une expression directement.
    return (n * (n + 1)) / 2;
}

bool ou_exclusif(bool a, bool b)
{
    return (a && !b) || (b && !a);
}

int main()
{
    int const a { 845 };
    int const b { 314 };

    std::cout << "Le PGCD de " << a << " et " << b << " vaut " << pgcd(a, b) << "." << std::endl;
    std::cout << "La somme de tous les entiers de 1 à 25 vaut " << somme(25) << "." << std::endl;

    std::cout << std::boolalpha;
    std::cout << "XOR(true, false) vaut " << ou_exclusif(true, false) << "." << std::endl;

    return 0;
}

Notez qu’on peut très bien donner en argument à une fonction une variable qui porte le même identificateur qu’un des paramètres. Il n’y a aucun problème à ça, car nous sommes dans deux portées différentes : main et la fonction en question.

Conséquence du point précédent, il n’est pas possible d’utiliser un paramètre de fonction en dehors de celle-ci. Le code suivant produit donc une erreur.

void fonction(int parametre)
{
    // Aucun problème.
    parametre = 5;
}

int main()
{
    // La variable parametre n'existe pas !
    parametre = 410;
    return 0;
}
Argument et paramètre

Chez beaucoup de personnes, la distinction entre paramètre et argument n’est pas claire.

  • Un paramètre, aussi appelé paramètre formel, c’est ce qu’attend une fonction pour travailler et qui est inscrit dans sa déclaration.
  • Un argument, aussi appelé paramètre réel, c’est la valeur transmise à la fonction quand on l’utilise.
void fonction(int entier)
{
    // entier est le paramètre de la fonction.
}

int main()
{
    // 4 est l'argument de la fonction.
    fonction(4);

    int const valeur { 5 };
    // valeur est l'argument de la fonction.
    fonction(valeur);
    return 0;
}

En pratique, beaucoup utilisent les deux de façon interchangeable et vous serez parfaitement compris si vous faites de même. Je tenais néanmoins à vous expliquer cela rigoureusement.

Exercices

Il est temps de pratiquer toutes ces nouvelles notions que vous avez acquises, afin de mieux les comprendre et bien les retenir.

Afficher un rectangle

Commençons par une fonction simple qui affichera un rectangle composé d’étoiles *. Voici un exemple en lui donnant une longueur de 4 et une largeur de 3.

***
***
***
***
Correction affichage de rectangles
#include <iostream> 

void rectangle(int longueur, int largeur)
{
    for (auto i { 0 }; i < longueur; ++i)
    {
        for (auto j { 0 }; j < largeur; ++j)
        {
            std::cout << "*";
        }

        std::cout << std::endl;
    }
}

int main()
{
    rectangle(2, 5);
    rectangle(4, 3);
    return 0;
}

Distributeur d’argent

Le but est de donner une quantité de coupures correspondant à une somme donnée, en utilisant les plus grosses en premiers. Par exemple, 285€ avec des coupures de 500€, 200€, 100€, 50€, 20€, 10€, 5€, 2€ et 1€ donne un total d’un billet de 200€, un billet de 50€, un billet de 20€, un billet de 10€ et enfin, un billet de 5€.

Par contre, 346€ avec uniquement 20€, 10€, 5€, 2€ et 1€ donne dix-sept billets de 20€, un billet de 5€ et une pièce de 1€. Il faut donc prendre en compte les coupures disponibles dans le résultat.

Correction distributeur d’argent
#include <iostream>
#include <vector>

std::vector<int> distributeur(int total, std::vector<int> coupures_disponibles)
{
    std::vector<int> resultat {};

    for (auto coupure : coupures_disponibles)
    {
        resultat.push_back(total / coupure);
        total %= coupure;
    }

    return resultat;
}

int main()
{
    std::vector<int> const coupures_disponibles { 500, 200, 100, 50, 20, 10, 5, 2, 1 };
    auto const coupures { distributeur(285, coupures_disponibles) };
    
    for (auto coupure : coupures)
    {
        std::cout << coupure << std::endl;
    }

    return 0;
}

Bonus : faire une fonction qui affiche de façon lisible le total. Par exemple, pour 285€, on peut imaginer la sortie suivante.

1 x 200 euros
1 x 50 euros
1 x 20 euros
1 x 10 euros
1 x 5 euros
Correction bonus
#include <iostream>
#include <vector>

std::vector<int> distributeur(int total, std::vector<int> coupures_disponibles)
{
    std::vector<int> resultat {};

    for (auto coupure : coupures_disponibles)
    {
        resultat.push_back(total / coupure);
        total %= coupure;
    }

    return resultat;
}

void affichage_distributeur(std::vector<int> total, std::vector<int> coupures_disponibles)
{
    int i { 0 };
    for (auto coupure : total)
    {
        // Si 0, ça veut dire qu'il n'y a aucune coupure de ce type dans le total, donc nous n'afficherons rien.
        if (coupure != 0)
        {
            std::cout << coupure << " x " << coupures_disponibles[i] << " euros" << std::endl;
        }
        ++i;
    }
}

int main()
{
    std::vector<int> const coupures_disponibles { 500, 200, 100, 50, 20, 10, 5, 2, 1 };
    auto const coupures_285 { distributeur(285, coupures_disponibles) };
    affichage_distributeur(coupures_285, coupures_disponibles);

    // Moins de coupures disponibles.
    std::vector<int> const autre_coupures_disponibles { 200, 50, 10, 1 };    
    auto const coupures_45874 { distributeur(45874, autre_coupures_disponibles) };
    affichage_distributeur(coupures_45874, autre_coupures_disponibles);

    return 0;
}

Parenthésage

Imaginons une expression de plusieurs dizaines de caractères, avec des parenthèses ouvrantes ( et fermantes ). Il serait fastidieux de regarder manuellement si une expression est correctement parenthésée, c’est-à-dire qu’elle a le même nombre de symboles ouvrants et fermants et qu’ils sont bien dans l’ordre.

Ainsi, l’expression ((())) est correcte, alors que ((()) est incorrecte parce qu’il manque une parenthèse fermante. Quant à )(, elle est également incorrecte parce que le symbole fermant est avant le symbole ouvrant.

Correction parenthésage
#include <iostream>

bool parentheses(std::string expression)
{
    int ouvrantes { 0 };
    int fermantes { 0 };

    for (auto caractere : expression)
    {
        if (caractere == '(')
        {
            ++ouvrantes;
        }
        else 
        {
            ++fermantes;
        }

        if (fermantes > ouvrantes)
        {
            // Pas la peine de continuer, l'expression est invalide.
            return false;
        }
    }

    return ouvrantes == fermantes;
}

int main()
{
    std::cout << std::boolalpha;
    std::cout << parentheses("((()))") << std::endl;
    std::cout << parentheses("((())") << std::endl;
    std::cout << parentheses("))((") << std::endl;

    return 0;
}

Quelles sont vos références ?

La problématique

J’ai écrit dans la première section que les paramètres d’une fonction étaient dans une portée différente, ce qui autorise de faire ceci.

void fonction(int a)
{
    a = 5;
}

int main()
{
    int a { 8 };
    // Aucun problème.
    fonction(a);

    return 0;
}

Il faut cependant savoir que ceci implique une copie. Pour chaque paramètre, le compilateur va réserver un espace en mémoire de la taille nécessaire et copier la variable qu’on lui passe. Nous allons donc manier des variables totalement différentes. C’est ce qui s’appelle le passage par copie.

#include <iostream>
#include <vector>

void fonction(int a, double b, std::vector<int> c)
{
    a = 5;
    b = 2.18781;
    c.assign(7, 0);

    // Ici, nous manipulons donc des copies.
    std::cout << a << std::endl;
    std::cout << b << std::endl;
    for (auto element : c)
    {
        std::cout << element << " ";
    }
    std::cout << std::endl << std::endl;
}

int main()
{
    int const entier { 42 };
    double const reel { 3.14159 };
    std::vector<int> const tableau { 1, 782, 4, 5, 71 };

    // Chaque variable va être copiée.
    fonction(entier, reel, tableau);

    // Affichons pour prouver que les variables originales n'ont pas changé.
    std::cout << entier << std::endl;
    std::cout << reel << std::endl;
    for (auto element : tableau)
    {
        std::cout << element << " ";
    }

    std::cout << std::endl;
    return 0;
}

Le côté négatif, c’est qu'on doit forcément copier l’argument, même si on ne le modifie pas. Dans le cas d’un entier ou d’un caractère, c’est idéal, mais dans le cas d’un tableau de plusieurs milliers d’éléments, on perd du temps inutilement. C’est comme si je demandais à consulter un certain original dans des archives et qu’on me faisait des photocopies à la place. S’il s’agit d’un post-it, pas grave, mais s’il s’agit d’un livre de 400 pages, bonjour le gâchis !

Les références

La solution consiste à ne pas passer une copie de la variable originale mais un alias, une référence vers celle-ci et qui soit manipulable comme si j’avais l’original entre les mains. Cela revient à me donner le document original et éventuellement, selon le contexte, le droit de le modifier.

Une référence en C++ est simple à déclarer mais doit réunir plusieurs conditions.

  • On utilise l’esperluette & pour ça.
  • Elle doit être de même type que la variable cible. Une référence sur un double ne peut pas se voir associée à une variable de type int.
  • Une référence cible une seule et unique variable. On ne peut pas créer de référence qui ne cible rien, ni changer la cible d’une référence une fois qu’on l’a créée.
  • Une référence peut être définie comme constante, ce qui implique qu’on ne pourra pas modifier sa valeur.
#include <iostream>

int main()
{
    int entier { 40 };
    // On initialise notre référence pour qu'elle référence entier.
    int & reference_entier { entier };

    std::cout << "Entier vaut : " << entier << std::endl;
    std::cout << "Référence vaut : " << reference_entier << std::endl;

    reference_entier += 2;
    std::cout << "Entier vaut : " << entier << std::endl;
    std::cout << "Référence vaut : " << reference_entier << std::endl;

    int const entier_constant { 0 };
    // On peut également déclarer des références constantes sur des entiers constants.
    int const & reference_const_entier_constant { entier_constant };
    // Ce qui interdit ce qui suit, car le type référencé est un int const.
    // reference_const_entier_constant = 1;

    // On ne peut pas déclarer de référence non constante sur un entier constant.
    //int & reference_entier_constant { entier_constant };

    // On peut par contre tout à fait déclarer une référence constante sur un objet non constant.
    // On ne peut donc pas modifier la valeur de l'objet en utilisant la référence, mais l'entier
    // original reste tout à fait modifiable.
    int const & reference_const_entier { entier };
    std::cout << "Entier vaut : " << entier << std::endl;
    std::cout << "Référence constante vaut : " << reference_const_entier << std::endl;

    entier = 8;
    std::cout << "Entier vaut : " << entier << std::endl;
    std::cout << "Référence constante vaut : " << reference_const_entier << std::endl;

    return 0;
}
Tableaux et références

Vous avez déjà utilisé des références sans le savoir. En effet, quand, dans le chapitre précédent, nous accédons à un élément d’un tableau, c’est en fait une référence qui est renvoyée, ce qui autorise à modifier un tableau comme nous l’avions vu.

// On change la valeur du premier élément.
tableau[0] = 5;

C++ est un langage qui cache plein de concepts sous des airs innocents. :)

Paramètres de fonctions

On peut tout à fait déclarer les paramètres d’une fonction comme étant des références. Ainsi, celles-ci ne recevront plus une copie mais bel et bien des références sur les objets. On parle donc de passage par référence. On peut, bien entendu, préciser si la référence est constante ou non. Dans le premier cas, toute modification du paramètre sera interdite, alors que le deuxième cas autorise à le modifier et conserver ces changements.

#include <iostream>
#include <string>

void fonction_reference_const(std::string const & texte)
{
    std::cout << "J'ai reçu une référence comme paramètre. Pas de copie, youpi !" << std::endl;
    // Interdit.
    // texte = "Changement";
}

void fonction_reference(std::string & modifiable)
{
    std::cout << "Je peux modifier la chaîne de caractères et les changements seront conservés." << std::endl;
    modifiable = "Exemple";
}

int main()
{
    std::string modifiable { "Du texte modifiable." };
    std::string const non_modifiable { "Du texte qui ne sera pas modifiable." };

    std::cout << "Avant : " << modifiable << std::endl;
    fonction_reference(modifiable);
    std::cout << "Après : " << modifiable << std::endl;

    fonction_reference_const(non_modifiable);
    // Possible.
    fonction_reference_const(modifiable);
    // Impossible, parce que la fonction attend une référence sur un std::string et nous lui donnons une référence sur un std::string const.
    //fonction_reference(non_modifiable);

    return 0;
}

Quand dois-je utiliser une référence constante ou une référence simple ?

Il suffit de se poser la question : « est-ce que ce paramètre est destiné à être modifié ou non ? » Si c’est le cas, alors on utilisera une référence simple. Dans le cas contraire, il faut utiliser une référence constante. Ainsi, l’utilisateur de la fonction aura des garanties que son objet ne sera pas modifié dans son dos.

Le cas des littéraux

Analysez le code suivant, sans le lancer, et dites-moi ce que vous pensez qu’il va faire. Si vous me dites qu’il va simplement afficher deux messages… dommage, vous avez perdu. :)

#include <iostream>

void test(int & a)
{
    std::cout << "Je suis dans la fonction test.\n";
}

void test_const(int const & a)
{
    std::cout << "Je suis dans la fonction test_const.\n";
}

int main()
{
    test(42);
    test_const(42);
    return 0;
}

En fait, ce code ne va même pas compiler, à cause de la ligne 15. Il y a une très bonne raison à ça : nous passons un littéral, qui est invariable et n’existe nulle part en mémoire, or nous tentons de le donner en argument à une fonction qui attend une référence, un alias, sur une variable modifiable. Mais un littéral n’est pas modifiable et tenter de le faire n’a aucun sens. Le compilateur refuse donc et ce code est invalide.

Par contre, supprimez-la et le code compilera. Pourtant, la seule différence entre les deux fonctions, c’est la présence d’un const. Eh bien c’est lui qui rend le code valide. En effet, le compilateur va créer un objet temporaire anonyme, qui n’existera que le temps que la fonction prendra pour travailler, et va lui assigner le littéral comme valeur. On peut réécrire le code précédent comme ceci pour mieux comprendre.

#include <iostream>
#include <string>

void test_const(int const & a)
{
    std::cout << "Je suis dans la fonction test_const.\n";
}

void test_string_const(std::string const & texte)
{
    std::cout << "Je suis dans la fonction test_string_const.\n";
}

int main()
{
    // test_const(42);
    {
        // Nouvelle portée.
        int const variable_temporaire { 42 };
        int const & reference_temporaire { variable_temporaire };
        test_const(reference_temporaire);
        // Nos variables disparaissent.
    }

    // Exemple avec une chaîne de caractères.
    // test_string_const("Salut toi !");
    {
        std::string const texte_temporaire { "Salut toi !" };
        std::string const & reference_temporaire { texte_temporaire };
        test_string_const(reference_temporaire);
    }

    return 0;
}

Le cas des types standard

Utiliser des références pour std::vector ou std::string d’accord, mais qu’en est-il des types simples comme int, char et double ? Y a-t-il un intérêt à utiliser des références constantes ? Doit-on bannir toutes les copies de nos codes sous prétexte d’efficacité ? Non.

L’usage en C++, c’est que les types natifs sont toujours passés par copie. Pourquoi cela ? Parce qu’ils sont très petits et le coût de la création d’une référence sur des types aussi simples est souvent plus élevé que celui d’une bête copie. En effet, le compilateur peut optimiser la copie et la rendre extrêmement rapide, bien plus qu’avec les références.

Donc ne tombez pas dans le piège de l’optimisation prématurée, qui consiste à vouloir améliorer les performances de son programme en faisant des changements qui n’apporteront aucune amélioration. Laissez plutôt ce travail au compilateur. ;)

Valeur de retour

Les références ont une contrainte particulière : elles doivent toujours être valides. Dans le cas d’un paramètre de fonction, il n’y a aucun danger. En effet, l’argument transmis à la fonction existera toujours quand celle-ci se terminera, comme le montre le code suivant.

void fonction(int & reference)
{
    // On modifie une référence sur une variable qui existe, pas de soucis.
    reference = 42;
}

int main()
{
    int variable { 24 };
    // On transmet variable...
    fonction(variable);
    // ... qui existe toujours après la fin de la fonction.

    return 0;
}

Cependant, dans le cas où l’on souhaite qu’une fonction renvoie une référence, il faut faire très attention parce qu’on peut renvoyer une référence sur un objet qui n’existe plus ! Ceci va donc entraîner un comportement indéterminé.

int& fonction()
{
    int variable { 42 };
    // Oho, variable sera détruite quand la fonction sera terminée !
    return variable;
}

int main()
{
    // Grosse erreur, notre référence est invalide !
    int& reference { fonction() };

    return 0;
}
Référence locale

Ne renvoyez JAMAIS de référence sur une variable locale.

Un mot sur la déduction de type

Certains reprochent son côté « rigide » à ce mot-clé. En effet, auto seul dégage la référence. À l’inverse, auto & sera toujours une référence. Et il en est de même pour const : auto const sera toujours constant et auto const & sera toujours une référence sur un objet constant.

int main()
{
    int variable { 2 };
    int & reference { variable };

    // Sera toujours une variable et jamais une référence.
    auto toujours_variable_1 { variable };
    auto toujours_variable_2 { reference };

    // Sera toujours une référence et jamais une variable.
    auto & toujours_reference_1 { variable };
    auto & toujours_reference_2 { reference };

    // On pourrait rajouter des exemples avec const, mais vous comprenez le principe.
    return 0;
}

Il existe cependant un mot-clef qui s’adapte toujours au type en paramètre parce qu’il conserve la présence (ou non) de const et de références. Il s’appelle decltype et s’utilise de deux façons. La première, c’est avec une expression explicite, comme un calcul, une autre variable ou le retour d’une fonction, ce qui donne decltype(expression) identifiant { valeur }; et est illustré ci-dessous.

#include <iostream>
#include <vector>

int main()
{
    std::vector<int> tableau { -8, 45, 35, 9 };

    // Sera toujours une variable.
    auto premier_element_variable { tableau[0] };
    // Est en fait une référence sur cet élément, ce qui autorise à le modifier.
    decltype(tableau[1]) deuxieme_element_reference { tableau[1] };

    // Modifions et regardons.
    premier_element_variable = 89;
    deuxieme_element_reference = 0;

    for (auto const & element : tableau)
    {
        std::cout << element << std::endl;
    }

    return 0;
}

La deuxième façon de l’utiliser, c’est de combiner decltype et auto, donnant decltype(auto) identifiant { valeur }; afin d’avoir le type exact de valeur et de ne pas se répéter. La ligne 11 de notre code précédent peut ainsi être raccourcie.

std::vector<int> tableau { -8, 45, 35, 9 };
// Sera de type int& si tableau est modifiable, de type int const & si tableau est déclaré comme const.
decltype(auto) deuxieme_element_reference { tableau[1] };

Ces deux mots-clefs ne sont pas deux concurrents s’opposant pour faire le même travail, bien au contraire. Ils sont une preuve de la liberté que C++ laisse au développeur. Nous verrons plus tard dans ce cours d’autres usages, qui rendront ces mots-clefs encore plus concrets pour vous. En attendant, retenez ceci pour vous aider à choisir.

  • Choisissez auto lorsque vous souhaitez explicitement une valeur (par copie).
  • Choisissez auto & (const ou non) lorsque vous souhaitez explicitement une référence.
  • Choisissez decltype lorsque vous souhaitez le type exact.

Nos fonctions sont surchargées !

Dans le chapitre sur les fichiers, je vous ai appris qu’il y a, en plus de std::ostringstream et std::istringstream, d’autres méthodes pour faire la conversion nombre <-> chaîne de caractères.

Voici, ci-dessous, comment cette fonction est déclarée dans la bibliothèque standard.

std::string to_string(int value);
std::string to_string(double value);

Attends une seconde ! Comment c’est possible qu’on ait deux fonctions avec le même nom ? o_O

La réponse est simple : ce qui différencie deux fonctions, ça n’est pas seulement leur identificateur mais également leurs paramètres. C’est ce qu’on appelle la signature d’une fonction. Dans notre cas, le compilateur voit deux signatures différentes : to_string(int) et to_string(double), sans que ça ne pose le moindre problème.

Cette possibilité s’appelle surcharge, de l’anglais « overloading ». Elle rend le code plus clair car on laisse au compilateur le soin d’appeler la bonne surcharge.

#include <iostream>
#include <string>

int main()
{
    int const entier { -5 };
    double const flottant { 3.14156 };

    std::string affichage { "Voici des nombres : " };
    affichage += std::to_string(entier) + ", ";
    affichage += std::to_string(flottant);

    std::cout << affichage << std::endl;

    return 0;
}

Par contre, le type de retour n’entre pas en compte dans la signature d’une fonction. Ainsi, deux fonctions avec le même identifiant, les mêmes paramètres et des types de retour différents seront rejetées par le compilateur car il ne saura pas laquelle utiliser. Et c’est logique, car comment, en lisant le code, sauriez-vous ce que la fonction va vous renvoyer ?

C’est pour ça que les fonctions convertissant des chaînes de caractères en nombres ont des noms bien distincts : std::stoi pour convertir une chaîne en int et std::stod pour double.

#include <iostream>
#include <string>

int main()
{
    int const entier { std::stoi("-8") };
    double const flottant { std::stod("2.71878") };

    std::cout << entier + flottant << std::endl;
    return 0;
}

[T.P] Gérer les erreurs d'entrée — Partie III

Avec ce que nous avons vu, nous sommes maintenant capable de faire des fonctions qui vont nous épargner d’avoir à dupliquer plusieurs fois le même code chaque fois que nous souhaiterons sécuriser nos entrées. Nous pourrions ainsi sécuriser le code suivant sans duplication.

#include <iostream>

int main()
{
    int jour { 0 };
    std::cout << "Quel jour es-tu né ? ";
    // Entrée.

    int mois { 0 };
    std::cout << "Quel mois ? ";
    // Entrée.

    int annee { 0 };
    std::cout << "Quelle année ? ";
    // Entrée.

    double taille { 0.0 };
    std::cout << "Quelle taille ? ";
    // Entree.

    std::cout << "Tu es né le " << jour << "/" << mois << "/" << annee << " et tu mesures " << taille << "m." << std::endl;
    return 0;
}
Correction T.P Partie III
#include <iostream>
#include <string>

void entree_securisee(int & variable)
{
    while (!(std::cin >> variable))
    {
        std::cout << "Entrée invalide. Recommence." << std::endl;
        std::cin.clear();
        std::cin.ignore(255, '\n');
    }
}

void entree_securisee(double & variable)
{
    while (!(std::cin >> variable))
    {
        std::cout << "Entrée invalide. Recommence." << std::endl;
        std::cin.clear();
        std::cin.ignore(255, '\n');
    }
}

int main()
{
    int jour { 0 };
    std::cout << "Quel jour es-tu né ? ";
    entree_securisee(jour);

    int mois { 0 };
    std::cout << "Quel mois ? ";
    entree_securisee(mois);

    int annee { 0 };
    std::cout << "Quelle année ? ";
    entree_securisee(annee);

    double taille { 0.0 };
    std::cout << "Quelle taille ? ";
    entree_securisee(taille);

    std::cout << "Tu es né le " << jour << "/" << mois << "/" << annee << " et tu mesures " << taille << "m." << std::endl;
    return 0;
}

On remarque quand même que si le code n’est plus répété à chaque entrée, nos fonctions sont quasiment identiques, la seule différence résidant dans le paramètre qu’elles attendent. Finalement, on duplique toujours le code. Mais comme vous vous en doutez, il y a une solution pour ça, que nous verrons en temps voulu.

Dessine-moi une fonction

Nous allons nous attarder sur un petit problème qui peut apparaître lorsque l’on crée des fonctions : les appels croisés.

Le problème

Introduisons ce problème par un exemple : essayons d’implémenter deux fonctions est_pair et est_impair, dont l’objectif est, comme leurs noms l’indiquent, de dire si un entier est pair ou impair. Une manière élégante de procéder est de faire en sorte que chacune appelle l’autre.

bool est_pair(int i)
{
    if (i == 0)
    {
        return true;
    }
    else if (i < 0)
    {
        return est_pair(-i);
    }
    else
    {
        return est_impair(i - 1);
    }        
}

bool est_impair(int i)
{
    if (i < 0)
    {
        return est_impair(-i);
    }
    else
    {
        return est_pair(i - 1);
    }
}

L’idée est de s’approcher pas à pas du cas 0 (dont on connaît la parité) en appelant la fonction avec l’entier i-1. C’est ce qu’on appelle de la programmation récursive, rappelant l’idée d’une récurrence en mathématiques.

Bref, ce qui est important ici, c’est que ce code ne compile pas. En effet, comme le compilateur lit le code de haut en bas, il ne connaît pas la fonction est_impair lorsqu’on l’utilise dans est_pair. Et ça ne marche pas non plus en échangeant les deux fonctions, puisque est_impair appelle aussi est_pair.

Heureusement, nos fonctions ne sont pas à jeter puisque C++ est bien fait, et qu’il a donc une solution à ce problème : les prototypes.

La solution

Un prototype est une expression qui décrit toutes les informations qui définissent une fonction, à part son implémentation.

  • Son identificateur.
  • Son type de retour.
  • Ses paramètres.

On peut résumer en disant que c’est tout simplement la première ligne de la définition d’une fonction, à laquelle on ajoute un point-virgule.

type_de_retour identifiant(paramètres);

Ces informations sont suffisantes pour que le compilateur sache que la fonction existe et qu’il sache comment elle s’utilise. Cela nous permet alors d’écrire, pour notre exemple précédent :

bool est_impair(int i);

bool est_pair(int i)
{
    if (i == 0)
    {
        return true;
    }
    else if (i < 0)
    {
        return est_pair(-i);
    }
    else
    {
        return est_impair(i - 1);
    }        
}

bool est_impair(int i)
{
    if (i < 0)
    {
        return est_impair(-i);
    }
    else
    {
        return est_pair(i - 1);
    }
}

Et maintenant, ça compile !

Utilisons les prototypes

Les prototypes ne servent pas qu’à résoudre les problèmes d’appels croisés. En effet, ils peuvent être utilisés pour améliorer la lisibilité du code. Tout comme le compilateur, nous n’avons pas besoin de l’implémentation pour savoir comment s’utilise une fonction. En plus, le nom nous suffit souvent, lorsqu’il est bien choisi, pour savoir à quoi sert la fonction.

Constat : nous ne voulons pas voir l’implémentation. Derrière cette phrase un tantinet abusive, je veux dire que l’essentiel de l’information est réunie dans le prototype.

On peut donc tirer parti de cela pour rendre nos programmes plus lisibles. Par exemple, on peut mettre tous les prototypes avant la fonction main et reléguer les implémentations à la fin du fichier, juste après cette dernière. Ainsi, lorsque l’on veut utiliser une fonction, le prototype n’est plus noyé dans les implémentations et on peut en un coup d’œil récupérer toutes les informations qui nous intéressent.

Cette idée de faire valoir les interfaces plutôt que les implémentations est très importante en programmation et l’on aura l’occasion de la recroiser plusieurs fois.


En résumé

  • Une fonction permet de factoriser un morceau de code utilisé régulièrement. Nous en utilisons beaucoup depuis le début de ce cours.
  • Une fonction a un identifiant et peut prendre ou non plusieurs paramètres, ainsi que renvoyer ou non une valeur. Dans le cas où elle ne renvoie rien, elle est déclarée avec le mot-clef void.
  • Les références sont des alias de variables qui permettent, à travers un autre nom, de les manipuler directement. On s’en sert, notamment, dans les paramètres des fonctions pour éviter des copies inutiles ou pour conserver les modifications faites sur un objet.
  • Il faut faire attention à ce qu’une référence soit toujours valide. Cela nous interdit donc de renvoyer une référence sur une variable locale.
  • Le mot-clef auto ne conserve pas les références, mais le nouveau mot-clef que nous avons introduit, decltype, si.
  • La signature d’une fonction est ce qui la rend unique et est composée de son identifiant et de ses paramètres. On peut donc avoir plusieurs fonctions avec le même identifiant si les paramètres sont différents, ce qu’on appelle la surcharge.
  • Le prototype d’une fonction permet, entre autres, de résoudre le problème des appels croisés.
  • Le prototype contenant l’identificateur, le type de retour et les paramètres d’une fonction, il donne suffisamment d’informations pour ne pas avoir besoin de lire l’implémentation.