Tous droits réservés

Qui c'est qui va construire tout ça ?

Publié :

Dans le chapitre précédent, nous avons appris à réfléchir en terme de services et nous avons vu comment le faire concrètement. Mais la programmation orientée objet, ce n’est pas que ça, autrement elle ne vaudrait pas le détour. Dans ce chapitre, nous allons voir quelques problèmes qui existent avec notre classe Fraction et les techniques qui existent pour les résoudre et améliorer la qualité de notre code.

Ce chapitre va nous introduire aux invariants, aux constructeurs et à la gestion d’erreurs.

Encapsulation et invariants

Cette classe, c’est open bar

Notre classe Fraction a été définie et implémentée suivant une logique de services et c’est une bonne chose. Reste que, pour l’instant, nos attributs sont toujours visibles et librement modifiables. Quel intérêt d’aller au pressing si n’importe qui peut avoir accès aux machines à laver et mettre le bazar ?

Vous voulez un exemple d’erreur qu’on peut obtenir en faisant n’importe quoi ? Regardez le code ci-dessous.

int main()
{
    Fraction f1 { 4, 2 };
    std::cout << "4/2 = " << f1.valeur_reelle() << std::endl;

    // Erreur !
    f1.denominateur = 0;
    // Ça va faire boom !
    std::cout << "4/0 = " << f1.valeur_reelle() << std::endl;

    return 0;
}
4/2 = 2
4/0 = inf

Rappelons que diviser un entier par 0 est mathématiquement indéfini. Je peux donc obtenir tout et n’importe quoi comme résultat. Ici, j’obtiens l’infini, mais ça aurait très bien pu être autre chose (d’où le résultat peut-être différent que vous avez chez vous).

Le vrai problème, c’est qu’en modifiant directement un attribut, je m’attribue des droits qui ne sont pas les miens et je peux potentiellement tout casser. Si on part du principe que ma classe doit représenter fidèlement le concept mathématique de fraction, alors on est face à un problème, puisque mon objet n’est désormais plus valide.

Tout est question d’invariant

Justement, qu’est-ce qui me permet de dire que mon objet n’est plus valide ? C’est qu'il ne respecte plus une condition qui devrait toujours être vraie, celle que le dénominateur ne doit jamais valoir zéro. Cette condition, qu’on appelle invariant, c’est moi, le concepteur de la classe, qui l’ai définie.

J’aurais très bien pu dire que je m’en fiche et que ce n’est pas une erreur de diviser par zéro. Ce sont des choix de conception. Je veux implémenter une classe qui offre des services proches de ceux d’une fraction mathématiquement parlant, donc je décide qu’il y a toujours un invariant à respecter, celui que le dénominateur ne soit jamais nul.

Tant que cet invariant tient toujours, chaque instance de ma classe Fraction est supposée rendre les services qu’elle expose (simplification, puissance, etc). Par contre, s’il vient à être violé, alors tout peut arriver. C’est typiquement de la programmation par contrat, déjà abordée dans la partie II.

Souvenez-vous de ce que nous avons parlé dans le chapitre consacré aux fonctions. Nous avions vu qu’il existe les préconditions, qui doivent être respectées avant l’appel à une fonction. Nous avions aussi parlé des postconditions, qu’une fonction doit garantir quand elle termine de s’exécuter. Le principe est le même ici.

Vous voulez d’autres exemples ? Revenons à notre blanchisserie. Son invariant est que votre linge soit toujours en bon état. Si vous vous amusez à changer la lessive ou la programmation de la machine à laver vous-mêmes, sans rien y connaître, il ne faudra pas s’étonner que le linge soit décoloré ou rétréci.

L’invariant d’un carré, c’est que la longueur soit égale à la largeur. L’invariant d’un jeu de tarot, c’est qu’il y ait 56 cartes. L’invariant d’une date, c’est que les jours, les mois et les années renseignés soient valides. L’invariant d’une machine à café, c’est qu’elle ne fasse pas de court-jus. En fait, dès qu’il y a des conditions à respecter quand on veut créer un objet, c’est qu’il y a un ou plusieurs invariants.

Encapsulons tout ça

La solution pour respecter nos invariants s’appelle l’encapsulation. Plutôt que de laisser nos attributs librement modifiables par le premier venu, nous allons les protéger, les enrober d’une couche protectrice, d’une capsule. Seuls les services seront publics et seuls eux seront à même de modifier les attributs. Il faut pour cela changer leur visibilité. Il en existe trois, qui peuvent s’appliquer tant aux attributs qu’aux fonctions membres.

  • Ils peuvent être publics et accessibles depuis le reste du monde, par tout le monde.
  • Ils peuvent être privés et seule les instances de la classe peuvent les manipuler et les utiliser.
  • Ils peuvent être d’un troisième type, que nous verrons en temps voulu.
Un mot sur la visibilité

