В моем посте о создании утилиты цветовой палитры в Alpine.js случайность играла большую роль: каждый образец генерировался как композиция случайно выбранных значений Hue (0..360), Saturation (0..100) и Lightness (0..100). Когда я создавал эту демонстрацию, я наткнулся на Web Crypto API. Обычно при генерации случайных значений я использую метод Math.random(), но в документации MDN упоминается, что Crypto.getRandomValues() более безопасен. В итоге я решил попробовать Crypto (с фоллбэком на модуль Math по мере необходимости). Но это заставило меня задуматься, действительно ли "более безопасный" означает "более случайный" для моего варианта использования.

Посмотреть пример в моем проекте JavaScript Demos на GitHub.

Посмотреть код в моем проекте JavaScript Demos на GitHub.

Случайность, с точки зрения безопасности, имеет значение. Я не специалист по безопасности, но, насколько я понимаю, генератор псевдослучайных чисел (ГПСЧ) считается "безопасным" в том случае, когда последовательность чисел, которую он произведет или уже произвел, не может быть вычислена злоумышленником.

Когда речь идет о "генераторах случайных цветов", таких, как моя утилита для создания цветовой палитры, понятие "случайности" гораздо более расплывчато. В моем случае генерация цвета настолько случайна, насколько это «ощущается» пользователем. Другими словами, эффективность случайности является частью пользовательского опыта (UX).

С этой целью я хочу попробовать сгенерировать несколько случайных визуальных элементов, используя как Math.random(), так и crypto.getRandomValues(), чтобы посмотреть, будет ли один из методов существенно отличаться по ощущениям. Каждая попытка будет содержать случайно сгенерированный элемент <canvas> и случайно сгенерированный набор целых чисел. Затем я воспользуюсь своей (глубоко ошибочной) человеческой интуицией, чтобы понять, выглядит ли один из методов "лучше" другого.

Метод Math.random() работает, возвращая десятичное значение от 0 (включительно) до 1 (исключительно). Это можно использовать для генерации случайных целых чисел, взяв результат случайности и умножив его на диапазон возможных значений.

Другими словами, если Math.random() вернет 0.25, вы выберете значение, которое ближе всего к 25% в заданном диапазоне минимума-максимума. А если Math.random() вернет 0.97, вы выберете значение, которое ближе всего к 97% в заданном диапазоне минимума-максимума.

Метод crypto.getRandomValues() работает совсем по-другому. Вместо того чтобы вернуть вам единственное значение, он ожидает принять TypedArray с заранее выделенным размером (длиной). Затем метод .getRandomValues() заполняет этот массив случайными значениями, ограниченными минимумом/максимумом, которые может хранить данный тип.

Чтобы облегчить это исследование, я хочу, чтобы оба подхода работали примерно одинаково. Поэтому вместо того, чтобы иметь дело с десятичными числами в одном алгоритме и целыми числами в другом, я приведу результаты алгоритмов к десятичным числам. Это означает, что я должен превратить value, возвращаемое .getRandomValues(), в десятичное число (0..1):

value / ( maxValue + 1 )

Я инкапсулирую эту разницу в два метода, randFloatWithMath() и randFloatWithCrypto():

/**
* С помощью модуля Math я возвращаю случайное число с плавающей запятой в диапазоне от 0 (включительно) до 1 (исключительно).
*/
function randFloatWithMath() {

	return Math.random();

}

/**
* С помощью модуля Crypto я возвращаю случайное число с плавающей запятой в диапазоне от 0 (включительно) до 1 (исключительно).
*/
function randFloatWithCrypto() {

	var [ randomInt ] = crypto.getRandomValues( new Uint32Array( 1 ) );
	var maxInt = 4294967295;

	return ( randomInt / ( maxInt + 1 ) );

}

Имея эти два метода, я могу присвоить один из них переменной randFloat(), которая может быть использована для генерации случайных значений в заданном диапазоне, используя любой из алгоритмов:

/**
* Я генерирую случайное целое число между заданными min и max, включительно.
*/
function randRange( min, max ) {

	return ( min + Math.floor( randFloat() * ( max - min + 1 ) ) );

}

Теперь перейдем к созданию экспериментов. Пользовательский интерфейс небольшой и работает на Alpine.js. В каждом эксперименте используется один и тот же компонент Alpine.js, но его конструктор получает аргумент, который определяет, какая реализация randFloat() будет использоваться:

<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1" />
	<link rel="stylesheet" type="text/css" href="./main.css" />
