Bonnes pratiques C++ (6/n)

Ce billet appartient à une série consacrée aux bonnes pratiques de développement C++. Certains sont toutefois applicables à d'autres langages. Aujourd’hui : la programmation objet.

Soignez votre modélisation objet

La programmation objet est née avec le langage Smalltalk dans les années 1970 et elle a été nommée ainsi parce que dans l’idée de ses concepteurs, il s’agissait de programmer en simulant le monde réel, c’est-à-dire en modélisant des objets physiques auxquels on peut appliquer des actions et qui interagissent entre eux. La métaphore reste valable avec le C++ moderne. Si votre application manipule des concepts qui s’apparentent à des objets physiques, créer des classes pour représenter chacun de ces concepts est probablement une bonne idée. Par exemple : une couleur, une date, un fichier, un utilisateur, un article sur un site marchand, le panier dans lequel stocker cet article, un bon de commande, une facture, une fenêtre à l’écran, des composants graphiques tels que des boutons ou des listes, une base de données, une exception ou une erreur, sont à coup sûr des concepts qui méritent que vous les implémentiez sous forme de classes.

Mais bien sûr, implémenter une classe pour chaque concept que l’application manipule ne suffit pas. Il faut aussi implémenter du code pour coordonner le fonctionnement de ces objets. Par exemple il peut s’agir, en réponse à un clic sur un objet Button, d’extraire le texte contenu dans un objet TextField pour le stocker dans un objet Record qui sera inséré ultérieurement dans un objet Database. Ce genre de code appartient typiquement à ce qu’on appelle un contrôleur. Un contrôleur est une classe qui ne modélise pas réellement un objet physique mais dont le rôle est plutôt de connecter et de faire fonctionner ensemble d’autres objets. Ainsi, dans l’architecture de la plupart des interfaces graphiques (Windows, macOS, Android…) à chaque fenêtre est souvent associé un objet contrôleur qui implémente les interactions entre les différents éléments UI contenus dans cette fenêtre et la couche métier de l’application.

Une autre catégorie de classes très utiles dans une application sont les façades : il s’agit de classes qui offrent une façade objet facile à utiliser à des bibliothèques tierces ou à une API système qui elles, ne sont pas orientées objets. Encapsuler ainsi un jeu de fonctions C externes dans une classe C++ présente plusieurs intérêts.

  • La gestion des ressources. Beaucoup d’API fonctionnent suivant un schéma qui consiste à allouer une ressource, l’utiliser, puis la libérer. Il peut s’agir d’un fichier (fopen, fread / fwrite et fclose sont l’illustration parfaite de ce fonctionnement), d’un socket, d’un bloc mémoire… En encapsulant cette ressource dans un objet, puis en l’allouant dans le constructeur et en la libérant dans le destructeur, on lie le cycle de vie de la ressource à celui de l’objet. Dans cet exemple un peu artificiel, un objet Chunk gère un bloc mémoire :
class Chunk {
public:
    Chunk(size_t s) : m_ptr(malloc(s)) {
    }

    ~Chunk() {
        free(m_ptr);
    }

private:
  uint8_t * m_ptr;
};

