Non è possibile creare una funzione pura chiamata random
che darà un risultato diverso ogni volta che viene chiamata. In effetti, non puoi nemmeno "chiamare" funzioni pure. Li applichi. Quindi non ti manca nulla, ma ciò non significa che i numeri casuali siano vietati nella programmazione funzionale. Permettetemi di dimostrare, userò la sintassi di Haskell per tutto.
Proveniente da un background imperativo, inizialmente potresti aspettarti che un tipo casuale abbia questo tipo:
random :: () -> Integer
Ma questo è già stato escluso perché random non può essere una pura funzione.
Considera l'idea di un valore. Un valore è una cosa immutabile. Non cambia mai e ogni osservazione che puoi fare al riguardo è coerente per tutti i tempi.
Chiaramente, random non può produrre un valore intero. Invece, produce una variabile casuale Integer. Il suo tipo potrebbe apparire così:
random :: () -> Random Integer
Tranne il fatto che passare un argomento è del tutto inutile, le funzioni sono pure, quindi uno random ()
è buono come un altro random ()
. Darò a caso, da qui in poi, questo tipo:
random :: Random Integer
Tutto bene e bene, ma non molto utile. Potresti aspettarti di essere in grado di scrivere espressioni come random + 42
, ma non puoi, perché non controllerà i caratteri. Non puoi ancora fare nulla con variabili casuali.
Ciò solleva una domanda interessante. Quali funzioni dovrebbero esistere per manipolare variabili casuali?
Questa funzione non può esistere:
bad :: Random a -> a
in alcun modo utile, perché allora potresti scrivere:
badRandom :: Integer
badRandom = bad random
Il che introduce un'incoerenza. badRandom è presumibilmente un valore, ma è anche un numero casuale; una contraddizione.
Forse dovremmo aggiungere questa funzione:
randomAdd :: Integer -> Random Integer -> Random Integer
Ma questo è solo un caso speciale di un modello più generale. Dovresti essere in grado di applicare qualsiasi funzione a cose casuali per ottenere altre cose casuali in questo modo:
randomMap :: (a -> b) -> Random a -> Random b
Invece di scrivere random + 42
, ora possiamo scrivere randomMap (+42) random
.
Se tutto ciò che avevi fosse randomMap, non saresti in grado di combinare variabili casuali insieme. Non è possibile scrivere questa funzione per esempio:
randomCombine :: Random a -> Random b -> Random (a, b)
Potresti provare a scriverlo in questo modo:
randomCombine a b = randomMap (\a' -> randomMap (\b' -> (a', b')) b) a
Ma ha il tipo sbagliato. Invece di finire con a Random (a, b)
, finiamo con aRandom (Random (a, b))
Questo può essere risolto aggiungendo un'altra funzione:
randomJoin :: Random (Random a) -> Random a
Ma, per ragioni che alla fine potrebbero diventare chiare, non lo farò. Invece ho intenzione di aggiungere questo:
randomBind :: Random a -> (a -> Random b) -> Random b
Non è immediatamente ovvio che ciò risolva effettivamente il problema, ma lo fa:
randomCombine a b = randomBind a (\a' -> randomMap (\b' -> (a', b')) b)
In effetti, è possibile scrivere randomBind in termini di randomJoin e randomMap. È anche possibile scrivere randomJoin in termini di randomBind. Ma lascerò fare questo come esercizio.
Potremmo semplificarlo un po '. Mi permetta di definire questa funzione:
randomUnit :: a -> Random a
randomUnit trasforma un valore in una variabile casuale. Ciò significa che possiamo avere variabili casuali che in realtà non sono casuali. Questo è sempre stato il caso però; avremmo potuto fare randomMap (const 4) random
prima. Il motivo che definisce randomUnit è una buona idea è che ora possiamo definire randomMap in termini di randomUnit e randomBind:
randomMap :: (a -> b) -> Random a -> Random b
randomMap f x = randomBind x (randomUnit . f)
Ok, ora stiamo arrivando da qualche parte. Abbiamo variabili casuali che possiamo manipolare. Però:
- Non è ovvio come potremmo effettivamente implementare queste funzioni,
- È piuttosto ingombrante.
Implementazione
Affronterò numeri pseudo casuali. È possibile implementare queste funzioni per numeri casuali reali, ma questa risposta sta già diventando piuttosto lunga.
In sostanza, il modo in cui funzionerà è che passeremo un valore di seed ovunque. Ogni volta che generiamo un nuovo valore casuale, produrremo un nuovo seme. Alla fine, quando avremo finito di costruire una variabile casuale, vorremmo campionarlo usando questa funzione:
runRandom :: Seed -> Random a -> a
Definirò il tipo casuale in questo modo:
data Random a = Random (Seed -> (Seed, a))
Quindi, dobbiamo solo fornire implementazioni di randomUnit, randomBind, runRandom e random che è abbastanza semplice:
randomUnit :: a -> Random a
randomUnit x = Random (\seed -> (seed, x))
randomBind :: Random a -> (a -> Random b) -> Random b
randomBind (Random f) g =
Random (\seed ->
let (seed', x) = f seed
Random g' = g x in
g' seed')
runRandom :: Seed -> Random a -> a
runRandom seed (Random f) = (snd . f) seed
Per caso, suppongo che ci sia già una funzione del tipo:
psuedoRandom :: Seed -> (Seed, Integer)
Nel qual caso random è giusto Random psuedoRandom
.
Rendere le cose meno ingombranti
Haskell ha zucchero sintattico per rendere le cose più piacevoli per gli occhi. Si chiama notazione e per usarlo tutto ciò che dobbiamo fare è creare un'istanza di Monad for Random.
instance Monad Random where
return = randomUnit
(>>=) = randomBind
Fatto. randomCombine
da prima si poteva ora scrivere:
randomCombine :: Random a -> Random b -> Random (a, b)
randomCombine a b = do
a' <- a
b' <- b
return (a', b')
Se lo facessi da solo, farei un ulteriore passo avanti e creerei un'istanza di Applicativo. (Non preoccuparti se questo non ha senso).
instance Functor Random where
fmap = liftM
instance Applicative Random where
pure = return
(<*>) = ap
Quindi randomCombine potrebbe essere scritto:
randomCombine :: Random a -> Random b -> Random (a, b)
randomCombine a b = (,) <$> a <*> b
Ora che abbiamo queste istanze, possiamo usare al >>=
posto di randomBind, join invece di randomJoin, fmap anziché randomMap, return invece di randomUnit. Inoltre riceviamo un intero carico di funzioni gratuitamente.
Ne vale la pena? Si potrebbe obiettare che arrivare a questo stadio, dove lavorare con numeri casuali non è del tutto orribile, è stato piuttosto difficile e prolisso. Cosa abbiamo ottenuto in cambio di questo sforzo?
La ricompensa più immediata è che ora possiamo vedere esattamente quali parti del nostro programma dipendono dalla casualità e quali parti sono interamente deterministiche. Nella mia esperienza, forzare una separazione rigorosa come questa semplifica immensamente le cose.
Abbiamo ipotizzato così lontano che desideriamo solo un singolo campione da ogni variabile casuale che generiamo, ma se si scopre che in futuro vorremmo davvero vedere più della distribuzione, questo è banale. Puoi semplicemente usare runRandom molte volte sulla stessa variabile casuale con semi diversi. Questo è ovviamente possibile nei linguaggi imperativi, ma in questo caso possiamo essere certi che non eseguiremo IO imprevisti ogni volta che campioneremo una variabile casuale e non dovremo stare attenti a inizializzare lo stato.