Ce concept de visibilité est à mettre en lien avec le découpage en fichiers d’en-têtes et fichiers sources. Dans les premiers, on trouve les fonctions, les classes, etc qu’un code peut appeler et utiliser. Ce sont les interfaces publiques, ce que le reste du monde voit. Au contraire, dans les fichiers sources, qui contiennent les détails d’implémentations, tout est masqué.

De la même manière, nos services seront publiquement exposés, alors que les détails d’implémentations seront cachés et inaccessibles par le reste du monde.

Par défaut, une structure a tous ses éléments publics, d’où le bug illustré précédemment. Heureusement, la visibilité n’est pas figée dans le marbre.

Modifier sa visibilité

Il existe deux mots-clés, public et private, qui permettent respectivement de rendre un élément public ou privé. C’est justement ce dont nous avons besoin. Grâce à public, nous pouvons permettre au reste du monde de faire appel à nos services. Grâce à private, on se protège en interdisant toute modification directe inappropriée.

Chaque mot-clé s’applique jusqu’à ce qu’on rencontre un autre modificateur de visibilité, ou jusqu’à la fin de la classe s’il n’y en a pas d’autres.

class Fraction 
{
    // Tout ce qui est en-dessous est maintenant public.
public:
    double valeur_reelle() const noexcept;
    void simplification() noexcept;
 
    // Tout ce qui est en-dessous est maintenant privé et caché.
private:
    int m_numerateur;
    int m_denominateur;
};
  • Les attributs sont maintenant préfixés par m_. Cela permet d’un coup d’œil de voir qu’on a affaire à un attribut privé. Cette convention n’est pas obligatoire, mais est très répandue dans le monde C++. Nous l’utiliserons dans la suite de ce cours.
  • Le mot-clé struct a été remplacé par class. Les deux sont interchangeables, la seule différence étant que, par défaut, tout est publique avec struct, alors que tout est privé avec class.
struct ou class ?

Les deux sont très proches et on peut sans problème adapter la visibilité dans les deux cas. Il y a néanmoins une convention répandue, qui stipule d’utiliser struct dans le cas d’une simple agrégation de données et de réserver class quand il y a présence d’au moins un invariant.

Par exemple, une structure Point, regroupant des coordonnées (x;y;z)(x;y;z), n’a pas vraiment d’invariant et peut être laissée comme telle. Au contraire, une fraction peut présenter au moins un invariant, celui de ne jamais avoir de dénominateur à zéro. C’est donc adapté pour une classe.

On cherche un constructeur

Si vous essayez de compiler le code ci-dessous, qui reprend toutes les modifications que nous venons de faire, vous allez obtenir une erreur. En effet, nous ne pouvons plus initialiser notre objet avec les valeurs que l’on souhaite.

#include <cmath>
#include <iostream>
#include <numeric>

class Fraction 
{
public:
    double valeur_reelle() const noexcept;
    void simplification() noexcept;

private:
    int m_numerateur;
    int m_denominateur;
};

double Fraction::valeur_reelle() const noexcept
{
    return static_cast<double>(m_numerateur) / m_denominateur;
}

void Fraction::simplification() noexcept
{
    int const pgcd { std::gcd(m_numerateur, m_denominateur) };
    m_numerateur /= pgcd;
    m_denominateur /= pgcd;
}

int main()
{
    Fraction const f1 { 5, 2 };
    std::cout << "5/2 = " << f1.valeur_reelle() << "\n";
    return 0;  
}
[Visual Studio]
Erreur C2440 'initialisation' : impossible de convertir de 'initializer list' en 'Fraction' TestCpp.cpp 30  
Erreur E0146 trop de valeurs d'initialiseur TestCpp.cpp 30  

------------------------------------------------------------

[GCC]
prog.cc: In function 'int main()':
prog.cc:44:30: error: no matching function for call to 'Fraction::Fraction(<brace-enclosed initializer list>)'
   44 |     Fraction const f1 { 5, 2 };
      |                              ^
prog.cc:5:7: note: candidate: 'Fraction::Fraction()'
    5 | class Fraction
      |       ^~~~~~~~
prog.cc:5:7: note:   candidate expects 0 arguments, 2 provided
prog.cc:5:7: note: candidate: 'constexpr Fraction::Fraction(const Fraction&)'
prog.cc:5:7: note:   candidate expects 1 argument, 2 provided
prog.cc:5:7: note: candidate: 'constexpr Fraction::Fraction(Fraction&&)'
prog.cc:5:7: note:   candidate expects 1 argument, 2 provided

------------------------------------------------------------

[Clang]
prog.cc:44:20: error: no matching constructor for initialization of 'const Fraction'
    Fraction const f1 { 5, 2 };
                   ^  ~~~~~~~~
prog.cc:5:7: note: candidate constructor (the implicit copy constructor) not viable: requires 1 argument, but 2 were provided
class Fraction 
      ^
prog.cc:5:7: note: candidate constructor (the implicit move constructor) not viable: requires 1 argument, but 2 were provided
prog.cc:5:7: note: candidate constructor (the implicit default constructor) not viable: requires 0 arguments, but 2 were provided
1 error generated.