Tout l’intérêt d’une telle façon de faire est que C++ gère automatiquement le cycle de vie des objets. Ils sont créés lorsqu’ils entrent dans la portée courante et détruits lorsqu’ils en sortent, sans qu’il y ait besoin d’écrire de code, y compris lorsque cette sortie se fait par des voies inhabituelles : instruction break dans le bloc où est déclaré l’objet, instruction return au milieu d’une fonction, envoi d’exception, etc. Et comme la ressource est liée à l’objet, la gestion du cycle de vie de cette ressource devient automatique. Il devient impossible d’oublier de fermer un fichier ou de libérer un bloc mémoire. (Ce pattern s’appelle RAII et une section y sera consacrée plus loin.)

  • Moderniser une API. Certaines bibliothèques ont une sémantique marquée par l’héritage historique du C, notamment en faisant un usage peu sûr des types (du moins selon les normes actuelles). Encapsuler de telles API derrière une façade robuste et moderne en C++ peut la rendre plus facile à utiliser et surtout, grâce à un typage plus rigoureux, plus difficile à mal utiliser. C’est par exemple ce que font les MFC, entre autres choses : elles encapsulent l’API Win32, qui est fondamentalement non typée (vous pouvez passer un handle de fenêtre à une fonction qui attend un handle de fichier sans que le compilateur n’y trouve rien à redire), dans un modèle objet fortement typé, plus sûr.
  • Unifier une API. Certaines bibliothèques sont disponibles sur plusieurs plateformes mais avec de légères différences qui s’avèrent agaçantes lorsqu’il faut écrire du code portable. Les sockets en sont un bon exemple : l’implémentation Berkeley disponibles sur tous les systèmes UNIX et l’implémentation Win32 disponible sous Windows fonctionnent globalement de la même manière, mais il y a de nombreuses différences dans la façon de faire certaines choses, dans le nom des fonctions et dans le type de leurs paramètres, dans les codes d’erreur, etc. Il s’avère alors pratique d’implémenter une classe Socket pour dissimuler ces détails techniques et proposer au reste de l’application une façade propre et unifiée. Ceci s’obtient typiquement par compilation conditionnelle :
class Socket {
private:
#ifdef WIN32
    SOCKET m_socket;
#else
    int    m_socket;
#endif

public:
    Socket() {
#ifdef WIN32
        m_socket = INVALID_SOCKET;
#else
        m_socket = -1;
#endif
    }

    ~Socket() {
#ifdef WIN32
        closesocket(m_socket);
#else
        close(m_socket);
#endif
    }

    // reste de la classe omis par souci
    // de brièveté
};

Enfin, une application contient généralement un certain nombre de classes utilitaires. Il s’agit de briques logicielles apportant une fonctionnalité précise qui ne tombe pas dans les catégories précédentes, comme un parseur XML ou JSON, un algorithme de compression ou de cryptographie, un pool de threads, ou encore un pilote pour un hardware spécifique. Pour prolonger la section précédente sur l’architecture, ces classes utilitaires appartiennent presque toujours aux couches les plus basses de l’application.

Cette distinction entre modèles d’objets physiques, contrôleurs, façades et classes utilitaires est fonctionnelle : il s’agit de décrire à quoi servent les classes, quel est leur rôle et quelle place elles occupent dans l’application. Mais il est nécessaire de considérer aussi les classes sous un aspect technique, à savoir : comment elles sont utilisées dans le code et avec quelle sémantique. Selon les réponses à ces questions, des précautions sont à prendre et des règles sont à respecter.

Les classes les plus courantes sont les classes de valeur. Elles stockent des valeurs, à la façon des types de base. On s’attend à pouvoir les utiliser de la même façon et dans les mêmes contextes qu’un int ou un double. On va les retrouver comme opérandes dans des expressions, comme type d’une variable ou comme paramètre d’une fonction. Une classe de valeur :

  • Possède obligatoirement un constructeur copie et implémente obligatoirement l’opérateur d’assignation = ;
  • Surcharge souvent des opérateurs, en particulier les opérateurs de comparaison == et != ;
  • N’est pas conçue pour être dérivée et notamment, ne possède aucune fonction virtuelle ;
  • Est habituellement instanciée sur la pile en tant que variable locale, ou bien dans un objet en tant que variable membre, mais rarement (si ce n’est jamais) par new ou std::make_shared.

Les classes modélisant des objets physiques sont typiquement des classes de valeur. Une matrice, un vecteur, un nombre complexe, une date, une couleur RGB, sont des valeurs que l’on souhaite manipuler à la façon de nos bons vieux types de base. La STL en propose beaucoup : std::pair, std::string ou std::vector, pour n’en citer que quelques-unes.

