Qual è la risposta di programmazione funzionale agli invarianti di tipo?


9

Sono consapevole che il concetto di invarianti esiste attraverso molteplici paradigmi di programmazione. Ad esempio, gli invarianti di loop sono rilevanti nella programmazione OO, funzionale e procedurale.

Tuttavia, un tipo molto utile trovato in OOP è un invariante dei dati di un tipo particolare. Questo è ciò che chiamo "invarianti di tipo" nel titolo. Ad esempio, un Fractiontipo potrebbe avere un numeratore denominator, con l'invariante che il loro gcd è sempre 1 (cioè la frazione è in forma ridotta). Posso garantirlo solo avendo un qualche tipo di incapsulamento del tipo, non lasciando che i suoi dati siano impostati liberamente. In cambio, non devo mai controllare se è ridotto, quindi posso semplificare algoritmi come i controlli di uguaglianza.

D'altra parte, se dichiaro semplicemente un Fractiontipo senza fornire questa garanzia attraverso l'incapsulamento, non posso scrivere in modo sicuro alcuna funzione su questo tipo che presupponga che la frazione sia ridotta, perché in futuro qualcun altro potrebbe venire e aggiungere un modo di ottenere una frazione non ridotta.

In generale, la mancanza di questo tipo di invariante potrebbe portare a:

  • Algoritmi più complessi come pre-condizioni devono essere controllati / garantiti in più punti
  • Violazioni ASCIUTTE poiché queste pre-condizioni ripetute rappresentano la stessa conoscenza di base (che l'invariante dovrebbe essere vero)
  • Dover applicare pre-condizioni attraverso errori di runtime piuttosto che garanzie in fase di compilazione

Quindi la mia domanda è quale sia la risposta di programmazione funzionale a questo tipo di invariante. Esiste un modo funzionale-idiomatico per ottenere più o meno la stessa cosa? O c'è qualche aspetto della programmazione funzionale che rende i benefici meno rilevanti?


molti linguaggi funzionali possono farlo in modo banale ... Scala, F # e le altre lingue che si adattano bene con OOP, ma anche Haskell ... praticamente qualsiasi lingua che ti permetta di definire i tipi e il loro comportamento lo supporta.
AK_

@AK_ Sono consapevole che F # può farlo (anche se IIRC richiede qualche piccolo salto del cerchio) e ho indovinato Scala come un altro linguaggio paradigmatico. Interessante che Haskell possa farlo- hai un link? Quello che sto veramente cercando è la risposta funzionale-idiomatica , piuttosto che linguaggi specifici che offrono una funzionalità. Ma ovviamente le cose possono diventare piuttosto confuse e soggettive quando inizi a parlare di ciò che è idiomatico, motivo per cui l'ho lasciato fuori questione.
Ben Aaronson,

Per i casi in cui la precondizione non può essere verificata in fase di compilazione, è idiomatico verificare nel costruttore. Prendi in considerazione una PrimeNumberlezione. Sarebbe troppo costoso eseguire più controlli ridondanti per la primalità per ogni operazione, ma non è un tipo di test che può essere eseguito in fase di compilazione. (Molte operazioni che vorresti eseguire sui numeri primi, diciamo la moltiplicazione, non formano una chiusura , cioè i risultati probabilmente non sono garantiti come primi. (Pubblicare come commenti poiché non conosco la programmazione funzionale da solo.)
rwong

Una domanda apparentemente non correlata, ma ... Le affermazioni o i test unitari sono più importanti?
rwong

@rwong Sì, alcuni begli esempi lì. In realtà non sono chiaro al 100% a quale punto finale stai guidando, però.
Ben Aaronson,

Risposte:


2

Alcuni linguaggi funzionali come OCaml hanno meccanismi incorporati per implementare tipi di dati astratti e quindi applicare alcuni invarianti . Le lingue che non dispongono di tali meccanismi si basano sull'utente che "non guarda sotto il tappeto" per imporre gli invarianti.

Tipi di dati astratti in OCaml

In OCaml, i moduli sono utilizzati per strutturare un programma. Un modulo ha un'implementazione e una firma , essendo quest'ultima una sorta di sommario di valori e tipi definiti nel modulo, mentre il primo fornisce le definizioni effettive. Questo può essere liberamente paragonato al dittico .c/.hfamiliare ai programmatori C.

Ad esempio, possiamo implementare il Fractionmodulo in questo modo:

# module Fraction = struct
  type t = Fraction of int * int
  let rec gcd a b =
    match a mod b with
    | 0 -> b
    | r -> gcd b r

  let make a b =
   if b = 0 then
     invalid_arg "Fraction.make"
   else let d = gcd (abs a) (abs b) in
     Fraction(a/d, b/d)

  let to_string (Fraction(a,b)) =
    Printf.sprintf "Fraction(%d,%d)" a b

  let add (Fraction(a1,b1)) (Fraction(a2,b2)) =
    make (a1*b2 + a2*b1) (b1*b2)

  let mult (Fraction(a1,b1)) (Fraction(a2,b2)) =
    make (a1*a2) (b1*b2)
end;;

module Fraction :
  sig
    type t = Fraction of int * int
    val gcd : int -> int -> int
    val make : int -> int -> t
    val to_string : t -> string
    val add : t -> t -> t
    val mult : t -> t -> t
  end

Questa definizione ora può essere utilizzata in questo modo:

# Fraction.add (Fraction.make 8 6) (Fraction.make 14 21);;
- : Fraction.t = Fraction.Fraction (2, 1)

Chiunque può produrre direttamente valori della frazione di tipo, bypassando la rete di sicurezza integrata Fraction.make:

# Fraction.Fraction(0,0);;
- : Fraction.t = Fraction.Fraction (0, 0)

Per evitare ciò, è possibile nascondere la definizione concreta del tipo in Fraction.tquesto modo:

# module AbstractFraction : sig
  type t
  val make : int -> int -> t
  val to_string : t -> string
  val add : t -> t -> t
  val mult : t -> t -> t
end = Fraction;;

module AbstractFraction :
sig
  type t
  val make : int -> int -> t
  val to_string : t -> string
  val add : t -> t -> t
  val mult : t -> t -> t
end

L'unico modo per creare un AbstractFraction.tè utilizzare la AbstractFraction.makefunzione.

Tipi di dati astratti nello schema

Il linguaggio Scheme non ha lo stesso meccanismo di tipi di dati astratti di OCaml. Fa affidamento sull'utente che "non guarda sotto il tappeto" per ottenere l'incapsulamento.

In Scheme, è consuetudine definire predicati come fraction?riconoscere i valori dando l'opportunità di convalidare l'input. Nella mia esperienza, l'uso dominante è quello di consentire all'utente di convalidare l'input, se forgia un valore, piuttosto che convalidare l'input in ogni chiamata della libreria.

Esistono tuttavia diverse strategie per imporre l'astrazione dei valori restituiti, come restituire una chiusura che restituisce il valore quando applicato o restituire un riferimento a un valore in un pool gestito dalla libreria - ma non ne ho mai visti nessuno in pratica.


+1 Vale anche la pena ricordare che non tutte le lingue OO applicano l'incapsulamento.
Michael Shaw,

5

L'incapsulamento non è una funzionalità fornita con OOP. Qualunque linguaggio che supporti la corretta modularizzazione ce l'ha.

Ecco come si fa in Haskell:

-- Rational.hs
module Rational (
    -- This is the export list. Functions not in this list aren't visible to importers.
    Rational, -- Exports the data type, but not its constructor.
    ratio,
    numerator,
    denominator
    ) where

data Rational = Rational Int Int

-- This is the function we provide for users to create rationals
ratio :: Int -> Int -> Rational
ratio num den = let (num', den') = reduce num den
                 in Rational num' den'

-- These are the member accessors
numerator :: Rational -> Int
numerator (Rational num _) = num

denominator :: Rational -> Int
denominator (Rational _ den) = den

reduce :: Int -> Int -> (Int, Int)
reduce a b = let g = gcd a b
             in (a `div` g, b `div` g)

Ora, per creare un Rational, si utilizza la funzione ratio, che impone l'invariante. Poiché i dati sono immutabili, non è possibile in seguito violare l'invariante.

Questo però ti costa qualcosa: non è più possibile per l'utente utilizzare la stessa dichiarazione decostruttiva usata dal denominatore e dal numeratore.


4

Lo fai allo stesso modo: crea un costruttore che imponga il vincolo e accetti di utilizzare quel costruttore ogni volta che crei un nuovo valore.

multiply lhs rhs = ReducedFraction (lhs.num * rhs.num) (lhs.denom * rhs.denom)

Ma Karl, in OOP non devi accettare di usare il costruttore. Oh veramente?

class Fraction:
  ...
  Fraction multiply(Fraction lhs, Fraction rhs):
    Fraction result = lhs.clone()
    result.num *= rhs.num
    result.denom *= rhs.denom
    return result

In effetti, le opportunità per questo tipo di abuso sono minori in FP. Si deve mettere il costruttore scorso, a causa di immutabilità. Vorrei che la gente smettesse di pensare all'incapsulamento come a una sorta di protezione contro colleghi incompetenti, o come a ovviare alla necessità di vincoli comunicativi. Non lo fa. Limita solo i luoghi che devi controllare. Buoni programmatori FP usano anche l'incapsulamento. Si presenta solo sotto forma di comunicazione di alcune funzioni preferite per apportare determinati tipi di modifiche.


Beh, è ​​possibile (e idiomatico) scrivere codice in C #, ad esempio, il che non consente ciò che hai fatto lì. E penso che ci sia una differenza abbastanza chiara tra una singola classe che è responsabile del rispetto di un invariante e ogni funzione scritta da chiunque, dovunque usi un certo tipo per imporre lo stesso invariante.
Ben Aaronson,

@BenAaronson Notare una differenza tra "imporre" e "propagare" un invariante.
rwong

1
+1. Questa tecnica è ancora più potente in FP perché i valori immutabili non cambiano; così puoi provare cose su di loro "una volta per tutte" usando i tipi. Questo non è possibile con oggetti mutabili perché ciò che è vero per loro ora potrebbe non essere vero in seguito; il meglio che puoi fare difensivamente ricontrolla lo stato dell'oggetto.
Doval,

@Doval Non lo vedo. Mettendo da parte il fatto che la maggior parte (?) Delle principali lingue OO ha un modo di rendere immutabili le variabili. In OO ho: Crea un'istanza, quindi la mia funzione muta i valori di quell'istanza in un modo che può o non può essere conforme all'invariante. In FP ho: Crea un'istanza, quindi la mia funzione crea una seconda istanza con valori diversi in un modo che può o meno essere conforme all'invariante. Non vedo come l'immutabilità mi abbia aiutato a sentirmi ancora più sicuro che il mio invariante sia conforme a tutti i casi del tipo
Ben Aaronson,

2
@BenAaronson Immutability non ti aiuterà a dimostrare di aver implementato correttamente il tuo tipo (ovvero tutte le operazioni conservano un determinato invariante). Quello che sto dicendo è che ti consente di propagare fatti sui valori. Si codifica una condizione (ad es. Questo numero è pari) in un tipo (verificandolo nel costruttore) e il valore prodotto è la prova che il valore originale ha soddisfatto la condizione. Con oggetti mutabili controlli lo stato corrente e mantieni il risultato in un valore booleano. Quel booleano è valido solo finché l'oggetto non è mutato, quindi la condizione è falsa.
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.