Le message d’erreur de Clang est plus clair que celui de GCC, puisqu’il nous indique clairement qu’il ne trouve pas le bon constructeur. Cette fonction-membre spéciale est appelée dès l’instanciation d’une classe et s’occupe d'initialiser l’objet. Comme il s’agit du point d’entrée depuis l’extérieur, c’est ici qu’on va pouvoir définir nos invariants.

  • Si tous les invariants sont respectés, alors l’objet est correctement initialisé et utilisable.
  • Si un des invariants est violé, alors on a une erreur, qu’on va signaler à l’utilisateur de notre code.

Notez que certains constructeurs sont définis implicitement par le compilateur.

  • Le constructeur par défaut, que nous allons voir tout de suite. C’est celui que GCC désigne par Fraction::Fraction().
  • Le constructeur par copie, que nous verrons dans le chapitre suivant, de la forme constexpr Fraction::Fraction(const Fraction&).
  • Le constructeur par mouvement, que nous verrons également, mais plus tard. C’est celui qui reste, constexpr Fraction::Fraction(Fraction&&).

La syntaxe

Tous les constructeurs d’une classe partagent la même syntaxe, ce qui va simplifier les choses pour vous. En fait, seuls les paramètres et leur nombre changent. Examinons ce que nous apprend GCC.

Déjà, aucun constructeur n’a de type de retour, pas même void. Le prototype du constructeur est donc constitué uniquement du nom de la classe, plus les éventuels arguments. Et comme pour les autres fonctions membres, on retrouve le nom de la classe et l’opérateur de résolution de portée ::, comme nous l’avons vu juste à l’instant avec le message de GCC. Enfin, on peut retrouver constexpr et/ou noexcept, si l’on le désire.

L’autre point surprenant, c’est que l’initialisation des attributs se fait directement en-dessous de sa signature. On appelle cette syntaxe la liste d’initialisation des membres (en anglais member initializer lists). Voyez par vous-même l’application concrète à notre classe Fraction.

#include <cassert>
#include <cmath>
#include <iostream>
#include <numeric>

class Fraction 
{
public:
    // Pas d'exception car on ne fait qu'initialiser deux entiers.
    Fraction(int numerateur, int denominateur) noexcept;
    double valeur_reelle() const noexcept;
    void simplification() noexcept;

private:
    int m_numerateur;
    int m_denominateur;
};

// Le qualificateur noexcept se répète aussi ici.
Fraction::Fraction(int numerateur, int denominateur) noexcept
    : m_numerateur(numerateur), m_denominateur(denominateur)
{
    assert(m_denominateur != 0 && "Le dénominateur ne peut pas valoir 0.");
}

double Fraction::valeur_reelle() const noexcept
{
    return static_cast<double>(m_numerateur) / m_denominateur;
}

void Fraction::simplification() noexcept
{
    int const pgcd { std::gcd(m_numerateur, m_denominateur) };
    m_numerateur /= pgcd;
    m_denominateur /= pgcd;
}

int main()
{
    Fraction const f1 { 5, 2 };
    std::cout << "5/2 = " << f1.valeur_reelle() << "\n";
    return 0;  
}

L’avantage de passer ainsi par un constructeur pour faire ça, c’est qu’on peut vérifier nos contrats AVANT de créer l’objet. Ainsi, si on reçoit un argument incorrect ou si une erreur externe survient, il est possible de réagir immédiatement, avec une assertion ou une exception.

Tout comme nous avions des assert pour vérifier les préconditions de nos fonctions libres, nous avons les constructeurs qui, en plus d’initialiser l’objet et de définir ses invariants, vont vérifier qu’il est possible de le créer à l’aide de préconditions.

Constructeur par défaut

Appelé en anglais default constructor, c’est lui qui est appelé quand on instancie une classe sans fournir d’argument. Par exemple, quand nous écrivons std::string chaine {} ou std::vector tableau {}, nos instances sont initialisées par défaut. Dans le premier cas, on obtient une chaîne de caractères vide, dans l’autre un tableau vide.

Par défaut, le compilateur en génère automatiquement un, sauf si des constructeurs personnalisés sont définis. Le problème, c’est qu’il est très basique. Pour tous les attributs objets, comme std::string ou std::vector, il se contente d’appeler leur constructeur par défaut. Par contre, dans le cas des types natifs, rien n’est fait, leur valeur est indéterminée, elle peut valoir n’importe quoi. Cela peut mener à quelques surprises, comme dans le cas de la classe Fraction.

int main()
{
    Fraction const f1 {};
    std::cout << "Valeur réelle par défaut = " << f1.valeur_reelle() << "\n";
    return 0;  
}
[Visual Studio]
Valeur réelle par défaut = -nan(ind)

------------------------------------------------------------

[GCC]
Valeur réelle par défaut = -nan

------------------------------------------------------------

[Clang]
Valeur réelle par défaut = -nan

Le code ci-dessus est buggué parce que m_numerateur et m_denominateur peuvent potentiellement valoir n’importe quoi, d’où le résultat étrange. Notre programme n’est donc pas très robuste, puisqu’il est impossible de prévoir le résultat.