Même si en l’état actuel votre application n’a pas besoin de toutes les fonctionnalités décrites ci-dessus, il est vivement conseillé de les implémenter. (D’autant que c’est le plus souvent trivial.) Le constructeur copie ou l’opérateur d’assignation notamment sont souvent appelés par le système à des moments que vous ne soupçonnez pas et il vaut mieux les fournir vous-mêmes que de laisser le compilateur générer automatiquement une implémentation qui ne fera peut-être pas ce que vous voulez. Il s’agit aussi d’éviter les mauvaises surprises. Dans cinq ou dix ans, un développeur reprendra votre code et voyant une classe de valeur, il l’utilisera comme telle et risquera de se faire piéger si votre implémentation ne respecte pas la sémantique habituelle d’une classe de valeur.

Une autre catégorie technique de classe sont les classes de base. Il s’agit des classes destinées à être à la racine d’une hiérarchie d’objets. Une classe de base :

  • Possède un destructeur qui est soit public et virtuel (dans le cas où les classes dérivées ont besoin de fournir un destructeur spécifique), soit protected et non virtuel (dans le cas où les classes dérivées n’ont pas besoin de destructeur). En pratique et dans le doute, fournissez toujours un destructeur public virtuel.
  • Possède un constructeur copie et un opérateur d’assignation protected, qui pourront être appelés par les constructeurs copie et les opérateurs d’assignation publics dans les classes dérivées.
  • Définit une interface par l’intermédiaire de fonctions virtuelles, éventuellement virtuelles pures.
  • Est habituellement instanciée sous la forme d’une de ses classes dérivées, sur le tas par new ou std::make_shared, plus rarement sur la pile ou comme variable membre d’une autre classe.

La section suivante reviendra en détail sur ces concepts de dérivation et d’héritage, et dans quelles situations ces classes sont utiles.

On rencontre ensuite les classes d’exception, qui servent à encapsuler les informations décrivant une erreur d’exécution. Elles sont particulières dans le sens où la moitié de l’information est « codée » par leur type (la nature de l’erreur, que le système va utiliser pour les envoyer vers le bon catch) et l’autre moitié par leurs variables membres (les informations détaillées sur l’erreur). Le moyen le plus simple et le plus efficace de gérer la durée de vie des classes d’exception consiste à toujours les envoyer par valeur et à les attraper par référence.

try {
    if (error) {
        // envoi par valeur
        throw MyException();
    }
} catch (MyException const & exc) {
    // réception par référence
}

Une classe d’exception :

  • Possède un constructeur (pour pouvoir instancier l’exception) et un constructeur copie (parce que le compilateur peut avoir besoin de copier l’objet lors du mécanisme interne de dispatch de l’exception). Ces constructeurs doivent être publics et ne doivent jamais envoyer d’exception. Envoyer une exception pendant le traitement d’une exception se solde par un appel immédiat à abort().
  • Possède souvent une ou plusieurs fonctions virtuelles, voir par exemple comment est utilisée la méthode what() dans l’objet std::exception.
  • Dérive de préférence de std::exception. Ce n’est pas obligatoire, mais cela permet de bénéficier des fonctionnalités de cette classe de base et de plus, si votre code n’envoie que des exceptions qui dérivent de cet objet, vous pouvez écrire des catch génériques pour attraper en dernier ressort tout ce qui ne l’a pas été par des catch plus spécifiques en amont.

Il existe encore d’autres types de classes, mais si la STL les utilise parfois, il est rare d’avoir à en écrire soi-même. Il s’agit par exemple des classes de trait, qui fournissent de l’information à propos d’autres types (la classe std::numeric_limits est un bon aperçu de ce que ça permet de faire) ou des classes de politique, qui sont utilisées en métaprogrammation et en policy-based design. Je n’en parlerai pas ici, si ce n’est pour dire que ce sont des techniques de programmation certes puissantes, mais complexes. L’objectif de cet ouvrage étant d’encourager à écrire du code facile à relire, à maintenir et à faire évoluer, je conseille plutôt de se tenir éloigné de ce genre de pratique dans un cadre professionnel. Si le sujet vous intéresse, l’ouvrage de référence sur la question est Modern C++ Design de Andrei Alexandrescu.

