Typage correct: l'aspect sous-estimé du code propre

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 Mars

Le 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:

//    ,  >= 2 N s void trajectory_correction(double momentum) { if (momentum < 2 /* N s */) { disintegrate(); } /* ... */ } 

Vous pouvez également imaginer que le composant Lockheed a appelé le code ci-dessus comme ceci:

 void main() { trajectory_correction(1.5 /* lbf s */); } 

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 primitifs

Une 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 :

 //  ,   >= 2 N s void trajectory_correction(Ns momentum) { if (momentum < Ns{ 2 }) { disintegrate(); } /* ... */ } 

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'état

Des 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.

RAII

RAII 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(); /*  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.

Conclusion

Nous 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.

Source: https://habr.com/ru/post/fr460149/


All Articles