Moralité, le constructeur par défaut ne nous plaît pas. Nous devons donc réfléchir et déterminer si créer une « fraction par défaut » a, ou non, du sens. À partir de cette réflexion, on part dans deux directions.

Initialiser une instance par défaut a du sens

On peut décider, à bon droit, qu’une fraction sans plus autre détail vaut 0, ou 1, ou 42, ou encore 97455. C’est notre choix en tant que concepteur. Pour cet exemple, nous déciderons qu’une fraction vaut par défaut zéro. On peut directement initialiser nos attributs et nous résolvons le problème sans même avoir à écrire nous-mêmes le constructeur par défaut.

class Fraction
{
private:
    int m_numerateur { 0 };
    int m_denominateur { 1 };
}

Dans le cas où le constructeur par défaut fait son travail, pas la peine de le redéfinir, cela ne ferait que rajouter de la lourdeur au code. On peut simplement demander explicitement au compilateur de le générer, ce qui rend le code plus clair. Cela se fait en rajoutant = default après la signature.

class Fraction
{
public:
    Fraction() = default;

private:
    int m_numerateur;
    int m_denominateur;
}

Initialiser une instance par défaut n’a pas de sens

On peut aussi très bien décider, à l’opposé, que l’utilisateur doit explicitement initialiser toute fraction qu’il souhaite créer. C’est aussi un choix valable. Dans ce cas, on va lui interdire l’utilisation du constructeur par défaut.

Si la classe contient d’autres constructeurs, le constructeur par défaut n’est pas implicitement créé, donc impossible de l’utiliser. Mais comme la clarté est une bonne pratique que nous nous efforçons de vous transmettre, nous allons dire explicitement au compilateur que nous n’en voulons pas. Cela se fait en ajoutant = delete à la fin du prototype du constructeur par défaut.

class Fraction
{
public:
    Fraction() = delete;

private:
    int m_numerateur;
    int m_denominateur;
};

Voici l’erreur que vous obtiendrez si vous essayez quand même d’utiliser le constructeur par défaut.

[Visual Studio]
Erreur C2280 'Fraction::Fraction(void)' : tentative de référencement d'une fonction supprimée
Erreur (active) E1790 impossible de faire référence au constructeur par défaut de "Fraction"    

------------------------------------------------------------

[GCC]
main.cpp: In function 'int main()':
main.cpp:26:18: error: use of deleted function 'Fraction::Fraction()'
   26 |     Fraction f1 {};
      |                  ^
main.cpp:10:5: note: declared here
   10 |     Fraction() = delete;
      |     ^~~~~~~~
      
------------------------------------------------------------

[Clang]
prog.cc:26:14: error: call to deleted constructor of 'Fraction'
    Fraction f1 {};
             ^  ~~
prog.cc:10:5: note: 'Fraction' has been explicitly marked deleted here
    Fraction() = delete;
    ^
1 error generated.
Pour ou contre

Le choix de proposer ou non un constructeur par défaut dépend de vos choix de conception. Dans certains cas, ça a du sens, comme une chaîne de caractères vide. Dans d’autres pas forcément. Nous aborderons de nouveau ce point plus tard.

Soyons explicites

Pour pouvoir obtenir le nombre réel correspondant à la division de deux entiers, nous avons utilisé l’opérateur static_cast, qui permet de faire une conversion explicite d’un type vers un autre, ici de int vers double. Mais qui dit explicite dit souvent implicite. En effet, C++ permet de nombreuses conversions implicites. C’est par exemple le cas dans la ligne suivante, où un caractère est implicitement converti en entier.

int const entier { 'A' };

De telles conversions peuvent aussi se produire lors de l’appel d’un constructeur à un argument (ou plusieurs, mais avec des valeurs par défaut). Parfois, ça nous rend service, comme lorsqu’on manipule des chaînes de caractères avec std::string.

#include <string>

class Mot
{
public:
    Mot(std::string const & mot);
private:
    std::string m_mot;
};

Mot::Mot(std::string const & mot)
    : m_mot(mot)
{
}

using namespace std::string_literals;

int main()
{
    // Conversion implicite d'une chaîne de caractères C en std::string au moment de l'appel au constructeur.
    Mot const arbre { "arbre" };

    // Pas de conversion car argument de type std::string.
    Mot const agrume { "agrume"s };

    return 0;  
}

L’exemple ci-dessus montre que les conversions implicites peuvent être utiles et même désirées. Ici, l’utilisateur peut passer au choix un littéral comme en C, ou bien un littéral de type std::string. On lui offre un plus grand choix, une plus grande liberté, sans rendre le code plus compliqué ou moins clair.

Mais d’autres fois, on veut empêcher cette conversion implicite. Imaginez par exemple une classe représentant un colis. En fonction de son poids et de plein d’autres paramètres, comme l’adresse, le mode de livraison, etc, le prix à payer varie. Modélisons tout ça (le strict minimum).