D’une manière générale, quelle que soit la fonction, la sémantique ou le type de la classe :

  • Une classe doit être simple à utiliser correctement et difficile à utiliser mal. Une classe dont les méthodes ont des effets de bords inattendus, ou bien dont les méthodes doivent être appelées dans un ordre précis et non intuitif, par exemple, est à proscrire. Cette erreur de conception n’est pas l’apanage des débutants, on la trouve même dans la STL : ainsi le manipulateur de flux std::setw qui s’applique uniquement à l’opération qui suit, tandis que les manipulateurs std::hex ou std::dec s’appliquent à toutes les opérations qui suivent. Ce comportement asymétrique induit facilement en erreur et je l’ai souvent vu causer des bugs d’affichage. Une autre difficulté peut venir d’un besoin d’initialisation inhabituel, par exemple devoir appeler une méthode statique registerMe() pour enregistrer la classe dans une factory. Là encore, c’est tendre un piège au collègue qui utilisera votre classe. Il vaut mieux trouver un moyen d’initialiser ou d’enregistrer automatiquement la classe au démarrage de l’application, ou bien à sa première utilisation.
  • Une classe = une tâche. C’est évident pour les classes de valeurs (une couleur, une date…) mais c’est aussi vrai pour les contrôleurs ou les classes utilitaires. Par exemple, si vous devez envoyer des données chiffrées sur un canal de communication, implémentez une classe Message qui représente les données à envoyer, une classe Cipher qui s’occupe du chiffrement, une classe Transport qui s’occupe de la transmission et débrouillez-vous pour que le couplage entre ces trois classes soit minimal ; ne mélangez pas toutes ces fonctionnalités dans une seule et même classe. Inversement, ne répartissez pas une seule fonctionnalité sur plusieurs classes. Pour reprendre l’exemple précédent, les classes Cipher et Transport ne doivent pas avoir un comportement spécifique en fonction du contenu du message ; si c’était le cas (et c’est une erreur de conception que j’ai rencontrée plus d’une fois), cela voudrait dire que la tâche de représentation du message est en fait implémentée à cheval sur Message, Cipher et Transport. Pour l’anecdote et pour citer un cas extrême de ce qu’il ne faut pas faire, j’ai déjà vu un projet où l’ensemble des boîtes de dialogues (oui, absolument toutes les boîtes de dialogue) étaient implémentée par un seul et unique objet !

Héritage et composition

Il existe deux façons pour une classe de bénéficier des fonctionnalités d’une autre classe : l’héritage et la composition. Contrairement à ce que l’on pourrait croire, dans l’immense majorité des cas, la composition est préférable.

Commençons donc par la composition. Cela consiste à avoir dans une classe A une variable membre de type B et à utiliser les méthodes de cette instance de B dans le code de A. Si nécessaire, certaines méthodes de B peuvent être réimplémentées dans A pour « transférer » l’appel à B. Par exemple :

class B {
public:
    B();

    int getSomeValue();
};

class A {
public:
    A() : m_b() { }

    int getSomeValue() { return m_b.getSomeValue(); }

private:
    B m_b;
};

