I. Introduction▲
Dans cet article, j'aborderai un des concepts fondamentaux introduits par Alex Stepanov et Paul McJones dans leur ouvrage de référence Elements of Programming (abrégé ici en EoP) : celui de type régulier (ou semi-régulier) (regular type) et d'état partiellement formé (partially-formed state).
À partir de ces concepts, j'essaierai d'en déduire des règles d'implémentation en C++ de ce que l'on appelle d'habitude des « types valeur » (value types), en me concentrant sur l'essentiel, qui me semble n'avoir pas été traité suffisamment en profondeur jusqu'à présent : les fonctions membres spéciales.
Alex Stepanov et Paul McJones nous ont apporté une toute nouvelle façon de voir ce sujet à l'aide d'une théorie mathématique des types et des algorithmes qui, pour l'essentiel, ne ressemble à rien de ce qui avait été fait auparavant. Leur contribution changera de façon permanente votre façon de voir la programmation informatique, mais huit ans après sa sortie, ce livre ne connaît pas l'adoption massive qu'il mériterait.
II. Mise en place du décor▲
Les fonctions membres spéciales sont ces fonctions membres d'un objet C++ que le compilateur peut écrire pour vous : le constructeur par défaut, les constructeurs de copie et de déplacement, les opérateurs d'affectation par copie et par déplacement, et le destructeur.
Un type régulier dans EoP correspond grossièrement au concept C++ « comparable par test d'égalité » (EqualityComparable) combiné au concept « constructible par copie » (CopyConstructible), se référer au livre pour plus de détails.
Un type valeur C++ est un type de données défini par son état, et son état seulement (on remarquera toutefois qu'EoP donne une définition très différente d'un value type). Prenons par exemple un int. Deux objets int différents mais tous deux de valeur 5 se comporteront de la même façon face à toutes les opérations ordinaires (en simplifiant, toutes les opérations sauf celle qui consiste à donner l'adresse de l'objet). À l'opposé, deux objets Shape (forme), même s'ils ont tous les deux la même position, la même couleur, la même texture, et ainsi de suite, pourront être affichés à l'écran l'un comme un carré et l'autre comme un triangle. Un tel objet Shape est défini par son comportement au moins autant que par son état. On appelle de tels types des polymorphes.
Il y a de nombreux intermédiaires entre ces deux extrêmes, mais on se limitera ici à cette distinction grossière. Se reporter à Designing value classes for modern C++ - Marc Mutz @ Meeting C++ 2014 pour un traitement quelque peu plus approfondi.
Dans cet article, nous examinerons deux classes différentes, Rect (rectangle) et Pen (stylo), et nous essaierons d'écrire leurs fonctions membres spéciales comme Stepanov nous le ferait faire.
III. Rect et Pen▲
La première classe, Rect, est simple : elle représente un rectangle dont les coordonnées sont des nombres entiers. Nous la définirons entièrement en ligne depuis le fichier d'en-tête. Pen, au contraire, sera assez différente : elle utilisera l'idiome pimpl (pointeur vers l'implémentation) pour cacher ses définitions internes aux utilisateurs. Voir Pimp My Pimpl et Pimp My Pimpl — Reloaded pour plus d'informations sur cet idiome.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
class
Rect {
int
x1, y1, x2, y2;
public
:
}
;
class
Pen {
class
Private; // définie hors-ligne
Private *
d;
public
:
}
;
La première tâche qui nous incombe est d'écrire le constructeur par défaut.
IV. Construction par défaut▲
EoP dit ceci sur le constructeur par défaut :
« [Il] ne prend pas d'arguments et laisse l'objet dans un état partiellement formé. »
Très bien, mais qu'est-ce qu'un « état partiellement formé » ? Voici venir la partie intéressante :
« Un objet est dans un état partiellement formé s'il peut être affecté depuis un autre objet ou détruit. »
Les auteurs ajoutent que toute autre opération sur des objets partiellement formés est indéfinie. En conséquence, de tels objets ne représentent en général pas une valeur valide du type.
La nécessité que les objets soient construits par défaut dans un premier temps est, selon EoP, dictée par la commodité pour le programmeur : T a = b; doit être équivalent à T a; a = b;, et l'utilisateur du type doit pouvoir écrire soit :
2.
3.
4.
5.
T a;
if
(cond)
a =
b;
else
a =
c;
soit :
T a =
(cond) ? b : c;
Sans la construction par défaut, en imaginant que tous les constructeurs écrits par le concepteur du type sont des constructeurs qui établissent une valeur valide, le programmeur serait contraint d'utiliser l'opérateur ternaire, que cela rentre dans les limitations de la longueur d'une ligne ou non, et que cela lui plaise personnellement ou pas.
V. Un constructeur par défaut pour Rect▲
Essayons à présent d'écrire un bout de code pour Rect :
2.
3.
4.
5.
class
Rect {
int
x1, y1, x2, y2;
public
:
Rect() =
default
;
}
;
Qu'en pensez-vous ?
Pour ma part, je n'aurais pas écrit le constructeur par défaut ainsi, j'aurais cherché à initialiser les valeurs du rectangle. Du moins jusqu'à ce qu'EoP m'ouvre les yeux.
Rappelez-vous qu'EoP demande seulement que le constructeur par défaut établisse un état partiellement formé, pas une valeur valide. Cela ne devrait pas vous surprendre, ce n'est au fond pas différent de ce que vous feriez avec des int :
2.
int
x;
Rect r;
Dans les deux cas, toute autre utilisation de l'objet construit par défaut autrement que dans une affectation ou une destruction donne un résultat indéfini, car ses données membres n'ont pas été initialisées.
Si vous ne vous sentez pas à l'aise avec cette implémentation, c'est que vous êtes en train de laisser le programmeur Java qui sommeille en vous prendre le contrôle. Ne le laissez pas faire. C'est du C++, nous prenons à bras le corps ce qui n'est pas défini.
Et, comme Howard Hinnant l'écrit sur reddit dans un commentaire sur cet article, nous donnons le pouvoir à nos utilisateurs :
2.
int
x =
{}
; // x vaut 0
Rect r =
{}
; // r vaut {0, 0, 0, 0}
Passons à présent à la classe Pen.
VI. Un constructeur par défaut sur Pen▲
2.
3.
4.
5.
6.
7.
class
Pen {
class
Private; // définie hors ligne
Private *
d;
public
:
Pen() : d(nullptr
) {}
// en ligne
~
Pen() {
delete
d; }
// hors ligne
}
;
Est-ce que nous aurions dû laisser le pointeur Pen::d non initialisé, ici aussi ?
Non. En procédant ainsi, le comportement de la destruction serait indéfini.
Aurait-il fallu créer un objet Pen::Private au moyen de new qui aurait servi à initialiser Pen::ddans le constructeur par défaut ?
Là aussi, c'est non. Il n'est pas nécessaire de former une valeur valide dans le constructeur par défaut, donc, dans l'esprit « ne pas payer pour ce que l'on n'utilise pas », nous ne faisons que le travail minimal nécessaire pour établir un état partiellement formé.
Enfonçons le clou à fond : est-ce qu'une implémentation de
Colour Pen::
colour() const
;
doit vérifier si d == nullptr ?
Non pour la troisième fois. Un coup d'œil vous permet de voir que l'objet est dans un état partiellement formé. Il n'y a pas besoin de vérifier au moment de l'exécution, sauf dans une optique de débogage.
De ce qui précède, il s'ensuit que vos constructeurs par défaut devraient être marqués noexcept. Si vos constructeurs par défaut déclenchent des exceptions, c'est qu'ils en font trop. Bien sûr, nous parlons toujours ici de types valeur, je ne vous ai jamais dit que les constructeurs par défaut de vos types RAII doivent être noexcept.
VII. Constructeur de déplacement et affectation de déplacement▲
Pour Rect, copier et déplacer sont la même chose, et le compilateur est le mieux placé pour les implémenter à votre place :
2.
3.
4.
5.
6.
class
Rect {
int
x1, y1, x2, y2;
public
:
Rect() =
default
;
// les fonctions membres spéciales de copie et de déplacement vont bien !
}
;
À nouveau, Pen est un peu plus intéressant :
2.
3.
4.
5.
6.
7.
8.
class
Pen {
class
Private; // défini hors ligne
Private *
d;
public
:
Pen() noexcept
: d(nullptr
) {}
// en ligne
Pen(Pen &&
other) noexcept
: d(other.d) {
other.d =
nullptr
; }
// en ligne
~
Pen() {
delete
d; }
// hors ligne
}
;
Les objets Pen sources du déplacement sont mis dans un état partiellement formé. En d'autres termes, déplacer depuis un objet a le même effet que la construction par défaut. Peut-on faire plus simple ?
Nous déléguons l'affectation par déplacement au constructeur de déplacement :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
class
Pen {
class
Private; // définie hors ligne
Private *
d;
public
:
Pen() noexcept
: d(nullptr
) {}
// en ligne
Pen(Pen &&
other) noexcept
: d(other.d) {
other.d =
nullptr
; }
// en ligne
Pen &
operator
=
(Pen &&
other) noexcept
// en ligne
{
Pen moved(std::
move(other)); swap(moved): return
*
this
; }
~
Pen() {
delete
d; }
// hors ligne
void
swap(Pen &
other) noexcept
{
using
std::
swap; swap(d, other.d); }
}
;
On remarquera que toutes les fonctions membres, à part le destructeur, sont en ligne pour le moment, et pourtant nous n'avons pas brisé l'encapsulation de la classe Pen::Private.
VIII. Controverse▲
Le standard ISO C++ décrit les objets sources du déplacement (moved-from), dans la section [lib.types.movedfrom], ainsi :
« Les objets des types de la bibliothèque standard C++ peuvent servir de source à un déplacement. Les opérations de déplacement peuvent être spécifiées explicitement ou générées implicitement. Sauf précision contraire, de tels objets moved-from peuvent être placés dans un état valide mais non spécifié. »
En grande partie à cause de cela, la chaîne de raisonnement simple décrite jusqu'à présent a moins d'amis que ce à quoi on pourrait s'attendre, et c'est pour cela que j'ai écrit cet article.
Vous rencontrerez sans doute beaucoup de résistance si vous essayez d'implémenter vos constructeurs par défaut et vos constructeurs de déplacement ainsi. Mais réfléchissez : quelle serait une « valeur par défaut » naturelle pour votre type ?
Il est facile d'opter pour un choix pas trop mauvais : pour un int, sans doute que la valeur construite par défaut devrait être zéro, et si l'on doit faire avec des valeurs partiellement formées, ou plutôt non initialisées, c'est parce que le C est mal fichu.
Je ne suis pas d'accord. Si vous utilisez int pour des additions, alors oui, zéro est une bonne valeur par défaut. Mais si vous l'utilisez dans des multiplications, alors peut-être que un serait un meilleur choix.
La morale de ceci, c'est que, pour la vaste majorité des types, il n'y a pas de valeur par défaut naturelle. S'il n'y en a pas, alors la contrainte de mettre en place des valeurs par défaut choisies au petit bonheur la chance pour chaque constructeur par défaut est une perte de temps : ne le faites pas.
Arrangez-vous plutôt pour que le constructeur par défaut mette en place un état partiellement formé, et fournisse des valeurs littérales (ou des fonctions de factory nommées quand c'est plus complexe) pour les différentes valeurs « par défaut » :
2.
3.
4.
5.
6.
7.
8.
class
Rect {
static
constexpr
Rect emptyRect =
{}
;
}
;
class
Pen {
static
Pen none();
static
Pen solidBlackCosmetic();
}
;
IX. Adopter les objets partiellement formés▲
Les objets partiellement formés n'ont rien de magique. Ils offrent une description simple du comportement des types natifs du C++ face à la construction par défaut, et des objets implémentés par pointeur (pimpl) face à la sémantique du déplacement, s'ils sont implémentés de façon naturelle.
Dans les deux cas, les objets partiellement formés sont facilement identifiés dans le code source avec un raisonnement local. Demander quelque chose de plus fantaisiste que le strict minimum comme résultat du déplacement d'un objet ou de la construction par défaut revient à violer le principe du C++ : « ne pas payer pour ce que l'on n'utilise pas ». Un corollaire est que l'on peut marquer ses constructeurs par défaut noexcept.
Dans un prochain épisode, nous examinerons un pointeur intelligent qui met en pratique ces règles et peut être utilisé comme pointeur dans une implémentation pimpl.
X. Remerciements et notes de la rédaction Developpez.com▲
Cet article, publié à l'origine sous le titre Stepanov-Regularity and Partially-Formed Objects vs. C++ Value Types, a été écrit par Mark Mutz, ingénieur auprès de KDAB.
Nos remerciements à KDAB pour l'autorisation à traduire et à publier ce tutoriel.
KDAB propose :
- des formations ;
- des conseils ;
- des développements ;
- de l'expertise C++, Qt et OpenGL.
Nous remercions David Faure pour la traduction, Lauren Rosenfeld pour la mise au gabarit et KartSeven pour sa relecture orthographique.