web-dev-qa-db-fra.com

Comment les langages fonctionnels gèrent-ils les nombres aléatoires?

Ce que je veux dire à ce sujet est que dans presque chaque tutoriel que j'ai lu sur les langages fonctionnels, c'est que l'une des grandes choses sur les fonctions, c'est que si vous appelez une fonction avec les mêmes paramètres deux fois, vous toujours finirez avec le même résultat.

Comment diable pouvez-vous alors créer une fonction qui prend une graine comme paramètre, puis retourne un nombre aléatoire basé sur cette graine?

Je veux dire que cela semblerait aller à l'encontre d'une des choses qui sont si bonnes sur les fonctions, non? Ou est-ce que je manque complètement quelque chose ici?

69
Electric Coffee

Vous ne pouvez pas créer une fonction pure appelée random qui donnera un résultat différent à chaque appel. En fait, vous ne pouvez même pas "appeler" des fonctions pures. Vous les appliquez. Donc, vous ne manquez rien, mais cela ne signifie pas que les nombres aléatoires sont interdits dans la programmation fonctionnelle. Permettez-moi de démontrer, je vais utiliser la syntaxe Haskell tout au long.

Venant d'un arrière-plan impératif, vous pouvez initialement vous attendre à ce que random ait un type comme celui-ci:

random :: () -> Integer

Mais cela a déjà été exclu car aléatoire ne peut pas être une fonction pure.

Considérez l'idée d'une valeur. Une valeur est une chose immuable. Cela ne change jamais et chaque observation que vous pouvez faire à ce sujet est cohérente pour toujours.

De toute évidence, aléatoire ne peut pas produire une valeur entière. Au lieu de cela, il produit une variable aléatoire Integer. Son type pourrait ressembler à ceci:

random :: () -> Random Integer

Sauf que passer un argument est complètement inutile, les fonctions sont pures, donc une random () est aussi bonne qu'une autre random (). Je vais donner au hasard, à partir d'ici, ce type:

random :: Random Integer

Ce qui est bien beau, mais pas très utile. Vous pouvez vous attendre à pouvoir écrire des expressions comme random + 42, Mais vous ne pouvez pas, car il ne vérifie pas la typographie. Vous ne pouvez encore rien faire avec des variables aléatoires.

Cela soulève une question intéressante. Quelles fonctions devraient exister pour manipuler des variables aléatoires?

Cette fonction ne peut pas exister:

bad :: Random a -> a

de toute manière utile, car alors vous pourriez écrire:

badRandom :: Integer
badRandom = bad random

Ce qui introduit une incohérence. badRandom est censé être une valeur, mais c'est aussi un nombre aléatoire; une contradiction.

Peut-être que nous devrions ajouter cette fonction:

randomAdd :: Integer -> Random Integer -> Random Integer

Mais ce n'est là qu'un cas particulier d'un schéma plus général. Vous devriez pouvoir appliquer n'importe quelle fonction à une chose aléatoire afin d'obtenir d'autres choses aléatoires comme ceci:

randomMap :: (a -> b) -> Random a -> Random b

Au lieu d'écrire random + 42, Nous pouvons maintenant écrire randomMap (+42) random.

Si tout ce que vous aviez était randomMap, vous ne pourriez pas combiner des variables aléatoires ensemble. Vous ne pouvez pas écrire cette fonction par exemple:

randomCombine :: Random a -> Random b -> Random (a, b)

Vous pourriez essayer de l'écrire comme ceci:

randomCombine a b = randomMap (\a' -> randomMap (\b' -> (a', b')) b) a

Mais il a le mauvais type. Au lieu de se retrouver avec une Random (a, b), on se retrouve avec une Random (Random (a, b))

Cela peut être corrigé en ajoutant une autre fonction:

randomJoin :: Random (Random a) -> Random a

Mais, pour des raisons qui pourraient éventuellement devenir claires, je ne vais pas le faire. Au lieu de cela, je vais ajouter ceci:

randomBind :: Random a -> (a -> Random b) -> Random b

Il n'est pas immédiatement évident que cela résout réellement le problème, mais il le fait:

