Objectif du cours
Ce chapitre explique les classes sans tomber dans l'orienté objet inutile.
Pour du C++ système, une classe est surtout intéressante lorsqu'elle permet de :
- regrouper un état et les opérations qui lui appartiennent ;
- protéger des invariants ;
- gérer automatiquement une ressource ;
- rendre l'ownership clair ;
- éviter les fuites et les erreurs de durée de vie ;
- fournir une interface simple autour d'une API bas niveau.
En C++ système, il est parfaitement normal de mélanger :
- fonctions libres ;
- structures ;
- classes ;
- fichiers .cpp/.h ;
- API C ;
- API système.
La programmation procédurale organise principalement le programme autour de fonctions et de données.
Exemple conceptuel :
Code: Select all
open_file()
read_file()
parse_header()
close_file()
La programmation orientée objet regroupe plutôt :
- des données ;
- des opérations associées ;
- des règles de validité ;
- éventuellement une durée de vie.
Code: Select all
class File
{
public:
bool open();
bool read();
void close();
};
Le C++ permet de choisir selon le problème.
Règle pratique :
Si un problème se résout clairement avec quelques fonctions et une structure simple, inutile d'ajouter une classe complexe.
Si une donnée possède un état, une ressource ou des règles qu'il faut protéger, une classe devient beaucoup plus intéressante.
2. Qu'est-ce qu'une classe ?
Une classe définit un type.
Elle peut contenir :
- des données membres ;
- des fonctions membres ;
- des constructeurs ;
- un destructeur ;
- des règles d'accès.
Code: Select all
class Counter
{
public:
void increment()
{
++value_;
}
int value() const
{
return value_;
}
private:
int value_ = 0;
};
- Counter est le type ;
- value_ représente son état ;
- increment() modifie cet état ;
- value() permet de le lire ;
- le membre privé ne peut pas être modifié directement depuis l'extérieur.
Une classe est une définition.
Un objet est une instance concrète de cette classe.
Code: Select all
Counter a;
Counter b;
- a possède son propre état ;
- b possède son propre état ;
- les deux objets ont le même type mais des données indépendantes.
4. Propriétés et comportements
Une classe regroupe généralement :
- des propriétés : les données ;
- des comportements : les opérations.
Code: Select all
class Buffer
{
private:
std::byte* data_;
std::size_t size_;
public:
std::size_t size() const;
void clear();
};
Les méthodes décrivent ce qu'on peut faire avec lui.
Mais cela ne signifie pas qu'il faut toujours transformer une donnée en objet complexe.
Une structure simple peut parfois suffire :
Code: Select all
struct Header
{
std::uint32_t magic;
std::uint16_t machine;
std::uint16_t sections;
};
5. Quand utiliser une classe
Une classe devient particulièrement utile dans quatre situations.
1. Une ressource doit être possédée
Exemples :
- HANDLE ;
- file descriptor ;
- socket ;
- mapping ;
- allocation mémoire ;
- mutex ;
- thread ;
- clé de registre.
Exemple :
Code: Select all
size_ <= capacity_
3. L'implémentation interne doit pouvoir changer
Le reste du programme doit connaître l'interface, pas forcément les détails.
4. Plusieurs opérations appartiennent naturellement au même état
Exemple : ouvrir, lire, écrire et fermer une même ressource.
6. Quand ne pas utiliser une classe
Une classe est inutile si elle ajoute uniquement du bruit.
Par exemple, pour une petite opération sans état :
Code: Select all
std::uint32_t checksum(const std::byte* data, std::size_t size);
Créer :
Code: Select all
class ChecksumCalculator
{
public:
std::uint32_t calculate(...);
};
Même chose pour un simple groupe de données sans invariant complexe :
Code: Select all
struct ProcessInfo
{
std::uint32_t pid;
std::uint64_t memory;
};
7. Encapsulation
L'encapsulation consiste à contrôler ce que le code extérieur peut modifier.
Les mots clés principaux sont :
Code: Select all
public
private
protected
Exemple :
Code: Select all
class Buffer
{
public:
std::size_t size() const
{
return size_;
}
private:
std::size_t size_ = 0;
};
Cela protège l'état interne.
8. Pourquoi private est utile en bas niveau
Dans du code système, une valeur incorrecte peut provoquer :
- lecture hors limites ;
- écriture hors limites ;
- double libération ;
- handle invalide ;
- use-after-free ;
- corruption mémoire.
Code: Select all
size_ <= capacity_
9. L'usage le plus important : RAII
Pour du C++ système, c'est probablement la partie la plus importante de ce chapitre.
RAII signifie que la durée de vie d'une ressource est liée à la durée de vie d'un objet.
L'idée est :
Code: Select all
construction
|
v
acquisition de ressource
|
v
utilisation
|
v
destruction objet
|
v
libération automatique
10. Exemple conceptuel avec un HANDLE
Sans RAII :
Code: Select all
HANDLE h = ...;
if (erreur1)
{
CloseHandle(h);
return;
}
if (erreur2)
{
CloseHandle(h);
return;
}
CloseHandle(h);
Avec une classe RAII :
Code: Select all
class UniqueHandle
{
public:
explicit UniqueHandle(HANDLE handle)
: handle_(handle)
{
}
~UniqueHandle()
{
if (handle_ != nullptr &&
handle_ != INVALID_HANDLE_VALUE)
{
CloseHandle(handle_);
}
}
private:
HANDLE handle_;
};
C'est ici qu'une classe apporte une vraie valeur bas niveau.
11. RAII avec les ressources Linux
Le même principe s'applique aux file descriptors.
Code: Select all
class FileDescriptor
{
public:
explicit FileDescriptor(int fd)
: fd_(fd)
{
}
~FileDescriptor()
{
if (fd_ >= 0)
{
close(fd_);
}
}
private:
int fd_;
};
- socket() / close() ;
- mmap() / munmap() ;
- pthread_mutex_init() / pthread_mutex_destroy() ;
- fopen() / fclose().
Ownership signifie : qui est responsable de libérer une ressource ?
Une bonne classe peut rendre cette question évidente.
Une ressource doit avoir un propriétaire clair.
Sinon on obtient facilement :
- fuite ;
- double fermeture ;
- double free ;
- adresse utilisée après destruction.
Une classe propriétaire contrôle la durée de vie d'une ressource.
Exemple conceptuel :
Code: Select all
class Mapping
{
private:
void* address_;
std::size_t size_;
};
Code: Select all
munmap(address_, size_);
Code: Select all
class BufferView
{
private:
const std::byte* data_;
std::size_t size_;
};
14. Relation has-a
Une relation has-a signifie qu'un objet contient ou utilise un autre objet.
Exemple :
Code: Select all
class ProcessManager
{
private:
Logger logger_;
};
Code: Select all
ProcessManager has a Logger
15. Composition
La composition est une relation has-a forte.
Code: Select all
class Server
{
private:
Socket socket_;
};
Si Socket utilise RAII, la socket native est automatiquement fermée.
La composition permet :
- de découper les responsabilités ;
- de réutiliser des composants ;
- de contrôler les durées de vie ;
- d'éviter de grosses hiérarchies ;
- de rendre l'ownership naturel.
L'agrégation représente une relation plus faible : un composant utilise un autre objet sans contrôler nécessairement sa durée de vie.
Code: Select all
class Parser
{
public:
Parser(Logger& logger)
: logger_(logger)
{
}
private:
Logger& logger_;
};
Une association peut être encore plus ponctuelle :
Code: Select all
class Worker
{
public:
void execute(Job& job);
};
Code: Select all
possède
utilise
emprunte
référence temporairement
Une relation is-a signifie qu'un type est une spécialisation d'un autre.
Exemple :
Code: Select all
NetworkDevice is a Device
Code: Select all
class Device
{
};
class NetworkDevice : public Device
{
};
L'héritage peut être utile pour :
- partager un contrat ;
- utiliser du polymorphisme ;
- spécialiser un comportement.
Une classe dérivée dépend fortement de la classe de base.
Modifier la base peut affecter toutes les classes dérivées.
19. Ne pas utiliser l'héritage uniquement pour réutiliser du code
Si une classe veut simplement utiliser une fonctionnalité d'une autre classe, la composition est souvent meilleure.
Mauvais raisonnement :
Code: Select all
"Je veux réutiliser Logger,
donc ProcessManager hérite de Logger."
Il doit plutôt contenir ou utiliser un Logger.
20. Composition ou héritage ?
Règle mentale :
Code: Select all
A est un B
-> héritage éventuellement
A possède/utilise un B
-> composition / agrégation / association
21. Polymorphisme
Le polymorphisme permet d'utiliser différentes implémentations derrière une interface commune.
Code: Select all
class Device
{
public:
virtual ~Device() = default;
virtual void start() = 0;
};
class DiskDevice : public Device
{
public:
void start() override
{
}
};
class NetworkDevice : public Device
{
public:
void start() override
{
}
};
22. virtual et override
Une fonction virtuelle permet de sélectionner l'implémentation au moment de l'exécution.
Code: Select all
device->start();
Code: Select all
void start() override;
23. Classe abstraite
Une classe contenant une fonction virtuelle pure peut servir d'interface.
Code: Select all
class Reader
{
public:
virtual ~Reader() = default;
virtual bool read(
std::byte* buffer,
std::size_t size
) = 0;
};
- FileReader ;
- MemoryReader ;
- NetworkReader.
Le polymorphisme dynamique implique généralement :
- une table virtuelle ;
- un pointeur virtuel dans les objets concernés ;
- une indirection lors de l'appel ;
- moins de possibilités d'optimisation dans certains cas.
- I/O ;
- réseau ;
- accès disque ;
- syscalls.
25. Hiérarchies de classes
Une hiérarchie représente plusieurs niveaux d'héritage.
Code: Select all
Device
|
+-- StorageDevice
| |
| +-- SSD
|
+-- NetworkDevice
Problèmes possibles :
- comportement hérité difficile à comprendre ;
- fort couplage ;
- modifications risquées ;
- méthodes virtuelles réparties sur plusieurs classes ;
- architecture rigide.
26. Sur-classification
La sur-classification consiste à créer des classes pour tout.
Exemple inutile :
Code: Select all
class IntegerValue
class FileNameString
class ProcessIdObject
class CounterManager
class CounterManagerFactory
Une abstraction doit résoudre un problème réel.
27. Classes trop générales
Une classe qui essaie de tout faire devient difficile à maintenir.
Code: Select all
class SystemManager
{
// fichiers
// mémoire
// sockets
// threads
// processus
// logs
// configuration
};
Mieux vaut séparer les responsabilités ou utiliser simplement des fonctions/modules lorsque cela suffit.
28. Héritage multiple
C++ permet :
Code: Select all
class C : public A, public B
{
};
Mais cela peut créer :
- ambiguïtés ;
- hiérarchies difficiles à comprendre ;
- problèmes de construction ;
- diamond problem ;
- couplage supplémentaire.
29. Mixins
Un mixin ajoute une capacité particulière à un type.
Conceptuellement :
Code: Select all
class LoggingCapability
{
};
class NetworkService
: public LoggingCapability
{
};
Composition, fonctions et RAII couvrent déjà énormément de besoins.
30. Classes et fichiers .cpp/.h
Une classe et un fichier ne répondent pas au même problème.
Le fichier organise physiquement le code :
Code: Select all
memory.h
memory.cpp
network.h
network.cpp
On peut parfaitement avoir :
Code: Select all
memory.cpp
allocate_region()
protect_region()
free_region()
Ou une classe Mapping si une ressource et sa durée de vie doivent être encapsulées.
31. Quand préférer fonctions + struct
Cette approche est excellente quand :
- les données sont simples ;
- il n'y a pas de ressource à posséder ;
- les invariants sont simples ;
- les opérations n'ont pas besoin d'état persistant ;
- une API procédurale est plus naturelle.
Code: Select all
struct ModuleInfo
{
void* base;
std::size_t size;
};
bool inspect_module(
const ModuleInfo& info
);
32. Quand préférer une classe
Une classe devient intéressante lorsque tu veux écrire quelque chose comme :
Code: Select all
Mapping mapping;
Socket socket;
Thread thread;
UniqueHandle handle;
MappedFile file;
Dans ce cas, la classe apporte :
- ownership ;
- RAII ;
- invariants ;
- interface ;
- durée de vie claire.
Code: Select all
class UniqueHandle
{
public:
UniqueHandle() = default;
explicit UniqueHandle(HANDLE handle)
: handle_(handle)
{
}
~UniqueHandle()
{
reset();
}
HANDLE get() const
{
return handle_;
}
void reset()
{
if (handle_ != nullptr &&
handle_ != INVALID_HANDLE_VALUE)
{
CloseHandle(handle_);
handle_ = nullptr;
}
}
private:
HANDLE handle_ = nullptr;
};
Code: Select all
objet vivant
=
handle possédé
objet détruit
=
handle fermé
Code: Select all
class Mapping
{
public:
Mapping(void* address, std::size_t size)
: address_(address),
size_(size)
{
}
~Mapping()
{
if (address_ != nullptr)
{
munmap(address_, size_);
}
}
private:
void* address_ = nullptr;
std::size_t size_ = 0;
};
35. Exception safety grâce à RAII
RAII reste utile lorsque l'exécution quitte une fonction prématurément :
- return ;
- exception ;
- branche d'erreur.
Donc leurs destructeurs libèrent les ressources.
C'est l'une des raisons pour lesquelles RAII est central en C++.
36. Destructeur virtuel
Si une classe de base doit être détruite polymorphiquement, son destructeur doit généralement être virtuel.
Code: Select all
class Device
{
public:
virtual ~Device() = default;
};
Code: Select all
Device* device = new NetworkDevice;
delete device;
Retenir :
Classe polymorphique détruite via la base -> destructeur virtuel.
37. Ne pas cacher les coûts importants
Une abstraction ne doit pas faire croire qu'une opération coûte presque rien si elle réalise en réalité :
- une allocation ;
- une copie importante ;
- un syscall ;
- une attente ;
- une opération réseau.
38. Ne pas modéliser le monde réel pour le plaisir
Les livres d'OOP utilisent souvent Animal, Monkey, Car ou Employee pour expliquer les relations.
Cela aide à comprendre la théorie, mais un programme système n'a pas besoin de transformer chaque concept en objet métier.
Des abstractions plus naturelles pour le système sont :
Code: Select all
UniqueHandle
MappedRegion
Socket
FileDescriptor
ThreadPool
ProcessSnapshot
ModuleView
Les classes sont vraiment utiles lorsque tu veux :
- posséder une ressource ;
- libérer cette ressource automatiquement ;
- protéger un état ;
- imposer un invariant ;
- regrouper des opérations autour d'un même état ;
- fournir une interface stable.
- fonction libre ;
- struct ;
- namespace ;
- fichier .cpp/.h ;
40. Les règles à retenir absolument
Règle 1
Ne fais pas de classe uniquement parce que tu programmes en C++.
Règle 2
Pour une ressource, pense RAII.
Règle 3
Un propriétaire doit être clairement identifiable.
Règle 4
Utilise private pour protéger les invariants.
Règle 5
Composition avant héritage dans beaucoup de situations.
Règle 6
Utilise l'héritage seulement lorsqu'il existe une vraie relation is-a.
Règle 7
Utilise override lorsqu'une méthode remplace une méthode virtuelle.
Règle 8
Une classe polymorphique supprimée via sa base doit généralement avoir un destructeur virtuel.
Règle 9
Évite les hiérarchies profondes.
Règle 10
Une abstraction doit réduire la complexité, pas l'augmenter.
Résumé mental
Code: Select all
Fonction
-> opération simple sans état complexe
struct
-> données simples
classe
-> état + invariants
ou ressource + durée de vie
composition
-> "possède/utilise un"
héritage
-> "est un"
RAII
-> acquisition dans l'objet
destruction automatique
polymorphisme
-> plusieurs implémentations
derrière un même contrat
Code: Select all
RESSOURCE NATIVE
|
v
OBJET RAII
|
v
OWNERSHIP CLAIR
|
v
DESTRUCTION AUTOMATIQUE