</head>
<body>

	<h1>
		<!-- Изучение случайности в JavaScript --> Exploring Randomness In JavaScript
	</h1>

	<div class="side-by-side">
		<section x-data="Explore( 'math' )">
			<h2>
				<!-- Модуль Math --> Math Module
			</h2>

			<!-- Очень большое количество случайных координат {X,Y}. -->
			<canvas
				x-ref="canvas"
				width="320"
				height="320">
			</canvas>

			<!-- Небольшое количество случайных значений координат. -->
			<p x-ref="list"></p>

			<p>
				<!-- Длительность --> Duration: <span x-text="duration"></span>
			</p>
		</section>

		<section x-data="Explore( 'crypto' )">
			<h2>
				<!-- Модуль Crypto --> Crypto Module
			</h2>

			<!-- Очень большое количество случайных координат {X,Y}. -->
			<canvas
				x-ref="canvas"
				width="320"
				height="320">
			</canvas>

			<!-- Небольшое количество случайных значений координат. -->
			<p x-ref="list"></p>

			<p>
				<!-- Длительность --> Duration: <span x-text="duration"></span>ms
			</p>
		</section>
	</div>

	<script type="text/javascript" src="./main.js" defer></script>
	<script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.min.js" defer></script>

</body>
</html>

Как видите, каждый компонент x-data="Explore" содержит два x-ref: canvas и list. Когда компонент инициализируется, он заполнит эти два x-ref случайными значениями с помощью методов fillCanvas() и fillList() соответственно.

Вот мой компонент JavaScript / Alpine.js:

/**
* С помощью модуля Math я возвращаю случайное число с плавающей запятой в диапазоне от 0 (включительно) до 1 (исключительно).
*/
function randFloatWithMath() {

	return Math.random();

}

/**
* С помощью модуля Crypto я возвращаю случайное число с плавающей запятой в диапазоне от 0 (включительно) до 1 (исключительно).
*/
function randFloatWithCrypto() {

	// Этот метод работает, заполняя массив случайными значениями заданного типа.
	// В нашем случае нам нужно только одно случайное значение, поэтому мы передадим массив
    // длиной 1.
	// --
	// Примечание: Для повышения производительности мы можем кэшировать типизированный массив и просто передавать
	// одну и ту же ссылку (это улучшает производительность вдвое). Но мы исследуем
	// случайность, а не производительность.
	var [ randomInt ] = crypto.getRandomValues( new Uint32Array( 1 ) );
	var maxInt = 4294967295;

	// В отличие от Math.random(), crypto генерирует нам целое число. Чтобы подставить его
	// в то же математическое уравнение, мы должны преобразовать целое число в десятичное,
	// чтобы получить такое же случайное значение.
	return ( randomInt / ( maxInt + 1 ) );

}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

function Explore( algorithm ) {

	// Каждому компоненту Alpine.js назначается своя стратегия генерации случайных
	// чисел с плавающей запятой (0..1). В остальном компоненты ведут себя
	// одинаково.
	var randFloat = ( algorithm === "math" )
		? randFloatWithMath
		: randFloatWithCrypto
	;

	return {
		duration: 0,
		// Публичные методы.
		init: init,
		// Приватные методы.
		fillCanvas: fillCanvas,
		fillList: fillList,
		randRange: randRange
	}

	// ---
	// ПУБЛИЧНЫЕ МЕТОДЫ.
	// ---

	/**
	* Я инициализирую компонент Alpine.js.
	*/
	function init() {

		var startedAt = Date.now();

		this.fillCanvas();
		this.fillList();

		this.duration = ( Date.now() - startedAt );

	}

	// ---
	// ПРИВАТНЫЕ МЕТОДЫ.
	// ---

	/**
	* Я заполняю canvas случайными пикселями {X,Y}.
	*/
	function fillCanvas() {

		var pixelCount = 200000;
		var canvas = this.$refs.canvas;
		var width = canvas.width;
		var height = canvas.height;

		var context = canvas.getContext( "2d" );
		context.fillStyle = "deeppink";

		for ( var i = 0 ; i < pixelCount ; i++ ) {

			var x = this.randRange( 0, width );
			var y = this.randRange( 0, height );

			// По мере добавления новых пикселей изменяем их непрозрачность.
			// Я надеялся, что это поможет показать потенциальную кластеризацию значений.
            context.globalAlpha = ( i / pixelCount );
			context.fillRect( x, y, 1, 1 );

		}

	}

	/**
	* Я заполняю список случайными значениями от 0 до 9.
	*/
	function fillList() {

		var list = this.$refs.list;
		var valueCount = 105;
		var values = [];

		for ( var i = 0 ; i < valueCount ; i++ ) {

			values.push( this.randRange( 0, 9 ) );

		}

		list.textContent = values.join( " " );

	}

	/**
	* Я генерирую случайное целое число между заданными min и max, включительно.
	*/
	function randRange( min, max ) {

		return ( min + Math.floor( randFloat() * ( max - min + 1 ) ) );

	}

}

Когда мы запускаем этот пример, мы получаем следующий результат:

Как я уже говорил выше, случайность с человеческой точки зрения очень размыта. Она больше связана с ощущениями, чем с математическими вероятностями. Например, вероятность того, что в одном ряду появятся два одинаковых значения подряд, равна вероятности того, что в одном ряду появятся два разных значения подряд. Но для человека это ощущается по-другому.

