Nella programmazione funzionale come si ottiene la modularità attraverso le leggi matematiche?


11

Ho letto in questa domanda che i programmatori funzionali tendono ad usare prove matematiche per assicurarsi che il loro programma funzioni correttamente. Questo suona molto più facile e veloce rispetto ai test unitari, ma non ho mai visto che provenire da un background OOP / Unit Testing.

Puoi spiegarmelo e darmi un esempio?


7
"Sembra molto più facile e veloce dei test unitari". Sì, suoni. In realtà, è praticamente impossibile per la maggior parte dei software. E perché il titolo menziona la modularità ma stai parlando di verifica?
Euforico,

@Euphoric In Unit Testing in OOP scrivi test per la verifica ... verifica che una parte del software funzioni correttamente, ma verifica anche che le tue preoccupazioni siano separate ... cioè modularità e riusabilità ... se lo capisco correttamente.
leeand00,

2
@Euforico Solo se si abusa della mutazione e dell'eredità e si lavora in lingue con sistemi di tipo difettoso (ovvero null).
Doval,

@ leeand00 Penso che tu stia abusando del termine "verifica". Modularità e riusabilità non vengono verificate direttamente dalla verifica del software (sebbene, naturalmente, la mancanza di modularità possa rendere il software più difficile da mantenere e riutilizzare, introducendo quindi bug e non superando il processo di verifica).
Andres F.

È molto più facile verificare parti del software se è scritto in modo modulare. Quindi puoi avere una prova reale che la funzione funziona correttamente per alcune funzioni, per altri puoi scrivere unit test.
Grizwako,

Risposte:


22

Una prova è molto più dura nel mondo OOP a causa di effetti collaterali, eredità illimitata ed nullessere un membro di ogni tipo. La maggior parte delle prove si basa su un principio di induzione per dimostrare che hai coperto tutte le possibilità e tutte e 3 queste cose rendono più difficile dimostrarlo.

Diciamo che stiamo implementando alberi binari che contengono valori interi (per mantenere la sintassi più semplice, non inserirò la programmazione generica in questo, sebbene non cambierebbe nulla.) In Standard ML, definirei che Questo:

datatype tree = Empty | Node of (tree * int * tree)

Questo introduce un nuovo tipo chiamato i treecui valori possono venire esattamente in due varietà (o classi, da non confondere con il concetto OOP di una classe) - un Emptyvalore che non porta informazioni e Nodevalori che portano una 3-tupla il cui primo e ultimo gli elementi sono se treeil cui elemento centrale è un int. L'approssimazione più vicina a questa dichiarazione in OOP sarebbe simile a questa:

public class Tree {
    private Tree() {} // Prevent external subclassing

    public static final class Empty extends Tree {}

    public static final class Node extends Tree {
        public final Tree leftChild;
        public final int value;
        public final Tree rightChild;

        public Node(Tree leftChild, int value, Tree rightChild) {
            this.leftChild = leftChild;
            this.value = value;
            this.rightChild = rightChild;
        }
    }
}

Con l'avvertenza che le variabili di tipo Albero non possono mai essere null.

Ora scriviamo una funzione per calcolare l'altezza (o profondità) dell'albero e supponiamo di avere accesso a una maxfunzione che restituisce il più grande di due numeri:

fun height(Empty) =
        0
 |  height(Node (leftChild, value, rightChild)) =
        1 + max( height(leftChild), height(rightChild) )

Abbiamo definito la heightfunzione in base ai casi: esiste una definizione per Emptyalberi e una definizione per Nodealberi. Il compilatore sa quante classi di alberi esistono e genererebbe un avviso se non si definissero entrambi i casi. L'espressione Node (leftChild, value, rightChild)nella firma funzione associa i valori della 3-tupla alle variabili leftChild, valuee rightChildrispettivamente in modo da poter fare riferimento ad essi nella definizione della funzione. È simile ad aver dichiarato variabili locali come questa in un linguaggio OOP:

Tree leftChild = tuple.getFirst();
int value = tuple.getSecond();
Tree rightChild = tuple.getThird();

Come possiamo dimostrare di aver implementato heightcorrettamente? Possiamo usare l' induzione strutturale , che consiste in: 1. Dimostrare che heightè corretto nei casi di base del nostro treetipo ( Empty) 2. Supponendo che le chiamate ricorsive heightsiano corrette, dimostrare che heightè corretto per i casi non di base ) (quando l'albero è in realtà a Node).

Per il passaggio 1, possiamo vedere che la funzione restituisce sempre 0 quando l'argomento è un Emptyalbero. Questo è corretto per definizione dell'altezza di un albero.

Per il passaggio 2, la funzione ritorna 1 + max( height(leftChild), height(rightChild) ). Supponendo che le chiamate ricorsive restituiscano veramente l'altezza dei bambini, possiamo vedere che anche questo è corretto.

