La conception logicielle consiste à réfléchir à la structure d’un programme avant et pendant son implémentation. Le but n’est pas de prévoir chaque ligne de code, mais de déterminer les grandes responsabilités, les composants, leurs interfaces, les données qu’ils manipulent et leurs interactions.
Un bon design doit rester compréhensible, modifiable et cohérent avec le code réel. En C++, cette étape est particulièrement importante parce que le langage laisse énormément de liberté : programmation procédurale, objets, templates, bibliothèque standard, gestion fine des ressources et concurrence peuvent coexister dans un même projet.
1. Qu’est-ce que la conception d’un programme ?
Le design se situe entre les besoins du programme et son implémentation.
On part de questions comme :
- Que doit faire le programme ?
- Quelles données doit-il manipuler ?
- Quels sont ses grands sous-systèmes ?
- Quelles responsabilités appartiennent à chaque composant ?
- Comment les composants communiquent-ils ?
- Quelles interfaces doivent être exposées ?
- Quelles erreurs peuvent survenir ?
- Quelles contraintes de performance, mémoire ou portabilité existent ?
Idée essentielle : le design explique comment les différentes parties du programme vont coopérer avant de s’intéresser aux détails de chaque instruction.
Il n’est pas nécessaire que le design soit figé. Lorsque l’implémentation révèle une mauvaise hypothèse, il faut pouvoir le corriger. Le document de conception doit suivre l’évolution réelle du logiciel.
2. Pourquoi la conception est importante
Commencer immédiatement à coder fonctionne parfois pour un très petit programme. Plus le projet grandit, plus cette méthode devient dangereuse.
Sans architecture claire, on finit facilement avec :
- des composants fortement dépendants ;
- des responsabilités mélangées ;
- des fonctions difficiles à modifier ;
- des duplications ;
- des interfaces incohérentes ;
- des problèmes de synchronisation découverts trop tard ;
- des solutions temporaires qui deviennent permanentes.
Dans un projet d’équipe, le design sert également de langage commun. Deux développeurs peuvent travailler sur des composants différents si leurs responsabilités et leurs interfaces sont définies.
Le design n’est pas une étape effectuée une seule fois. Il doit être maintenu avec le programme. Une documentation qui décrit une architecture qui n’existe plus peut être pire que l’absence de documentation.
3. Concevoir spécifiquement pour C++
C++ est un langage multi-paradigme. Une architecture professionnelle n’est donc pas obligée d’être entièrement orientée objet.
On peut combiner :
- programmation procédurale ;
- classes et encapsulation ;
- programmation générique avec les templates ;
- algorithmes et conteneurs de la bibliothèque standard ;
- fonctions libres ;
- abstractions de ressources ;
- programmation concurrente.
Une opération simple n’a pas besoin d’une hiérarchie de classes complexe. Inversement, lorsqu’un ensemble de données possède des invariants et des opérations bien définies, une abstraction peut rendre le programme beaucoup plus sûr.
C++ offre également une bibliothèque standard très importante. Un design doit donc prendre en compte ce qui existe déjà avant de créer ses propres structures ou algorithmes.
Enfin, C++ permet d’implémenter de nombreux design patterns, mais un pattern ne doit jamais être ajouté simplement parce qu’il existe. Il doit résoudre un problème réel.
4. Deux principes fondamentaux : abstraction et réutilisation
Deux idées reviennent constamment dans la conception C++ :
- abstraction : exposer ce qu’un composant permet de faire sans obliger son utilisateur à connaître tous ses détails internes ;
- réutilisation : éviter de résoudre plusieurs fois le même problème lorsqu’une solution correcte existe déjà.
Un appelant devrait connaître le contrat d’un composant, pas toute son implémentation.
5. Abstraction
Une abstraction sépare l’interface de l’implémentation.
Supposons qu’un composant représente un tampon de données. Le reste du programme devrait pouvoir demander sa taille, lire ou ajouter des données selon le contrat prévu sans connaître exactement comment la mémoire interne est organisée.
On peut imaginer :
Code: Select all
class Buffer
{
public:
void append(const std::byte* data, std::size_t size);
std::size_t size() const;
private:
std::vector<std::byte> data_;
};
- ce que l’utilisateur du composant peut faire ;
- la manière dont le composant le réalise.
L’encapsulation permet également de protéger les invariants.
Si n’importe quelle partie du programme peut modifier directement toutes les données internes, il devient difficile de garantir qu’elles restent cohérentes.
Une abstraction efficace doit cependant éviter de masquer inutilement des informations importantes. Dans du code système, les coûts, la durée de vie des ressources, les possibilités d’échec ou les contraintes de concurrence peuvent faire partie du contrat.
L’objectif n’est donc pas de « cacher tout », mais de cacher les détails qui ne devraient pas concerner l’appelant.
6. Réutilisation
Réutiliser signifie exploiter une solution existante lorsqu’elle répond correctement au besoin.
La réutilisation peut concerner :
- une fonction ;
- un algorithme ;
- une classe ;
- un composant interne ;
- une bibliothèque ;
- un framework ;
- une architecture ;
- un design pattern.
Si plusieurs composants réalisent exactement la même opération, il peut être préférable d’extraire cette opération dans une fonction ou une abstraction commune.
Mais il faut éviter l’excès inverse : créer immédiatement une abstraction générique pour du code qui n’existe qu’à un seul endroit.
Une bonne abstraction naît souvent d’un besoin réel de généralisation, pas d’une généralisation anticipée de tout le programme.
7. Écrire du code réutilisable
Le code réutilisable doit éviter de dépendre inutilement du contexte dans lequel il a été écrit.
Par exemple, un algorithme générique de recherche ne devrait pas dépendre d’un type métier précis s’il peut fonctionner avec plusieurs types.
Les templates permettent notamment d’exprimer certaines généralisations sans sacrifier les types.
Code: Select all
template<typename T>
const T* find_value(const T* begin, const T* end, const T& value)
{
for (; begin != end; ++begin)
{
if (*begin == value)
return begin;
}
return nullptr;
}
Pour favoriser la réutilisation :
- limiter les dépendances inutiles ;
- définir clairement le contrat ;
- éviter les hypothèses cachées ;
- séparer les responsabilités ;
- documenter les contraintes ;
- éviter de mélanger logique générique et détails propres à une application.
La réutilisation ne concerne pas uniquement le code.
Une architecture ayant déjà résolu correctement une catégorie de problèmes peut servir de modèle à d’autres projets.
C’est notamment l’idée derrière les design patterns : ils donnent un vocabulaire à des solutions de conception récurrentes.
Cela ne signifie pas copier mécaniquement une architecture. Il faut comprendre le problème auquel elle répond et vérifier que le nouveau contexte possède réellement le même problème.
9. Réutiliser une bibliothèque ou écrire soi-même ?
Lorsqu’une fonctionnalité existe déjà dans une bibliothèque, deux possibilités existent :
- utiliser cette bibliothèque ;
- implémenter soi-même la fonctionnalité.
Elle peut également fournir :
- des fonctionnalités déjà documentées ;
- une gestion de nombreux cas limites ;
- des optimisations ;
- une compatibilité avec plusieurs plateformes ;
- des corrections accumulées au fil du temps.
Il faut apprendre son interface, suivre ses évolutions et accepter certaines décisions prises par ses auteurs.
Une bibliothèque peut :
- être trop générale ;
- être plus lourde que nécessaire ;
- avoir un modèle de gestion des erreurs différent du vôtre ;
- introduire des dépendances supplémentaires ;
- changer d’API ;
- être abandonnée ;
- présenter des contraintes de licence ;
- avoir des performances inadaptées à votre cas précis.
Pour une primitive complexe, critique et déjà correctement implémentée, la réutilisation est souvent préférable.
Pour une fonctionnalité minuscule, extrêmement spécifique ou ayant des contraintes particulières, une implémentation locale peut parfois être plus raisonnable.
10. Choisir une bibliothèque
Avant d’intégrer une bibliothèque, il faut comprendre ce qu’elle garantit réellement.
Fonctionnalités et limitations
Il faut vérifier que l’API répond au problème réel, mais aussi connaître ses limites.
Questions utiles :
- quelles plateformes sont supportées ?
- quelles versions de compilateur sont nécessaires ?
- quelles autres bibliothèques sont requises ?
- le code est-il thread-safe ?
- quelles hypothèses sont faites sur les entrées ?
- quelles fonctionnalités ne sont volontairement pas supportées ?
Lorsqu’une bibliothèque retourne une ressource, il faut savoir qui en possède la responsabilité.
Par exemple :
- qui libère une zone mémoire retournée ?
- avec quelle fonction ?
- la ressource reste-t-elle valide après l’appel suivant ?
- un pointeur retourné peut-il être conservé ?
- la bibliothèque utilise-t-elle RAII ?
Gestion des erreurs
Il faut comprendre comment les erreurs sont signalées :
- valeur de retour ;
- exception ;
- code d’erreur ;
- objet résultat ;
- état global ou local.
Courbe d’apprentissage
Une bibliothèque très puissante peut demander beaucoup de temps avant d’être utilisée correctement.
Ce coût ne disparaît pas après l’intégration : les futurs développeurs devront eux aussi comprendre la dépendance.
11. Comprendre les performances
Une bibliothèque ou un algorithme ne doit pas être jugé uniquement sur son temps d’exécution pour un exemple précis.
On cherche également à comprendre comment son coût évolue lorsque la quantité de données augmente.
C’est le rôle de la notation Big-O.
12. Notation Big-O
Big-O décrit l’évolution asymptotique du coût d’un algorithme en fonction de la taille de son entrée.
Les complexités courantes sont :
- : constante ;
Code: Select all
O(1) - : logarithmique ;
Code: Select all
O(log n) - : linéaire ;
Code: Select all
O(n) - : linéarithmique ;
Code: Select all
O(n log n) - : quadratique ;
Code: Select all
O(n²) - : exponentielle.
Code: Select all
O(2^n)
Le coût ne dépend pratiquement pas du nombre d’éléments.
L’accès à un élément d’un tableau par son indice est un exemple classique.
Code: Select all
value = array[index];
À chaque étape, une partie importante de l’espace de recherche est éliminée.
La recherche binaire dans des données triées est un exemple classique.
O(n)
Le nombre d’opérations augmente approximativement proportionnellement au nombre d’éléments.
Code: Select all
for (const auto& value : values)
{
process(value);
}
Cette complexité apparaît notamment dans plusieurs algorithmes de tri efficaces.
O(n²)
Elle apparaît fréquemment lorsque chaque élément est comparé à beaucoup d’autres éléments.
Code: Select all
for (std::size_t i = 0; i < n; ++i)
{
for (std::size_t j = 0; j < n; ++j)
{
process(i, j);
}
}
13. Les limites de Big-O
Big-O ne donne pas directement le temps réel.
Deux algorithmes
Code: Select all
O(n)Les performances réelles dépendent également :
- des constantes cachées ;
- des allocations ;
- de la localité mémoire ;
- du cache processeur ;
- des branchements ;
- des appels système ;
- des entrées/sorties ;
- du parallélisme ;
- du matériel.
Il faut donc combiner :
- analyse de complexité ;
- mesures réelles ;
- profilage.
Il faut identifier les parties réellement coûteuses avant d’investir du temps dans une optimisation.
14. Bibliothèques open source
Une bibliothèque open source donne accès à son code source selon les droits définis par sa licence.
Cela présente plusieurs avantages :
- possibilité d’inspecter l’implémentation ;
- possibilité d’étudier un comportement précis ;
- possibilité de compiler soi-même ;
- possibilité de corriger ou adapter le code lorsque la licence l’autorise ;
- possibilité d’auditer certaines propriétés importantes.
La licence peut imposer des conditions sur :
- la redistribution ;
- les modifications ;
- l’attribution ;
- la distribution du code source ;
- l’intégration dans un produit commercial.
Il faut aussi examiner :
- l’activité du projet ;
- la fréquence des corrections ;
- la qualité de la documentation ;
- la communauté ;
- la gestion des vulnérabilités ;
- la possibilité de maintenir soi-même le code si le projet disparaît.
Avant de chercher une dépendance externe, il faut vérifier si la bibliothèque standard fournit déjà ce qui est nécessaire.
Elle contient notamment des outils pour :
- conteneurs ;
- chaînes ;
- algorithmes ;
- itérateurs ;
- gestion des ressources ;
- smart pointers ;
- temps ;
- threads et synchronisation ;
- entrées/sorties ;
- programmation générique.
Code: Select all
std::vector
std::string
std::unique_ptr
std::sort
std::thread
std::mutex
std::chrono
C++ conserve également une grande partie de la bibliothèque standard C, ce qui permet d’utiliser de nombreuses fonctionnalités historiques de C lorsque cela est approprié.
Cependant, utiliser la bibliothèque standard n’est pas une obligation absolue. Une application peut avoir des contraintes auxquelles une implémentation spécialisée répond mieux.
16. Méthode de conception d’un programme
Pour un programme suffisamment important, on peut suivre une progression structurée.
- définir les exigences ;
- identifier les grands sous-systèmes ;
- définir leurs responsabilités ;
- définir leurs interfaces ;
- choisir le modèle de concurrence ;
- identifier les classes ou abstractions utiles ;
- choisir les structures de données ;
- choisir les algorithmes ;
- déterminer les interactions ;
- prévoir la gestion des erreurs.
On ne commence pas par décider du nom de toutes les fonctions. On commence par comprendre les grandes frontières du système.
17. Définir les exigences
Avant l’architecture, il faut savoir ce que le programme doit faire.
Les exigences peuvent être fonctionnelles :
- charger un fichier ;
- traiter des commandes ;
- afficher certaines informations ;
- accepter plusieurs utilisateurs.
- limite de mémoire ;
- latence maximale ;
- plateformes supportées ;
- niveau de fiabilité ;
- contraintes de sécurité ;
- nombre d’utilisateurs ;
- besoin de concurrence.
18. Découper le programme en sous-systèmes
Une fois les exigences comprises, le programme peut être divisé en grands blocs fonctionnels.
Chaque sous-système doit avoir une responsabilité identifiable.
Par exemple :
Code: Select all
Application
|
+-- Input
|
+-- Core
|
+-- Storage
|
+-- Network
|
+-- UI
Le principe important est de limiter les dépendances.
Si le composant réseau doit connaître les détails internes de l’interface utilisateur, du stockage et de toutes les structures métier, l’architecture devient fortement couplée.
Une meilleure solution consiste à définir des interfaces étroites entre les sous-systèmes.
19. MVC : Model, View, Controller
MVC est un exemple classique de séparation des responsabilités.
Model
Le modèle contient les données et la logique associée au domaine.
View
La vue présente les informations.
Controller
Le contrôleur reçoit les actions et orchestre les modifications nécessaires.
Schématiquement :
Code: Select all
Utilisateur
|
v
Controller
|
v
Model
|
v
View
La logique métier ne devrait pas dépendre inutilement de la manière dont les informations sont affichées.
Une même logique pourrait alors être utilisée avec une interface console, graphique ou une autre couche de présentation.
20. Concevoir le multithreading
Le modèle de threading doit être pensé suffisamment tôt.
Il faut identifier :
- quelles tâches peuvent réellement fonctionner en parallèle ;
- quels threads existent ;
- leur durée de vie ;
- quelles données sont partagées ;
- qui possède ces données ;
- comment elles sont synchronisées ;
- comment les threads s’arrêtent.
Les threads introduisent des coûts :
- synchronisation ;
- contention ;
- changements de contexte ;
- complexité ;
- risques de race conditions ;
- risques de deadlocks.
Lorsqu’un partage est nécessaire, il faut définir précisément qui peut lire ou modifier les données et sous quelles conditions.
Le modèle de concurrence fait partie de l’architecture, pas uniquement de l’implémentation.
21. Identifier les classes et abstractions
Après les grands sous-systèmes, on peut descendre vers des composants plus précis.
Toutes les données ne nécessitent pas une classe complexe.
Une classe devient particulièrement intéressante lorsqu’elle doit :
- maintenir un invariant ;
- posséder une ressource ;
- regrouper données et opérations fortement liées ;
- fournir une interface stable ;
- masquer une implémentation susceptible de changer.
L’héritage doit représenter une relation utile au design.
La composition est souvent plus simple :
Code: Select all
class Engine
{
};
class Application
{
private:
Engine engine_;
};
Code: Select all
ApplicationCode: Select all
Engine22. Hiérarchies de classes
Une hiérarchie peut être utile lorsque plusieurs types partagent réellement un contrat commun et doivent être manipulés polymorphiquement.
Code: Select all
class Device
{
public:
virtual ~Device() = default;
virtual void start() = 0;
};
class NetworkDevice : public Device
{
public:
void start() override;
};
Une conception professionnelle cherche donc généralement à garder les relations aussi simples que possible.
23. Structures de données et algorithmes
Pour chaque sous-système, il faut déterminer quelles données doivent être conservées et quelles opérations doivent être efficaces.
Le choix d’une structure de données doit dépendre des opérations nécessaires.
Par exemple, les besoins peuvent être :
- accès séquentiel rapide ;
- accès par indice ;
- recherche fréquente ;
- insertions fréquentes ;
- ordre stable ;
- unicité des éléments ;
- association clé/valeur.
Code: Select all
std::vector
std::array
std::deque
std::map
std::unordered_map
std::set
Dans du code sensible aux performances, il faut également considérer l’organisation réelle en mémoire. Une structure théoriquement élégante peut être moins efficace qu’une structure contiguë bénéficiant d’une meilleure localité cache.
24. Design patterns
Un design pattern décrit une solution générale à un problème de conception récurrent.
Il ne s’agit pas d’un morceau de code à copier.
Deux exemples importants sont Observer et Mediator.
Observer
Plusieurs composants souhaitent être informés lorsqu’un autre composant change d’état.
Schématiquement :
Code: Select all
Subject
|
+--> Observer A
+--> Observer B
+--> Observer C
Mediator
Lorsque de nombreux objets communiquent directement entre eux, le nombre de dépendances peut exploser.
Un médiateur centralise certaines interactions :
Code: Select all
Component A \
Component B ---> Mediator
Component C /
25. Définir les interactions entre composants
Connaître les composants ne suffit pas. Il faut également comprendre leurs interactions.
On peut représenter une séquence conceptuelle :
Code: Select all
User
|
v
Controller
|
| request
v
Service
|
| query
v
Storage
|
| result
v
Service
|
v
Controller
- des dépendances circulaires ;
- des responsabilités mal placées ;
- des appels inutiles ;
- des problèmes de durée de vie ;
- des zones nécessitant une synchronisation.
26. Concevoir la gestion des erreurs
La gestion des erreurs ne doit pas être ajoutée à la fin du développement.
Pour chaque sous-système, il faut se demander :
- quelles erreurs peuvent survenir ?
- lesquelles peuvent être récupérées ?
- lesquelles doivent être propagées ?
- qui possède suffisamment de contexte pour les traiter ?
- quelles informations doivent être journalisées ?
Erreurs utilisateur
Par exemple :
- commande invalide ;
- chemin inexistant ;
- valeur hors limites.
Erreurs système
Par exemple :
- échec d’allocation ;
- échec d’ouverture d’un fichier ;
- erreur réseau ;
- ressource indisponible.
Erreurs de programmation
Elles correspondent à des violations d’hypothèses internes ou d’invariants.
Les assertions peuvent aider à détecter certaines situations pendant le développement.
Code: Select all
assert(pointer != nullptr);
27. Propagation des erreurs
Une erreur doit idéalement être traitée au niveau qui possède suffisamment d’informations pour prendre une décision.
Une fonction très basse dans l’architecture sait peut-être seulement qu’une opération a échoué. Elle ne sait pas nécessairement s’il faut afficher un message, réessayer ou abandonner l’opération complète.
Elle peut donc propager l’échec.
Le niveau supérieur possède davantage de contexte et peut décider quoi faire.
Cela évite que chaque fonction profonde impose sa propre politique au programme entier.
28. Journalisation et diagnostic
Une erreur correctement gérée doit également pouvoir être diagnostiquée.
Les informations utiles peuvent inclure :
- l’opération ayant échoué ;
- le composant concerné ;
- le code d’erreur ;
- le contexte pertinent ;
- la date et l’heure ;
- éventuellement le thread concerné.
Dans les programmes système, une bonne stratégie de diagnostic est particulièrement importante : un problème de concurrence ou de ressource peut être difficile à reproduire.
29. Maintenir le design pendant l’évolution du programme
Un design initial n’est jamais parfait.
Au cours de l’implémentation, on découvre :
- de nouvelles contraintes ;
- des dépendances inattendues ;
- des problèmes de performances ;
- des cas limites ;
- des abstractions mal choisies.
Le danger est de conserver un document théorique pendant que le code évolue dans une autre direction.
Le design utile est celui qui décrit le système réel.
Cela implique parfois de refactoriser le code lorsque des décisions initiales ne sont plus adaptées.
30. Principes pratiques à retenir
Pour concevoir efficacement un programme C++ :
- comprendre le problème avant de choisir les abstractions ;
- concevoir d’abord les grandes responsabilités ;
- garder des interfaces petites et claires ;
- cacher les détails d’implémentation qui ne concernent pas l’appelant ;
- éviter les dépendances inutiles ;
- privilégier la composition lorsque l’héritage n’apporte rien ;
- réutiliser les solutions fiables plutôt que réinventer systématiquement ;
- comprendre les dépendances externes avant de les intégrer ;
- analyser la complexité des algorithmes ;
- mesurer les performances réelles ;
- penser au modèle de threading dès la conception ;
- prévoir la gestion des erreurs ;
- maintenir le design synchronisé avec le code.
Ces principes deviennent encore plus importants lorsque le programme manipule directement des ressources système.
Prenons une architecture simplifiée :
Code: Select all
Application
|
+-- ProcessManager
|
+-- MemoryManager
|
+-- FileManager
|
+-- IPC
|
+-- Diagnostics
Le composant mémoire ne devrait pas connaître toute la logique de l’interface utilisateur. Le composant IPC ne devrait pas modifier directement des structures internes appartenant au gestionnaire de processus.
Les ressources natives doivent également avoir une propriété claire.
Conceptuellement :
Code: Select all
acquisition ressource
|
v
objet propriétaire
|
v
utilisation
|
v
destruction automatique
Cela permet d’utiliser les abstractions C++ sans perdre le contrôle précis nécessaire au développement système.
32. Conclusion
La conception professionnelle ne consiste pas à transformer chaque programme en énorme architecture orientée objet.
Elle consiste à prendre consciemment les décisions importantes avant qu’elles ne deviennent difficiles à changer.
Les deux idées centrales sont :
- abstraction : définir des frontières propres entre les composants ;
- réutilisation : exploiter intelligemment le code et les conceptions existantes.
Pour du C++ système, le meilleur design reste généralement celui qui conserve des abstractions suffisamment fortes pour rendre le programme maintenable tout en laissant visibles les éléments qui comptent réellement : propriété des ressources, durée de vie, mémoire, erreurs, synchronisation et coût des opérations.
