Bonjour chers collègues.
Il n'y a pas si longtemps, notre attention a été attirée par le livre
presque terminé de la maison d'édition Manning «Programmation avec les types», qui détaille l'importance d'une bonne frappe et son rôle dans l'écriture de code propre et durable.

Dans le même temps, sur le blog de l’auteur, nous avons trouvé un article écrit, apparemment, aux premiers stades du travail sur le livre et permettant de faire une impression de son contenu. Nous suggérons de discuter de l’intérêt des idées de l’auteur et potentiellement de l’ensemble
Orbiteur climatique de MarsLe vaisseau spatial Mars Climate Orbiter s'est écrasé lors de l'atterrissage et s'est effondré dans l'atmosphère martienne, car le composant logiciel Lockheed a donné la valeur de momentum, mesurée en livre-force sec., Tandis que l'autre composant développé par la NASA a pris la valeur de momentum en Newtons- sec
Vous pouvez imaginer le composant développé par la NASA sous la forme suivante:
Vous pouvez également imaginer que le composant Lockheed a appelé le code ci-dessus comme ceci:
void main() { trajectory_correction(1.5 ); }
La livre-force-seconde (lbfs) est d'environ 4,448222 newtons par seconde (Ns). Ainsi, du point de vue de Lockheed, le passage de 1,5 lbf à
trajectory_correction
devrait être parfaitement normal: 1,5 lbf correspond à environ 6,672333 Ns, bien au-dessus du seuil de 2 Ns.
Le problème est l'interprétation des données. En conséquence, le composant NASA compare lbfs avec Ns sans conversion et interprète par erreur l'entrée en lbfs comme entrée en Ns. Puisque 1,5 est inférieur à 2, l'orbiteur s'est effondré. C'est un contre-motif bien connu appelé obsession primitive.
Obsession des primitifsUne fixation sur les primitives se manifeste lorsque nous utilisons un type de données primitif pour représenter une valeur dans un domaine problématique et autoriser des situations telles que celles décrites ci-dessus. Si vous représentez les codes postaux sous forme de nombres, les numéros de téléphone sous forme de chaînes, Ns et lbfs sous forme de nombres à double précision, c'est exactement ce qui se produit.
Il serait beaucoup plus sûr de définir un type simple de
Ns
:
struct Ns { double value; }; bool operator<(const Ns& a, const Ns& b) { return a.value < b.value; }
De même, vous pouvez définir un type simple de
lbfs
:
struct lbfs { double value; }; bool operator<(const lbfs& a, const lbfs& b) { return a.value < b.value; }
Vous pouvez maintenant implémenter une variante sécurisée de
trajectory_correction
:
Si vous appelez cela avec
lbfs
, comme dans l'exemple ci-dessus, le code ne compile tout simplement pas en raison d'une incompatibilité de type:
void main() { trajectory_correction(lbfs{ 1.5 }); }
Remarquez comment les informations sur le type de valeur, qui sont généralement indiquées dans les commentaires, (
2 /*Ns */, /* lbfs */
) sont maintenant dessinées dans le système de type et exprimées dans le code: (
Ns{ 2 }, lbfs{ 1.5 }
) .
Bien sûr, il est possible de fournir une réduction de
lbfs
à
Ns
sous la forme d'un opérateur explicite:
struct lbfs { double value; explicit operator Ns() { return value * 4.448222; } };
Armé de cette technique, vous pouvez appeler
trajectory_correction
aide d'un transtypage statique:
void main() { trajectory_correction(static_cast<Ns>(lbfs{ 1.5 })); }
Ici, l'exactitude du code est obtenue en multipliant par un coefficient. Un cast peut également être effectué implicitement (en utilisant le mot clé implicite), auquel cas le cast sera appliqué automatiquement. En règle générale, vous pouvez utiliser l'un des chargements Python ici:
Explicite vaut mieux qu'implicite
La morale de cette histoire est que, bien que nous ayons aujourd'hui des mécanismes de vérification de type très intelligents, ils doivent encore fournir suffisamment d'informations pour détecter ce type d'erreur. Ces informations entrent dans le programme si nous déclarons des types en tenant compte des spécificités de notre domaine.
Espace d'étatDes problèmes surviennent lorsqu'un programme se termine dans un
mauvais état . Les types aident à réduire le champ de leur occurrence. Essayons de traiter le type comme l'ensemble des valeurs possibles. Par exemple, bool est l'ensemble
{true, false}
, où une variable de ce type peut prendre l'une de ces deux valeurs. De même,
uint32_t
est l'ensemble
{0 ...4294967295}
. En considérant les types de cette manière, nous pouvons définir l'espace d'état de notre programme comme le produit des types de toutes les variables vivantes à un certain moment.
Si nous avons une variable de type
bool
et une variable de type
uint32_t
, alors notre espace d'état sera
{true, false} X {0 ...4294967295}
. Cela signifie simplement que les deux variables peuvent être dans tous les états possibles pour elles, et puisque nous avons deux variables, le programme peut se retrouver dans n'importe quel état combiné de ces deux types.
Tout devient beaucoup plus intéressant si l'on considère les fonctions qui initialisent les valeurs:
bool get_momentum(Ns& momentum) { if (!some_condition()) return false; momentum = Ns{ 3 }; return true; }
Dans l'exemple ci-dessus, nous prenons Ns par référence et initialisons si une condition est remplie. La fonction renvoie
true
si la valeur a été correctement initialisée. Si la fonction, pour une raison quelconque, ne peut pas définir la valeur, elle renvoie
false
.
Considérant cette situation du point de vue de l'espace d'état, nous pouvons dire que l'espace d'état est un produit de
bool X Ns
. Si la fonction retourne vrai, cela signifie que l'impulsion a été définie, et est l'une des valeurs possibles de
Ns
. Le problème est le suivant: si la fonction retourne
false
, cela signifie que l'impulsion n'a pas été définie. D'une manière ou d'une autre, l'élan appartient à l'ensemble des valeurs possibles de Ns, mais ce n'est pas une valeur valide. Il existe souvent des bogues dans lesquels l'état inacceptable suivant commence accidentellement à se propager:
void example() { Ns momenum; get_momentum(momentum); trajectory_correction(momentum); }
Au lieu de cela, nous devons simplement le faire:
void example() { Ns momentum; if (get_momentum(momentum)) { trajectory_correction(momentum); } }
Cependant, il existe une meilleure façon de procéder de force:
std::optional<Ns> get_momentum() { if (!some_condition()) return std::nullopt; return std::make_optional(Ns{ 3 }); }
Si vous utilisez
optional
, l'espace d'état de cette fonction diminuera considérablement: au lieu de
bool X Ns
nous obtenons
Ns + 1
. Cette fonction renverra une
nullopt
Ns
valide ou
nullopt
pour indiquer aucune valeur. Maintenant, nous ne pouvons tout simplement pas avoir un
Ns
invalide qui se propagerait dans le système. De plus, il devient désormais impossible d'oublier de vérifier la valeur de retour, car l'option ne peut pas être implicitement convertie en
Ns
- nous devrons la décompresser spécialement:
void example() { auto maybeMomentum = get_momentum(); if (maybeMomentum) { trajectory_correction(*maybeMomentum); } }
Fondamentalement, nous nous efforçons pour que nos fonctions retournent un résultat ou une erreur, plutôt qu'un résultat et une erreur. Ainsi, nous excluons les conditions dans lesquelles nous avons des erreurs, et nous sommes également à l'abri de résultats inacceptables, qui pourraient ensuite s'infiltrer dans d'autres calculs.
De ce point de vue, lever des exceptions est normal, car il correspond au principe décrit ci-dessus: une fonction retournera un résultat ou lèvera une exception.
RAIIRAII signifie que l'acquisition des ressources est l'initialisation, mais dans une plus large mesure, ce principe est associé à la libération des ressources. Le nom est apparu pour la première fois en C ++, cependant, ce modèle peut être implémenté dans n'importe quel langage (voir, par exemple,
IDisposable
de .NET). RAII fournit un nettoyage automatique des ressources.
Quelles sont les ressources? Voici quelques exemples: mémoire dynamique, connexions à la base de données, descripteurs de système d'exploitation. En principe, une ressource est quelque chose qui vient du monde extérieur et qui peut revenir après que nous n'en ayons plus besoin. Nous retournons la ressource en utilisant l'opération appropriée: la libérer, la supprimer, la fermer, etc.
Ces ressources étant externes, elles ne sont pas explicitement exprimées dans notre système de types. Par exemple, si nous sélectionnons un fragment de mémoire dynamique, nous aurons un pointeur par lequel nous devrons appeler
delete
:
struct Foo {}; void example() { Foo* foo = new Foo(); delete foo; }
Mais que se passe-t-il si nous oublions de le faire ou si quelque chose nous empêche d'appeler
delete
?
void example() { Foo* foo = new Foo(); throw std::exception(); delete foo; }
Dans ce cas, nous n'appelons plus
delete
et obtenons une fuite de ressources. En principe, un tel nettoyage manuel des ressources n'est pas souhaitable. Pour la mémoire dynamique, nous avons
unique_ptr
pour nous aider à le gérer:
void example() { auto foo = std::make_unique<Foo>(); throw std::exception(); }
Notre
unique_ptr
est un objet de pile, par conséquent, s'il
unique_ptr
portée (lorsque la fonction lève une exception ou lorsque la pile se déroule lorsqu'une exception a été levée), son destructeur est appelé. C'est ce destructeur qui implémente l'appel de
delete
. En conséquence, nous n'avons plus à gérer la ressource mémoire - nous transférons ce travail à l'encapsuleur, qui en est propriétaire et est responsable de sa publication.
Des wrappers similaires existent (ou peuvent être créés) pour toutes les autres ressources (par exemple, OS HANDLE de Windows peut être encapsulé dans un type, auquel cas son destructeur appellera
CloseHandle
).
La principale conclusion dans ce cas est de ne jamais faire de nettoyage manuel des ressources; Soit utiliser le wrapper existant, soit s'il n'y a pas de wrapper approprié pour votre scénario spécifique, nous l'implémenterons nous-mêmes.
ConclusionNous avons commencé cet article avec un exemple bien connu qui montre l'importance de la frappe, puis nous avons examiné trois aspects importants de l'utilisation des types pour aider à écrire du code plus sécurisé:
- Déclarer et utiliser des types plus forts (par opposition à l'obsession des primitives).
- Réduire l'espace d'état, renvoyer un résultat ou une erreur, pas un résultat ou une erreur.
- RAII et gestion automatique des ressources.
Ainsi, les types aident beaucoup à rendre le code plus sûr et à l'adapter pour une réutilisation.