E questo completa la prova. I passaggi 1 e 2 combinati esauriscono tutte le possibilità. Si noti, tuttavia, che non abbiamo mutazione, niente null e ci sono esattamente due varietà di alberi. Elimina queste tre condizioni e la prova diventa rapidamente più complicata, se non impraticabile.


EDIT: Dal momento che questa risposta è salita in cima, vorrei aggiungere un esempio meno banale di una prova e coprire l'induzione strutturale un po 'più a fondo. Sopra abbiamo dimostrato che se heightrestituisce , il suo valore di ritorno è corretto. Tuttavia, non abbiamo dimostrato che restituisca sempre un valore. Possiamo usare anche l'induzione strutturale per dimostrarlo (o qualsiasi altra proprietà). Ancora una volta, durante il passaggio 2, possiamo assumere le proprietà trattenute delle chiamate ricorsive fintanto che tutte le chiamate ricorsive operano su un figlio diretto del albero.

Una funzione può non riuscire a restituire un valore in due situazioni: se genera un'eccezione e se viene ripetuta per sempre. Innanzitutto proviamo che se non vengono generate eccezioni, la funzione termina:

  1. Dimostra che (se non vengono generate eccezioni) la funzione termina per i casi base ( Empty). Poiché restituiamo incondizionatamente 0, termina.

  2. Prova che la funzione termina nei casi non di base ( Node). Ci sono tre chiamate di funzione qui: +, max, e height. Lo sappiamo +e maxterminiamo perché fanno parte della libreria standard del linguaggio e sono definiti in questo modo. Come accennato in precedenza, possiamo ammettere che la proprietà che stiamo cercando di dimostrare sia vera per le chiamate ricorsive fintanto che operano su sottotitoli immediati, quindi anche le chiamate per heightterminare.

Questo conclude la prova. Nota che non saresti in grado di provare la conclusione con un unit test. Ora non resta che mostrare che heightnon generano eccezioni.

  1. Dimostrare che heightnon genera eccezioni nel caso di base ( Empty). Restituire 0 non può generare un'eccezione, quindi abbiamo finito.
  2. Dimostrare che heightnon genera eccezioni sul caso non base ( Node). Supponi ancora una volta che sappiamo +e maxnon facciamo eccezioni. E l'induzione strutturale ci consente di presumere che le chiamate ricorsive non verranno lanciate (perché operano sui figli immediati dell'albero). Ma aspetta! Questa funzione è ricorsiva, ma non ricorsiva alla coda . Potremmo far saltare lo stack! La nostra tentata prova ha scoperto un bug. Possiamo risolverlo cambiando heightin modo ricorsivo della coda .

Spero che ciò dimostri che le prove non devono essere spaventose o complicate. In effetti, ogni volta che scrivi un codice, hai costruito in modo informale una prova nella tua testa (altrimenti, non saresti convinto di aver appena implementato la funzione.) Evitando la mutazione nulla, non necessaria e l'eredità senza restrizioni puoi provare che il tuo intuito è correggo abbastanza facilmente. Queste restrizioni non sono così dure come potresti pensare:

  • null è un difetto del linguaggio e eliminarlo è incondizionatamente buono.
  • La mutazione è talvolta inevitabile e necessaria, ma è necessaria molto meno spesso di quanto si pensi, soprattutto quando si hanno strutture di dati persistenti.
  • Per quanto riguarda avere un numero finito di classi (in senso funzionale) / sottoclassi (in senso OOP) contro un numero illimitato di esse, questo è un argomento troppo grande per una singola risposta . Basti dire che c'è un trade design là fuori: provabilità di correttezza vs flessibilità di estensione.

8
  1. È molto più facile ragionare sul codice quando tutto è immutabile . Di conseguenza, i loop sono più spesso scritti come ricorsione. In generale, è più facile verificare la correttezza di una soluzione ricorsiva. Spesso, una tale soluzione leggerà anche in modo molto simile a una definizione matematica del problema.

    Tuttavia, nella maggior parte dei casi esiste una motivazione molto bassa per eseguire una prova formale effettiva della correttezza. Le prove sono difficili, richiedono molto tempo (umano) e hanno un ROI basso.

  2. Alcuni linguaggi funzionali (specialmente della famiglia ML) hanno sistemi di tipo estremamente espressivi che possono rendere molto più complete le garanzie che un sistema di tipo C (ma alcune idee come i generici siano diventate comuni anche nei linguaggi tradizionali). Quando un programma supera un controllo del tipo, questo è un tipo di prova automatica. In alcuni casi, questo sarà in grado di rilevare alcuni errori (ad esempio dimenticando il caso di base in una ricorsione o dimenticando di gestire alcuni casi in una corrispondenza del modello).

    D'altra parte, questi sistemi di tipo devono essere tenuti molto limitati per tenerli decidibili . Quindi, in un certo senso, otteniamo garanzie statiche rinunciando alla flessibilità - e queste restrizioni sono un motivo per cui esistono complicati documenti accademici sulla falsariga di " Una soluzione monadica a un problema risolto, a Haskell ".

    Mi piacciono sia le lingue molto liberali, sia le lingue molto limitate, ed entrambe hanno le rispettive difficoltà. Ma non è il caso che uno sarebbe "migliore", ognuno è solo più conveniente per un diverso tipo di attività.

