Quando sono utili i tipi di grado superiore?


89

Faccio sviluppo in F # da un po 'e mi piace. Tuttavia, una parola d'ordine che so non esiste in F # è tipi di tipo superiore. Ho letto materiale su tipi di livello superiore e penso di aver capito la loro definizione. Solo non sono sicuro del motivo per cui sono utili. Qualcuno può fornire alcuni esempi di quali tipi di livello superiore rendono facili in Scala o Haskell, che richiedono soluzioni alternative in F #? Anche per questi esempi, quali sarebbero le soluzioni alternative senza i tipi di livello superiore (o viceversa in F #)? Forse sono così abituato a lavorarci intorno che non noto l'assenza di quella funzionalità.

(Penso) Capisco che al posto di tipi di tipo superiore myList |> List.map fo myList |> Seq.map f |> Seq.toListsuperiore ti permetta di scrivere semplicemente myList |> map fe restituirà un List. È fantastico (supponendo che sia corretto), ma sembra un po 'meschino? (E non potrebbe essere fatto semplicemente consentendo il sovraccarico delle funzioni?) Di solito converto in Seqcomunque e poi posso convertire in quello che voglio in seguito. Di nuovo, forse sono troppo abituato a lavorarci sopra. Ma c'è qualche esempio in cui i tipi di livello superiore ti fanno davvero risparmiare sia quando premi i tasti che come protezione dai tipi?


2
Molte delle funzioni in Control.Monad fanno uso di tipi superiori, quindi potresti voler cercare alcuni esempi. In F # le implementazioni dovrebbero essere ripetute per ogni tipo di monade concreta.
Lee

1
@Lee ma non potresti semplicemente creare un'interfaccia IMonad<T>e poi restituirla ad es. IEnumerable<int>O IObservable<int>quando hai finito? È tutto solo per evitare il casting?
aragosta

4
Il casting non è sicuro, quindi risponde alla tua domanda sulla sicurezza dei tipi. Un altro problema è come returnfunzionerebbe dal momento che appartiene davvero al tipo di monade, non a un'istanza particolare, quindi non lo vorresti affatto mettere IMonadnell'interfaccia.
Lee

4
@ Lee sì, stavo solo pensando che avresti dovuto lanciare il risultato finale dopo l'espressione, niente di grave perché hai appena creato l'espressione in modo da conoscere il tipo. Ma sembra che dovresti lanciare anche all'interno di ogni impl di bindaka SelectManyecc. Il che significa che qualcuno potrebbe usare l'API per binduna IObservablead una IEnumerablee assumere che avrebbe funzionato, che sì che schifo, se questo è il caso e non c'è modo per aggirare questo. Solo che non sono sicuro al 100% che non ci sia modo di aggirarlo.
aragosta

6
Ottima domanda. Devo ancora vedere un solo esempio pratico convincente di questa caratteristica del linguaggio che sia utile IRL.
JD

Risposte:


79

