No puede crear una función pura llamada random
que dará un resultado diferente cada vez que se llama. De hecho, ni siquiera puede "llamar" funciones puras. Los aplicas. Así que no te estás perdiendo nada, pero esto no significa que los números aleatorios estén fuera de los límites en la programación funcional. Permítame demostrar, usaré la sintaxis de Haskell en todo momento.
Viniendo de un contexto imperativo, inicialmente puede esperar que aleatorio tenga un tipo como este:
random :: () -> Integer
Pero esto ya se ha descartado porque el azar no puede ser una función pura.
Considere la idea de un valor. Un valor es una cosa inmutable. Nunca cambia y cada observación que puede hacer al respecto es coherente en todo momento.
Claramente, aleatorio no puede producir un valor entero. En cambio, produce una variable aleatoria entera. Su tipo podría verse así:
random :: () -> Random Integer
Excepto que pasar un argumento es completamente innecesario, las funciones son puras, por lo que una random ()
es tan buena como la otra random ()
. Daré al azar, de aquí en adelante, este tipo:
random :: Random Integer
Lo cual está muy bien, pero no es muy útil. Es posible que pueda escribir expresiones como random + 42
, pero no puede, porque no va a escribir. No puedes hacer nada con variables aleatorias, todavía.
Ésto plantea una pregunta interesante. ¿Qué funciones deberían existir para manipular variables aleatorias?
Esta función no puede existir:
bad :: Random a -> a
de alguna manera útil, porque entonces podrías escribir:
badRandom :: Integer
badRandom = bad random
Lo que introduce una inconsistencia. Se supone que badRandom es un valor, pero también es un número aleatorio; Una contradicción.
Quizás deberíamos agregar esta función:
randomAdd :: Integer -> Random Integer -> Random Integer
Pero esto es solo un caso especial de un patrón más general. Debería poder aplicar cualquier función a una cosa aleatoria para obtener otras cosas aleatorias como esta:
randomMap :: (a -> b) -> Random a -> Random b
En lugar de escribir random + 42
, ahora podemos escribir randomMap (+42) random
.
Si todo lo que tuviera fuera randomMap, no podría combinar variables aleatorias. No podría escribir esta función, por ejemplo:
randomCombine :: Random a -> Random b -> Random (a, b)
Puedes intentar escribirlo así:
randomCombine a b = randomMap (\a' -> randomMap (\b' -> (a', b')) b) a
Pero tiene el tipo incorrecto. En lugar de terminar con un Random (a, b)
, terminamos con unRandom (Random (a, b))
Esto se puede solucionar agregando otra función:
randomJoin :: Random (Random a) -> Random a
Pero, por razones que eventualmente pueden quedar claras, no voy a hacer eso. En cambio, voy a agregar esto:
randomBind :: Random a -> (a -> Random b) -> Random b
No es inmediatamente obvio que esto realmente resuelva el problema, pero lo hace:
randomCombine a b = randomBind a (\a' -> randomMap (\b' -> (a', b')) b)
De hecho, es posible escribir randomBind en términos de randomJoin y randomMap. También es posible escribir randomJoin en términos de randomBind. Pero, me iré haciendo esto como ejercicio.
Podríamos simplificar esto un poco. Permítame definir esta función:
randomUnit :: a -> Random a
randomUnit convierte un valor en una variable aleatoria. Esto significa que podemos tener variables aleatorias que en realidad no son aleatorias. Sin embargo, este siempre fue el caso; podríamos haberlo hecho randomMap (const 4) random
antes. La razón por la cual randomUnit es una buena idea es que ahora podemos definir randomMap en términos de randomUnit y randomBind:
randomMap :: (a -> b) -> Random a -> Random b
randomMap f x = randomBind x (randomUnit . f)
Ok, ahora estamos llegando a alguna parte. Tenemos variables aleatorias que podemos manipular. Sin embargo:
- No es obvio cómo podríamos implementar estas funciones,
- Es bastante engorroso.
Implementación
Abordaré números pseudoaleatorios. Es posible implementar estas funciones para números aleatorios reales, pero esta respuesta ya se está haciendo bastante larga.
Esencialmente, la forma en que esto funcionará es que vamos a pasar un valor semilla por todas partes. Cada vez que generamos un nuevo valor aleatorio, produciremos una nueva semilla. Al final, cuando hayamos terminado de construir una variable aleatoria, querremos muestrearla usando esta función:
runRandom :: Seed -> Random a -> a
Voy a definir el tipo aleatorio de esta manera:
data Random a = Random (Seed -> (Seed, a))
Entonces, solo necesitamos proporcionar implementaciones de randomUnit, randomBind, runRandom y random, que es bastante sencillo:
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
Para el azar, voy a suponer que ya hay una función del tipo:
psuedoRandom :: Seed -> (Seed, Integer)
En cuyo caso el azar es justo Random psuedoRandom
.
Hacer las cosas menos engorrosas
Haskell tiene azúcar sintáctica para hacer que estas cosas sean más agradables para los ojos. Se llama do-notation y para usarlo todo lo que tenemos que hacer es crear una instancia de Monad for Random.
instance Monad Random where
return = randomUnit
(>>=) = randomBind
Hecho. randomCombine
de antes ahora podría escribirse:
randomCombine :: Random a -> Random b -> Random (a, b)
randomCombine a b = do
a' <- a
b' <- b
return (a', b')
Si estuviera haciendo esto por mí mismo, incluso iría un paso más allá y crearía una instancia de aplicativo. (No se preocupe si esto no tiene sentido).
instance Functor Random where
fmap = liftM
instance Applicative Random where
pure = return
(<*>) = ap
Entonces randomCombine podría escribirse:
randomCombine :: Random a -> Random b -> Random (a, b)
randomCombine a b = (,) <$> a <*> b
Ahora que tenemos estas instancias, podemos usar en >>=
lugar de randomBind, join en lugar de randomJoin, fmap en lugar de randomMap, return en lugar de randomUnit. También obtenemos una gran cantidad de funciones de forma gratuita.
¿Vale la pena? Se podría argumentar que llegar a esta etapa, donde trabajar con números aleatorios no es completamente horrendo, fue bastante difícil y largo. ¿Qué obtuvimos a cambio de este esfuerzo?
La recompensa más inmediata es que ahora podemos ver exactamente qué partes de nuestro programa dependen de la aleatoriedad y qué partes son completamente deterministas. En mi experiencia, forzar una separación estricta como esta simplifica enormemente las cosas.
Asumimos hasta ahora que solo queremos una única muestra de cada variable aleatoria que generamos, pero si resulta que en el futuro realmente nos gustaría ver más de la distribución, esto es trivial. Puede usar runRandom muchas veces en la misma variable aleatoria con diferentes semillas. Esto es posible, por supuesto, en lenguajes imperativos, pero en este caso, podemos estar seguros de que no realizaremos IO no anticipadas cada vez que muestreemos una variable aleatoria y no tengamos que tener cuidado con la inicialización del estado.