J'ai lu dans cette question que les programmeurs fonctionnels ont tendance à utiliser des preuves mathématiques pour s'assurer que leur programme fonctionne correctement. Cela semble beaucoup plus facile et plus rapide que les tests unitaires, mais venant d'un arrière-plan OOP / Unit Testing, je ne l'ai jamais vu faire.
Pouvez-vous me l'expliquer et me donner un exemple?
testing
functional-programming
leeand00
la source
la source
null
).Réponses:
Une preuve est beaucoup plus difficile dans le monde OOP en raison des effets secondaires, de l'héritage illimité et du fait d'
null
être membre de tout type. La plupart des preuves reposent sur un principe d'induction pour montrer que vous avez couvert toutes les possibilités, et ces 3 éléments rendent cela plus difficile à prouver.Disons que nous implémentons des arbres binaires qui contiennent des valeurs entières (dans un souci de simplification de la syntaxe, je n'y apporterai pas de programmation générique, bien que cela ne changera rien.) Dans ML standard, je définirais cela comme ce:
datatype tree = Empty | Node of (tree * int * tree)
Cela introduit un nouveau type appelé
tree
dont les valeurs peuvent se présenter dans exactement deux variétés (ou classes, à ne pas confondre avec le concept OOP d'une classe) - uneEmpty
valeur qui ne contient aucune information et desNode
valeurs qui portent un triplet dont le premier et le dernier les éléments sonttree
s et dont l'élément central est unint
. L'approximation la plus proche de cette déclaration dans la POO ressemblerait à ceci:Avec la mise en garde que les variables de type Tree ne peuvent jamais l'être
null
.Écrivons maintenant une fonction pour calculer la hauteur (ou la profondeur) de l'arbre, et supposons que nous avons accès à une
max
fonction qui renvoie le plus grand de deux nombres:Nous avons défini la
height
fonction par cas - il y a une définition pour lesEmpty
arbres et une définition pour lesNode
arbres. Le compilateur sait combien de classes d'arbres existent et émettrait un avertissement si vous ne définissiez pas les deux cas. L'expressionNode (leftChild, value, rightChild)
de la signature de la fonction lie les valeurs du 3-tuple aux variablesleftChild
,value
etrightChild
respectivement afin que nous puissions faire référence dans la définition de la fonction. Cela revient à déclarer des variables locales comme celle-ci dans un langage OOP:Comment prouver que nous avons mis en œuvre
height
correctement? Nous pouvons utiliser l' induction structurelle , qui consiste à: 1. prouver queheight
c'est correct dans le ou les cas de base de notretree
type (Empty
) 2. en supposant que les appels récursifs àheight
sont corrects, prouver queheight
c'est correct pour le cas non-base (s ) (lorsque l'arbre est en fait unNode
).Pour l'étape 1, nous pouvons voir que la fonction retourne toujours 0 lorsque l'argument est un
Empty
arbre. Ceci est correct par définition de la hauteur d'un arbre.Pour l'étape 2, la fonction revient
1 + max( height(leftChild), height(rightChild) )
. En supposant que les appels récursifs retournent vraiment la taille des enfants, nous pouvons voir que cela est également correct.Et cela complète la preuve. Les étapes 1 et 2 combinées épuisent toutes les possibilités. Notez, cependant, que nous n'avons pas de mutation, pas de null et qu'il existe exactement deux variétés d'arbres. Supprimez ces trois conditions et la preuve devient rapidement plus compliquée, voire impossible.
EDIT: Puisque cette réponse a atteint le sommet, je voudrais ajouter un exemple moins trivial d'une preuve et couvrir l'induction structurelle un peu plus en profondeur. Ci-dessus, nous avons prouvé que si
height
retourne , sa valeur de retour est correcte. Cependant, nous n'avons pas prouvé qu'il renvoie toujours une valeur. Nous pouvons également utiliser l'induction structurelle pour le prouver (ou toute autre propriété). Encore une fois, à l'étape 2, nous sommes autorisés à supposer que la propriété détient des appels récursifs tant que les appels récursifs fonctionnent tous sur un enfant direct du arbre.Une fonction peut ne pas retourner une valeur dans deux situations: si elle lève une exception et si elle boucle pour toujours. Prouvons d'abord que si aucune exception n'est levée, la fonction se termine:
Prouver que (si aucune exception n'est levée) la fonction se termine pour les cas de base (
Empty
). Puisque nous renvoyons inconditionnellement 0, il se termine.Prouver que la fonction se termine dans les cas non-base (
Node
). Il y a trois appels de fonction ici:+
,max
etheight
. Nous le savons+
et nous nousmax
terminons parce qu'ils font partie de la bibliothèque standard du langage et ils sont définis de cette façon. Comme mentionné précédemment, nous sommes autorisés à supposer que la propriété que nous essayons de prouver est vraie sur les appels récursifs tant qu'ils opèrent sur des sous-arbres immédiats, donc les appels seheight
terminent également.Cela conclut la preuve. Notez que vous ne pourrez pas prouver la terminaison avec un test unitaire. Il ne reste plus qu'à montrer que
height
cela ne lève pas d'exceptions.height
cela ne lève pas d'exceptions sur le cas de base (Empty
). Renvoyer 0 ne peut pas lever d'exception, nous avons donc terminé.height
cela ne lève pas d'exception sur le cas non-base (Node
). Supposons encore une fois que nous connaissons+
etmax
ne lançons pas d'exceptions. Et l'induction structurelle nous permet de supposer que les appels récursifs ne lanceront pas non plus (car ils opèrent sur les enfants immédiats de l'arbre). Mais attendez! Cette fonction est récursive, mais pas récursive de queue . On pourrait faire sauter la pile! Notre tentative de preuve a révélé un bogue. Nous pouvons le corriger en changeantheight
pour être récursif de queue .J'espère que cela montre que les preuves ne doivent pas être effrayantes ou compliquées. En fait, chaque fois que vous écrivez du code, vous avez construit de manière informelle une preuve dans votre tête (sinon, vous ne seriez pas convaincu que vous venez d'implémenter la fonction.) En évitant la mutation nulle, inutile et l'héritage illimité, vous pouvez prouver que votre intuition est corriger assez facilement. Ces restrictions ne sont pas aussi sévères que vous pourriez le penser:
null
est un défaut de langage et le supprimer est inconditionnellement bon.la source
Il est beaucoup plus facile de raisonner sur le code lorsque tout est immuable . Par conséquent, les boucles sont le plus souvent écrites sous forme de récursivité. En général, il est plus facile de vérifier l'exactitude d'une solution récursive. Souvent, une telle solution se lira également de manière très similaire à une définition mathématique du problème.
Cependant, dans la plupart des cas, il y a très peu de motivation pour effectuer une véritable preuve formelle d'exactitude. Les preuves sont difficiles, prennent beaucoup de temps (humain) et ont un faible retour sur investissement.
Certains langages fonctionnels (en particulier de la famille ML) ont des systèmes de type extrêmement expressifs qui peuvent faire des garanties beaucoup plus complètes qu'un système de type C (mais certaines idées comme les génériques sont également devenues courantes dans les langages traditionnels). Lorsqu'un programme réussit une vérification de type, il s'agit d'une sorte de preuve automatisée. Dans certains cas, cela pourra détecter certaines erreurs (par exemple, oublier le cas de base dans une récursivité, ou oublier de gérer certains cas dans une correspondance de modèle).
D'autre part, ces systèmes de type doivent être maintenus très limités afin de les garder décidables . Donc, dans un sens, nous gagnons des garanties statiques en abandonnant la flexibilité - et ces restrictions sont une raison pour laquelle des articles universitaires compliqués du type « Une solution monadique à un problème résolu, en Haskell » existent.
J'apprécie à la fois les langues très libérales et les langues très restreintes, et les deux ont leurs difficultés respectives. Mais ce n'est pas le cas que l'on serait «mieux», chacun est juste plus pratique pour un type de tâche différent.
Il faut ensuite souligner que les preuves et les tests unitaires ne sont pas interchangeables. Ils nous permettent tous les deux de mettre des limites à l'exactitude du programme:
Les tests mettent une limite supérieure à l'exactitude: si un test échoue, le programme est incorrect, si aucun test échoue, nous sommes certains que le programme gérera les cas testés, mais il peut toujours y avoir des bogues non découverts.
Les preuves mettent une limite inférieure à l'exactitude: il peut être impossible de prouver certaines propriétés. Par exemple, il peut être facile de prouver qu'une fonction renvoie toujours un nombre (c'est ce que font les systèmes de types). Mais il peut être impossible de prouver que le nombre sera toujours
< 10
.la source
Un mot d'avertissement peut être de mise ici:
Bien qu'il soit généralement vrai ce que les autres écrivent ici - en bref, que les systèmes de type avancés, l'immuabilité et la transparence référentielle contribuent beaucoup à l'exactitude - il n'est pas vrai que les tests ne sont pas effectués dans le monde fonctionnel. Au contraire !
En effet, nous avons des outils comme Quickcheck, qui génèrent des cas de test automatiquement et de manière aléatoire. Vous énoncez simplement les lois auxquelles une fonction doit obéir, puis la vérification rapide vérifie ces lois pour des centaines de cas de test aléatoires.
Vous voyez, c'est un niveau un peu plus élevé que les contrôles d'égalité triviaux sur une poignée de cas de test.
Voici un exemple d'une implémentation d'une arborescence AVL:
La deuxième loi (ou propriété) peut être lue comme suit: Pour tous les arbres arbitraires
t
, la règle suivante est valable: si ellet
n'est pas vide, alors pour toutes les clésk
de cet arbre, elle contiendra la recherchek
dans l'arbre qui résulte de la suppression.k
à partir det
, le résultat seraNothing
(ce qui indique: introuvable).Cela vérifie la fonctionnalité appropriée pour la suppression d'une clé existante. Quelles lois devraient régir la suppression d'une clé inexistante? Nous voulons certainement que l'arbre résultant soit le même que celui dont nous avons supprimé. On peut l'exprimer facilement:
De cette façon, les tests sont vraiment amusants. De plus, une fois que vous apprenez à lire les propriétés de contrôle rapide, elles servent de spécification testable par machine .
la source
Je ne comprends pas exactement ce que signifie la réponse liée par «parvenir à la modularité grâce aux lois mathématiques», mais je pense avoir une idée de ce que l'on entend.
Découvrez Functor :
Il ne vient pas avec des cas de test, mais plutôt avec quelques lois qui doivent être satisfaites.
Supposons maintenant que vous implémentiez
Functor
( source ):Le problème est de vérifier que votre implémentation respecte les lois. Comment procédez-vous?
Une approche consiste à écrire des cas de test. La limite fondamentale de cette approche est que vous vérifiez le comportement dans un nombre fini de cas (bonne chance en testant de manière exhaustive une fonction avec 8 paramètres!), Et donc passer des tests ne peut garantir rien d'autre que leur réussite.
Une autre approche consiste à utiliser un raisonnement mathématique, c'est-à-dire une preuve, basé sur la définition réelle (plutôt que sur le comportement dans un nombre limité de cas). L'idée ici est qu'une preuve mathématique peut être plus efficace; cependant, cela dépend de l'adaptabilité de votre programme à la preuve mathématique.
Je ne peux pas vous guider à travers une preuve formelle réelle que l'
Functor
instance ci-dessus satisfait aux lois, mais je vais essayer de donner un aperçu de ce à quoi pourrait ressembler la preuve:fmap id = id
Nothing
fmap id Nothing
=Nothing
par la partie 1 de la mise en œuvreid Nothing
=Nothing
par la définition deid
Just x
fmap id (Just x)
=Just (id x)
=Just x
par la partie 2 de la mise en œuvre, puis par la définition deid
fmap (p . q) = (fmap p) . (fmap q)
Nothing
fmap (p . q) Nothing
=Nothing
par la partie 1(fmap p) . (fmap q) $ Nothing
=(fmap p) $ Nothing
=Nothing
par deux applications de la partie 1Just x
fmap (p . q) (Just x)
=Just ((p . q) x)
=Just (p (q x))
par la partie 2, puis par la définition de.
(fmap p) . (fmap q) $ (Just x)
=(fmap p) $ (Just (q x))
=Just (p (q x))
par deux applications de la deuxième partiela source
"Méfiez-vous des bogues dans le code ci-dessus; je l'ai seulement prouvé correct, pas essayé." - Donald Knuth
Dans un monde parfait, les programmeurs sont parfaits et ne font pas d'erreurs, donc il n'y a pas de bugs.
Dans un monde parfait, les informaticiens et les mathématiciens sont également parfaits et ne font pas non plus d'erreurs.
Mais nous ne vivons pas dans un monde parfait. Nous ne pouvons donc pas compter sur les programmeurs pour ne faire aucune erreur. Mais nous ne pouvons pas supposer que tout informaticien qui fournit une preuve mathématique qu'un programme est correct n'a fait aucune erreur dans cette preuve. Je ne ferais donc pas attention à quiconque essaie de prouver que son code fonctionne. Écrivez des tests unitaires et montrez-moi que le code se comporte conformément aux spécifications. Rien d'autre ne me convaincra de rien.
la source