Objectif du chapitre
Concevoir du code réutilisable ne signifie pas transformer chaque fonction en framework générique. Le but est surtout d'écrire des composants dont le comportement est clair, dont les détails internes sont correctement isolés et qui peuvent évoluer sans obliger à réécrire tout le programme.
En développement système, ces principes sont particulièrement utiles pour séparer la logique métier des détails Win32/POSIX, isoler la gestion des ressources, créer des bibliothèques internes et définir des interfaces propres entre plusieurs couches d'un programme.
1. Pourquoi concevoir du code réutilisable
Le principe
Un morceau de code est réellement réutilisable lorsqu'il peut servir dans plusieurs contextes sans devoir être copié puis profondément modifié.
La réutilisation permet notamment :
- d'éviter de dupliquer la même logique ;
- de corriger un bug à un seul endroit ;
- de tester un composant indépendamment ;
- de faire évoluer l'implémentation sans modifier tous ses utilisateurs ;
- de construire progressivement une bibliothèque de composants fiables.
Ne pas chercher la réutilisation à tout prix
Il existe cependant un danger inverse : essayer de rendre générique un code qui n'en a pas besoin.
Une abstraction inutile peut :
- augmenter la complexité ;
- rendre le flot d'exécution moins évident ;
- ajouter des couches inutiles ;
- compliquer le débogage ;
- rendre les coûts mémoire ou CPU moins visibles.
2. L'abstraction
Interface et implémentation
L'abstraction consiste principalement à séparer :
- ce qu'un composant permet de faire ;
- la manière dont ce composant réalise l'opération.
Exemple conceptuel :
Code: Select all
class Buffer
{
public:
bool write(const void* data, size_t size);
size_t size() const;
private:
// détails internes
};
Cette séparation permet de modifier l'implémentation tout en conservant la même interface.
Application système
Le même principe peut être appliqué à une couche système.
Par exemple, un programme Windows peut avoir un composant chargé des fichiers. Le reste du programme demande une lecture ou une écriture sans dépendre partout des détails internes utilisés pour gérer les handles, les buffers ou les erreurs.
Cela ne signifie pas qu'il faut cacher tous les détails du système. Une bonne abstraction bas niveau doit conserver visibles les informations importantes : erreur, taille, propriété d'une ressource, caractère synchrone/asynchrone, etc.
3. Découper correctement un programme
Séparer les concepts logiques
Deux fonctionnalités qui évoluent pour des raisons différentes devraient généralement être séparées.
Un programme réseau peut par exemple distinguer :
- transport réseau ;
- encodage/décodage des messages ;
- gestion des commandes ;
- journalisation ;
- configuration.
Cohésion et dépendances
Un composant cohérent regroupe des éléments qui travaillent réellement ensemble.
À l'inverse, les dépendances entre composants devraient rester maîtrisées.
Une mauvaise architecture ressemble souvent à ceci :
Code: Select all
A dépend de B
B dépend de C
C dépend de A
Une organisation plus simple est préférable :
Code: Select all
Application
|
+-- Réseau
|
+-- Fichiers
|
+-- Journalisation
4. Généricité avec les templates
Pourquoi les templates permettent la réutilisation
Un template permet d'écrire une logique indépendante d'un type précis.
Code: Select all
template<typename T>
T maximum(T a, T b)
{
return a > b ? a : b;
}
Les conteneurs de la bibliothèque standard reposent fortement sur ce principe :
Code: Select all
std::vector<int>
std::vector<char>
std::vector<std::byte>
Structures et algorithmes génériques
Il est souvent préférable de rendre générique un algorithme lorsque son fonctionnement ne dépend pas réellement du type traité.
Les templates permettent donc de réutiliser :
- des structures de données ;
- des algorithmes ;
- des wrappers ;
- des fonctions utilitaires ;
- certains mécanismes de personnalisation.
Il ne faut pas transformer chaque fonction en template.
Les templates peuvent :
- rendre les messages d'erreur plus complexes ;
- augmenter le temps de compilation ;
- produire plusieurs instanciations du code ;
- rendre une interface plus difficile à comprendre.
5. Vérifications et sécurité des interfaces
Une interface robuste doit définir ce qu'elle accepte et ce qu'elle garantit.
Précondition
Une précondition décrit ce qui doit être vrai avant l'appel d'une opération.
Exemples :
- un pointeur doit être valide ;
- une taille ne doit pas dépasser une limite ;
- un objet doit avoir été initialisé ;
- un handle doit représenter une ressource utilisable.
Une postcondition décrit ce qui doit être vrai après l'opération lorsqu'elle réussit.
Exemple : après l'ajout d'un élément, la taille logique du conteneur a augmenté.
Invariant
Un invariant est une propriété qui doit rester vraie pendant la vie valide d'un objet ou d'une structure.
Par exemple :
Code: Select all
used <= capacity
Vérifier les entrées
Plus une interface est exposée à du code que vous ne contrôlez pas, plus ses entrées doivent être considérées avec prudence.
C'est particulièrement important pour :
- un parser ;
- une bibliothèque ;
- une API publique ;
- des données provenant du réseau ;
- une frontière entre user mode et kernel mode.
6. Concevoir pour l'extension
Un programme évolue. Une bonne conception permet donc d'ajouter certaines fonctionnalités sans réécrire constamment son cœur.
Supposons un système qui traite plusieurs formats de données. Si chaque nouveau format nécessite de modifier dix fonctions centrales, le système est fortement couplé.
Une meilleure architecture définit une frontière claire entre le mécanisme général et ce qui varie.
Attention à l'anticipation excessive
Il est impossible de prévoir toutes les évolutions futures.
Il vaut mieux :
- identifier les variations réellement probables ;
- garder les composants indépendants ;
- éviter les hypothèses inutiles ;
- refactoriser lorsque de nouveaux besoins apparaissent.
7. Concevoir une bonne interface
Une interface représente ce qu'un composant autorise ses utilisateurs à faire.
En C++, les niveaux d'accès participent à cette séparation.
Code: Select all
public:
// interface accessible
protected:
// principalement destiné aux classes dérivées
private:
// détails internes
Tout ce qui devient public peut ensuite être utilisé par d'autres parties du programme.
Modifier une fonction privée touche essentiellement l'implémentation.
Modifier une fonction publique peut casser tous les utilisateurs de l'interface.
Il faut donc éviter d'exposer des détails internes simplement parce que cela facilite momentanément l'implémentation.
Une interface est un contrat
Une interface ne se limite pas à ses signatures.
Son contrat comprend également :
- la signification des paramètres ;
- les valeurs de retour ;
- les erreurs possibles ;
- la propriété des ressources ;
- la durée de vie des données ;
- les garanties de thread-safety ;
- les effets secondaires.
8. Concevoir une API
Une API est une interface destinée à être utilisée par d'autres composants ou développeurs.
Une API publique doit être pensée avec davantage de prudence qu'une petite fonction interne, car elle peut devenir difficile à modifier une fois utilisée par de nombreux programmes.
Caractéristiques d'une bonne API
Une bonne API cherche à être :
- compréhensible ;
- cohérente ;
- prévisible ;
- difficile à mal utiliser ;
- suffisamment stable ;
- documentée.
Une bonne règle consiste à rendre facile l'utilisation la plus fréquente tout en laissant accès aux possibilités avancées lorsque cela est nécessaire.
Une API qui oblige tous les utilisateurs à comprendre vingt options pour réaliser une opération simple est probablement trop complexe.
À l'inverse, une API tellement simplifiée qu'elle empêche les usages avancés peut devenir inutilisable pour certains projets.
9. Surcharge d'opérateurs et interfaces naturelles
C++ permet de surcharger des opérateurs.
Code: Select all
a + b
a == b
a[index]
Par exemple, comparer deux objets avec :
Code: Select all
a == b
En revanche, détourner un opérateur pour effectuer une action sans rapport rend le code trompeur.
Principe important :
une surcharge doit conserver la signification intuitive de l'opérateur.
L'objectif n'est pas d'utiliser toutes les possibilités syntaxiques du C++, mais de rendre le code plus évident.
10. Interfaces complètes et interfaces trop chargées
Une interface doit fournir suffisamment d'opérations pour remplir son rôle.
Une interface trop limitée force parfois l'utilisateur à contourner l'abstraction ou à accéder directement aux détails internes.
Mais l'extrême inverse est également mauvais.
Une interface contenant énormément de fonctions sans relation forte devient difficile à :
- comprendre ;
- tester ;
- documenter ;
- maintenir ;
- faire évoluer.
Lorsque plusieurs groupes d'opérations sont indépendants, plusieurs petites interfaces sont souvent préférables à une énorme interface universelle.
L'utilisateur ne dépend alors que des fonctionnalités dont il a réellement besoin.
11. Documentation et utilisabilité
Une interface techniquement correcte peut rester mauvaise si personne ne comprend comment l'utiliser.
La documentation doit notamment préciser :
- le rôle du composant ;
- les paramètres ;
- les valeurs retournées ;
- les erreurs ;
- les contraintes ;
- la durée de vie des objets ;
- la propriété des ressources ;
- les comportements particuliers.
Par exemple, lorsqu'une fonction fournit un handle ou un pointeur, il faut savoir qui est responsable de sa libération.
Une interface claire réduit la quantité de connaissances implicites nécessaires pour utiliser correctement le composant.
12. Interfaces génériques et personnalisables
Une bibliothèque réutilisable doit parfois permettre à l'utilisateur de modifier une partie de son comportement.
Plusieurs mécanismes C++ permettent cela :
- templates ;
- fonctions de rappel ;
- objets fonction ;
- lambdas ;
- interfaces spécialisées.
Inversion des dépendances
Au lieu qu'un composant dépende directement d'une implémentation précise, il peut dépendre d'une interface ou d'un contrat.
Conceptuellement :
Code: Select all
Logique
|
v
Interface
^
|
Implémentation
Ce principe peut faciliter les tests et permettre de remplacer une implémentation.
Mais en bas niveau, il ne faut pas automatiquement ajouter des classes virtuelles ou plusieurs couches d'indirection. Une fonction, un callback ou un template peut parfois résoudre le problème beaucoup plus simplement.
13. Généralité contre facilité d'utilisation
Une interface extrêmement générique peut devenir difficile à utiliser.
Inversement, une interface extrêmement simple peut être incapable de répondre aux besoins particuliers.
Il faut donc rechercher un compromis.
Une stratégie efficace consiste à fournir :
- une interface simple pour les opérations courantes ;
- des mécanismes supplémentaires pour les utilisateurs avancés.
14. Concevoir une abstraction réussie
Une abstraction réussie réduit la quantité d'informations que l'appelant doit connaître.
Mais elle ne doit pas mentir sur le fonctionnement réel du système.
Par exemple, masquer complètement qu'une opération peut :
- bloquer ;
- allouer beaucoup de mémoire ;
- échouer ;
- attendre une ressource ;
- effectuer une copie importante ;
Principe pratique
Cachez les détails qui ne sont pas nécessaires à l'appelant, mais conservez visibles les propriétés qui influencent la correction, les performances ou la gestion des ressources.
15. Les principes SOLID
SOLID regroupe cinq principes classiques de conception orientée objet. Ils ne doivent pas être considérés comme des lois absolues. Plusieurs idées restent cependant utiles même lorsqu'on écrit du C++ système peu orienté objet.
S — Single Responsibility Principle (SRP)
Un composant devrait avoir une responsabilité principale clairement définie.
Une classe qui gère simultanément :
- le réseau ;
- les fichiers ;
- le parsing ;
- l'affichage ;
- la configuration ;
SRP ne signifie pas « une fonction par classe ». Il signifie surtout qu'un composant ne devrait pas regrouper des responsabilités sans rapport.
O — Open/Closed Principle (OCP)
Un composant devrait idéalement pouvoir être étendu sans devoir constamment modifier son fonctionnement central.
Cela peut être obtenu avec différents mécanismes :
- composition ;
- callbacks ;
- templates ;
- interfaces ;
- parfois héritage.
L — Liskov Substitution Principle (LSP)
Lorsqu'un type dérivé est utilisé à la place de son type de base, il doit respecter le contrat attendu du type de base.
Si une classe dérivée change complètement la signification des opérations héritées, la hiérarchie est probablement mauvaise.
Ce principe concerne principalement les conceptions utilisant le polymorphisme et l'héritage.
I — Interface Segregation Principle (ISP)
Il est généralement préférable de disposer de petites interfaces cohérentes plutôt que d'une énorme interface obligeant tous les utilisateurs à dépendre de fonctionnalités inutiles.
Conceptuellement :
Code: Select all
Mauvais :
HugeInterface
|- réseau
|- fichiers
|- logs
|- configuration
|- affichage
Meilleur :
NetworkInterface
FileInterface
LogInterface
Les composants de haut niveau ne devraient pas nécessairement dépendre directement de tous les détails des composants de bas niveau.
Ils peuvent dépendre d'un contrat stable.
Cela permet de remplacer certaines implémentations sans modifier toute la logique.
Attention : appliquer DIP ne signifie pas automatiquement créer une classe abstraite pour chaque fonction. En C++, les templates, callbacks et fonctions peuvent également découpler des composants.
16. Application au développement bas niveau
Les principes du chapitre deviennent intéressants lorsqu'ils sont appliqués avec pragmatisme au code système.
Exemple : programme réseau
Au lieu d'écrire un énorme fichier contenant :
Code: Select all
socket
threads
parsing
commandes
logs
configuration
Code: Select all
Network
Protocol
CommandHandler
Logger
Configuration
Le protocole transforme les octets en messages compréhensibles.
Le gestionnaire de commandes décide quoi faire.
Le logger enregistre les événements.
Cette séparation permet de modifier le protocole sans réécrire toute la gestion réseau.
Exemple : Win32
Un programme Windows peut isoler les détails spécifiques au système :
Code: Select all
Application
|
+-- logique générale
|
+-- couche Windows
|
+-- fichiers
+-- processus
+-- synchronisation
+-- réseau
Exemple : user mode et driver
La frontière entre une application et un driver est elle-même une interface.
Le protocole d'échange doit définir clairement :
- les opérations disponibles ;
- la structure des entrées ;
- la structure des sorties ;
- les tailles ;
- les erreurs ;
- les versions éventuelles ;
- les validations nécessaires.
Performances et abstraction
Dans un programme classique, cacher un détail d'implémentation est souvent sans conséquence.
Dans du code système ou très performant, certains coûts doivent rester compréhensibles.
Il faut savoir si une opération implique :
- une allocation ;
- une copie ;
- un verrou ;
- une attente ;
- une transition vers le système ;
- une opération I/O ;
- une création/destruction de ressource.
17. Ce qu'il faut réellement retenir
Pour du C++ bas niveau, les points les plus importants de ce chapitre sont les suivants :
- séparer interface et implémentation ;
- éviter la duplication lorsque plusieurs morceaux de code représentent réellement la même logique ;
- découper les responsabilités sans créer artificiellement des dizaines de couches ;
- garder des dépendances simples entre composants ;
- utiliser les templates lorsqu'une logique est réellement indépendante du type ;
- définir clairement préconditions, postconditions et invariants ;
- valider particulièrement soigneusement les données venant de l'extérieur ;
- exposer le minimum nécessaire dans une interface ;
- considérer une interface comme un contrat ;
- documenter la propriété et la durée de vie des ressources ;
- concevoir les extensions probables sans essayer de prévoir tout le futur ;
- rendre le cas courant simple sans interdire les usages avancés ;
- éviter les interfaces gigantesques ;
- ne pas appliquer SOLID mécaniquement ;
- ne pas cacher les coûts importants derrière des abstractions opaques.
Le but de la conception n'est pas d'avoir le plus de classes, de patterns ou d'abstractions possible.
Le but est d'obtenir un programme dont les différentes parties ont des responsabilités claires, communiquent par des contrats compréhensibles et peuvent évoluer sans provoquer des modifications incontrôlées dans tout le projet.
Pour du développement système, une bonne architecture doit également rester proche de la réalité de la machine : ressources, mémoire, erreurs, synchronisation et coûts doivent rester maîtrisables.
