Générer des nombres aléatoires a toujours été un casse-tête pour nos pauvres ordinateurs totalement déterministes. Les Frameworks .NET (classique ou Core) nous offrent quelques solutions encore faut-il en connaitre les limites et s’en servir correctement….
Nombres aléatoires ?
Je sais, encore un billet sur C#, mais n'oubliez pas que c'est l'outil de base pour programmer vos apps et que bien le connaître est incontournable ! De plus j'ai commencé ma "carrière" de MVP en tant que MVP C# en 2009 et bien que le temps passe je reste très attaché à ce merveilleux langage !
Définissons d’abord ce qu’est un nombre aléatoire : cela n’existe pas
En effet, seule une série de nombres peut, éventuellement, se voir qualifier d’aléatoire. Un nombre pris seul, de façon totalement isolée n’est ni aléatoire ni non aléatoire, cela est indéterminable sans un contexte "humain". Tenez, prenez "12". Est-il aléatoire ? Vous ne pourrez pas le dire ! Pour vous, dans votre contexte, ce chiffre vient d'apparaitre sous vos yeux, il a "quelque chose" d'aléatoire. Pour moi c'est juste le nombre de mois dans une année auquel je pensais. Sa génération n'a pas été aléatoire mais cela est impossible à savoir. Si je vous dis "14 15 92", c'est une série. Est-elle aléatoire ? Même sans connaître mes intentions vous pouvez facilement découvrir qu'il s'agit des premières décimales de Pi. Qui n'est pas un nombre aléatoire (puisqu'on peut en calculer chaque chiffre et que ce sont toujours les mêmes à la même position). L'aléatoire d'une série est donc testable à la différence d'un nombre unique.
Donc même une série de nombres, pour mériter le qualificatif d’aléatoire, doit répondre à des exigences mathématiques précises. Sans entrer dans les méandres des théories que vous trouverez aisément dans la littérature spécialisée, il faut être convaincu qu’aucun procédé algorithmique, aussi sophistiqué ou “rusé” soit-il ne peut générer une suite de nombres aléatoires.
Ici on touche aussi à la différence entre aléatoire et complexité. La complexité d'un nombre étant directement liée à la taille du programme P le plus court pour générer ce nombre. Ainsi il existe des programmes très courts (pas forcément les plus rapides cela n'a rien à voir) pour générer Pi. La complexité de Pi, dans le sens de la théorie de la Complexité de Kolmogorov, est très faible !
Encore faudrait-il différencier complexité aléatoire et complexité organisée. Un tas de sable est de la complexité aléatoire comme l'état à un instant T des molécules d'un gaz. En revanche vous, votre poisson rouge, le processeur de votre PC ressortez de la complexité organisée.
Donc si une série de nombre est générée par un programme (un algorithme), au mieux, il s’agira d’une suite de nombres pseudo aléatoire répondant à certains critères (c’est à dire simulant au plus proche certaines courbes ou lois comme celle de Poisson ou d’autres formes de distribution).
Un ordinateur étant une machine déterministe (même si certains bugs peuvent parfois nous en faire douter !) l’aléatoire est totalement hors du champ de ses possibilités, quelle que soit sa taille, sa puissance, sa mémoire ou la présence d’un coprocesseur mathématique (dont on ne parle plus depuis quelques années puisque systématiquement intégré aux CPU ce qui ne fut pas le cas pendant longtemps).
Les nombres aléatoires ne peuvent ainsi être générés qu’en s’appuyant sur des phénomènes physiques eux-mêmes réputés aléatoires. C’est pour cela que pour générer des vraies séries de nombres aléatoires sur un ordinateur il faut absolument utiliser un hardware spécifique. Il en existe de différentes natures selon le degré de précision dans l’aléatoire qu’on désire (s’il est possible de parler de précision ici). Ces boitiers qui peuvent se brancher sur un port USB par exemple, utilisent des phénomènes quantiques qu’ils amplifient et numérisent pour obtenir des nombres : bruit thermique d’une résistance en général.
Donc hors de ces hardwares, parler de suite de nombres aléatoires avec un ordinateur est un abus de langage dans le meilleur des cas et une hérésie mathématique dans le pire…Et "un" nombre aléatoire n'existe pas vraiment sans préciser le contexte et sa méthode de génération.
La classe Random
Tout le monde la connait, c’est le moyen le plus simple d’obtenir des nombres pseudo aléatoires sous .NET.
Mais de ce que je peux constater lorsque j’audite du code, cette classe est mal utilisée dans la grande majorité des cas.
Erreur n°1 – Initialisation avec l’heure
A vouloir trop bien faire sans savoir ce qui se cache derrière une classe, on fait des bêtises ou du code inutile, voire les deux à la fois.
La classe Random, lorsqu’elle est instanciée utilise déjà l’horloge pour créer une graine ! Inutile donc d’écrire du code du type :
1: var r = new Random(DateTime.Now.Millisecond);
Cela est totalement inutile.
Attention : L'initialisation interne de Random dépend de la version de .NET ! Ce qui est dit ici s'applique au Framework .NET, sous Core (donc .NET 5, 6 ou 7 qui sont des Core sans le mot Core puisque ce dernier n'est plus qu'un simple "noyau" mais a vocation à devenir le seul "vrai" .NET), Random a une initialisation différence utilisant deux méthodes différentes, l'une quand la classe est utilisée tel quel (méthode XoshiroImpl() ) ou est une classe héritée de Random (méthode Net5CompatDerivedImpl()) pour conserver un comportement compatible avec NET 5.
Si vous désirez reproduire le cas des séries identiques utilisez un Framework .NET, si vous compilez en Core vous ne retrouverez pas ce comportement...
Erreur n°2 – Multiplier les instances pour avoir “plus” d’aléatoire
Imaginons plusieurs instances d’une classe métier qui doivent chacune être en mesure de s’appuyer sur des nombres aléatoires (que ces instances fonctionnent dans le même thread ou en multi-thread n’a pas d’importance). Je vois souvent du code qui déclare une instance de Random dans chaque instance de ladite classe métier.
Illusion… et surtout grosse erreur !
Si les instances en question sont créées les unes à la suite des autres il y a de fortes chances pour qu’elles s’initialisent dans la même tranche horaire (résolution de 20 ms environ) et qu’elles génèrent toutes la même série de nombres !
Pour s’en convaincre écrivons le code suivant :
1: var r = new Random();
2: var r2 = new Random();
3: for (var i = 0; i<10; i++)
4: {
5: Console.WriteLine(r.Next(100)+" -- "+r2.Next(100) );
6: }
(Pour tester des bouts de code ce genre sans charger VS et créer un projet, ce qui est très enquiquinant, je vous conseille fortement l’utilisation de LinqPad qui intègre aussi un petit éditeur de CSharp. C’est gratuit et génial pour tester des requêtes LINQ aussi).
La sortie sera la suivante par exemple :
3 -- 3
20 -- 20
15 -- 15
18 -- 18
83 -- 83
55 -- 55
52 -- 52
2 -- 2
39 -- 39
39 -- 39
Bien entendu à chaque “run” la série sera différence, mais regardez bien les deux colonnes de nombres... Et oui, elles sont identiques. La raison ? Les variables r et r2 sont créées à la suite et il s’écoule moins de 20 ms entre ces créations, elles possèdent donc toutes deux la même graine (et fabriqueront ainsi exactement la même série de nombres).
Nota : Ne vous attendez pas à retrouver la même suite... Bien entendu ni la graine (heure du PC) ni l'OS ni la rapidité de votre machine ne seront identiques à ceux de ce run de test. Il se pourrait même que vous ne voyiez pas l'effet si votre machine est assez rapide. Mais pensez deux secondes que C# et dotnet tournent aussi sur des smartphones, voire des raspberry et autres IoT. Refaites le test sur ces machines et vérifiez le résultat...
Centraliser les appels
Il ne sert donc à rien de multiplier les instances de Random dans une application en espérant avoir “plus” d’aléatoire au final. Bien au contraire on risque d’obtenir, comme l’exemple ci-dessus le démontre, une uniformité qui n’a vraiment plus rien d’aléatoire, même “pseudo” !
Si les exigences mathématiques sont assez faibles on peut parfaitement se contenter de Random. Mais alors, le plus malin consiste à créer une seule instance pour toute l’application. On s’assure bien ainsi que chaque run de l’application se basera sur une série différence et surtout qu’au sein de l’application tous les nombres sembleront bien être aléatoires...
Je vous passe l’exemple d’une classe statique déclarant une instance de Random et exposant des méthodes statiques calquant les méthodes principales de cette dernière. C’est enfantin.
Bref, la façon la plus simple d’avoir réellement des nombres pseudo aléatoires dans une application est de n’utiliser qu’une seule instance centralisée de Random.
System.Security.Cryptography
Ce namespace, comme son nom le laisse deviner, contient de nombreuses classes fort utiles en cryptographie. Et qui dit cryptographie dit nombres (pseudo) aléatoires. Mais comme il s’agit ici de sécurité les exigences mathématiques placent la barre un peu plus haut.
Loin de moi l’idée d’aborder ce sujet en quelques lignes. Je veux juste attirer votre attention sur le fait que dans cet espace de noms se trouve de quoi remplacer Random de façon plus efficace en évitant le risque de répétition des valeurs si plusieurs instances doivent être créées de façon proche dans le temps.
Si la solution d’une classe centralisant tous les appels de génération de nombre aléatoire vers une instance unique de Random ne vous convient pas (et il est vrai que cela n'est pas totalement satisfaisant), si les méthodes de Random ne vous semblent pas assez “solides” pour votre application, vous serez alors tenté d'utiliser RNGCryptoServiceProvider du namespace indiqué.
Cette classe permet de créer des instances de RandomNumberGenerator offrant un comportement plus fiable que Random.
Toutefois le framework (de base ou Core) évoluant beaucoup avec le temps, la classe RNGCryptoServiceProvider est désormais obsolète. Microsoft conseil ainsi de passer directement par les méthodes de RandomNumberGenerator, classe offrant aussi des méthodes statiques n'obligeant ainsi pas à créer et stocker des instances.
Pour de l'aléatoire de "meilleure qualité" on utilisera ainsi plutôt le code suivant :
1: var r3 = RandomNumberGenerator.GetInt32(0,101);
2: var r4 = RandomNumberGenerator.GetInt32(0,101);
Le code devient rudimentaire et créera des séries de nombres (pseudo) aléatoires dans la range [0,100] (le second paramètre est exclusif alors que le premier est inclusif).
Ici aucune instance n'est créée par notre code, les tirages ne seront plus "synchronisés" comme dans l'exemple avec Random.
Efficacité
Sur ma machine en 64bits 32 coeurs (mais un seul est utilisé), il faut 0,0151 millisecondes pour générer chaque nombre d'une série de 1 milliard dans la range 0,100. Contre 0,0109 avec la classe Random. Il y a donc un prix à payer pour obtenir un aléatoire de meilleure qualité, presque 50% de temps de calcul en plus pour chaque nombre.
Toutefois on notera qu'on parle ici de millième de millisecondes (5 au lieu de 0) et que franchement même pour un jeu à 120 frames / seconde ce n'est pas cela qui va ralentir votre programme ! Mais sachez qu'il y a un coût, négligeable pour 1 milliards de tirages malgré tout.
Comme toujours dans notre métier il faut choisir entre consommation CPU et consommation mémoire ou bien, comme ici, entre sophistication et rapidité. Mais les performances des machines modernes sont telles qu'en réalité vous n'avez aucune raison de vous privez de la méthode la plus fiable (sauf si cela ne sert à rien d'un point de vue fonctionnel) !
Conclusion
Il y aurait bien d’autres choses à dire sur les nombres aléatoires, pseudo ou non. C’est un sujet passionnant. Mais le but de ce billet était principalement d’attirer votre attention sur les mauvaises utilisations de Random et vous signaler l’existence dans le Framework (classique ou Core) d’autres classes plus “pointues” pour produire des séries pseudo aléatoires.
Stay Tuned !