randomCombine a b = randomBind a (\a' -> randomMap (\b' -> (a', b')) b)

En fait, il est possible d'écrire randomBind en termes de randomJoin et randomMap. Il est également possible d'écrire randomJoin en termes de randomBind. Mais, je vais laisser faire cela comme un exercice.

Nous pourrions simplifier un peu cela. Permettez-moi de définir cette fonction:

randomUnit :: a -> Random a

randomUnit transforme une valeur en variable aléatoire. Cela signifie que nous pouvons avoir des variables aléatoires qui ne sont pas réellement aléatoires. Ce fut toujours le cas cependant; nous aurions pu faire randomMap (const 4) random avant. La raison pour laquelle randomUnit est une bonne idée est que nous pouvons maintenant définir randomMap en termes de randomUnit et randomBind:

randomMap :: (a -> b) -> Random a -> Random b
randomMap f x = randomBind x (randomUnit . f)

Ok, maintenant nous arrivons quelque part. Nous avons des variables aléatoires que nous pouvons manipuler. Toutefois:

  • La façon dont nous pourrions réellement implémenter ces fonctions n'est pas évidente,
  • C'est assez lourd.

La mise en oeuvre

Je vais aborder des nombres pseudo aléatoires. Il est possible d'implémenter ces fonctions pour des nombres aléatoires réels, mais cette réponse devient déjà assez longue.

Essentiellement, la façon dont cela va fonctionner est que nous allons transmettre une valeur de départ partout. Chaque fois que nous générons une nouvelle valeur aléatoire, nous produirons une nouvelle graine. À la fin, lorsque nous aurons fini de construire une variable aléatoire, nous voudrons en échantillonner à l'aide de cette fonction:

runRandom :: Seed -> Random a -> a

Je vais définir le type aléatoire comme ceci:

data Random a = Random (Seed -> (Seed, a))

Ensuite, nous avons juste besoin de fournir des implémentations de randomUnit, randomBind, runRandom et random, ce qui est assez simple:

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

Pour le hasard, je vais supposer qu'il existe déjà une fonction du type:

psuedoRandom :: Seed -> (Seed, Integer)

Dans ce cas, random est juste Random psuedoRandom.

Rendre les choses moins lourdes

Haskell a du sucre syntaxique pour rendre les choses comme ça plus agréables aux yeux. Cela s'appelle do-notation et pour l'utiliser, nous devons créer une instance de Monad for Random.

instance Monad Random where
  return = randomUnit
  (>>=) = randomBind

Terminé. randomCombine d'avant pouvait maintenant s'écrire:

randomCombine :: Random a -> Random b -> Random (a, b)
randomCombine a b = do
  a' <- a
  b' <- b
  return (a', b')