#include <cassert>
#include <iostream>

enum class ModeLivraison
{
    Standard,
    Express,
    Urgent,
    Economique
};

class Adresse
{
    // Juste pour l'exemple.
};

class Colis
{
public:
    Colis(double poids);
private:
    double m_poids { 0. };
};

Colis::Colis(double poids)
    : m_poids(poids)
{
    assert(poids >= 0.1 && "Le poids ne peut pas être inférieur à 100 g.");
}

double calcul_prix(Colis const & colis, Adresse const & adresse, ModeLivraison mode) 
{
    // Plein d'opérations complexes pour calculer le prix à payer pour le colis.
    return 10;
}

int main()
{
    Adresse adresse_livraison {};
    std::cout << "Voici le prix à payer : " << calcul_prix(4, adresse_livraison, ModeLivraison::Express) << "\n";
    return 0;
}
Voici le prix à payer : 10

Notez l’appel à la fonction libre calcul_prix(4, adresse_livraison, ModeLivraison::Express). C’est ici que se niche le problème. En effet, le premier argument est sensé être une référence sur un objet Colis const, mais pourtant le compilateur ne bronche pas qu’on lui ait donné un entier. Normal, il fait appel à un constructeur de conversion.

Dans notre cas, la conversion implicite qu’il fait d’un entier vers un Colis nuit à la lisibilité du code. On ne comprend pas tout de suite à quoi on a affaire en voyant ce littéral 4. Mieux vaut interdire la conversion implicite, ce qui se fait en utilisant le mot-clé explicit devant le prototype du constructeur (attention, il est absent de devant l’implémentation).

explicit Colis(double poids);

Maintenant, le compilateur va nous signaler que passer un entier est invalide.

[Visual Studio]
Erreur C2664 'double calcul_prix(const Colis &,const Adresse &,ModeLivraison)' : impossible de convertir l'argument 1 de 'int' en 'const Colis &'
Erreur (active) E0415 il n'existe aucun constructeur approprié pour la conversion de "int" en "Colis"

------------------------------------------------------------

[GCC]
main.cpp: In function 'int main()':
main.cpp:41:61: error: invalid initialization of reference of type 'const Colis&' from expression of type 'int'
   41 |     std::cout << "Voici le prix à payer : " << calcul_prix(4, adresse_livraison, ModeLivraison::Express) << "\n";
      |                                                             ^
main.cpp:32:34: note: in passing argument 1 of 'double calcul_prix(const Colis&, const Adresse&, ModeLivraison)'
   32 | double calcul_prix(Colis const & colis, Adresse const & adresse, ModeLivraison mode)
      |                    ~~~~~~~~~~~~~~^~~~~

------------------------------------------------------------

[Clang]
prog.cc:41:49: error: no matching function for call to 'calcul_prix'
    std::cout << "Voici le prix à payer : " << calcul_prix(4, adresse_livraison, ModeLivraison::Express) << "\n";
                                               ^~~~~~~~~~~
prog.cc:32:8: note: candidate function not viable: no known conversion from 'int' to 'const Colis' for 1st argument
double calcul_prix(Colis const & colis, Adresse const & adresse, ModeLivraison mode) 
       ^
1 error generated.
explicit ou pas ?

Là encore, le choix de déclarer un constructeur comme étant explicite ne dépend que de vous et de vos choix de conception. On recommande souvent de définir comme explicit tout constructeur prenant un seul argument (ce qui inclut ceux ayant plusieurs paramètres optionnels), car les conversions implicites peuvent nuire à la compréhension du code.

Néanmoins, dans certains cas, elles sont appréciables. Par exemple, on peut très bien passer un entier 5 à une fonction attendant un nombre complexe (de type std::complex) car un entier est un cas particulier de nombre complexe (avec une partie imaginaire nulle). Il faut donc peser à chaque fois le pour et le contre.

En toute amitié

Avez-vous noté que, depuis le début de ce chapitre, j’ai volontairement enlevé la fonction pow ? C’est que, maintenant que nous avons encapsulé nos attributs, nous n’y avons plus accès en dehors de la classe. Comme pow est une fonction libre et ne fait donc pas partie de la classe Fraction, elle ne peut plus accéder aux attributs m_numerateur et m_denominateur.

#include <cassert>
#include <cmath>
#include <iostream>
#include <numeric>

class Fraction 
{
public:
    Fraction(int numerateur, int denominateur) noexcept;
    double valeur_reelle() const noexcept;
    void simplification() noexcept;
    Fraction pow(Fraction const & fraction, int puissance);

private:
    int m_numerateur;
    int m_denominateur;
};

Fraction::Fraction(int numerateur, int denominateur) noexcept
    : m_numerateur(numerateur), m_denominateur(denominateur)
{
    assert(m_denominateur != 0 && "Le dénominateur ne peut pas valoir 0.");
}

double Fraction::valeur_reelle() const noexcept
{
    return static_cast<double>(m_numerateur) / m_denominateur;
}