Quindi va sottolineato che le prove e i test unitari non sono intercambiabili. Entrambi ci consentono di porre limiti alla correttezza del programma:

  • Il test pone un limite superiore alla correttezza: se un test fallisce, il programma non è corretto, se nessun test fallisce, siamo certi che il programma gestirà i casi testati, ma potrebbero esserci ancora bug non ancora scoperti.

    int factorial(int n) {
      if (n <= 1) return 1;
      if (n == 2) return 2;
      if (n == 3) return 6;
      return -1;
    }
    
    assert(factorial(0) == 1);
    assert(factorial(1) == 1);
    assert(factorial(3) == 6);
    // oops, we forgot to test that it handles n > 3…
    
  • Le prove mettono un limite inferiore alla correttezza: potrebbe essere impossibile provare determinate proprietà. Ad esempio, può essere facile dimostrare che una funzione restituisce sempre un numero (questo è ciò che fanno i sistemi). Ma potrebbe essere impossibile provare che il numero sarà sempre < 10.

    int factorial(int n) {
      return n;  // FIXME this is just a placeholder to make it compile
    }
    
    // type system says this will be OK…
    

1
"Potrebbe essere impossibile provare determinate proprietà ... Ma potrebbe essere impossibile provare che il numero sarà sempre <10." Se la correttezza del programma dipende dal fatto che il numero è inferiore a 10, dovresti essere in grado di dimostrarlo. È vero che il sistema di tipi non può (almeno non escludere un sacco di programmi validi) - ma puoi farlo.
Doval,

@Doval Sì. Tuttavia, il sistema dei tipi è solo un esempio di un sistema per una prova. I sistemi di tipi sono visibilmente limitati e non possono valutare la verità di certe affermazioni. Una persona può eseguire prove molto più complesse, ma sarà comunque limitata in ciò che può provare. C'è ancora un limite che non può essere attraversato, è solo più lontano.
amon,

1
D'accordo, penso solo che l'esempio sia stato un po 'fuorviante.
Doval,

2
In lingue tipicamente dipendenti, come Idris, potrebbe anche essere possibile dimostrare che ritorna inferiore a 10.
Ingo

2
Forse un modo migliore per affrontare la preoccupazione che @Doval solleva sarebbe quello di affermare che alcuni problemi sono indecidibili (ad esempio, fermare il problema), richiedere troppo tempo per dimostrare, o avrebbe bisogno di nuova matematica per essere scoperto per dimostrare il risultato. La mia opinione personale è che dovresti chiarire che se qualcosa si è dimostrato vero, non è necessario testarlo. La prova mette già un limite superiore e inferiore. Il motivo per cui prove e test non sono intercambiabili è perché una prova può essere troppo difficile da fare o direttamente impossibile. Inoltre i test possono essere automatizzati (per quando cambia il codice).
Thomas Eding,

7

Un avvertimento può essere qui in ordine:

Sebbene sia generalmente vero ciò che gli altri scrivono qui - in breve, che sistemi di tipo avanzati, immutabilità e trasparenza referenziale contribuiscono molto alla correttezza - non è il caso che i test non vengano eseguiti nel mondo funzionale. Al contrario !

Questo perché abbiamo strumenti come Quickcheck, che generano casi di test automaticamente e in modo casuale. Dichiarate semplicemente le leggi a cui una funzione deve obbedire e quindi il controllo rapido controllerà queste leggi per centinaia di casi di test casuali.

Vedete, questo è un po 'più alto dei banali controlli di uguaglianza su una manciata di casi di test.

Ecco un esempio da un'implementazione di un albero AVL:

--- A generator for arbitrary Trees with integer keys and string values
aTree = arbitrary :: Gen (Tree Int String)


--- After insertion, a lookup with the same key yields the inserted value        
p_insert = forAll aTree (\t -> 
             forAll arbitrary (\k ->
               forAll arbitrary (\v ->
                lookup (insert t k v) k == Just v)))

--- After deletion of a key, lookup results in Nothing
p_delete = forAll aTree (\t ->
            not (null t) ==> forAll (elements (keys t)) (\k ->
                lookup (delete t k) k == Nothing))

