Concevoir du code réutilisable en C++

Salut ce forum apprends les bases du c++

Moderator: Rick

Post Reply
Hydraxx
Site Admin
Posts: 114
Joined: Mon Jan 12, 2026 4:04 pm
Location: France
Contact:

Concevoir du code réutilisable en C++

Post by Hydraxx »

Concevoir du code réutilisable en C++

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.
Copier du code est souvent rapide au début, mais les copies divergent ensuite. Une correction effectuée dans une version peut être oubliée dans les autres.

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.
En bas niveau, une abstraction est intéressante lorsqu'elle simplifie réellement le programme sans masquer des propriétés importantes comme la durée de vie d'une ressource, une allocation, un changement d'état ou une opération potentiellement bloquante.


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.
L'utilisateur d'un composant devrait pouvoir connaître son contrat sans devoir comprendre toute son implémentation.

Exemple conceptuel :

Code: Select all

class Buffer
{
public:
    bool write(const void* data, size_t size);
    size_t size() const;

private:
    // détails internes
};
Le code utilisant Buffer connaît les opérations disponibles. Il n'a pas besoin de savoir immédiatement comment la mémoire est organisée.

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.
Le but n'est pas d'avoir énormément de fichiers ou de classes. Le but est d'éviter qu'une modification locale nécessite de comprendre et modifier des éléments sans rapport.

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
Les composants deviennent alors difficiles à isoler.

Une organisation plus simple est préférable :

Code: Select all

Application
    |
    +-- Réseau
    |
    +-- Fichiers
    |
    +-- Journalisation
Chaque composant possède une responsabilité compréhensible.


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;
}
Le compilateur génère les versions nécessaires selon les types utilisés.

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>
Le même concept de tableau dynamique est réutilisé avec plusieurs types.

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.
Attention à la complexité

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.
La généricité doit répondre à un besoin réel.


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.
Postcondition

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
Si cette relation doit toujours être vraie, toutes les opérations du composant doivent la préserver.

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.
Une interface bien conçue réduit les états invalides et rend les erreurs difficiles à provoquer accidentellement.


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.
Prévoir vingt extensions hypothétiques produit souvent une architecture plus compliquée que le problème réel.


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
Exposer le minimum nécessaire

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.
En programmation système, ces informations sont essentielles.


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.
Rendre le cas courant simple

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]
Cela peut rendre une interface naturelle lorsque l'opération possède réellement le sens attendu.

Par exemple, comparer deux objets avec :

Code: Select all

a == b
est généralement intuitif si l'égalité a une définition claire.

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.
Découper les grosses interfaces

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.
En bas niveau, documenter la propriété d'une ressource est particulièrement important.

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.
Le principe général consiste à laisser configurable ce qui doit varier sans obliger l'utilisateur à modifier directement l'implémentation de la bibliothèque.

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
La logique connaît le comportement attendu, pas nécessairement tous les détails de l'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.
C'est particulièrement pertinent dans les bibliothèques système, où certains utilisateurs veulent simplement effectuer une opération tandis que d'autres ont besoin de contrôler précisément buffers, délais, synchronisation ou autres paramètres.


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 ;
peut produire une abstraction dangereuse dans du code système.

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 ;
est probablement trop chargée.

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.
Il ne faut cependant pas créer des points d'extension partout « au cas où ».

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
D — Dependency Inversion Principle (DIP)

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
on peut séparer conceptuellement :

Code: Select all

Network
Protocol
CommandHandler
Logger
Configuration
Le code réseau transporte les données.

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
Cela évite de disperser des détails d'API système dans toutes les parties du programme.

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.
Une mauvaise interface user/kernel devient difficile à modifier et peut surtout devenir dangereuse si les données provenant du user mode ne sont pas correctement vérifiées.

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.
Une abstraction ne doit donc pas rendre les coûts importants invisibles.


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.
La règle essentielle

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.

Return to “Bases du C++”

Who is online

Users browsing this forum: No registered users and 0 guests