Si je faisais cela pour moi-même, j'irais même plus loin et créerais une instance d'Applicative. (Ne vous inquiétez pas si cela n'a aucun sens).

instance Functor Random where
  fmap = liftM

instance Applicative Random where
  pure = return
  (<*>) = ap

Alors randomCombine pourrait s'écrire:

randomCombine :: Random a -> Random b -> Random (a, b)
randomCombine a b = (,) <$> a <*> b

Maintenant que nous avons ces instances, nous pouvons utiliser >>= Au lieu de randomBind, joindre au lieu de randomJoin, fmap au lieu de randomMap, retourner au lieu de randomUnit. Nous obtenons également une multitude de fonctions gratuitement.

Est-ce que ça vaut le coup? Vous pourriez faire valoir que parvenir à ce stade, où travailler avec des nombres aléatoires n'est pas complètement horrible, était assez difficile et de longue haleine. Qu'avons-nous obtenu en échange de cet effort?

La récompense la plus immédiate est que nous pouvons maintenant voir exactement quelles parties de notre programme dépendent du hasard et quelles parties sont entièrement déterministes. D'après mon expérience, forcer une séparation stricte comme celle-ci simplifie énormément les choses.

Nous avons supposé jusqu'à présent que nous voulons juste un échantillon unique de chaque variable aléatoire que nous générons, mais s'il s'avère qu'à l'avenir nous aimerions en fait voir plus de la distribution, c'est trivial. Vous pouvez simplement utiliser runRandom beaucoup de fois sur la même variable aléatoire avec des graines différentes. Ceci est, bien sûr, possible dans les langages impératifs, mais dans ce cas, nous pouvons être certains que nous n'allons pas effectuer une imprévue IO chaque fois que nous échantillonnons une variable aléatoire et que nous ne le faisons pas ' Il ne faut pas faire attention à l'initialisation de l'état.

89
dan_waterworth

Tu n'as pas tort. Si vous donnez deux fois la même graine à un RNG, le premier nombre pseudo-aléatoire qu'il renvoie sera le même. Cela n'a rien à voir avec la programmation fonctionnelle vs la programmation des effets secondaires; la définition d'une graine est qu'une entrée spécifique provoque une sortie spécifique de valeurs bien réparties mais décidément non aléatoires. C'est pourquoi cela s'appelle pseudo-aléatoire, et c'est souvent une bonne chose d'avoir, par exemple pour écrire des tests unitaires prévisibles, comparer de manière fiable différentes méthodes d'optimisation sur le même problème, etc.

Si vous voulez réellement des nombres non pseudo-aléatoires à partir d'un ordinateur, vous devez le connecter à quelque chose de vraiment aléatoire, comme une source de désintégration des particules, des événements imprévisibles se produisant dans le réseau sur lequel l'ordinateur est allumé, etc. obtenir à droite et généralement coûteux même si cela fonctionne, mais c'est le seul moyen pas d'obtenir des valeurs pseudo-aléatoires (généralement les valeurs que vous recevez de votre langage de programmation sont basées sur certains = seed, même si vous n'en avez pas explicitement fourni un.)

Cela, et seulement cela, compromettrait la nature fonctionnelle d'un système. Étant donné que les générateurs non pseudo-aléatoires sont rares, cela ne revient pas souvent, mais oui, si vous avez vraiment une méthode générant de vrais nombres aléatoires, alors au moins ce petit bout de votre langage de programmation ne peut pas être 100% fonctionnel. Le fait qu'une langue fasse ou non une exception est simplement une question de pragmatisme de l'implémentateur du langage.

10
Kilian Foth

Une façon consiste à la considérer comme une séquence infinie de nombres aléatoires:

IEnumerable<int> randomNumberGenerator = new RandomNumberGenerator(seed);

Autrement dit, considérez-le comme une structure de données sans fond, comme un Stack où vous ne pouvez appeler que Pop, mais vous pouvez l'appeler pour toujours. Comme une pile immuable normale, en retirer une du haut vous donne une autre pile (différente).

Ainsi, un générateur de nombres aléatoires immuable (avec évaluation paresseuse) pourrait ressembler à:

class RandomNumberGenerator
{
    private readonly int nextSeed;
    private RandomNumberGenerator next;

    public RandomNumberGenerator(int seed)
    {
        this.nextSeed = this.generateNewSeed(seed);
        this.RandomNumber = this.generateRandomNumberBasedOnSeed(seed);
    }

    public int RandomNumber { get; private set; }

    public RandomNumberGenerator Next
    {
        get
        {
            if(this.next == null) this.next = new RandomNumberGenerator(this.nextSeed);
            return this.next;
        }
    }

    private static int generateNewSeed(int seed)
    {
        //...
    }

    private static int generateRandomNumberBasedOnSeed(int seed)
    {
        //...
    }
}

C'est fonctionnel.

6
Scott Whitlock

C'est la même chose pour les langages non fonctionnels. Ignorer le problème légèrement séparé des nombres vraiment aléatoires ici.

Un générateur de nombres aléatoires prend toujours une valeur de départ et pour la même valeur de départ renvoie la même séquence de nombres aléatoires (très utile si vous avez besoin de tester un programme qui utilise des nombres aléatoires). Fondamentalement, il commence par la graine que vous choisissez, puis utilise le dernier résultat comme graine pour la prochaine itération. La plupart des implémentations sont donc des fonctions "pures" telles que vous les décrivez: prenez une valeur et pour la même valeur, renvoyez toujours le même résultat.

5
thorsten müller