Cette architecture présente de nombreux avantages.

  • Un meilleur contrôle des méthodes visibles. Dans la plupart des cas, vous n’avez pas besoin, voire vous ne voulez surtout pas, que toutes les méthodes de l’objet B soient héritées par l’objet A et soient visibles de l’extérieur, par exemple parce que leur appel risquerait de mettre l’objet dans un état incohérent. Avec la composition, vous pouvez choisir précisément quelles méthodes de B sont redéclarées dans A. Vous pouvez même en améliorer la signature, comme changer le type d’un paramètre pour un type plus précis ou bien le rendre const.
  • Une grande flexibilité et un faible couplage. Une variable membre a une visibilité limitée. Vous pouvez donc changer son stockage, par exemple transformer ce membre de type B en un B* ou en un std::shared_ptr<B> sans affecter le code extérieur à votre classe. Vous n’aurez que quelques adaptations localisées à faire. De même, s’il s’avère que vous devez finalement appuyer votre classe A sur les fonctionnalités de la classe C plutôt que B, vous pourrez faire les changements nécessaires sans impacter le code extérieur, ce qui n’aurait pas été forcément possible en cas d’héritage étant donné que du code extérieur aurait probablement été écrit en fonction de cet héritage de B.
  • Moins de risque de collision de noms. Lorsque A dérive de B, toutes les fonctions définies dans B deviennent visibles dans A. Il y a alors un risque de définir par inadvertance dans A une fonction qui porte le même nom qu’une fonction dans B. C’est tout à fait valide et ne déclenche ni warning ni erreur de compilation mais dans beaucoup de cas, ce ne sera simplement pas la bonne version de la fonction qui sera appelée. Inutile de dire que ce type de bug est très difficile à localiser…
  • Un plus grand nombre de cas d’application. La plupart des classes ne sont pas conçues pour être dérivées, alors que toutes peuvent être utilisées comme variables membres. D’une façon générale, pratiquement aucune classe de la STL n’est prévue pour être dérivée. N’héritez jamais de std::string ou de std::vector !
  • Une meilleure robustesse. Les changements ultérieurs dans la classe de base B ont moins de risque d’impacter votre classe A dans un pattern de composition que dans un pattern de dérivation. De tels changements peuvent produire des bugs extrêmement subtils ; par exemple, une fonction qui change de nom alors que vous l’aviez surchargée et qui ne sera donc plus appelée, ou bien une fonction anciennement virtuelle et qui ne l’est plus, etc. (Il est toutefois possible d’éviter ce problème avec le mot-clef override. Ce sera l’objet d’une prochaine section.)

L’héritage consiste en revanche à faire dériver la classe A de la classe B. Elle hérite alors de toutes les fonctions et variables définies dans B. Ce schéma de conception ne devrait être utilisé que dans un cas très précis (ou presque), celui qui consiste à modéliser des objets qui ont une interface similaire mais un comportement différent.

Un exemple archétypal d’utilisation appropriée de la dérivation est la bibliothèque de composants graphiques. Dans un système comme Windows ou macOS, absolument tout ce qui s’affiche à l’écran est une fenêtre ; et toutes les fenêtres ont des propriétés et une interface logicielle similaires. Elles sont caractérisées par les mêmes paramètres : leur position à l’écran, leurs dimensions, leur couleur de fond, le style de leurs décorations (bordures, ancres de dimensionnement, barre de titre, etc.) ; et elles offrent toutes les mêmes fonctionnalités : se dessiner à la demande, réagir à la souris, accepter des saisies clavier, etc. En revanche, chaque type de fenêtre est différent. Un bouton ne ressemble pas à une liste ou à un champ texte et ne réagit pas de la même façon aux événements de la souris. On peut imaginer l’architecture suivante :

class Window {
public:
    Window();

    virtual void paint() {
        // code pour dessiner une fenêtre vide
    }

    virtual void mouseClick(int x, int y) {
        // rien à faire : on ignore la souris
    }

    virtual void keyInput(char key) {
        // rien à faire : on ignore le clavier
    }

private:
    int   left, top, width, height;
    Color backgroundColor;
};

class Button : public Window {
public:
    Button();

    void paint() override {
        // code pour dessiner un bouton
    }

    void mouseClick(int x, int y) override {
        // code pour réagir à un click sur le bouton
    }

    void keyInput(char key) override {
        // rien à faire : on ignore le clavier
    }
};

class TextField : public Window {
    TextField();

    void paint() override {
        // code pour dessiner un champ de saisie
    }

    void mouseClick(int x, int y) override {
        // code pour réagir à un click sur le texte
    }

    void keyInput(char key) override {
        // code pour réagir à une saisie clavier
    }
};