void Fraction::simplification() noexcept
{
    int const pgcd { std::gcd(m_numerateur, m_denominateur) };
    m_numerateur /= pgcd;
    m_denominateur /= pgcd;
}

Fraction pow(Fraction const & fraction, int puissance)
{
    Fraction copie { fraction };
    copie.m_numerateur = std::pow(fraction.m_numerateur, puissance);
    copie.m_denominateur = std::pow(fraction.m_denominateur, puissance);
    return copie;
}

int main()
{
    Fraction const f1 { 5, 2 };
    Fraction const f2 { pow(f1, 3) }; 
    return 0;
}
[Visual Studio]
Erreur (active) E0265 membre "Fraction::m_numerateur" (déclaré à la ligne 15) est inaccessible
Erreur (active) E0265 membre "Fraction::m_denominateur" (déclaré à la ligne 16) est inaccessible    
Erreur (active) E0265 membre "Fraction::m_denominateur" (déclaré à la ligne 16) est inaccessible
Erreur C2248 'Fraction::m_numerateur' : impossible d'accéder à private membre déclaré(e) dans la classe 'Fraction'
Erreur C2248 'Fraction::m_numerateur' : impossible d'accéder à private membre déclaré(e) dans la classe 'Fraction'

------------------------------------------------------------

[GCC]
prog.cc: In function 'Fraction pow(const Fraction&, int)':
prog.cc:40:11: error: 'int Fraction::m_numerateur' is private within this context
   40 |     copie.m_numerateur = std::pow(fraction.m_numerateur, puissance);
      |           ^~~~~~~~~~~~
prog.cc:15:9: note: declared private here
   15 |     int m_numerateur;
      |         ^~~~~~~~~~~~
prog.cc:40:44: error: 'int Fraction::m_numerateur' is private within this context
   40 |     copie.m_numerateur = std::pow(fraction.m_numerateur, puissance);
      |                                            ^~~~~~~~~~~~
prog.cc:15:9: note: declared private here
   15 |     int m_numerateur;
      |         ^~~~~~~~~~~~
prog.cc:41:11: error: 'int Fraction::m_denominateur' is private within this context
   41 |     copie.m_denominateur = std::pow(fraction.m_denominateur, puissance);
      |           ^~~~~~~~~~~~~~
prog.cc:16:9: note: declared private here
   16 |     int m_denominateur;
      |         ^~~~~~~~~~~~~~
prog.cc:41:46: error: 'int Fraction::m_denominateur' is private within this context
   41 |     copie.m_denominateur = std::pow(fraction.m_denominateur, puissance);
      |                                              ^~~~~~~~~~~~~~
prog.cc:16:9: note: declared private here
   16 |     int m_denominateur;
      |   

------------------------------------------------------------

[Clang]
prog.cc:40:11: error: 'm_numerateur' is a private member of 'Fraction'
    copie.m_numerateur = std::pow(fraction.m_numerateur, puissance);
          ^
prog.cc:15:9: note: declared private here
    int m_numerateur;
        ^
prog.cc:40:44: error: 'm_numerateur' is a private member of 'Fraction'
    copie.m_numerateur = std::pow(fraction.m_numerateur, puissance);
                                           ^
prog.cc:15:9: note: declared private here
    int m_numerateur;
        ^
prog.cc:41:11: error: 'm_denominateur' is a private member of 'Fraction'
    copie.m_denominateur = std::pow(fraction.m_denominateur, puissance);
          ^
prog.cc:16:9: note: declared private here
    int m_denominateur;
        ^
prog.cc:41:46: error: 'm_denominateur' is a private member of 'Fraction'
    copie.m_denominateur = std::pow(fraction.m_denominateur, puissance);
                                             ^
prog.cc:16:9: note: declared private here
    int m_denominateur;
        ^

4 errors generated.

Pour résoudre ce problème, on peut partir sur plusieurs solutions.

  1. On peut rendre les attributs publics.
  2. On peut ajouter des services numerateur et denominateur, qui retournent les attributs respectifs, ainsi que des services changer_numerateur et changer_denominateur
  3. On peut utiliser l’amitié.

Attributs publics

Concernant la première solution, autant pour m_numerateur, cela ne causerait pas de problème, autant pour m_denominateur, nous prendrions le risque que l’invariant puisse être violé.

Accesseurs

La deuxième solution peut sembler bonne, à première vue. Seulement voilà, il faut pour cela ajouter quatre nouveaux services à notre classe : deux accesseurs / getters (de l’anglais to get, qui veut dire « obtenir »), qui permettent d’obtenir un accès en lecture aux attributs m_numerateur et m_denominateur, ainsi que deux mutateurs / setters (de l’anglais to set, qui signifie « mettre à jour, modifier »), qui permettent eux de les modifier.

Déjà, on sait que plus on écrit de code, plus on a de chance d'introduire des bugs par inadvertance. Et plus une classe contient de services, plus elle sera difficilement maintenable et demandera plus d’efforts pour s’assurer qu’elle est correcte.

