Non sono molto esperto in Haskell, quindi questa potrebbe essere una domanda molto facile.
Quale limitazione linguistica risolve Rank2Types ? Le funzioni in Haskell non supportano già argomenti polimorfici?
Non sono molto esperto in Haskell, quindi questa potrebbe essere una domanda molto facile.
Quale limitazione linguistica risolve Rank2Types ? Le funzioni in Haskell non supportano già argomenti polimorfici?
Risposte:
Le funzioni in Haskell non supportano già argomenti polimorfici?
Lo fanno, ma solo di rango 1. Ciò significa che mentre puoi scrivere una funzione che accetta diversi tipi di argomenti senza questa estensione, non puoi scrivere una funzione che utilizza il suo argomento come tipi diversi nella stessa chiamata.
Ad esempio, la seguente funzione non può essere digitata senza questa estensione perché g
viene utilizzata con diversi tipi di argomenti nella definizione di f
:
f g = g 1 + g "lala"
Nota che è perfettamente possibile passare una funzione polimorfica come argomento a un'altra funzione. Quindi qualcosa di simile map id ["a","b","c"]
è perfettamente legale. Ma la funzione può usarlo solo come monomorfico. Nell'esempio map
utilizza id
come se avesse tipo String -> String
. E ovviamente puoi anche passare una semplice funzione monomorfica del tipo dato invece di id
. Senza rank2types non c'è modo per una funzione di richiedere che il suo argomento debba essere una funzione polimorfica e quindi non c'è nemmeno modo di usarlo come funzione polimorfica.
f' g x y = g x + g y
. Il suo tipo di rango 1 dedotto è forall a r. Num r => (a -> r) -> a -> a -> r
. Poiché forall a
è al di fuori delle frecce della funzione, il chiamante deve prima scegliere un tipo per a
; se scelgono Int
, otteniamo f' :: forall r. Num r => (Int -> r) -> Int -> Int -> r
, e ora abbiamo corretto l' g
argomento in modo che possa richiedere Int
ma non String
. Se abilitiamo RankNTypes
possiamo annotare f'
con type forall b c r. Num r => (forall a. a -> r) -> b -> c -> r
. Non posso usarlo, però, cosa sarebbe g
?
È difficile capire il polimorfismo di rango superiore a meno che non si studia il sistema F. direttamente il , perché Haskell è progettato per nasconderti i dettagli nell'interesse della semplicità.
Ma fondamentalmente, l'idea approssimativa è che i tipi polimorfici non hanno realmente la a -> b
forma che hanno in Haskell; in realtà si presentano così, sempre con quantificatori espliciti:
id :: ∀a.a → a
id = Λt.λx:t.x
Se non conosci il simbolo "∀", viene letto come "per tutti"; ∀x.dog(x)
significa "poiché x, x è un cane". "Λ" è il lambda maiuscolo, utilizzato per astrarre i parametri di tipo; quello che dice la seconda riga è che id è una funzione che accetta un tipo t
e quindi restituisce una funzione parametrizzata da quel tipo.
Vedi, nel sistema F, non puoi semplicemente applicare una funzione del genere id
a un valore subito; per prima cosa devi applicare la funzione Λ a un tipo per ottenere una funzione λ che applichi a un valore. Quindi per esempio:
(Λt.λx:t.x) Int 5 = (λx:Int.x) 5
= 5
Standard Haskell (cioè, Haskell 98 e 2010) semplifica questo per te non avendo nessuno di questi quantificatori di tipo, lambda maiuscole e applicazioni di tipo, ma dietro le quinte GHC li inserisce quando analizza il programma per la compilazione. (Questa è tutta roba in fase di compilazione, credo, senza overhead di runtime.)
Ma la gestione automatica di Haskell di questo significa che assume che "∀" non appare mai sul ramo di sinistra di un tipo di funzione ("→"). Rank2Types
e RankNTypes
disattivare queste restrizioni e consentire di ignorare le regole predefinite di Haskell per dove inserireforall
.
Perché dovresti farlo? Perché il Sistema F completo e illimitato è estremamente potente e può fare molte cose interessanti. Ad esempio, l'occultamento del tipo e la modularità possono essere implementati utilizzando tipi di rango superiore. Prendiamo ad esempio una semplice vecchia funzione del seguente tipo di rango 1 (per impostare la scena):
f :: ∀r.∀a.((a → r) → a → r) → r
Per utilizzarlo f
, il chiamante deve prima scegliere quali tipi utilizzare r
e a
, quindi fornire un argomento del tipo risultante. Quindi potresti scegliere r = Int
e a = String
:
f Int String :: ((String → Int) → String → Int) → Int
Ma ora confrontalo con il seguente tipo di rango superiore:
f' :: ∀r.(∀a.(a → r) → a → r) → r
Come funziona una funzione di questo tipo? Bene, per usarlo, prima devi specificare per quale tipo usare r
. Diciamo di scegliere Int
:
f' Int :: (∀a.(a → Int) → a → Int) → Int
Ma ora si ∀a
trova all'interno della freccia della funzione, quindi non puoi scegliere quale tipo utilizzare a
; è necessario applicare f' Int
a una funzione Λ del tipo appropriato. Ciò significa che l'implementazione di f'
deve scegliere il tipo da utilizzare a
, non il chiamantef'
. Senza tipi di rango superiore, al contrario, il chiamante sceglie sempre i tipi.
A cosa serve? Bene, per molte cose in realtà, ma un'idea è che puoi usarlo per modellare cose come la programmazione orientata agli oggetti, dove gli "oggetti" raggruppano alcuni dati nascosti insieme ad alcuni metodi che lavorano sui dati nascosti. Quindi, ad esempio, un oggetto con due metodi, uno che restituisce an Int
e un altro che restituisce a String
, potrebbe essere implementato con questo tipo:
myObject :: ∀r.(∀a.(a → Int, a -> String) → a → r) → r
Come funziona? L'oggetto è implementato come una funzione che ha alcuni dati interni di tipo nascosto a
. Per utilizzare effettivamente l'oggetto, i suoi client passano una funzione di "callback" che l'oggetto chiamerà con i due metodi. Per esempio:
myObject String (Λa. λ(length, name):(a → Int, a → String). λobjData:a. name objData)
Qui stiamo, fondamentalmente, invocando il secondo metodo dell'oggetto, quello il cui tipo è a → String
per uno sconosciuto a
. Ebbene, sconosciuto ai myObject
clienti di; ma questi clienti sanno, dalla firma, che saranno in grado di applicare una delle due funzioni ad essa e ottenere una Int
o unaString
.
Per un esempio Haskell reale, di seguito è riportato il codice che ho scritto quando ho imparato da solo RankNTypes
. Questo implementa un tipo chiamato ShowBox
che raggruppa un valore di un tipo nascosto insieme alla sua Show
istanza di classe. Nota che nell'esempio in basso, creo un elenco di ShowBox
cui il primo elemento è stato composto da un numero e il secondo da una stringa. Poiché i tipi vengono nascosti utilizzando i tipi di rango più elevato, ciò non viola il controllo del tipo.
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ImpredicativeTypes #-}
type ShowBox = forall b. (forall a. Show a => a -> b) -> b
mkShowBox :: Show a => a -> ShowBox
mkShowBox x = \k -> k x
-- | This is the key function for using a 'ShowBox'. You pass in
-- a function @k@ that will be applied to the contents of the
-- ShowBox. But you don't pick the type of @k@'s argument--the
-- ShowBox does. However, it's restricted to picking a type that
-- implements @Show@, so you know that whatever type it picks, you
-- can use the 'show' function.
runShowBox :: forall b. (forall a. Show a => a -> b) -> ShowBox -> b
-- Expanded type:
--
-- runShowBox
-- :: forall b. (forall a. Show a => a -> b)
-- -> (forall b. (forall a. Show a => a -> b) -> b)
-- -> b
--
runShowBox k box = box k
example :: [ShowBox]
-- example :: [ShowBox] expands to this:
--
-- example :: [forall b. (forall a. Show a => a -> b) -> b]
--
-- Without the annotation the compiler infers the following, which
-- breaks in the definition of 'result' below:
--
-- example :: forall b. [(forall a. Show a => a -> b) -> b]
--
example = [mkShowBox 5, mkShowBox "foo"]
result :: [String]
result = map (runShowBox show) example
PS: per chiunque stia leggendo questo articolo e si sia chiesto come ExistentialTypes
mai usa GHC forall
, credo che il motivo sia perché sta usando questo tipo di tecnica dietro le quinte.
exists
parola chiave, potresti definire un tipo esistenziale come (ad esempio) data Any = Any (exists a. a)
, dove Any :: (exists a. a) -> Any
. Usando ∀xP (x) → Q ≡ (∃xP (x)) → Q, possiamo concludere che Any
potrebbe anche avere un tipo forall a. a -> Any
ed è da lì che forall
proviene la parola chiave. Credo che i tipi esistenziali implementati da GHC siano solo normali tipi di dati che contengono anche tutti i dizionari della classe di caratteri richiesti (non sono riuscito a trovare un riferimento per supportarlo, mi dispiace).
data ApplyBox r = forall a. ApplyBox (a -> r) a
; quando si esegue la corrispondenza del pattern ApplyBox f x
, si ottiene f :: h -> r
e x :: h
per un tipo "nascosto" limitato h
. Se ho capito bene, il caso del dizionario typeclass è tradotto in qualcosa di simile: data ShowBox = forall a. Show a => ShowBox a
è tradotto in qualcosa di simile data ShowBox' = forall a. ShowBox' (ShowDict' a) a
; instance Show ShowBox' where show (ShowBox' dict val) = show' dict val
; show' :: ShowDict a -> a -> String
.
La risposta di Luis Casillas fornisce molte ottime informazioni su cosa significano i tipi di rango 2, ma mi limiterò a espandere su un punto che non ha trattato. Richiedere che un argomento sia polimorfico non consente solo di utilizzarlo con più tipi; limita anche ciò che quella funzione può fare con i suoi argomenti e come può produrre il suo risultato. Cioè, offre al chiamante meno flessibilità. Perché dovresti farlo? Inizierò con un semplice esempio:
Supponiamo di avere un tipo di dati
data Country = BigEnemy | MediumEnemy | PunyEnemy | TradePartner | Ally | BestAlly
e vogliamo scrivere una funzione
f g = launchMissilesAt $ g [BigEnemy, MediumEnemy, PunyEnemy]
che assume una funzione che dovrebbe scegliere uno degli elementi della lista che gli viene data e restituire IO
un'azione lanciando missili su quel bersaglio. Potremmo dare f
un tipo semplice:
f :: ([Country] -> Country) -> IO ()
Il problema è che potremmo correre accidentalmente
f (\_ -> BestAlly)
e poi saremmo in grossi guai! Dare f
un tipo polimorfico di grado 1
f :: ([a] -> a) -> IO ()
non aiuta affatto, perché scegliamo il tipo a
quando chiamiamo f
, e lo specializziamo Country
e usiamo di \_ -> BestAlly
nuovo il nostro malevolo . La soluzione è utilizzare un tipo di rango 2:
f :: (forall a . [a] -> a) -> IO ()
Ora la funzione che passiamo deve essere polimorfa, quindi \_ -> BestAlly
non digitare check! In effetti, nessuna funzione che restituisce un elemento non presente nella lista che le viene data effettuerà il controllo del tipo (sebbene alcune funzioni che entrano in cicli infiniti o producono errori e quindi non tornano mai lo faranno).
Quanto sopra è artificioso, ovviamente, ma una variazione di questa tecnica è la chiave per rendere ST
sicura la monade.
I tipi di rango superiore non sono così esotici come hanno fatto le altre risposte. Che tu ci creda o no, molti linguaggi orientati agli oggetti (inclusi Java e C #!) Li presentano. (Naturalmente, nessuno in quelle comunità li conosce con il nome spaventoso "tipi di rango superiore".)
L'esempio che fornirò è un'implementazione da manuale del modello Visitor, che uso sempre nel mio lavoro quotidiano. Questa risposta non è intesa come un'introduzione al modello di visitatore; quella conoscenza è prontamente disponibile altrove .
In questa fatua e immaginaria applicazione delle risorse umane, desideriamo operare su dipendenti che possono essere dipendenti a tempo pieno o appaltatori temporanei. La mia variante preferita del pattern Visitor (e in effetti quella per cui è rilevante RankNTypes
) parametrizza il tipo di ritorno del visitatore.
interface IEmployeeVisitor<T>
{
T Visit(PermanentEmployee e);
T Visit(Contractor c);
}
class XmlVisitor : IEmployeeVisitor<string> { /* ... */ }
class PaymentCalculator : IEmployeeVisitor<int> { /* ... */ }
Il punto è che un numero di visitatori con diversi tipi di ritorno possono tutti operare sugli stessi dati. Ciò significa che non IEmployee
deve esprimere alcuna opinione su ciò che T
dovrebbe essere.
interface IEmployee
{
T Accept<T>(IEmployeeVisitor<T> v);
}
class PermanentEmployee : IEmployee
{
// ...
public T Accept<T>(IEmployeeVisitor<T> v)
{
return v.Visit(this);
}
}
class Contractor : IEmployee
{
// ...
public T Accept<T>(IEmployeeVisitor<T> v)
{
return v.Visit(this);
}
}
Desidero attirare la vostra attenzione sui tipi. Si noti che IEmployeeVisitor
quantifica universalmente il suo tipo di ritorno, mentre lo IEmployee
quantifica all'interno del suo Accept
metodo, vale a dire a un livello più alto. Tradurre clunkily da C # a Haskell:
data IEmployeeVisitor r = IEmployeeVisitor {
visitPermanent :: PermanentEmployee -> r,
visitContractor :: Contractor -> r
}
newtype IEmployee = IEmployee {
accept :: forall r. IEmployeeVisitor r -> r
}
Così il gioco è fatto. I tipi di rango più elevato vengono visualizzati in C # quando si scrivono tipi contenenti metodi generici.
Le diapositive del corso Haskell di Bryan O'Sullivan a Stanford mi hanno aiutato a capire Rank2Types
.
Per chi ha familiarità con i linguaggi orientati agli oggetti, una funzione di rango più elevato è semplicemente una funzione generica che si aspetta come argomento un'altra funzione generica.
Ad esempio in TypeScript potresti scrivere:
type WithId<T> = T & { id: number }
type Identifier = <T>(obj: T) => WithId<T>
type Identify = <TObj>(obj: TObj, f: Identifier) => WithId<TObj>
Vedi come il tipo di funzione generico Identify
richiede una funzione generica del tipo Identifier
? Ciò crea Identify
una funzione di rango superiore.
Accept
ha un tipo polimorfico di rango 1, ma è un metodo di IEmployee
, che è esso stesso di rango 2. Se qualcuno me ne dà un IEmployee
, posso aprirlo e usare il suo Accept
metodo in qualsiasi tipo.
Visitee
classe che introduci. Una funzione f :: Visitee e => T e
è (una volta che la roba della classe è stata desugared) essenzialmente f :: (forall r. e -> Visitor e r -> r) -> T e
. Haskell 2010 ti consente di cavartela con il polimorfismo di rango 2 limitato usando classi come questa.
forall
nel mio esempio. Non ho un riferimento fuori mano, ma potresti trovare qualcosa in "Scrap Your Type Classes" . Il polimorfismo di rango superiore può effettivamente introdurre problemi di controllo del tipo, ma l'ordinamento limitato implicito nel sistema di classi va bene.