Les flux dans la STL sont un autre exemple d’utilisation appropriée de la dérivation. Tous les flux d’entrée ont la même interface : on peut en extraire des caractères, tester si l’on est arrivé à la fin du flux, déterminer la taille du flux, bufferiser les données, etc. En revanche, chaque type de flux possède une implémentation propre, puisqu’on ne lit pas un caractère de la même manière depuis un fichier ou depuis un bloc mémoire.

Un dernier exemple est le cas des interfaces pures. On en a déjà vu une application pratique pour fabriquer des objets mock up dans les tests unitaires, ou bien pour implémenter la délégation pour résoudre le problème de l’inversion de contrôle. Ce sont des classes qui ne contiennent que des fonctions virtuelles pures et dont on va dériver plusieurs implémentations concrètes.

class IClock {
    virtual time_t getTime() = 0;
};

class SystemClock : public IClock {
    time_t getTime() override {
      return time(nullptr);
    }
};

class FakeClock : public IClock {
    time_t getTime() override {
      return 12345678;
    }
};

Il est même possible de construire une classe qui dérive de plusieurs interfaces pures, et c’est probablement le seul cas acceptable d’utilisation de l’héritage multiple. Par exemple, on peut imaginer dans un pattern de délégation, une boite de dialogue devant réagir à la fois aux événements envoyés par un bouton et par un champ texte :

class MyDialog
  : public DialogBox,
    public IButtonDelegate,
    public ITextFieldDelegate {
};

Tout l’intérêt de la dérivation réside dans l’utilisation des fonctions virtuelles. Ce sont elles qui vont permettre à une classe de base d’exposer une interface et aux différentes classes dérivées d’en fournir leur implémentation propre. Si votre classe de base n’a besoin de déclarer aucune fonction virtuelle, c’est un indice que vous ne devriez pas utiliser l’héritage mais plutôt la composition.

Le concept de dérivation entraîne le principe de substitution de Liskov qui dit en gros : qui peut le plus peut le moins. Techniquement, cela veut dire que si A dérive de B, alors partout où l’on attend un objet de type B, on peut sans risque utiliser un objet de type A puisque par construction, tout ce que sait faire B, A sait le faire aussi ; tandis que l’inverse n’est pas vrai. Et de fait, en pratique, un compilateur C++ n’émettra aucune erreur dans le premier sens, tandis qu’il exigera une conversion de type explicite dans le sens inverse. (Pour l’anecdote, et parce que les femmes sont souvent invisibilisées en informatique, signalons que le docteur Liskov à l’origine de ce principe de substitution était une doctoresse et qu’elle s’appelait Barbara Liskov.)

class Window {
};

class Button : public Window {
};

class TextField : public Window {
};

// OK: un Button est aussi une Window
Window * obj1 = new Button();

// Erreur : une Window n'est pas forcément un Button
Button * obj2 = obj1;

// OK: un TextField est aussi une Window
Window * obj3 = new TextField();

// OK: conversion explicite, mais gare au plantage si
// obj3 ne contient pas réellement un TextField !
TextField * obj4 = dynamic_cast<TextField *>(obj3);

Pour reprendre l’exemple déjà cité de la bibliothèque de composants graphiques, ce principe de substitution est ce qui permet d’écrire des fonctions agissant sur une fenêtre générique sans avoir besoin de savoir exactement de quel type de fenêtre il s’agit. Ainsi, les fonctions MoveWindow ou CloseWindow de l’API Win32 acceptent un argument de type HWND, mais elles opéreront en réalité de la même façon sur un bouton, une liste ou une boite de dialogue, parce qu’elles n’ont besoin d’aucune des fonctionnalités propres aux boutons, aux listes ou aux boîtes de dialogue, mais juste des fonctionnalités de base des fenêtres.

N’exposez pas vos tripes inutilement