La seconda legge (o proprietà) che possiamo leggere come segue: per tutti gli alberi arbitrari t, vale quanto segue: se tnon è vuoto, allora per tutte le chiavi kdi quell'albero conterrà quella ricerca knell'albero che è il risultato dell'eliminazione kda t, il risultato sarà Nothing(che indica: non trovato).

Questo controlla la corretta funzionalità per l'eliminazione di una chiave esistente. Quali leggi dovrebbero disciplinare la cancellazione di una chiave inesistente? Desideriamo sicuramente che l'albero risultante sia uguale a quello da cui abbiamo eliminato. Possiamo esprimerlo facilmente:

p_delete_nonexistant = forAll aTree (\t ->
                          forAll arbitrary (\k -> 
                              k `notElem` keys t ==> delete t k == t))

In questo modo, i test sono davvero divertenti. Inoltre, una volta che impari a leggere le proprietà di controllo rapido, vengono utilizzate come specifiche testabili dalla macchina .


4

Non capisco esattamente cosa significhi la risposta collegata "raggiungere la modularità attraverso le leggi matematiche", ma penso di avere un'idea di cosa si intende.

Dai un'occhiata a Functor :

La classe Functor è definita in questo modo:

 class Functor f where
   fmap :: (a -> b) -> f a -> f b

Non viene fornito con casi di test, ma piuttosto con un paio di leggi che devono essere soddisfatte.

Tutte le istanze di Functor dovrebbero obbedire:

 fmap id = id
 fmap (p . q) = (fmap p) . (fmap q)

Ora supponiamo che tu attui Functor( fonte ):

instance  Functor Maybe  where
    fmap _ Nothing       = Nothing
    fmap f (Just a)      = Just (f a)

Il problema è verificare che l'implementazione soddisfi le leggi. Come fai a farlo?

Un approccio è quello di scrivere casi di test. Il limite fondamentale di questo approccio è che stai verificando il comportamento in un numero finito di casi (buona fortuna testare esaustivamente una funzione con 8 parametri!), E quindi passare i test non può garantire altro che il superamento dei test.

Un altro approccio consiste nell'utilizzare il ragionamento matematico, cioè una dimostrazione, basato sulla definizione effettiva (anziché sul comportamento in un numero limitato di casi). L'idea qui è che una prova matematica può essere più efficace; tuttavia, questo dipende da quanto il tuo programma sia suscettibile di prove matematiche.

Non posso guidarti attraverso una prova formale effettiva che l' Functoristanza di cui sopra soddisfa le leggi, ma cercherò di fornire uno schema di come potrebbe essere la prova:

  1. fmap id = id
    • se abbiamo Nothing
      • fmap id Nothing= Nothingdalla parte 1 dell'attuazione
      • id Nothing= Nothingdalla definizione diid
    • se abbiamo Just x
      • fmap id (Just x)= Just (id x)= Just xdalla parte 2 dell'implementazione, quindi dalla definizione diid
  2. fmap (p . q) = (fmap p) . (fmap q)
    • se abbiamo Nothing
      • fmap (p . q) Nothing= Nothingdalla parte 1
      • (fmap p) . (fmap q) $ Nothing= (fmap p) $ Nothing= Nothingda due applicazioni della parte 1
    • se abbiamo Just x
      • fmap (p . q) (Just x)= Just ((p . q) x)= Just (p (q x))dalla parte 2, quindi dalla definizione di.
      • (fmap p) . (fmap q) $ (Just x)= (fmap p) $ (Just (q x))= Just (p (q x))per due applicazioni della seconda parte

-1

"Attenzione ai bug nel codice sopra; l'ho solo dimostrato corretto, non provato." - Donald Knuth

In un mondo perfetto, i programmatori sono perfetti e non commettono errori, quindi non ci sono bug.

In un mondo perfetto, anche gli informatici e i matematici sono perfetti e non fanno neppure errori.

Ma non viviamo in un mondo perfetto. Quindi non possiamo fare affidamento sui programmatori per non commettere errori. Ma non possiamo supporre che qualsiasi scienziato informatico che fornisce una prova matematica che un programma sia corretto non abbia commesso errori in quella prova. Quindi non presterei attenzione a nessuno che prova a provare che il suo codice funziona. Scrivi unit-test e mostrami che il codice si comporta secondo le specifiche. Nient'altro non mi convincerà di niente.


5
Anche i test unitari possono avere errori. Ancora più importante, i test possono solo mostrare la presenza di bug, mai la loro assenza. Come ha affermato @Ingo nella sua risposta, fanno ottimi controlli di integrità e completano bene le prove, ma non sono un sostituto per loro.
Doval,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.