Тем не менее, если сравнить эти визуализации случайной генерации, ни одна из них не кажется существенно отличающейся с точки зрения распределения. Конечно, модуль Crypto значительно медленнее (половина из этого - затраты на выделение ресурсов под TypedArray). Но с точки зрения "ощущений" ни один из них не является лучше другого.

Скажу лишь, что при использовании генерации в утилите цветовой палитры мне, вероятно, не было необходимости использовать модуль Crypto - возможно, стоило остановиться на Math. Это гораздо быстрее и ощущается таким же случайным. Я буду использовать модуль Crypto для работы с криптографией на стороне клиента (чего мне пока не приходилось делать).

Комментарии (14)


  1. SUNsung
    03.07.2024 20:11

    Отличная статья о том как "проверять" рандом
    Только я бы еще добавил цветовую дифференциацию и запускал бы параллельно в нескольких потоках (которые отличаются цветом). За js не скажу но в некоторых языках программирования как раз асинхрон показывает кривизну рандома. Может оказатся что для одновременных запросов одно и то же число выдает.


    1. KasperGreen
      03.07.2024 20:11
      +1

      А я думал нужно запустить рандом множество раз и убедиться, что с большим количеством попыток, становится равномернее распределение цифр.

      А тут оказывается достаточно было один раз запустить и внешне посмотреть. Так сказать проверка генератора случайных чисел на глазок.

      Может оказатся что для одновременных запросов одно и то же число выдает.

      Таким грешат генераторы черпающие энтропию в системном времени.


      1. alienator
        03.07.2024 20:11

        В этом «равномернее» убедиться тоже очень непросто, как и выбрать из разных видов «равномерности».

        Например, можно считать ГСЧ очень хорошим, а потом рассмотреть выдаваемые числа как координаты точек в пятимерном пространстве, и окажется, что все точки легли на одну плоскость.

        Но придумывать с нуля ничего не надо, есть разнообразные тесты ГСЧ, начиная с хорошо известных Diehard tests.


  1. Djaler
    03.07.2024 20:11
    +5

    вероятность того, что подряд появятся два одинаковых значения, равна вероятности того, что подряд появятся два разных значения

    Вероятность первого - 1/10, вероятность второго - 9/10. Или я что-то не так понимаю?


    1. zorn-v100500
      03.07.2024 20:11

      50/50 - либо появится, либо не появится ))


    1. zhenkuzne Автор
      03.07.2024 20:11

      Например, вероятность того, что в одном ряду появятся два одинаковых значения подряд, равна вероятности того, что в одном ряду появятся два разных значения подряд.

      Спасибо за замечание, я думаю, так правильнее.


      1. zorn-v100500
        03.07.2024 20:11

        Т.е. вероятность получить 88 точно такая же как и у 80,81,82,83,84,85,86,87,89
        Вы ничего не путаете ?


        1. zhenkuzne Автор
          03.07.2024 20:11

          Имеется в виду не это, а то, что в ряду (который на картинке изображен) вероятность того, что появится хотя бы одна пара одинаковых значений такая же, как появление хотя бы одной пара разных значений.

          Вот выдержка с оригинала:

          For example, having two of the same values generated in a row is the same exact probability of having any two values generated in a row.


          1. zorn-v100500
            03.07.2024 20:11

            Вы наверное путаете с орел/решка. Когда 50/50 (за которую заминусили), тогда да, вероятность одинаковая. Но вы предлагаете 1/10 вероятность, называя ее 50/50 )


  1. ALapinskas
    03.07.2024 20:11

    Случайность, с точки зрения безопасности, имеет значение. Я не специалист по безопасности, но, насколько я понимаю, генератор псевдослучайных чисел (ГПСЧ) считается "безопасным" в том случае, когда последовательность чисел, которую он произведет или уже произвел, не может быть вычислена злоумышленником.

    По-моему, "безопасный" алгоритм должен работать от источника энтропии, все остальные алгоритмы могут быть вычислены, т.е. они псевдослучайны.


  1. adminNiochen
    03.07.2024 20:11
    +4

    На дворе 2024 год, кто-то до сих пор использует var и пишет function ObjectCreator вместо class...


    1. zorn-v100500
      03.07.2024 20:11

      А чем классы лучше чистых функций применительно к JS ?


      1. TAZAQ
        03.07.2024 20:11

        Чистые функции в отрыве от кода автора? В нём чистотой и не пахнет


  1. khe404
    03.07.2024 20:11

    Какая сложная и одновременно простая штука -- случайное число.

    Когда нужно чтобы число "казалось" случайным достаточно использовать некий математический алгоритм, например ЛКМ https://ru.wikipedia.org/wiki/Линейный_конгруэнтный_метод

    А когда нужно чтобы число было "заранее непредсказуемым" нужно использовать информацию взятую вне системы, иначе условны недоброжелатель сможет повторить вычисления.