Un des principes fondamentaux de la programmation objet est l’encapsulation : un objet n’expose pas ses données, mais seulement des méthodes permettant d’agir dessus. Il y a de très bonnes raisons à cela. (Et par conséquent, que des mauvaises raisons de ne pas le respecter.)

  • L’indépendance vis-à-vis de la représentation des données. Seul l’objet à besoin de savoir comment sont stockées ses données internes. C’est un détail technique d’implémentation qui ne regarde que lui et en aucun cas le monde extérieur. Du point de vue du développeur qui conçoit l’objet, cela lui permet de choisir librement le stockage et la représentation qui lui semblent les plus appropriées, voire d’en changer ultérieurement si nécessaire. Du point de vue du développeur qui utilise l’objet, ne pas avoir directement accès aux données l’empêche d’écrire du code qui dépendrait d’une représentation ou d’un format en particulier, ce qui garantit un code plus robuste à long terme. Un changement à l’intérieur d’un objet a moins de risque de se répercuter à l’extérieur de cet objet, et même aucun risque si l’interface et la sémantique de l’objet sont conservées. (Et qu’on peut s’en assurer grâce à des tests unitaires !)
  • La préservation des invariants de classe. Le plus souvent, les différentes variables membres d’un objet contiennent des valeurs qui doivent rester cohérentes entre elles. Par exemple, un std::vector stocke un pointeur sur un bloc mémoire et un entier indiquant la taille de ce bloc ; et à tout moment, cette taille doit effectivement correspondre à la taille réelle du bloc, sans quoi il y a fort à parier que les opérations futures sur ce std::vector se solderont par une catastrophe. Ces relations logiques entre variables, qui doivent rester vraies en toutes circonstances, s’appellent des invariants de classe, et il est évidemment beaucoup plus facile de ne pas les violer si le monde extérieur ne peut pas modifier les variables internes à l’objet sans prévenir.
  • La maintenance. Il est plus facile de lire et de comprendre un petit morceau de code qu’un gros morceau de code, et lorsque l’on doit faire évoluer un objet écrit par quelqu’un d’autre, il est justement nécessaire de comprendre d’abord comment fonctionne son code. C’est beaucoup plus facile à faire si les variables membres de l’objet en question ne sont accédées que par un nombre limité de méthodes internes, plutôt qu’accédées par des dizaines de méthodes aux quatre coins de l’application.

Pour toutes ces raisons, les variables membres d’une classe doivent toujours être déclarées private. La très rare exception à cette règle est le cas des structures, qui permettent de manipuler facilement des tuples de valeurs (par exemple : les coordonnées x et y d’un point, les quatre champs d’une adresse IP, une liste de paramètres de configuration, etc.) entre lesquelles il n’y a pas d’invariant de classe à respecter.

La visibilité protected est à proscrire également pour les variables. Rendre une variable visible dans les classes dérivées est pratique, mais tout aussi dangereux que la visibilité public : en l’absence de documentation (et les projets ne sont jamais documentés…) un développeur qui implémente une classe dérivée peut mal utiliser ou ne pas savoir que faire des variables dont il hérite. Par exemple, il peut en modifier la valeur alors qu’il n’aurait pas dû, violant ainsi un invariant de classe ; ou bien il peut ne pas initialiser une variable alors que la classe de base s’attendait à ce qu’il le fasse ; ou inversement. Sauf cas trivial, il vaut donc mieux déclarer toutes les variables private et si nécessaire, proposer des accesseurs protected.

Absolument tout ce qui vient d’être dit pour les objets est également valable pour un module ou même pour un simple fichier. Ne rendez pas visibles de l’extérieur des fonctions ou des variables globales qui n’ont pas absolument besoin de l’être. Pour cela, il faut bien sûr ne pas faire figurer ces fonctions et ces variables dans le fichier .h du module ; mais il faut aussi les déclarer static dans le fichier .cpp. Cela facilitera la lecture pour votre successeur, qui pourra ainsi identifier d’un coup d’œil si une déclaration est privée ou publique, et cela vous évitera des conflits au moment de l’édition des liens si par malchance il existe une autre fonction du même nom dans un autre fichier : une variable ou une fonction statique ne génère pas d’entrée dans la table des symboles.