De plus, en utilisant cette solution, on dévoile des détails d’implémentation, puisque chaque attribut fait l’objet d’un accesseur et d’un mutateur. On ne pense plus en terme de comportements, de services, mais de nouveau en simple agrégat de données.

Enfin, les services que l’on est amené à écrire n’ont pas toujours du sens. Prenons l’exemple d’une classe Lampe. On peut imaginer récupérer la puissance de la lampe, c’est un service qui peut être attendu. Par contre, que dire d’un mutateur qui permettrait de modifier sa puissance ? Soit le setter ne fait aucune vérification, auquel cas pourquoi ne pas rendre l’attribut public ? Soit on a des préconditions, mais qui auront vraisemblablement déjà été vérifiées dans le constructeur, ce qui veut dire duplication de code, avec plus de risque de bugs, d’erreurs, etc.

Et parfois, cette solution n’est tout simplement pas envisageable. C’est le cas si on a plusieurs invariants à respecter. Si je rajoute à ma classe Fraction l’invariant « La fraction doit toujours être sous sa forme simplifiée » alors le code suivant ne permet pas de faire ce qu’on veut.

Fraction f1 { 3, 2 };
// On obtient 4/2 qui se simplifie tout de suite en 2/1.
f1.setNumerateur(4);

Fraction f2 { 3, 2 };
// On obtient 3/3 qui se simplifie tout de suite en 1/1.
f2.setDenominateur(3);

Fraction f3 { 3, 2 };
// On obtient 2/3.
f3.setNumerateur(4);
f3.setDenominateur(3);

Fraction f4 { 3, 2 };
// On obtient 4/1.
f4.setDenominateur(3);
f4.setNumerateur(4);

// Le seul moyen possible de passer à 4/3.
f1 = Fraction { 4, 3 };

La troisième, c’est la bonne

Il nous reste donc la troisième solution, l’amitié. Dire d’une fonction qu’elle est amie de la nôtre signifie qu’elle a un droit d’accès privilégié à ladite classe, tant en lecture qu’en écriture. Elle fait partie intégrante de l’interface, des services offerts par cette classe.

L’amitié se traduit en C++ avec le mot-clé friend, qui s’écrit devant le prototype, que l’on place à l’intérieur de la classe et non plus à l’extérieur, comme pour une fonction libre non-amie. Ensuite, il n’y a plus qu’à implémenter la fonction de la même manière qu’avant.

class Fraction
{
public:
    friend Fraction pow(Fraction const & fraction, int puissance);
};

// Aucun problème maintenant.
Fraction pow(Fraction const & fraction, int puissance)
{
    Fraction copie { fraction };
    copie.numerateur = std::pow(fraction.numerateur, puissance);
    copie.denominateur = std::pow(fraction.denominateur, puissance);
    return copie;
}

Je le répète, mais les fonctions amies ne sont pas des fonctions externes venues « squatter » notre classe. Bien au contraire, elles sont les services que rend la classe, au même titre que les fonctions membres. Elles font donc partie de ce qu’on a défini comme l’interface de la classe, tout en gardant « l’aspect syntaxique » d’une fonction libre.

Bien entendu, avec de grands pouvoirs viennent de grandes responsabilités. Nous avons certes un accès privilégié à la classe, mais cela implique que nous devons respecter ses invariants, comme n’importe quelle autre fonction membre.

Exercices

Un autre constructeur pour la classe Fraction

On pourrait très bien vouloir instancier une fraction à partir d’une chaîne de caractères, comme "35/8". Il nous suffit d’écrire un autre constructeur. Demandons-nous simplement quels seront les cas d’erreurs possibles.

  • Pas de numérateur.
  • Pas de dénominateur.
  • Pas de séparateur, ou caractère invalide.
  • Trop de séparateur.
  • Dénominateur qui vaut 0.

Traduit en code, on obtient ceci.

int main()
{
    // Doit compiler.
    Fraction f1 { "4/2" };
    assert(f1.valeur_reelle() == 2 && "La valeur réelle de f1 doit être 2.");
    
    // Doit générer une erreur.
    //Fraction f2 { "4\2" };
    
    // Doit générer une erreur.
    //Fraction f3 { "4///2" };

    // Doit générer une erreur.
    //Fraction f4 { "4/" };

    // Doit générer une erreur.
    //Fraction f5 { "/2" };

    // Doit générer une erreur.
    //Fraction f6 { "4/0" }; 

    return 0;
}
Correction

Voici une façon de faire parmi d’autres. Notez que je n’utilise pas la liste d’initialisation, car le code est plus complexe qu’une simple affectation et qu’il y a un peu de travail avant d’extraire le numérateur et le dénominateur.

Comme certaines fonctions de la bibliothèque standard n’ont pas la garantie noexcept (comme std::begin) et que je n’essaye pas de rattraper une potentielle exception, je ne peux pas garantir que mon constructeur est noexcept.

