Il polimorfismo del tipo di ritorno (solo) in Haskell è una buona cosa?


29

Una cosa con cui non sono mai riuscito a fare i conti con Haskell è come puoi avere costanti e funzioni polimorfiche il cui tipo di ritorno non può essere determinato dal loro tipo di input, come

class Foo a where
    foo::Int -> a

Alcuni dei motivi per cui non mi piace questo:

Trasparenza referenziale:

"In Haskell, dato lo stesso input, una funzione restituirà sempre lo stesso output", ma è davvero vero? read "3"restituisce 3 se utilizzato in un Intcontesto, ma genera un errore quando utilizzato in un, diciamo, (Int,Int)contesto. Sì, puoi obiettare che readsta prendendo anche un parametro type, ma l'implicazione del parametro type gli fa perdere parte della sua bellezza secondo me.

Limitazione del monomorfismo:

Una delle cose più fastidiose di Haskell. Correggimi se sbaglio, ma l'intero motivo dell'MR è che il calcolo apparentemente condiviso potrebbe non essere dovuto al fatto che il parametro type è implicito.

Digitare default:

Ancora una delle cose più fastidiose di Haskell. Succede ad esempio se si passa il risultato di funzioni polimorfiche nel loro output a funzioni polimorfiche nel loro input. Ancora una volta, correggimi se sbaglio, ma ciò non sarebbe necessario senza funzioni il cui tipo di ritorno non può essere determinato dal loro tipo di input (e dalle costanti polimorfiche).

Quindi la mia domanda è (correndo il rischio di essere timbrato come una "domanda di discussione"): sarebbe possibile creare un linguaggio simile a Haskell in cui il controllo del tipo non consente questo tipo di definizioni? In tal caso, quali sarebbero i vantaggi / gli svantaggi di tale restrizione?

Riesco a vedere alcuni problemi immediati:

