C++ sans *pointeurs
Les pointeurs sont utilisés plus souvent que nécessaire en C++.
Je voudrais présenter ici comment caractériser les utilisations abusives et par quoi les remplacer.
Objectifs
La décision d’utiliser des pointeurs dépend en grande partie de l’API des objets utilisés.
API est à comprendre dans un sens très large : je considère que des classes utilisées dans une autre partie d’une même application exposent une API.
L’objectif est donc de concevoir des API de manière à ce que leur utilisation ne nécessite pas de manipuler de pointeurs, ni même si possible de smart pointers.
Cela peut paraître surprenant, mais c’est en fait ainsi que vous utilisez les classes de la STL ou de Qt : vos méthodes ne retournent jamais un raw pointer ni un smart pointer vers une string nouvellement créée.
De manière générale, vous n’écririez pas ceci :
ni ceci :
À la place, vous écririez sûrement :
Notre objectif est d’écrire des classes qui s’utiliseront de la même manière.
Ownership
Il faut distinguer deux types de raw pointers :
- ceux qui détiennent l’objet pointé (owning), qui devront être libérés ;
- ceux qui ne le détiennent pas (non-owning).
Le plus simple est de les comparer sur un exemple.
Owning
Ici, nous avons la responsabilité de supprimer info
au bon moment.
C’est ce type de pointeurs dont nous voulons nous débarrasser.
Non-owning
Ici, le pointeur permet juste de passer l’adresse de l’objet, mais la méthode
writeDataTo(…)
ne doit pas gérer sa durée de vie : elle ne le détient donc
pas.
Cet usage est tout-à-fait légitime, nous souhaitons le conserver.
Pour savoir si un pointeur est owning ou non, il suffit de se poser la
question suivante : est-ce que lui affecter nullptr
provoquerait une fuite
mémoire ?
Pourquoi ?
Voici quelques exemples illustrant pourquoi nous voulons éviter les owning raw pointers.
Fuite mémoire
Il est facile d’oublier de supprimer un pointeur dans des cas particuliers.
Par exemple :
Ici, si l’ouverture du fichier a échoué, parser
ne sera jamais libéré.
L’exemple suivant est encore plus significatif :
Appeler une méthode sans s’occuper du résultat peut provoquer des fuites mémoires.
Double suppression
Il est également possible, par inattention, de supprimer plusieurs fois le même pointeur (ce qui entraîne un undefined behavior).
Par exemple, si device
fait partie de la liste devices
, ce code le supprime
deux fois :
Utilisation après suppression
L’utilisation d’un pointeur après sa suppression est également indéfinie.
Je vais prendre un exemple réel en Qt.
Supposons qu’une classe DeviceMonitor
surveille le branchement de
périphériques, et crée pour chacun un objet Device
.
Lorsqu’un périphérique est débranché, un signal Qt provoque
l’exécution du slot DeviceMonitor::onDeviceLeft(Device *)
. Nous voulons
alors signaler au reste de l’application que le device est parti (signal
DeviceMonitor::deviceLeft(Device *)
), puis supprimer l’object device
correspondant :
Mais c’est loin d’être trivial.
Si nous le supprimons immédiatement comme ceci, et qu’un slot est branché à
DeviceMonitor::deviceLeft(Device *)
en
Qt::QueuedConnection
, alors il est possible que le pointeur
soit déjà supprimé quand ce slot sera exécuté.
Un proverbe dit que quand ça crashe avec un delete
, “il faut appeller
deleteLater()
pour corriger le problème” :
Mais malheureusement, ici, c’est faux : si le slot branché au signal
DeviceMonitor::deviceLeft(Device *)
est associé à un QObject
vivant dans un autre thread, rien ne garantit que son
exécution aura lieu avant la suppression du pointeur.
L’utilisation des owning raw pointers n’est donc pas seulement vulnérable aux erreurs d’inattention (comme dans les exemples précédents) : dans des cas plus complexes, il devient difficile de déterminer quand supprimer le pointeur.
Responsabilité
De manière plus générale, lorsque nous avons un pointeur, nous ne savons pas forcément qui a la responsabilité de le supprimer, ni comment le supprimer :
Qt fournit un mécanisme pour supprimer automatiquement les QObject *
quand
leur parent est détruit. Cependant, cette fonctionnalité ne s’applique qu’aux
relations de composition.
Résumons les inconvénients des owning raw pointeurs :
- la gestion mémoire est manuelle ;
- leur utilisation est propice aux erreurs ;
- la responsabilité de suppression n’est pas apparente ;
- déterminer quand supprimer le pointeur peut être difficile.
Valeurs
Laissons de côté les pointeurs quelques instants pour observer ce qu’il se passe avec de simples valeurs (des objets plutôt que des pointeurs vers des objets) :
C’est plus simple : la gestion mémoire est automatique, et le code est plus sûr. Par exemple, les fuites mémoire et les double suppressions sont impossibles.
Ce sont des avantages dont nous souhaiterions bénéficier également pour les pointeurs.
Privilégier les valeurs
Dans les cas où les pointeurs sont utilisés uniquement pour éviter de retourner des copies (et non pour partager des objets), il est préférable de retourner les objets par valeur à la place.
Par exemple, si vous avez une classe :
Évitez :
Préférez :
Certes, dans certains cas, il est moins efficace de passer un objet par valeur qu’à travers un pointeur (car il faut le copier).
Mais cette inefficacité est à relativiser.
D’abord parce que dans certains cas (quand l’objet est copié à partir d’une
rvalue reference), la copie sera remplacée par un move. Le move
d’un vector
par exemple n’entraîne aucune copie (ni move) de ses
éléments.
Ensuite parce que les compilateurs optimisent le retour par valeur
(RVO), ce qui fait qu’en réalité dans les exemples ci-dessus, aucun Result
ni Vector
n’est jamais copié ni mové : ils sont directement créés à
l’endroit où ils sont affectés (sauf si vous compilez avec le paramètre
-fno-elide-constructors
).
Mais évidemment, il y a des cas où nous ne pouvons pas simplement remplacer un pointeur par une valeur, par exemple quand un même objet doit être partagé entre différentes parties d’un programme.
Nous voudrions les avantages des valeurs également pour ces cas-là. C’est l’objectif de la suite du billet.
Idiomes C++
Pour y parvenir, nous avons besoin de faire un détour par quelques idiomes couramment utilisés en C++.
Ils ont souvent un nom étrange. Par exemple :
- RAII (Resource Acquisition Is Initialization)
- PIMPL (Pointer to IMPLementation)
- CRTP (Curiously Recurring Template Pattern)
- SFINAE (Substitution Failure Is Not An Error)
- IIFE (Immediately-Invoked Function Expression)
Nous allons étudier les deux premiers.
RAII
Prenons un exemple simple :
Nous souhaitons rendre cette méthode thread-safe grâce à un mutex
(std::mutex
en STL ou QMutex
en Qt).
Supposons que validate()
et something()
puissent lever une exception.
Le mutex doit être déverrouillé à la fin de l’exécution de la méthode. Le problème, c’est que cela peut se produire à différents endroits, donc nous devons gérer tous les cas :
Le code est beaucoup plus complexe et propice aux erreurs.
Avec des classes utilisant RAII (std::lock_guard
en STL ou
QMutexLocker
en Qt), c’est beaucoup plus simple :
En ajoutant une seule ligne, la méthode est devenue thread-safe.
Cette technique consiste à utiliser le cycle de vie d’un objet pour acquérir une ressource dans le constructeur (ici verrouiller le mutex) et la relâcher dans le destructeur (ici le déverrouiller).
Voici une implémentation simplifiée possible de QMutexLocker
:
Comme l’objet est détruit lors de la sortie du scope de la méthode (que ce
soit par un return
ou par une exception survenue n’importe où), le mutex
sera toujours déverrouillé.
Au passage, dans l’exemple ci-dessus, nous remarquons que la variable locker
n’est jamais utilisée. RAII complexifie donc la détection des variables
inutilisées, car le compilateur doit détecter les effets de bords. Mais il s’en
sort bien : ici, il n’émet pas de warning.
Smart pointers
Les smart pointers utilisent RAII pour gérer automatiquement la durée de vie des pointeurs. Il en existe plusieurs.
Dans la STL :
std::unique_ptr
std::shared_ptr
std::weak_ptr
std::auto_ptr
(à bannir)
Dans Qt :
QSharedPointer
(équivalent destd::shared_ptr
)QWeakPointer
(équivalent destd::weak_ptr
)QScopedPointer
(ersatz destd::unique_ptr
)QScopedArrayPointer
QPointer
QSharedDataPointer
QExplicitlySharedDataPointer
Scoped pointers
Le smart pointer le plus simple est le scoped pointer. L’idée est vraiment
la même que QMutexLocker
, sauf qu’au lieu de vérouiller et
déverrouiller un mutex, il stocke un raw pointer et le supprime.
En plus de cela, comme tous les smart pointers, il redéfinit certains opérateurs pour pouvoir être utilisé comme un raw pointer.
Par exemple, voici une implémentation simplifiée possible de
QScopedPointer
:
Et un exemple d’utilisation :
Shared pointers
Les shared pointers permettent de partager l’ownership (la responsabilité de suppression) d’une ressource.
Ils contiennent un compteur de références, indiquant le nombre d’instances partageant le même pointeur. Lorsque ce compteur tombe à 0, le pointeur est supprimé (il faut donc éviter les cycles).
En pratique, voici ce à quoi ressemblerait une liste de Device
s partagés par
des QSharedPointer
s :
Le partage d’un pointeur découle toujours de la copie d’un shared pointer.
C’est la raison pour laquelle getDevice(…)
et add(…)
manipulent un
QSharedPointer
.
Le piège à éviter est de créér plusieurs smart pointers indépendants sur le même raw pointer. Dans ce cas, il y aurait deux refcounts à 1 plutôt qu’un refcount à 2, et le pointeur serait supprimé dès la destruction du premier shared pointer, laissant l’autre pendouillant.
Petite parenthèse : la signature des méthodes add
et remove
sont
différentes car une suppression ne nécessite pas de manipuler la durée de
vie du Device
passé en paramètre.
Refcounted smart pointers are about managing te owned object’s lifetime.
Copy/assign one only when you intend to manipulate the owned object’s lifetime.
Au passage, si en Qt vous passez vos objets de la couche C++ à la couche QML, il faut aussi passer les shared pointers afin de ne pas casser le partage, ce qui implique d’enregistrer le type :
Listons donc les avantages des shared pointers :
- la gestion mémoire est automatique ;
- l’ownership est géré automatiquement ;
- l’utilisation est moins propice aux erreurs (à part la possibilité de créer des smart pointers indépendants sur le même raw pointer) ;
Cependant, si la gestion mémoire est automatique, elle n’est pas
transparente : elle nécessite de manipuler explicitement des
QSharedPointer
, ce qui est verbeux.
Il est certes possible d’utiliser un alias (typedef) pour atténuer la verbosité :
Mais quoi qu’il en soit, cela reste plus complexe que des valeurs.
Pour aller plus loin, nous allons devoir faire un détour inattendu, par un idiome qui n’a a priori rien à voir.
PImpl
PImpl sert à réduire les dépendances de compilation.
Opaque pointers are a way to hide the implementation details of an interface from ordinary clients, so that the implementation may be changed without the need to recompile the modules using it.
Prenons la classe Person
suivante (person.h
) :
Elle contient juste un nom et un âge. Elle définit par ailleurs une méthode
privée, countYears(…)
, qu’on imagine appelée dans getAge()
.
Chaque classe désirant utiliser la classe Person
devra l’inclure :
Par conséquent, à chaque modification de ces parties privées (qui sont pourtant
que des détails d’implémentation), toutes les classes incluant person.h
devront être recompilées.
C’est ce que PImpl permet d’éviter, en séparant la classe en deux :
- une interface publique ;
- une implémentation privée.
Concrètement, la classe Person
précédente est la partie privée. Renommons-la :
Créons la partie publique, définissant l’interface souhaitée :
Elle contient un pointeur vers PersonPrivate
, et lui délègue tous les appels.
Évidemment, Person
ne doit pas inclure PersonPrivate
, sinon nous aurions les
mêmes dépendances de compilation, et nous n’aurions rien résolu. Il faut
utiliser à la place une forward declaration.
Voici son implémentation :
Le pointeur vers la classe privée est souvent nommé d
car il s’agit d’un
d-pointer.
Donc comme prévu, tout cela n’a rien à voir avec notre objectif d’éviter d’utiliser des pointeurs.
Partage
Mais en fait, si. PImpl permet de séparer les classes manipulées explicitement de l’objet réellement modifié :
Il y a une relation 1-1 entre la classe publique et la classe privée correspondante. Mais nous pouvons imaginer d’autres cardinalités.
Par exemple, Qt partage implicitement les parties privées d’un grand nombre de classes. Il ne les copie que lors d’une écriture (CoW) :
Par exemple, lorsqu’une QString
est copiée, la même zone mémoire
sera utilisée pour les différentes instances, jusqu’à ce qu’une modification
survienne.
Cependant, il ne s’agit que d’un détail d’implémentation utilisé pour améliorer les performances. Du point de vue utilisateur, tout se passe comme si les données étaient réellement copiées :
En d’autres termes, les classes publiques ci-dessus ont une sémantique de valeur.
Resource handles
À la place, nous pouvons décider de partager inconditionnellement la partie privée, y compris après une écriture :
Dans ce cas, la classe publique a sémantique d’entité. Elle est qualifiée de resource handle.
C’est bien sûr le cas des smart pointers :
Mais aussi d’autres classes, comme l’API Dom de Qt :
PImpl avec des smart pointers
Tout-à-l’heure, j’ai présenté PImpl en utilisant un owning raw pointer :
Mais en fait, à chaque type de relation correspond un type de smart pointer directement utilisable pour PImpl.
Pour une relation 1-1 classique :
Pour une relation 1-N à sémantique de valeur (CoW) :
Pour une relation 1-N à sémantique d’entité :
Par exemple, donnons à notre classe Person
une sémantique d’entité :
Person
se comporte maintenant comme un pointeur.
p1
et p2
sont alors des resource handles vers PersonPrivate
:
Évidemment, ce n’est pas approprié pour la classe Person
, car le comportement
est trop inattendu.
Mais je vais présenter un cas réel où ce design est approprié.
En pratique
Pour l’entreprise dans laquelle je suis salarié, j’ai implémenté une fonctionnalité permettant d’utiliser une souris USB branchée sur un PC pour contrôler un téléphone Android connecté en USB.
Concrètement, cela consiste à tranférer (grâce à libusb
), à partir
du PC, les événements HID reçus de la souris vers le téléphone Android.
J’ai donc (entre autres) créé des resources handles UsbDevice
et
UsbDeviceHandle
qui wrappent les structures C libusb_device
et libusb_device_handle
, suivant les principes
détaillés dans ce billet.
Leur utilisation illustre bien, d’après moi, les bénéfices d’une telle conception.
UsbDevice
peut être retourné par valeur, et passé en paramètre d’un signal
par const reference (exactement comme nous le ferions avec un
QString
).
Si une souris est trouvée dans la liste, on la retourne simplement ; sinon, on
retourne un UsbDevice
“null”.
La gestion mémoire est totalement automatique et transparente. Les problèmes présentés sont résolus.
UsbDevice
peut naviguer entre la couche C++ et QML.
Grâce à RAII, les connexions (UsbDeviceHandle
) sont fermées automatiquement.
En particulier, si la connexion à la souris échoue, la connexion au téléphone Android est automatiquement fermée.
Résultat
Dans ces différents exemples, new
et delete
ne sont jamais utilisés, et
par construction, la mémoire sera correctement gérée. Ou plus précisément,
si un problème de gestion mémoire existe, il se situera dans l’implémentation de
la classe elle-même, et non partout où elle est utilisée.
Ainsi, nous manipulons des handles se comportant comme des pointeurs, ayant les mêmes avantages que les valeurs :
- gestion mémoire automatique et transparente ;
- simple ;
- efficace ;
- sûr et robuste.
Ils peuvent par contre présenter quelques limitations.
Par exemple, ils sont incompatibles avec QObject
. En effet,
techniquement, la classe d’un resource handle doit pouvoir être copiée (pour
supporter le passage par valeur), alors qu’un QObject
n’est pas
copiable :
QObject
s are identities, not values.
Très concrètement, cela implique que UsbDevice
ne pourrait pas supporter de
signaux (en tout cas, pas directement). C’est d’ailleurs le cas de beaucoup de
classes de Qt : par exemple QString
et QList
n’héritent
pas de QObject
.
Résumé
C’est juste une heuristique…
Conclusion
En suivant ces principes, nous pouvons nous débarrasser des owning
raw pointers et des new
et delete
“nus”. Cela contribue à rendre le code
plus simple et plus robuste.
Ce sont d’ailleurs des objectifs qui guident les évolutions du langage C++ :
- A brief introduction to C++’s model for type and resource-safety
- Writing good C++14
- Elements of Modern C++ Style
return 0;