Quindi il tipo di tipo è il suo tipo semplice. Ad esempio, Intha tipo, il *che significa che è un tipo di base e può essere istanziato da valori. In base a una definizione approssimativa di tipo di tipo superiore (e non sono sicuro di dove F # disegna la linea, quindi includiamolo) i contenitori polimorfici sono un ottimo esempio di tipo di tipo superiore.

data List a = Cons a (List a) | Nil

Il costruttore del tipo Listha tipo, il * -> *che significa che deve essere passato un tipo concreto per ottenere un tipo concreto: List Intpuò avere abitanti come [1,2,3]ma Listnon può.

Assumerò che i vantaggi dei contenitori polimorfici siano ovvi, ma * -> *esistono tipi di tipi più utili dei semplici contenitori. Ad esempio, le relazioni

data Rel a = Rel (a -> a -> Bool)

o parser

data Parser a = Parser (String -> [(a, String)])

entrambi hanno anche gentile * -> *.


Tuttavia, possiamo fare di più su Haskell avendo tipi con tipi di ordine ancora superiore. Ad esempio, potremmo cercare un tipo con tipo (* -> *) -> *. Un semplice esempio di ciò potrebbe essere il Shapetentativo di riempire un contenitore di tipo * -> *.

data Shape f = Shape (f ())

[(), (), ()] :: Shape List

Ciò è utile per caratterizzare Traversablei messaggi di posta elettronica in Haskell, ad esempio, poiché possono sempre essere suddivisi nella loro forma e contenuto.

split :: Traversable t => t a -> (Shape t, [a])

Come altro esempio, consideriamo un albero parametrizzato sul tipo di ramo che ha. Ad esempio, potrebbe essere un albero normale

data Tree a = Branch (Tree a) a (Tree a) | Leaf

Ma possiamo vedere che il tipo di ramo contiene un Pairdi se Tree aquindi possiamo estrarre quel pezzo dal tipo parametricamente

data TreeG f a = Branch a (f (TreeG f a)) | Leaf

data Pair a = Pair a a
type Tree a = TreeG Pair a

Questo TreeGtipo di costruttore è gentile (* -> *) -> * -> *. Possiamo usarlo per creare altre varianti interessanti come un fileRoseTree

type RoseTree a = TreeG [] a

rose :: RoseTree Int
rose = Branch 3 [Branch 2 [Leaf, Leaf], Leaf, Branch 4 [Branch 4 []]]

O quelli patologici come a MaybeTree

data Empty a = Empty
type MaybeTree a = TreeG Empty a

nothing :: MaybeTree a
nothing = Leaf

just :: a -> MaybeTree a
just a = Branch a Empty

Oppure un TreeTree

type TreeTree a = TreeG Tree a

treetree :: TreeTree Int
treetree = Branch 3 (Branch Leaf (Pair Leaf Leaf))

Un altro posto in cui compare è nelle "algebre dei funtori". Se eliminiamo alcuni strati di astrattezza, questo potrebbe essere considerato meglio come una piega, come sum :: [Int] -> Int. Le algebre sono parametrizzate sul funtore e sul vettore . Il funtore ha gentile * -> *e il tipo portatore *così insieme

data Alg f a = Alg (f a -> a)

ha gentile (* -> *) -> * -> *. Algutile per la sua relazione con i tipi di dati e gli schemi di ricorsione costruiti sopra di essi.

-- | The "single-layer of an expression" functor has kind `(* -> *)`
data ExpF x = Lit Int
            | Add x x
            | Sub x x
            | Mult x x

-- | The fixed point of a functor has kind `(* -> *) -> *`
data Fix f = Fix (f (Fix f))

type Exp = Fix ExpF

exp :: Exp
exp = Fix (Add (Fix (Lit 3)) (Fix (Lit 4))) -- 3 + 4

fold :: Functor f => Alg f a -> Fix f -> a
fold (Alg phi) (Fix f) = phi (fmap (fold (Alg phi)) f)

Infine, anche se sono teoricamente possibile, non ho mai visto un ancora di tipo costruttore di alto-kinded. A volte vediamo funzioni di quel tipo come mask :: ((forall a. IO a -> IO a) -> IO b) -> IO b, ma penso che dovrai scavare nel prologo del tipo o nella letteratura tipizzata in modo dipendente per vedere quel livello di complessità nei tipi.


3
Controllo a macchina e modifico il codice in pochi minuti, sono al telefono in questo momento.
J. Abrahamson

12
@ J.Abrahamson +1 per una buona risposta e avere la pazienza di digitarla sul tuo telefono O_o
Daniel Gratzer

3
@lobsterism A TreeTreeè solo patologico, ma più praticamente significa che hai due diversi tipi di alberi intrecciati l'uno con l'altro: spingere quell'idea un po 'oltre può ottenere alcune nozioni di tipo sicuro molto potenti come il rosso staticamente sicuro / alberi neri e il tipo FingerTree equilibrato staticamente.
J. Abrahamson

3
@JonHarrop Un esempio standard del mondo reale è l'astrazione sulle monadi, ad esempio con stack di effetti in stile mtl. Tuttavia, potresti non essere d'accordo sul fatto che questo sia prezioso per il mondo reale. Penso che sia generalmente chiaro che le lingue possono esistere con successo senza HKT, quindi qualsiasi esempio fornirà un qualche tipo di astrazione che è più sofisticato di altri linguaggi.
J. Abrahamson

2
Puoi avere, ad esempio, sottoinsiemi di effetti autorizzati in varie monadi e astrarre su qualsiasi monade che soddisfi tale specifica. Ad esempio, le monadi che istanziano il "telescrivente" che consente la lettura e la scrittura a livello di carattere potrebbero includere sia l'IO che un'astrazione pipe. È possibile astrarre su varie implementazioni asincrone come un altro esempio. Senza HKT limiti qualsiasi tipo composto da quel brano generico.
J. Abrahamson

64

Considera la Functorclasse di tipo in Haskell, dove fè una variabile di tipo di livello superiore:

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

Ciò che questa firma di tipo dice è che fmap cambia il parametro di tipo di un fda aa b, ma rimane fcosì com'era. Quindi, se usi fmapsu un elenco ottieni un elenco, se lo usi su un parser ottieni un parser e così via. E queste sono garanzie statiche in fase di compilazione.

Non conosco F #, ma consideriamo cosa succede se proviamo a esprimere l' Functorastrazione in un linguaggio come Java o C #, con ereditarietà e generici, ma senza generici di tipo superiore. Primo tentativo:

interface Functor<A> {
    Functor<B> map(Function<A, B> f);
}

Il problema con questo primo tentativo è che un'implementazione dell'interfaccia può restituire qualsiasi classe che implementa Functor. Qualcuno potrebbe scrivere un il FunnyList<A> implements Functor<A>cui mapmetodo restituisce un diverso tipo di raccolta, o anche qualcos'altro che non è affatto una raccolta ma è comunque un file Functor. Inoltre, quando si utilizza il mapmetodo non è possibile richiamare alcun metodo specifico del sottotipo sul risultato a meno che non lo si abbassi al tipo che ci si aspetta effettivamente. Quindi abbiamo due problemi:

  1. Il sistema dei tipi non ci permette di esprimere l'invariante secondo cui il mapmetodo restituisce sempre la stessa Functorsottoclasse del ricevitore.
  2. Pertanto, non esiste un modo staticamente indipendente dai tipi per invocare un non Functormetodo sul risultato di map.

Ci sono altri modi più complicati che puoi provare, ma nessuno di loro funziona davvero. Ad esempio, potresti provare ad aumentare il primo tentativo definendo dei sottotipi Functorche limitano il tipo di risultato:

interface Collection<A> extends Functor<A> {
    Collection<B> map(Function<A, B> f);
}

interface List<A> extends Collection<A> {
    List<B> map(Function<A, B> f);
}

interface Set<A> extends Collection<A> {
    Set<B> map(Function<A, B> f);
}

interface Parser<A> extends Functor<A> {
    Parser<B> map(Function<A, B> f);
}

// …

Questo aiuta a impedire agli implementatori di quelle interfacce più strette di restituire il tipo sbagliato di Functordal mapmetodo, ma poiché non c'è limite al numero di Functorimplementazioni che puoi avere, non c'è limite al numero di interfacce più strette di cui avrai bisogno.

( EDIT: E nota che questo funziona solo perché Functor<B>appare come il tipo di risultato, e quindi le interfacce figlio possono restringerlo. Quindi, AFAIK non possiamo restringere entrambi gli usi Monad<B>nella seguente interfaccia:

interface Monad<A> {
    <B> Monad<B> flatMap(Function<? super A, ? extends Monad<? extends B>> f);
}

In Haskell, con variabili di tipo di rango superiore, questo è (>>=) :: Monad m => m a -> (a -> m b) -> m b.)

Un altro tentativo consiste nell'usare generici ricorsivi per cercare di fare in modo che l'interfaccia restringa il tipo di risultato del sottotipo al sottotipo stesso. Esempio di giocattolo:

/**
 * A semigroup is a type with a binary associative operation.  Law:
 *
 * > x.append(y).append(z) = x.append(y.append(z))
 */
interface Semigroup<T extends Semigroup<T>> {
    T append(T arg);
}

class Foo implements Semigroup<Foo> {
    // Since this implements Semigroup<Foo>, now this method must accept 
    // a Foo argument and return a Foo result. 
    Foo append(Foo arg);
}

class Bar implements Semigroup<Bar> {
    // Any of these is a compilation error:

    Semigroup<Bar> append(Semigroup<Bar> arg);

    Semigroup<Foo> append(Bar arg);

    Semigroup append(Bar arg);

    Foo append(Bar arg);

}

Ma questo tipo di tecnica (che è piuttosto arcana per il tuo sviluppatore OOP run-of-the-mill, diamine anche per il tuo sviluppatore funzionale run-of-the-mill) non può ancora esprimere il Functorvincolo desiderato :

interface Functor<FA extends Functor<FA, A>, A> {
    <FB extends Functor<FB, B>, B> FB map(Function<A, B> f);
}

Il problema qui è che questo non si limita FBad avere lo stesso Fdi FA— in modo che quando dichiari un tipo List<A> implements Functor<List<A>, A>, il mapmetodo può comunque restituire un NotAList<B> implements Functor<NotAList<B>, B>.

Prova finale, in Java, utilizzando tipi grezzi (contenitori non parametrizzati):

interface FunctorStrategy<F> {
    F map(Function f, F arg);
} 

Qui Fverrà creata un'istanza per tipi non parametrizzati come just Listor Map. Ciò garantisce che a FunctorStrategy<List>può restituire solo un List— ma hai abbandonato l'uso delle variabili di tipo per tenere traccia dei tipi di elementi degli elenchi.

Il cuore del problema qui è che linguaggi come Java e C # non consentono ai parametri di tipo di avere parametri. In Java, se Tè una variabile di tipo, puoi scrivere Te List<T>, ma non T<String>. I tipi di livello superiore rimuovono questa restrizione, in modo che tu possa avere qualcosa di simile (non completamente pensato):

interface Functor<F, A> {
    <B> F<B> map(Function<A, B> f);
}

class List<A> implements Functor<List, A> {

    // Since F := List, F<B> := List<B>
    <B> List<B> map(Function<A, B> f) {
        // ...
    }

}

E affrontando questo bit in particolare:

(Penso) Capisco che al posto di tipi di tipo superiore myList |> List.map fo myList |> Seq.map f |> Seq.toListsuperiore ti permetta di scrivere semplicemente myList |> map fe restituirà un List. È fantastico (supponendo che sia corretto), ma sembra un po 'meschino? (E non potrebbe essere fatto semplicemente consentendo il sovraccarico delle funzioni?) Di solito converto in Seqcomunque e poi posso convertire in quello che voglio in seguito.

Esistono molti linguaggi che generalizzano l'idea della mapfunzione in questo modo, modellandola come se, in fondo, la mappatura riguardasse le sequenze. Questa tua osservazione è in questo spirito: se hai un tipo che supporta la conversione da e verso Seq, ottieni l'operazione mappa "gratuitamente" riutilizzandola Seq.map.

In Haskell, tuttavia, la Functorclasse è più generale di così; non è legato alla nozione di sequenze. Puoi implementarlo fmapper i tipi che non hanno una buona mappatura con sequenze, come IOazioni, combinatori di parser, funzioni, ecc .:

instance Functor IO where
    fmap f action =
        do x <- action
           return (f x)

 -- This declaration is just to make things easier to read for non-Haskellers 
newtype Function a b = Function (a -> b)

instance Functor (Function a) where
    fmap f (Function g) = Function (f . g)  -- `.` is function composition

Il concetto di "mappatura" in realtà non è legato alle sequenze. È meglio capire le leggi del funtore:

(1) fmap id xs == xs
(2) fmap f (fmap g xs) = fmap (f . g) xs

In modo molto informale:

  1. La prima legge dice che mappare con una funzione identity / noop è lo stesso che non fare nulla.
  2. La seconda legge dice che qualsiasi risultato che puoi produrre mappando due volte, puoi anche produrre mappando una volta.

Questo è il motivo per cui si desidera fmappreservare il tipo: non appena si ottengono mapoperazioni che producono un tipo di risultato diverso, diventa molto, molto più difficile fornire garanzie come questa.


Così mi sono interessato al tuo ultimo bit, perché è utile avere una fmapsu Function aquando si ha già un .operazione? Capisco perché .ha senso essere la definizione dell'operazione fmap, ma non capisco dove avresti mai bisogno di usare fmapinvece di .. Forse se potessi fare un esempio in cui sarebbe utile, mi aiuterebbe a capire.
aragosta

1
Ah, capito: puoi fare una fn doubledi un funtore, dove double [1, 2, 3][2, 4, 6]e double sindà una fn che è il doppio del peccato. Posso vedere dove se inizi a pensare in quella mentalità, quando esegui una mappa su un array ti aspetti un array indietro, non solo un seq, perché, beh, qui stiamo lavorando sugli array.
aragosta

@lobsterism: ci sono algoritmi / tecniche che si basano sulla capacità di estrarre un Functore lasciare che il cliente della libreria lo scelga. La risposta di J. Abrahamson fornisce un esempio: le pieghe ricorsive possono essere generalizzate utilizzando funtori. Un altro esempio sono le monadi libere; si può pensare a questi come a una sorta di libreria di implementazione di interpreti generici, in cui il client fornisce il "set di istruzioni" come arbitrario Functor.
Luis Casillas

3
Una risposta tecnicamente valida, ma mi lascia a chiedermi perché qualcuno lo vorrebbe mai in pratica. Non mi sono trovato a cercare Haskell's Functoro a SemiGroup. Dove i programmi reali utilizzano maggiormente questa caratteristica del linguaggio?
JD

28

Non voglio ripetere le informazioni in alcune risposte eccellenti già qui, ma c'è un punto chiave che vorrei aggiungere.

Di solito non hai bisogno di tipi di livello superiore per implementare una particolare monade, o funtore (o funtore applicativo, o freccia, o ...). Ma così facendo per lo più manca il punto.

In generale, ho scoperto che quando le persone non vedono l'utilità di funtori / monadi / altri, è spesso perché pensano a queste cose una alla volta . Le operazioni su Functor / monad / etc non aggiungono davvero nulla a nessuna istanza (invece di chiamare bind, fmap, ecc. Potrei semplicemente chiamare qualunque operazione ho usato per implementare bind, fmap, ecc.). Quello che vuoi veramente per queste astrazioni è che tu possa avere codice che funzioni genericamente con qualsiasi funtore / monade / ecc.

In un contesto in cui tale codice generico è ampiamente utilizzato, ciò significa che ogni volta che scrivi una nuova istanza di monade il tuo tipo ottiene immediatamente accesso a un gran numero di operazioni utili che sono già state scritte per te . Questo è il punto di vedere monadi (e funtori e ...) ovunque; non in modo che io possa usare bindpiuttosto che concate mapda implementare myFunkyListOperation(il che non mi guadagna nulla in sé), ma piuttosto in modo che quando ne ho bisogno myFunkyParserOperatione myFunkyIOOperationposso riutilizzare il codice che ho visto originariamente in termini di elenchi perché in realtà è monade-generico .

Ma per astrarre attraverso un tipo parametrizzato come una monade con l'indipendenza dai tipi , sono necessari tipi di livello superiore (come ben spiegato in altre risposte qui).


9
Questa è più vicina ad essere una risposta utile rispetto a qualsiasi altra risposta che ho letto finora, ma mi piacerebbe comunque vedere un'unica applicazione pratica in cui i tipi superiori sono utili.
JD

"Quello che vuoi veramente per queste astrazioni è che tu possa avere codice che funzioni genericamente con qualsiasi funtore / monade". F # ha ottenuto le monadi sotto forma di espressioni di calcolo 13 anni fa, originariamente con monadi seq e asincrone. Oggi F # gode di una terza monade, query. Con così poche monadi che hanno così poco in comune, perché dovresti astrarre su di esse?
JD

@ JonHarrop Sei chiaramente consapevole del fatto che altre persone hanno scritto codice utilizzando un numero enorme di monadi (e funtori, frecce, ecc; HKT non sono solo monadi) in linguaggi che supportano HKT e trovano usi per astrarre su di loro. E chiaramente non pensi che nessuno di quel codice abbia alcun uso pratico, e sei curioso del motivo per cui altre persone si preoccuperebbero di scriverlo. Che tipo di intuizione speri di ottenere tornando ad avviare un dibattito su un post di 6 anni che hai già commentato 5 anni fa?
Ben

"sperando di guadagnare tornando ad avviare un dibattito su un post di 6 anni". Retrospettiva. Con il senno di poi ora sappiamo che le astrazioni di F # sulle monadi rimangono in gran parte inutilizzate. Pertanto la capacità di astrarre più di 3 cose in gran parte diverse non è convincente.
JD

@ JonHarrop Il punto della mia risposta è che le singole monadi (o funtori, o così via) non sono davvero più utili di funzionalità simili espresse senza un'interfaccia nomade, ma l'unificazione di molte cose disparate lo è. Rimanderò alla tua esperienza su F #, ma se stai dicendo che ha solo 3 monadi individuali (piuttosto che implementare un'interfaccia monadica per tutti i concetti che potrebbero averne uno, come fallimento, statefulness, analisi, ecc.), sì, non sorprende che non si ottengano molti benefici dall'unificazione di queste 3 cose.
Ben

15

Per una prospettiva più specifica per .NET, tempo fa ho scritto un post sul blog a riguardo. Il punto cruciale è che, con i tipi di livello superiore, potresti potenzialmente riutilizzare gli stessi blocchi LINQ tra IEnumerablese IObservables, ma senza i tipi di livello superiore questo è impossibile.

Il più vicino si potrebbe ottenere (ho capito dopo la pubblicazione del blog) è quello di rendere la propria IEnumerable<T>e IObservable<T>ed entrambi esteso da un IMonad<T>. Ciò ti consentirebbe di riutilizzare i tuoi blocchi LINQ se sono indicati IMonad<T>, ma poi non è più tipicamente sicuro perché ti consente di mescolare e abbinare IObservablese IEnumerablesall'interno dello stesso blocco, il che, sebbene possa sembrare intrigante abilitarlo, dovresti fondamentalmente ottieni solo un comportamento indefinito.

Ho scritto un post successivo su come Haskell rende tutto questo facile. (Un no-op, davvero - limitare un blocco a un certo tipo di monade richiede codice; abilitare il riutilizzo è l'impostazione predefinita).


2
Ti do un +1 per essere l'unica risposta che menziona qualcosa di pratico ma non credo di aver mai usato IObservablesnel codice di produzione.
JD

5
@ JonHarrop Questo sembra falso. In F # tutti gli eventi sono IObservable, e tu usi gli eventi nel capitolo WinForms del tuo libro.
Dax Fohl

1
Microsoft mi ha pagato per scrivere quel libro e mi ha chiesto di coprire quella caratteristica. Non ricordo di aver utilizzato gli eventi nel codice di produzione, ma cercherò.
JD

Il riutilizzo tra IQueryable e IEnumerable sarebbe possibile anche suppongo
KolA

Quattro anni dopo e ho finito di cercare: abbiamo messo fuori produzione Rx.
JD

13

L'esempio più utilizzato di polimorfismo di tipo superiore in Haskell è l' Monadinterfaccia. Functore Applicativesono di tipo superiore allo stesso modo, quindi mostrerò Functorper mostrare qualcosa di conciso.

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

Ora, esamina quella definizione, osservando come fviene utilizzata la variabile di tipo . Vedrai che fnon può significare un tipo che ha valore. È possibile identificare i valori in quel tipo di firma perché sono argomenti e risultati di una funzione. Quindi le variabili di tipo ae bsono tipi che possono avere valori. Così sono le espressioni di tipo f ae f b. Ma non fse stesso. fè un esempio di una variabile di tipo più elevato. Dato che* è il tipo di tipi che possono avere valori, fdeve avere il tipo * -> *. Cioè, ci vuole un tipo che può avere valori, perché sappiamo da un esame precedente che ae bdeve avere valori. E sappiamo anche che f aef b deve avere valori, quindi restituisce un tipo che deve avere valori.

Questo lo rende futilizzato nella definizione di Functoruna variabile di tipo più elevato.

Le interfacce Applicativee ne Monadaggiungono altre, ma sono compatibili. Ciò significa che lavorano anche su variabili di tipo con tipo * -> *.

Lavorare su tipi di livello superiore introduce un ulteriore livello di astrazione: non sei limitato alla semplice creazione di astrazioni rispetto ai tipi di base. Puoi anche creare astrazioni su tipi che modificano altri tipi.


4
Un'altra grande spiegazione tecnica di cosa sono i tipi superiori che mi lascia chiedermi a cosa siano utili. Dove l'hai sfruttato nel codice reale?
JD
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.