Se, diciamo, 2avesse solo il tipo Integer, 2/3non digitare più check con la definizione corrente di /. Ma in questo caso, penso che classi di tipo con dipendenze funzionali potrebbero venire in soccorso (sì, so che questa è un'estensione). Inoltre, penso che sia molto più intuitivo avere funzioni che possono assumere diversi tipi di input, piuttosto che avere funzioni limitate nei loro tipi di input, ma passiamo loro solo valori polimorfici.

La digitazione di valori mi piace []e Nothingmi sembra un dado più duro da decifrare. Non ho pensato a un buon modo per gestirli.

Risposte:


37

In realtà penso che il polimorfismo del tipo di ritorno sia una delle migliori caratteristiche delle classi di tipo. Dopo averlo usato per un po ', a volte è difficile per me tornare alla modellazione in stile OOP dove non ce l'ho.

Considera la codifica dell'algebra. In Haskell abbiamo una classe di tipo Monoid(ignorando mconcat)

class Monoid a where
   mempty :: a
   mappend :: a -> a -> a

Come potremmo codificarlo come interfaccia in un linguaggio OO? La risposta breve è che non possiamo. Questo perché il tipo di memptyè (Monoid a) => aaka, restituisce il polimorfismo del tipo. Avere la capacità di modellare l'algebra è incredibilmente utile IMO. *

Inizi il tuo post con il reclamo su "Trasparenza referenziale". Ciò solleva un punto importante: Haskell è un linguaggio orientato al valore. Quindi espressioni come read 3non devono essere comprese come cose che calcolano valori, ma possono anche essere comprese come valori. Ciò significa che il vero problema non è il polimorfismo del tipo di ritorno: sono i valori con il tipo polimorfico ( []e Nothing). Se la lingua dovrebbe avere questi, allora deve davvero avere tipi di ritorno polimorfici per coerenza.

Dovremmo essere in grado di dire che []è di tipo forall a. [a]? Credo di si. Queste funzionalità sono molto utili e rendono la lingua molto più semplice.

Se Haskell avesse il polimorfismo del sottotipo []potrebbe essere un sottotipo per tutti [a]. Il problema è che non conosco un modo per codificare che senza che il tipo dell'elenco vuoto sia polimorfico. Considera come verrebbe fatto in Scala (è più breve di farlo nel linguaggio canonico OOP tipicamente statico, Java)

abstract class List[A]
case class Nil[A] extends List[A]
case class Cons[A](h: A. t: List[A]) extends List[A]

Anche qui, Nil()è un oggetto di tipo Nil[A]**

Un altro vantaggio del polimorfismo del tipo di ritorno è che rende l'incorporamento di Curry-Howard molto più semplice.

Considera i seguenti teoremi logici:

 t1 = forall P. forall Q. P -> P or Q
 t2 = forall P. forall Q. P -> Q or P

Possiamo banalmente catturarli come teoremi in Haskell:

data Either a b = Left a | Right b
t1 :: a -> Either a b
t1 = Left
t2 :: a -> Either b a
t2 = Right

Per riassumere: mi piace il polimorfismo del tipo di ritorno e penso che rompa la trasparenza referenziale se hai una nozione limitata di valori (anche se questo è meno convincente nel caso della classe di tipo ad hoc). D'altra parte, trovo i tuoi punti su MR e digito il default in modo convincente.


*. Nei commenti che ysdx sottolinea questo non è strettamente vero: potremmo implementare nuovamente le classi di tipi modellando l'algebra come un altro tipo. Come il java:

abstract class Monoid<M>{
   abstract M empty();
   abstract M append(M m1, M m2);
}

Devi quindi passare oggetti di questo tipo con te. Scala ha una nozione di parametri impliciti che evita alcuni, ma nella mia esperienza non tutti, il sovraccarico di gestire esplicitamente queste cose. Mettere i tuoi metodi di utilità (metodi di fabbrica, metodi binari, ecc.) Su un tipo separato associato a F risulta essere un modo incredibilmente bello di gestire le cose in un linguaggio OO che supporta i generici. Detto questo, non sono sicuro che avrei criticato questo modello se non avessi avuto esperienza nel modellare le cose con le macchine da scrivere e non sono sicuro che lo faranno le altre persone.

Ha anche dei limiti, non c'è modo di ottenere un oggetto che implementi la classe per un tipo arbitrario. Devi passare esplicitamente i valori, usare qualcosa come gli impliciti di Scala o usare una qualche forma di tecnologia di iniezione di dipendenza. La vita diventa brutta. D'altra parte, è bello poter avere più implementazioni per lo stesso tipo. Qualcosa può essere un monoide in più modi. Inoltre, portare in giro queste strutture separatamente ha un aspetto IMO più matematicamente moderno, costruttivo. Quindi, anche se in genere preferisco ancora il modo di Haskell di farlo, probabilmente ho esagerato con il mio caso.

Le macchine da scrivere con polimorfismo del tipo di ritorno rendono questo tipo di cose facile da gestire. Questo non significa che sia il modo migliore per farlo.

**. Jörg W Mittag sottolinea che non è proprio il modo canonico di farlo a Scala. Invece, seguiremmo la libreria standard con qualcosa di più simile a:

abstract class List[+A] ...  
case class Cons[A](head: A, tail: List[A]) extends List[A] ...
case object Nil extends List[Nothing] ...

Questo sfrutta il supporto di Scala per i tipi inferiori, nonché i parametri di tipo covariante. Quindi, Nilè di tipo Nilno Nil[A]. A questo punto siamo abbastanza lontani da Haskell, ma è interessante notare come Haskell rappresenti il ​​tipo di fondo

undefined :: forall a. a

Cioè, non è il sottotipo di tutti i tipi, è polimorficamente (sp) un membro di tutti i tipi.
Ancora più polimorfismo del tipo di ritorno.


4
"Come potremmo codificarlo come interfaccia in un linguaggio OO?" È possibile utilizzare l'algebra di prima classe: interfaccia Monoid <X> {X empty (); X append (X, X); } Tuttavia è necessario passarlo esplicitamente (ciò avviene dietro la scena in Haskell / GHC).
ysdx,

@ysdx Questo è vero. E nei linguaggi che supportano i parametri impliciti si ottiene qualcosa di molto vicino alle classi di tipi di haskell (come in Scala). Ne ero consapevole. Il mio punto però era che questo rende la vita piuttosto difficile: mi ritrovo a dover usare contenitori che immagazzinano la "typeclass" dappertutto (schifo!). Tuttavia, probabilmente avrei dovuto essere meno iperbolico nella mia risposta.
Philip JF,

+1, risposta fantastica. Un nitpick irrilevante, però: Nilprobabilmente dovrebbe essere un case object, non un case class.
Jörg W Mittag,

@ Jörg W Mittag Non è del tutto irrilevante. Ho modificato per indirizzare il tuo commento.
Philip JF,

1
Grazie per una bella risposta. Probabilmente mi ci vorrà un po 'di tempo per digerirlo / capirlo.
dainichi,

12

Solo per notare un malinteso:

"In Haskell, dato lo stesso input, una funzione restituirà sempre lo stesso output", ma è davvero vero? leggi "3" restituisce 3 se utilizzato in un contesto Int, ma genera un errore quando utilizzato in un contesto, diciamo, (Int, Int).

Solo perché mia moglie guida una Subaru e io guido una Subaru non significa che guidiamo la stessa macchina. Solo perché ci sono 2 funzioni nominate readnon significa che sia la stessa funzione. Indeed read :: String -> Intè definito nell'istanza Read di Int, dove read :: String (Int, Int)è definito nell'istanza Read di (Int, Int). Quindi, sono funzioni completamente diverse.

Questo fenomeno è comune nei linguaggi di programmazione e di solito si chiama sovraccarico .


6
Penso che ti sia mancato il punto della domanda. Nella maggior parte delle lingue che hanno polimorfismo ad hoc, ovvero sovraccarico, la selezione della funzione da chiamare dipende solo dai tipi di parametro, non dal tipo di ritorno. Questo rende più facile ragionare sul significato delle chiamate di funzione: inizia semplicemente dalle foglie dell'albero delle espressioni e procedi "verso l'alto". In Haskell (e il piccolo numero di altre lingue che supportano il sovraccarico del tipo di ritorno) potenzialmente devi considerare l'intero albero delle espressioni contemporaneamente, anche per capire il significato di una piccola sottoespressione.
Laurence Gonsalves,

1
Penso che tu abbia colto perfettamente il nocciolo della domanda. Persino Shakespeare disse: "Una funzione con qualsiasi altro nome avrebbe funzionato altrettanto bene". +1
Thomas Eding,

@Laurence Gonsalves - L'inferenza del tipo in Haskell non è referenzialmente trasparente. Il significato del codice può dipendere dal contesto in cui viene utilizzato a causa dell'inferenza del tipo che estrae le informazioni verso l'interno. Ciò non si limita ai problemi di tipo restituito. Haskell ha efficacemente integrato Prolog nel suo sistema di tipi. Questo può rendere il codice meno chiaro, ma presenta anche grandi vantaggi. Personalmente, penso che il tipo di trasparenza referenziale che conta di più sia ciò che accade in fase di esecuzione, perché la pigrizia sarebbe impossibile da affrontare senza di essa.
Steve314,

@ Steve314 Immagino che devo ancora vedere una situazione in cui la mancanza di inferenza di tipo referenzialmente trasparente non rende il codice meno chiaro. Per ragionare su qualcosa di complesso bisogna essere in grado di "spezzare" mentalmente le cose. Se mi dici che hai un gatto, non penso a una nuvola di miliardi di atomi o singole cellule. Allo stesso modo, quando leggo il codice, partiziono le espressioni nelle loro sottoespressioni. Haskell lo sconfigge in due modi: inferenza di tipo "sbagliato" e sovraccarico dell'operatore eccessivamente complesso. La comunità di Haskell ha anche un'avversione per i genitori, il che peggiora ulteriormente.
Laurence Gonsalves,

1
@LaurenceGonsalves Hai ragione sul fatto che la infixfunzione possa essere abusata. Ma questo è un fallimento degli utenti, quindi. OTOH, la limitatezza, come in Java, non è il modo giusto per IMHO. Per vedere questo, non cercare oltre un codice che si occupa di BigIntegers.
Ingo,

7

Vorrei davvero che il termine "polimorfismo del tipo di ritorno" non fosse mai stato creato. Incoraggia un fraintendimento di ciò che sta accadendo. Basti dire che, pur eliminando il "polimorfismo del tipo di ritorno" sarebbe un cambiamento estremamente ad hoc ed espressivo che uccide il cambiamento, non risolverebbe nemmeno da remoto i problemi sollevati nella domanda.

Il tipo restituito non è in alcun modo speciale. Ritenere:

class Foo a where
    foo :: Maybe a -> Bool

x = foo Nothing

Questo codice causa tutti gli stessi problemi del "polimorfismo del tipo di ritorno" e dimostra gli stessi tipi di differenze anche con OOP. Nessuno parla però di "primo argomento Forse tipo polimorfismo".

L'idea chiave è che l'implementazione sta usando i tipi per risolvere quale istanza usare. I valori (runtime) di qualsiasi tipo non hanno nulla a che fare con esso. In effetti funzionerebbe anche per tipi che non hanno valori. In particolare, i programmi Haskell non hanno significato senza i loro tipi. (Ironia della sorte, questo rende Haskell un linguaggio in stile Church in contrapposizione a un linguaggio in stile Curry. Ho un articolo sul blog che sta elaborando su questo.)


"Questo codice causa gli stessi problemi del" polimorfismo del tipo di ritorno ". No non lo fa. Posso guardare "foo Nothing" e determinare quale sia il suo tipo. È un bool. Non è necessario guardare al contesto.
dainichi,

4
In realtà, il codice non digita check perché il compilatore non sa com'è anel caso del "tipo restituito". Ancora una volta, non c'è nulla di speciale nei tipi di ritorno. Dobbiamo conoscere il tipo di tutte le sottoespressioni. Prendere in considerazione let x = Nothing in if foo x then fromJust x else error "No foo".
Derek Elkins il

2
Per non parlare del "polimorfismo del secondo argomento"; una funzione come Int -> a -> Boolè, al curry, in realtà Int -> (a -> Bool)e così via, il polimorfismo nel valore di ritorno di nuovo. Se lo consenti ovunque, deve essere ovunque.
Ryan Reich,

4

Per quanto riguarda la tua domanda sulla trasparenza referenziale sui valori polimorfici, ecco qualcosa che può aiutare.

Considera il nome 1 . Indica spesso oggetti diversi (ma fissi):

  • 1 come in un numero intero
  • 1 come in un reale
  • 1 come in una matrice di identità quadrata
  • 1 come in una funzione di identità

Qui è importante notare che in ogni contesto , 1è un valore fisso. In altre parole, ogni coppia nome-contesto indica un oggetto unico . Senza il contesto, non possiamo sapere quale oggetto denotiamo. Pertanto, il contesto deve essere dedotto (se possibile) o fornito esplicitamente. Un bel vantaggio (oltre alla notazione conveniente) è la capacità di esprimere il codice in contesti generici.

Ma poiché questa è solo notazione, avremmo potuto usare invece la seguente notazione:

  • integer1 come in un numero intero
  • real1 come in un reale
  • matrixIdentity1 come in una matrice di identità quadrata
  • functionIdentity1 come in una funzione di identità

Qui, otteniamo nomi che sono espliciti. Questo ci consente di derivare il contesto solo dal nome. Pertanto, è necessario solo il nome dell'oggetto per indicare completamente un oggetto.

Le classi di tipo Haskell hanno eletto il precedente schema di notazione. Ora ecco la luce alla fine del tunnel:

readè un nome, non un valore (non ha contesto), ma read :: String -> aè un valore (ha sia un nome che un contesto). Quindi le funzioni read :: String -> Inte read :: String -> (Int, Int)sono letteralmente diverse funzioni. Pertanto, se non sono d'accordo sui propri input, la trasparenza referenziale non viene interrotta.

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.