Un contrat est défini entre deux parties d’un programme. Définir précisément ce qui est attendu d’un fournisseur de service et d’un utilisateur de ce service est essentiel. Avoir des interfaces faciles à comprendre, favorisant un usage efficace, peu sujettes aux erreurs et facilitant les tests, constitue probablement l’aspect le plus important de l’organisation du code.
Interface rule summary:
constExpects() pour exprimer les préconditionsEnsures() pour exprimer les postconditionsT*) ou une référence (T&)not_nullVoir également:
Raison
Correctitude. Les suppositions non énoncées dans une interface sont facilement négligées et difficiles à tester.
Exemple, mauvais
Contrôler le comportement d’une fonction via une variable globale (dans l’espace de noms) (un mode d’appel) est implicite et potentiellement source de confusion. Par exemple:
int round(double d)
{
return (round_up) ? ceil(d) : d; // ne pas : dépendance « invisible »
}
Il ne sera pas évident pour un appelant que l’appel de round(7.2) puisse donner des résultats différents.
Exception
Parfois, nous contrôlons les détails d’un ensemble d’opérations à travers une variable d’environnement, par ex. sortie normale vs. verbeuse ou debug vs. optimisé. L’utilisation d’un contrôle non local peut prêter à confusion, mais elle ne contrôle que les détails d’implémentation d’une sémantique restée autrement fixe.
Exemple, mauvais
Un rapport via des variables non locales (p.ex. errno) est facile à ignorer. Par exemple:
// ne pas : pas de test de la valeur de retour de fprintf
fprintf(connection, “logging: %d %d %d\n”, x, y, s);
Que se passe-t-il si la connexion est perdue, de sorte qu’aucune sortie de journalisation n’est produite ? Cf. I.??? @TODO-LINK.
Alternative: Lever une exception. Une exception ne peut pas être ignorée.
Formulation alternative: Éviter de transmettre des informations à travers une interface via un état non local ou implicite. Notez que les fonctions non constantes passent des informations à d’autres fonctions membres via l’état de leur objet.
Formulation alternative: Une interface devrait être une fonction ou un ensemble de fonctions. Les fonctions peuvent être des modèles de fonctions et les ensembles de fonctions peuvent être des classes ou des modèles de classe.
Application
constRaison
Les variables globales non const cachent des dépendances et la rendent sujettes à des changements imprévisibles.
Exemple
struct Data {
// ... beaucoup de choses ...
} data; // data non-const
void compute() // ne pas
{
// ... utiliser data ...
}
void output() // ne pas
{
// ... utiliser data ...
}
Qui d’autre pourrait modifier data ?
Avertissement
L’initialisation des objets globaux n’est pas totalement ordonnée. Si vous utilisez un objet global, initialisez-le avec une constante. Notez qu’il est possible d’obtenir un ordre d’initialisation indéfini même pour les objets const.
Exception
Un objet global est souvent meilleur qu’un singleton.
Remarque
Les constantes globales sont utiles.
Remarque
La règle contre les variables globales s’applique également aux variables en espace de noms.
Formulation alternative: Si vous utilisez des données globales (ou plus généralement de l’échelle de nom) pour éviter la copie, envisagez de passer ces données comme un objet par référence const. Une autre solution consiste à définir les données comme l’état d’un objet et les opérations comme fonctions membres.
Avertissement: Méfiez-vous des conditions de course : si un fil peut accéder à des données non locales (ou données passées par référence) alors qu’un autre fil exécute le callee, nous pouvons avoir une condition de course. Chaque pointeur ou référence à des données mutables est un potentiel de course.
Utiliser des pointeurs ou références globaux pour accéder et changer des données non constantes n’est pas une meilleure alternative aux variables globales non constantes puisque cela ne résout pas les problèmes de dépendances cachées ou de conditions de course potentielles.
Remarque
Vous ne pouvez pas avoir une condition de course sur des données immuables.
Références : Voir les règles pour l’appel de fonctions (#ss-call) @TODO-LINK
Remarque
La règle est « éviter », pas « ne pas utiliser ». Évidemment il y aura des exceptions (rare), comme cin, cout, et cerr.
Application
const déclarées à l’échelle de nom et les pointeurs/références globales vers des données non constantes.Raison
Les singletons sont essentiellement des objets globaux compliqués déguisés.
Exemple
class Singleton {
// ... beaucoup de choses pour s'assurer que seul un Singleton est créé,
// qu'il est initialisé correctement, etc.
};
Il existe de nombreuses variantes de l’idée de singleton, c’est en partie le problème.
Remarque
Si vous ne souhaitez pas qu’un objet global change, déclarez-le const ou constexpr.
Exception
Vous pouvez utiliser le plus simple « singleton » (si simple qu’il n’est souvent pas considéré comme un singleton) pour obtenir l’initialisation à la première utilisation, si besoin :
X& myX()
{
static X my_x {3};
return my_x;
}
C’est l’une des solutions les plus efficaces pour les problèmes liés à l’ordre d’initialisation. Dans un environnement multithread, l’initialisation d’un static local ne crée pas de condition de course (à moins d’accéder de façon négligée à un objet partagé depuis son constructeur).
Notez qu’une initialisation d’un static local ne signifie pas une condition de course. Cependant, si la destruction de X implique une opération qui doit être synchronisée, nous devons utiliser une solution moins simple. Par exemple:
X& myX()
{
static auto p = new X {3};
return *p; // fuite potentielle
}
Maintenant quelqu’un doit delete cet objet d’une manière thread-safe. C’est une erreur potentielle, donc nous n’utilisons pas cette technique à moins que
myX soit dans un code multithread,X doive être détruit (par ex. libération d’une ressource),X doive être synchronisé.Si vous, comme beaucoup, définissez un singleton comme une classe pour laquelle un seul objet est créé, des fonctions comme myX ne sont pas des singletons, et cette technique utile n’est pas une exception à la règle « pas de singleton ».
Application
Très difficile en général.
singleton.static local de type X et retourne un pointeur ou une référence vers celui‑ci, bannir cela.Raison
Les types sont la documentation la plus simple et la mieux documentée, améliorent la lisibilité grâce à leur signification bien définie et sont vérifiés à la compilation. De plus, un code typé précisément est souvent mieux optimisé.
Exemple, ne pas
Considérez :
void pass(void* data); // type faible et sous-qualifié, suspect
Les appelants ne savent pas quels types sont autorisés et si les données peuvent être modifiées car const n’est pas spécifié. Notez que tous les types de pointeur se convertissent implicitement à void*, il est donc facile pour un appelant de fournir cette valeur.
Le callee doit static_cast la donnée à un type vérifié pour l’utiliser. C’est sujet aux erreurs et verbeux.
Utilisez const void* uniquement pour passer des données dans des conceptions indescriptibles en C++. Envisagez d’utiliser un variant ou un pointeur vers la base à la place.
Alternative : Souvent, un paramètre modèle peut éliminer le void*, le transformant en T* ou T&. Pour le code générique, ces T peuvent être des paramètres de modèle générique ou être contrainte par des concepts.
Exemple, mauvais
Considérez :
draw_rect(100, 200, 100, 500); // que signifient les nombres ?
draw_rect(p.x, p.y, 10, 20); // quelles unités 10 et 20 représentent‑elles ?
Il est clair que l’appelant décrit un rectangle, mais il n’est pas clair quelles parties se rapportent à quels. De plus, un int peut porter des formes arbitraires d’information, incluant des valeurs de nombreuses unités, donc il faut deviner la signification des quatre int. Probablement, les deux premiers sont une paire de coordonnées x,y, mais que sont les deux derniers ?
Les commentaires et noms de paramètres peuvent aider, mais nous pouvons être explicites :
void draw_rectangle(Point top_left, Point bottom_right);
void draw_rectangle(Point top_left, Size height_width);
draw_rectangle(p, Point{10, 20}); // deux coins
draw_rectangle(p, Size{10, 20}); // un coin et un couple (hauteur, largeur)
Naturellement, nous ne pouvons pas attraper toutes les erreurs via le système de types statiques (ex. la première argument doit être un coin supérieur gauche) laissé par convention (naming et commentaires).
Exemple, mauvais
Considérez :
set_settings(true, false, 42); // que signifient les nombres ?
Les types de paramètres ainsi que leurs valeurs ne communiquent pas quel réglage est spécifié ou ce que ces valeurs signifient.
Cette conception est plus explicite, sûre et lisible :
alarm_settings s{};
s.enabled = true;
s.displayMode = alarm_settings::mode::spinning_light;
s.frequency = alarm_settings::every_10_seconds;
set_settings(s);
Pour le cas d’un ensemble de valeurs booléennes, envisagez un enum de drapeaux ; un motif qui exprime un ensemble de valeurs booléennes.
enable_lamp_options(lamp_option::on | lamp_option::animate_state_transitions);
Exemple, mauvais
Dans l’exemple suivant, il n’est pas clair dans l’interface ce que time_to_blink signifie : secondes ? millisecondes ?
void blink_led(int time_to_blink) // mauvais -- l'unité est ambiguë
{
// ...
// faire quelque chose avec time_to_blink
// ...
}
void use()
{
blink_led(2);
}
Exemple, bon
Les types std::chrono::duration aident à rendre l’unité explicite :
void blink_led(milliseconds time_to_blink) // bon -- l'unité est explicite
{
// ...
// faire quelque chose avec time_to_blink
// ...
}
void use()
{
blink_led(1500ms);
}
La fonction peut également être écrite de telle façon qu’elle accepte n’importe quelle unité de durée.
template<class rep, class period>
void blink_led(duration<rep, period> time_to_blink) // bon -- accepte n'importe quelle unité
{
// supposons que millisecond est l'unité la plus petite pertinente
auto milliseconds_to_blink = duration_cast<milliseconds>(time_to_blink);
// ...
// faire quelque chose avec milliseconds_to_blink
// ...
}
void use()
{
blink_led(2s);
blink_led(1500ms);
}
Application
void* comme paramètre ou type de retour.Raison
Les arguments ont une signification qui peut restreindre leur utilisation appropriée dans l’appelé.
Exemple
Considérez :
double sqrt(double x);
Ici x doit être non négatif. Le système de types ne peut pas (facilement et naturellement) l’exprimer, il faut donc d’autres moyens. Par exemple :
double sqrt(double x); // x doit être non négatif
Certaines préconditions peuvent être exprimées comme assertions. Par exemple :
double sqrt(double x) { Expects(x >= 0); /* ... */ }
Idéalement, cette Expects(x >= 0) devrait faire partie de l’interface de sqrt() mais ce n’est pas facile à faire. Pour l’instant, nous la plaçons dans la définition (corps de fonction).
Références : Expects() est décrite dans les directives de soutien GSL @TODO-LINK
Remarque
Préférer une spécification formelle des exigences, comme Expects(p);. Si cela est irréalisable, utiliser du texte anglais dans les commentaires, tel que // la séquence [p:q) est triée en utilisant <.
Remarque
La plupart des fonctions membres ont comme précondition qu’une certaine invariant de classe tient. Cet invariant est établi par un constructeur et doit être rétabli à la sortie par chaque fonction membre appelée depuis l’extérieur de la classe. Nous n’avons pas besoin de le mentionner pour chaque fonction membre.
Application
(Not enforceable)
Voir également : les règles pour passer des pointeurs. @TODO-LINK
Expects() pour exprimer les préconditionsRaison
Pour mettre en évidence qu’une condition est une précondition et permettre l’utilisation d’outils.
Exemple
int area(int height, int width)
{
Expects(height > 0 && width > 0); // bon
if (height <= 0 || width <= 0) my_error(); // obscur
// ...
}
Remarque
Les préconditions peuvent être exprimées de plusieurs façons, y compris des commentaires, des instructions if et assert(). Cela les rend difficiles à distinguer du code ordinaire, difficiles à mettre à jour, difficiles à manipuler par des outils, et peuvent avoir la mauvaise sémantique (dois‑je toujours aborter en mode debug et rien ne vérifier en mode production ?).
Remarque
Les préconditions devraient faire partie de l’interface plutôt que de l’implémentation, mais nous n’avons pas encore la capacité linguistique pour le faire. Une fois l’appui de la langue disponible (p.ex. voir la proposition de contrat), nous adopterons la version standard des préconditions, postconditions et assertions.
Remarque
Expects() peut également être utilisé pour vérifier une condition en plein milieu d’un algorithme.
Remarque
Non, utiliser unsigned n’est pas un bon moyen de contourner le problème de assurer qu’une valeur est non négative @TODO-LINK
Application
(Not enforceable) Trouver la variété de façons dont les préconditions peuvent être vérifiées n’est pas faisable. Avertir ceux qui peuvent être identifiés facilement (assert()) a une valeur douteuse en l’absence d’un support langue.
Raison
Pour détecter les malentendus sur le résultat et éventuellement détecter des implémentations erronées.
Exemple, mauvais
Considérez :
int area(int height, int width) { return height * width; } // mauvais
Ici, nous avons accidentellement laissé de côté la spécification de précondition, donc il n’est pas clair que height et width doivent être positifs. Nous avons également omis la spécification de postcondition, donc il n’est pas évident que l’algorithme (height * width) soit incorrect pour des surfaces > le plus grand entier. Une déformation peut survenir. Considérez :
int area(int height, int width)
{
auto res = height * width;
Ensures(res > 0);
return res;
}
Exemple, mauvais
Considérez un bogue de sécurité célèbre :
void f() // problématique
{
char buffer[MAX];
// ...
memset(buffer, 0, sizeof(buffer));
}
Il n’y avait pas de postcondition indiquant que le tampon devait être effacé et l’optimiseur a éliminé l’appel memset() apparemment redondant :
void f() // meilleur
{
char buffer[MAX];
// ...
memset(buffer, 0, sizeof(buffer));
Ensures(buffer[0] == 0);
}
Remarque
Les postconditions sont souvent exprimées de façon informelle dans un commentaire indiquant la finalité d’une fonction ; Ensures() peut être utilisé pour rendre cela plus systématique, visible et vérifiable.
Remarque
Les postconditions sont d’autant plus importantes lorsqu’elles concernent quelque chose qui n’est pas directement reflété dans un résultat retourné, tel qu’un état d’une structure de données utilisée.
Exemple
Considérez une fonction qui manipule une Record, utilisant un mutex pour éviter les conditions de course :
mutex m;
void manipulate(Record& r) // ne pas
{
m.lock();
// ... pas de m.unlock() ...
}
Ici, nous « avons oublié » d’indiquer que le mutex devrait être libéré, donc nous ne savons pas si l’échec d’assurer la libération du mutex était un bug ou un trait. Afficher la postcondition aurait clarifié :
void manipulate(Record& r) // postcondition : m est débloqué à la sortie
{
m.lock();
// ... pas de m.unlock() ...
}
Le bug devient maintenant évident (mais uniquement d’un humain lisant les commentaires).
Mieux encore, utilisez RAII @TODO-LINK afin d’assurer que la postcondition (« le verrou doit être libéré ») est respectée dans le code :
void manipulate(Record& r) // meilleur
{
lock_guard<mutex> _ {m};
// ...
}
Remarque
Idéalement, les postconditions sont exprimées dans l’interface/déclaration afin que les utilisateurs puissent les voir le plus aisément. Seules les postconditions relatives aux utilisateurs peuvent être exprimées dans l’interface. Les postconditions relatives uniquement à l’état interne appartiennent à la définition/implémentation.
Application
(Not enforceable) Ceci est un principe directif qui n’est pas réalisable à vérifier dans le cas général. Des vérificateurs spécifiques aux domaines (comme les vérificateurs de détention de verrou) existent pour de nombreuses chaînes d’outils.
Ensures() pour exprimer les postconditionsRaison
Pour mettre en évidence qu’une condition est une postcondition et permettre l’utilisation d’outils.
Exemple
void f()
{
char buffer[MAX];
// ...
memset(buffer, 0, MAX);
Ensures(buffer[0] == 0);
}
Remarque
Les postconditions peuvent être exprimées de plusieurs façons, y compris des commentaires, des instructions if et assert(). Cela les rend difficiles à distinguer du code ordinaire, difficiles à mettre à jour, difficiles à manipuler par des outils, et peuvent avoir la mauvaise sémantique.
Alternative : Les postconditions de la forme « cette ressource doit être libérée » sont mieux exprimées par RAII @TODO-LINK.
Remarque
Ideally, this Ensures should be part of the interface, but that’s not easily done. For now, we place it in the definition (function body). Once language support becomes available (e.g., see the contract proposal), we will adopt the standard version of preconditions, postconditions, and assertions.
Application
(Not enforceable) Trouver la variété de façons que les postconditions peuvent être vérifiées n’est pas faisable. Avertir ceux qui peuvent être identifiés facilement (assert()) a une valeur douteuse en l’absence d’un support langue.
Raison
Spécifier l’interface de façon précise et vérifiable à la compilation dans un futur proche.
Exemple
Utiliser la spécification de exigences de la C++20. Par ex. :
template<typename Iter, typename Val>
requires input_iterator<Iter> && equality_comparable_with<iter_value_t<Iter>, Val>
Iter find(Iter first, Iter last, Val v)
{
// ...
}
Voir également : Programmation générique @TODO-LINK et concepts @TODO-LINK.
Application
Avertir si tout paramètre de modèle non var-adic n’est pas restreint par un concept (dans sa déclaration ou mentionné dans une clause requires).
Raison
Il ne devrait pas être possible d’ignorer une erreur car cela pourrait laisser le système ou une computation dans un état non défini (ou inattendu). C’est une source majeure d’erreur.
Exemple
int printf(const char* ...); // mauvais : retourne un nombre négatif si l'affichage échoue
template<class F, class ...Args>
// bon : lancer `system_error` si impossible de démarrer le nouveau thread
explicit thread(F&& f, Args&&... args);
À propos de qu’est une erreur?
Une erreur signifie que la fonction ne peut pas atteindre son objectif annoncé (y compris l’établissement des postconditions). Ignorer une erreur peut conduire à des résultats erronés ou à des états système indéfinis. Par exemple, ne pas pouvoir se connecter à un serveur distant n’est pas en soi une erreur : le serveur peut refuser la connexion pour de nombreuses raisons, donc la chose naturelle est de retourner un résultat que l’appelant devrait toujours vérifier. Cependant, si l’échec d’établir une connexion est considéré comme une erreur, alors une erreur devrait lancer une exception.
Exception
De nombreuses fonctions d’interface traditionnelles (p.ex. des gestionnaires de signal UNIX) utilisent des codes d’erreur (p.ex. errno) pour rapporter ce qui sont en fait des codes de statut, et non des erreurs. Vous n’avez pas de bonne alternative à l’utilisation de tels, donc appeler ces fonctions ne viole pas la règle.
Alternative
Si vous ne pouvez pas utiliser des exceptions (par ex. votre code est plein d’utilisation de pointeur brut ou a des contraintes temps réel hard), envisagez un style qui renvoie une paire de valeurs :
int val;
int error_code;
tie(val, error_code) = do_something();
if (error_code) {
// ... gérer l'erreur ou quitter ...
}
// ... utiliser val ...
Ce style malheureusement conduit à des variables non initialisées. Depuis C++17, la fonctionnalité de bindings structurés peut être utilisée pour initialiser des variables directement à partir de la valeur de retour :
auto [val, error_code] = do_something();
if (error_code) {
// ... gérer l'erreur ou quitter ...
}
// ... utiliser val ...
À propos de performance.
Voir également : I.5 et I.7 pour le signalement des violations de précondition et postcondition.
Application
errno.T*) ou une référence (T&)Raison
Si un doute persiste que l’appelant ou le callee possède un objet, des fuites ou des destructions prématurées surviendront.
Exemple
Considérez :
X* compute(args) // ne pas
{
X* res = new X{};
// ...
return res;
}
Qui supprime le X retourné ? Le problème serait plus difficile à repérer si compute retournait une référence. Considérez : retourner le résultat par valeur (utiliser le passage par valeur ou move semantics si le résultat est volumineux) :
vector<double> compute(args) // bon
{
vector<double> res(10000);
// ...
return res;
}
Alternative : Pass ownership @TODO-LINK en utilisant un «pointeur intelligent», tel que unique_ptr (pour la propriété exclusive) ou shared_ptr (pour la propriété partagée). Cependant, cela est moins élégant et souvent moins efficace que retourner l’objet lui‑même, donc utilisez les pointeurs intelligents uniquement si la sémantique de référence est nécessaire.
Alternative : Parfois, un ancien code ne peut être modifié à cause de contraintes de compatibilité ABI ou de ressources manquantes. Dans ce cas, marquez les pointeurs propriétaires en utilisant owner de la bibliothèque de soutien GSL @TODO-LINK :
owner<X*> compute(args) // Le ownership est clairement transféré
{
owner<X*> res = new X{};
// ...
return res;
}
Ceci indique aux outils d’analyse que res est un owner. Autrement dit, sa valeur doit être delete ou transféré à un autre owner, comme c’est le cas ici via le return.
owner est utilisé de façon similaire dans l’implémentation des handles de ressources.
Note
Chaque objet passé sous forme de pointeur brut (ou d’itérateur) est supposé être la propriété de l’appelant, de sorte que sa durée de vie est gérée par celui‑ci. Autrement, les API de transfert de propriété sont relativement rares comparées aux API de passage de pointeur. L’adresse par défaut est donc : « pas de transfert de propriété ».
Voir aussi : Passage d’arguments @TODO-LINK, utilisation d’arguments de pointeur intelligents @TODO-LINK et retour de valeur @TODO-LINK.
Application
delete d’un pointeur brut qui n’est pas owner<T>. Suggérer l’utilisation d’un handle de ressources de la bibliothèque standard ou de owner<T>.reset ou sur la suppression explicite d’un owner sur chaque chemin d’exécution.new ou d’un appel de fonction retournant un owner est assignée à un pointeur brut ou à une référence non-owner.not_nullRaison
Pour éviter des erreurs de dereference nullptr. Pour améliorer la performance en supprimant les vérifications redondantes de nullptr.
Exemple
int length(const char* p); // il n'est pas clair si length(nullptr) est valide
length(nullptr); // OK ?
int length(not_null<const char*> p); // mieux : on peut supposer que p ne peut être nullptr
int length(const char* p); // on doit supposer que p peut être nullptr
En déclarant l’intention dans le code source, les implémenteurs et les outils peuvent fournir de meilleurs diagnostics, comme l’identification de certains groupes d’erreurs via l’analyse statique, et réaliser des optimisations, comme l’élimination des branches et des tests nullptr.
Note
not_null est défini dans la bibliothèque de soutien GSL @TODO-LINK.
Note
L’hypothèse que le pointeur char* pointe vers une chaîne de caractères de type C (une chaîne terminée par zéro) est toujours implicite et peut être source de confusion et d’erreur. Utilisez czstring en lieu et place de const char*.
// on peut supposer que p ne peut être nullptr
// on peut supposer que p pointe vers un tableau nul-terminé de caractères
int length(not_null<czstring> p);
Note : length() est, bien sûr, std::strlen() en déguisement.
Application
nullptr avant l’accès, sur tous les chemins de contrôle, alors avertir qu’elle devrait être déclarée not_null.nullptr sur tous les chemins de retour, avertir que le type de retour devrait être déclaré not_null.Raison
Les interfaces de style (pointeur, taille) sont sujettes aux erreurs. De plus, un pointeur simple (vers tableau) doit compter sur une convention pour permettre à l’appelant de déterminer la taille.
Exemple
Considérez :
void copy_n(const T* p, T* q, int n); // copier de [p:p+n) vers [q:q+n)
Que se passe‑t-il s’il y a moins de n éléments dans le tableau pointées par q ? Alors, nous surchargerons probablement de la mémoire non liée. Que se passe‑t-il s’il y a moins de n éléments dans le tableau pointé par p ? Alors, nous lisons probablement hors limites. Les deux sont un comportement indéfini et un bug potentiellement très grave.
Alternative
Considérez l’utilisation de span explicite :
void copy(span<const T> r, span<T> r2); // copier r vers r2
Exemple, mauvais
Considérez :
void draw(Shape* p, int n); // interface médiocre
Circle arr[10];
// ...
draw(arr, 10);
Passer 10 comme argument n peut être une erreur : la convention la plus courante suppose [0:n) mais cela n’est jamais indiqué. De pire, l’appel à draw() compile : il y a une conversion implicite de tableau en pointeur (decay) puis une conversion de Circle en Shape. Il n’existe aucun moyen pour draw() d’itérer en toute sécurité à travers ce tableau : il n’a aucun moyen de connaître la taille des éléments.
Alternative : utilisez une classe de support assurant que le nombre d’éléments est correct et empêchant les conversions implicites dangereuses. Par ex. :
void draw2(span<Circle>);
Circle arr[10];
// ...
draw2(span<Circle>(arr)); // déduire le nombre d’éléments
draw2(arr); // déduire le type d’objet et la taille du tableau
void draw3(span<Shape>);
draw3(arr); // erreur : ne peut pas convertir Circle[10] en span<Shape>
Cette draw2() transmet la même quantité d’information à draw(), mais rend explicite que c’est censé être une plage de Circles. Voir ???.
Exception
Utilisez zstring et czstring pour représenter les chaînes de caractères zéro-terminées. Mais lorsque vous faites cela, utilisez std::string_view ou span<char> du GSL @TODO-LINK pour prévenir les erreurs de plage.
Application
zstring/czstring pointeurs.zstring/czstring pointeurs.Raison
Une initialisation complexe peut conduire à un ordre d’exécution indéfini.
Exemple
// file1.cpp
extern const X x;
const Y y = f(x); // lit x, écrit y
// file2.cpp
extern const Y y;
const X x = g(y); // lit y, écrit x
Comme x et y se trouvent dans différents modules de traduction, l’ordre des appels à f() et g() est indéfini ; l’un accède à un const non initialisé. Cela démontre que les problèmes d’ordre d’initialisation ne se limitent pas aux variables globales.
Remarque
Les problèmes d’ordre d’initialisation deviennent particulièrement difficiles à gérer dans le code concurrent. Il est habituellement préférable d’éviter complètement les objets globaux de l’espace de noms.
Application
Raison
Avoir beaucoup d’arguments ouvre des opportunités de confusion. Passer beaucoup d’arguments est souvent coûteux comparé aux alternatives.
Discussion
Les deux raisons les plus fréquentes pour lesquelles les fonctions ont trop d’arguments sont :
Manque d’abstraction. Il manque une abstraction, donc une valeur composite est passée comme éléments individuels au lieu d’un unique objet qui impose une invariant. Cela ne fait pas seulement que la liste d’arguments croît, mais il y a erreur parce que les valeurs des composants ne sont plus protégées par une invariant imposée.
Violation de « une fonction, une responsabilité ». La fonction essaie de faire plus d’une tâche, et doit probablement être refactorisée.
Exemple
La merge() de la bibliothèque standard est à la limite de ce que nous pouvons gérer de façon confortable :
template<class InputIterator1, class InputIterator2, class OutputIterator, class Compare>
OutputIterator merge(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result, Compare comp);
Notez qu’il s’agit d’un problème #1 ci‑dessus — manque d’abstraction. Au lieu de passer un range (abstraction), STLa a passé des paires d’itérateurs (non encapsulés).
Pour simplifier l’usage le plus fréquent, le paramètre de comparaison peut être par défaut à < :
template<class InputIterator1, class InputIterator2, class OutputIterator>
OutputIterator merge(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result);
Cela ne diminue pas la complexité globale mais réduit la surface de complexité présentée aux utilisateurs fréquents.
Pour vraiment réduire le nombre d’arguments, nous devons regrouper les arguments dans des abstractions de niveau supérieur :
template<class InputRange1, class InputRange2, class OutputIterator>
OutputIterator merge(InputRange1 r1, InputRange2 r2, OutputIterator result);
Regrouper les arguments en « bundles » est une technique générale pour réduire le nombre d’arguments et augmenter les opportunités de vérification.
Exemple
Les profils de sécurité recommandent de remplacer
void f(int* some_ints, int some_ints_length); // BAD : C style, unsafe
par
void f(gsl::span<int> some_ints); // GOOD : sécurisé, vérifié en borne
Parler d’une abstraction a des bénéfices en sûreté et robustesse, et naturellement réduit aussi le nombre d’arguments.
Remarque
Combien d’arguments sont trop nombreux ? Essayez d’utiliser moins de quatre (4) paramètres. Il y a des fonctions qui sont meilleures avec quatre paramètres individuels, mais pas beaucoup.
Alternative : Utiliser une meilleure abstraction : grouper les arguments dans des objets significatifs et les passer par valeur ou référence.
Alternative : Utiliser des arguments par défaut ou surnoms pour permettre les formes d’appels les plus courantes à être faites avec moins d’arguments.
Application
Raison
Les arguments adjacents du même type sont facilement inversés par erreur.
Exemple, mauvais
Considérez :
void copy_n(T* p, T* q, int n); // copier de [p:p+n) vers [q:q+n)
C’est une mauvaise variante d’une interface style K&R C. Il est facile d’inverser les arguments « vers » et « de ».
Utiliser const pour l’argument « de » :
void copy_n(const T* p, T* q, int n); // copier de [p:p+n) vers [q:q+n)
Exception
Si l’ordre des paramètres n’est pas important, il n’y a pas de problème :
int max(int a, int b);
Alternative
Ne pas donner de pointeur de tableau, donnez un objet qui représente une plage (p. ex. span) :
void copy_n(span<const T> p, span<T> q); // copier p à q
Alternative
Définir une structure comme type du paramètre et nommer les champs pour ces paramètres :
struct SystemParams {
string config_file;
string output_path;
seconds timeout;
};
void initialize(SystemParams p);
Cela tend à rendre les appels lisibles aux futurs lecteurs, car les paramètres sont souvent remplis par nom à l’appel.
Remarque
Ce n’est qu’au concepteur de l’interface que l’on peut aborder la source des violations.
Stratégie d’application
(Simple) Avertir si deux paramètres consécutifs partagent le même type.
Nous cherchons encore une technique moins simple.
Raison
Une classe abstraite vide (sans données membres non statiques) est plus susceptible d’être stable que des classes de base avec état.
Exemple, mauvais
Vous n’avez tout simplement pas vu que Shape était là :
class Shape { // mauvais : interface chargée de données
public:
Point center() const { return c; }
virtual void draw() const;
virtual void rotate(int);
// ...
private:
Point c;
vector<Point> outline;
Color col;
};
Cela forcerait chaque classe dérivée à calculer un centre — même si c’est non triviale et que le centre n’est jamais utilisé. De même, aucun Shape ne possède une Color, et beaucoup de Shape seraient mieux représentées sans un Outline de Point.
Utiliser une classe abstraite pure :
class Shape { // meilleur : interface abstraite
public:
virtual Point center() const = 0; // fonction pure virtuelle
virtual void draw() const = 0;
virtual void rotate(int) = 0;
// ...
// ... pas de membres de données ...
virtual ~Shape() = default;
};
Application
(Simple) Avertir si un pointeur/référence à une classe C est assigné à un pointeur/référence vers une classe de base de C et que la classe de base contient des membres de données.
Raison
Les compilateurs différents implémentent des emplacements binaires différents pour les classes, la gestion des exceptions, les noms de fonctions, etc.
Exception
Les ABIs communs émergent sur certaines plateformes qui vous libèrent des restrictions draconiennes.
Remarque
Si vous utilisez un seul compilateur, vous pouvez utiliser le plein C++ dans les interfaces. Cela peut nécessiter une recompilation après chaque mise à jour vers une nouvelle version de compilateur.
Application
(Not enforceable) Il est difficile d’identifier fiablement où l’interface constitue une partie d’un ABI.
Raison
Car les membres privés participent au layout des classes et à la résolution d’overload, les changements à ces détails d’implémentation nécessitent la recompilation de tous les utilisateurs d’une classe. Une classe d’interface non-polymorphe qui contient un pointeur vers l’implémentation (Pimpl) peut isoler les utilisateurs de la classe des changements de son implémentation en échange d’une indirection.
Exemple
interface (widget.h)
class widget {
class impl;
std::unique_ptr<impl> pimpl;
public:
void draw(); // public API qui sera redirigé vers l'implémentation
widget(int); // défini dans le fichier d'implémentation
~widget(); // défini dans le fichier d'implémentation, où impl est un type complet
widget(widget&&) noexcept; // défini dans le fichier d'implémentation
widget(const widget&) = delete;
widget& operator=(widget&&) noexcept; // défini dans le fichier d'implémentation
widget& operator=(const widget&) = delete;
};
implementation (widget.cpp)
class widget::impl {
int n; // données privées
public:
void draw(const widget& w) { /* ... */ }
impl(int n) : n(n) {}
};
void widget::draw() { pimpl->draw(*this); }
widget::widget(int n) : pimpl{std::make_unique<impl>(n)} {}
widget::widget(widget&&) noexcept = default;
widget::~widget() = default;
widget& widget::operator=(widget&&) noexcept = default;
Notes
Voir GOTW #100 et cppreference pour les compromis, et les détails supplémentaires d’implémentation associés à cette idiom.
Application
(Not enforceable) Il est difficile d’identifier fiablement où l’interface est dans un ABI.
Raison
Pour garder le code simple et sûr. Parfois, des techniques peu esthétiques, non sécurisées ou susceptibles au bug sont nécessaires pour des raisons logiques ou de performance. Dans ce cas, les garder localement plutôt que “infecter” les interfaces afin que les plus grands groupes de programmeurs n’aient pas à se soucier des subtilités.
Exemple
Considérons un program
bool owned;
owner<istream*> inp;
switch (source) {
case std_in: owned = false; inp = &cin; break;
case command_line: owned = true; inp = new istringstream{argv[2]}; break;
case file: owned = true; inp = new ifstream{argv[2]}; break;
}
istream& in = *inp;
Cela violait les règles contre l’initialisation non propre, la règle contre l’ignorer ownership et la règle contre les constantes magiques (#res-always) @TODO-LINK, (#ri-raw), (#res-magic) @TODO-LINK. En particulier, quelqu’un doit se souvenir d’écrire
if (owned) delete inp;
Nous pourrions gérer cette particularité en utilisant unique_ptr avec un deleter spécial qui ne fait rien pour cin, mais c’est compliqué pour les novices.
Nous écrivons donc une classe
class Istream { [[gsl::suppress("lifetime")]]
public:
enum Opt { from_line = 1 };
Istream() { }
Istream(czstring p) : owned{true}, inp{new ifstream{p}} {} // lire depuis un fichier
Istream(czstring p, Opt) : owned{true}, inp{new istringstream{p}} {} // lire depuis la ligne de commande
~Istream() { if (owned) delete inp; }
operator istream&() { return *inp; }
private:
bool owned = false;
istream* inp = &cin;
};
Maintenant, la nature dynamique de l’ownership distream est encapsulée. Presum
ivement, un peu de vérification fera l’affaire en code réel.
Application