Qual è la risposta alla programmazione funzionale agli invarianti basati sui tipi?

9

Sono consapevole del fatto che il concetto di invarianti esiste attraverso più paradigmi di programmazione. Ad esempio, invarianti di loop sono rilevanti in OO, programmazione funzionale e procedurale.

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

D'altra parte, se dichiaro semplicemente un tipo Fraction senza fornire questa garanzia tramite l'incapsulamento, non posso scrivere in modo sicuro alcuna funzione di questo tipo che presuppone che la frazione sia ridotta, perché in futuro qualcun altro potrebbe vieni e aggiungi un modo per ottenere una frazione non ridotta.

Generalmente, la mancanza di questo tipo di invariante potrebbe portare a:

  • Gli algoritmi più complessi come pre-condizioni devono essere controllati / assicurati in più posizioni
  • Le violazioni DRY come queste pre-condizioni ripetute rappresentano la stessa conoscenza di base (che l'invariante dovrebbe essere vero)
  • Dover applicare le pre-condizioni tramite errori di runtime piuttosto che garanzie in fase di compilazione

Quindi la mia domanda è quale sia la risposta alla 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?

    
posta Ben Aaronson 21.05.2015 - 13:57
fonte

3 risposte

2

Alcuni linguaggi funzionali come OCaml hanno meccanismi integrati per implementare tipi di dati astratti , quindi impongono alcuni invarianti . Le lingue che non hanno tali meccanismi si basano sull'utente "non guardare sotto il tappeto" per far rispettare gli invarianti.

Tipi di dati astratti in OCaml

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

Ad esempio, possiamo implementare il modulo Fraction 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 può ora 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 i valori della frazione di tipo, aggirando la rete di sicurezza costruita in Fraction.make :

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

Per evitare ciò, è possibile nascondere la definizione concreta del tipo Fraction.t come quella:

# 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 funzione AbstractFraction.make .

Tipi di dati astratti in Schema

Il linguaggio Scheme non ha lo stesso meccanismo dei tipi di dati astratti di OCaml. Si basa sull'utente "non guardare sotto il tappeto" per ottenere l'incapsulamento.

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

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

    
risposta data 21.05.2015 - 15:29
fonte
5

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

Ecco approssimativamente come lo fai 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 usa la funzione ratio, che impone l'invariante. Poiché i dati sono immutabili, non puoi in seguito violare l'invarianza.

Questo però ti costa qualcosa: non è più possibile per l'utente usare la stessa dichiarazione di decostruzione come denominatore e uso del numeratore.

    
risposta data 21.05.2015 - 15:18
fonte
4

Lo fai allo stesso modo: crea un costruttore che applica il vincolo e accetta di usare 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 è necessario concordare per usare il costruttore. Oh davvero?

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. hai per mettere il costruttore per ultimo, a causa dell'immutabilità. Vorrei che le persone smettessero di pensare all'incapsulamento come una sorta di protezione contro colleghi incompetenti o che evitassero la necessità di comunicare i vincoli. Non lo fa Limita solo i posti che devi controllare. I buoni programmatori FP usano anche l'incapsulamento. Si tratta semplicemente della comunicazione di alcune funzioni preferite per apportare determinati tipi di modifiche.

    
risposta data 21.05.2015 - 14:55
fonte