Fraction::Fraction(std::string const & fraction)
{
    // Vérification que la chaîne n'est pas vide.
    assert(!std::empty(fraction) && "La chaîne ne peut pas être vide.");

    // Vérification de la présence du séparateur /.
    auto debut = std::begin(fraction);
    auto fin = std::end(fraction);
    assert(std::count(debut, fin, '/') == 1 && "Il ne doit y avoir qu'un seul séparateur '/'.");
    auto iterateur = std::find(debut, fin, '/');

    // Récupération du numérateur.
    std::string const n { debut, debut + iterateur };
    assert(!std::empty(n) && "Le numérateur doit être renseigné.");
    m_numerateur = std::stoi(n);

    // Pour sauter le caractère /.
    ++iterateur;
    // Récupération du dénominateur.
    std::string const d { iterateur, fin };
    assert(!std::empty(d) && "Le dénominateur doit être renseigné.");
    m_denominateur = std::stoi(d);
    assert(m_denominateur != 0 && "Le dénominateur ne peut pas être nul.");
}

Pile

Une pile est une structure de données qui fonctionne sur le principe du « dernier arrivé, premier parti. » On peut lui ajouter ou supprimer des éléments, mais à chaque fois c’est le dernier élément qui est modifié. On ne peut pas directement supprimer le premier élément, par exemple. Il faut d’abord supprimer tous les autres.

On peut par exemple modéliser l’ordre des appels à des fonctions en C++ avec une pile. D’abord, il y aura main, puis une autre fonction appelée dans main, puis une autre, etc. Une fois qu’une fonction a fini son exécution, on la retire de la pile et on revient à la fonction précédente, jusqu’à ce que la pile soit vide, quand le programme est terminé.

Voici une liste des opérations que nous allons implémenter.

  • Ajouter un élément.
  • Retirer le dernier élément.
  • Obtenir un accès au premier élément.
  • Obtenir le nombre total d’éléments.
  • Déterminer si la pile est vide ou non.

Avec ces spécifications, nous pouvons maintenant définir un jeu de tests, qui nous servira à tester que notre code est correct et fonctionnel.

int main()
{
    Pile pile {};
    assert(pile.est_vide() && "La pile est vide.");
    assert(pile.taille() == 0 && "Au début, la pile n'a aucun élément.");

    pile.ajouter(5);
    pile.ajouter(10);
    pile.ajouter(15);
    assert(pile.taille() == 3 && "Maintenant, la pile a trois éléments.");

    int const premier { pile.premier() };
    assert(premier == 5 && "Le premier élément vaut 5.");
    assert(pile.taille() == 3 && "La pile a toujours trois éléments.");

    pile.supprimer();
    assert(pile.taille() == 2 && "Maintenant, la pile n'a plus que deux éléments.");
    assert(!pile.est_vide() && "La pile n'est pas vide.");

    pile.supprimer();
    pile.supprimer();
    assert(pile.est_vide() && "Maintenant la pile est vide.");

    return 0; 
}
Correction

En soit, le code n’est pas très compliqué car nous nous basons entièrement sur std::vector. Nous nous contentons juste de rajouter des contraintes, celles inhérentes aux piles.

Enfin, sachez que les piles existent déjà en C++, avec std::stack. Donc, à l’avenir, quand vous utiliserez des piles, ne reprenez pas cet exercice, utilisez la bibliothèque standard. ;)

Pile.hpp
#ifndef PILE_HPP
#define PILE_HPP

#include <vector>

class Pile
{
public:
    void ajouter(int valeur);
    void supprimer();
    // Pas de noexcept car en interne, std::vector ne fournit pas la garantie que récupérer un élément ne lancera pas d'exception.
    int premier() const;
    bool est_vide() const noexcept;
    std::size_t taille() const noexcept;

private:
    std::vector<int> m_pile;
};

#endif
Pile.cpp
#include <cassert>
#include "Pile.hpp"

void Pile::ajouter(int valeur)
{
    m_pile.push_back(valeur);
}

void Pile::supprimer()
{
    assert(m_pile.size() > 0 && "Impossible de supprimer un élément d'une pile vide."); 
    m_pile.pop_back();
}

int Pile::premier() const
{
    assert(m_pile.size() > 0 && "Impossible de récupérer le premier élément d'une pile vide."); 
    return m_pile[0];
}

bool Pile::est_vide() const noexcept
{
    return m_pile.empty();
}

std::size_t Pile::taille() const noexcept
{
    return m_pile.size();
}

En résumé

  • Les classes peuvent posséder des invariants, des contrats, qui doivent être respectés.
  • Grâce à l’encapsulation et aux différentes visibilités, il est possible de n’exposer que les services que l’on veut, tout en protégeant les invariants de la classe.
  • Une convention C++ répandue incite à préfixer les attributs privés avec m_.
  • Le constructeur permet d’initialiser l’objet, tout en définissant quels seront les invariants de la classe.
  • Si aucun constructeur n’est défini, le compilateur implémente un constructeur par défaut.
  • On peut initialiser directement des attributs, ce qui permet dans certains cas de se passer du constructeur par défaut.
  • On peut demander explicitement au compilateur de supprimer le constructeur par défaut.