Исследуем фактор случайности в JavaScript
В одном недавнем посте я рассказал, как написать утилиту для работы с палитрой в 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.
С точки зрения безопасности случайность имеет значение. Я не эксперт по безопасности, но, насколько могу судить, генератор псевдослучайных чисел (PRNG) считается «безопасным», если выдаёт (или уже выдал) такую последовательность чисел, которую не может раскрыть злоумышленник.
Когда речь заходит о «генерации случайных цветов», как в моей утилите-палитре, концепция «случайности» становится ещё более размытой. В моём случае сгенерированные цвета получатся случайными лишь настолько, насколько они кажутся «случайными» пользователю. Иными словами, эффективность подбора случайности — это одна из частных характеристик пользовательского восприятия (UX).
Поэтому я решил попробовать несколько случайных визуальных элементов, воспользовавшись как Math.random()
, так и crypto.getRandomValues()
, чтобы проверить, ощущается ли существенная разница в работе двух методов. В каждом из испытаний у нас будет сгенерированный случайным образом элемент
Метод Math.random()
возвращает десятичную дробь в диапазоне от 0 (включая) до 1 (исключая). С его помощью можно генерировать случайные числа так: брать результат генерации случайного числа и умножать его на диапазон возможных значений.
Иными словами, если метод Math.random()
вернёт 0.25, то вы выберете значение, оказывающееся ближе всего к 25% в заданном диапазоне от минимального к максимальному. Если бы Math.random()
вернул 0.97, то вы выбрали бы значение, оказывающееся ближе всего к 97% в заданном диапазоне от минимального к максимальному.
Метод crypto.getRandomValues()
действует совершенно иначе. Он не возвращает отдельное значение, вместо этого вы передаёте в него типизированный массив заранее выделенного размера (длины). Затем метод .getRandomValues()
заполняет этот массив случайными числами, сгенерированными в диапазоне от минимального к максимальному значению. Учитывается, сколько значений можно сохранить в любом заданном типе.
Чтобы упростить это исследование, я хотел добиться, чтобы оба подхода действовали примерно одинаково. Поэтому, чтобы не приходилось иметь дело с десятичными дробями в одном алгоритме и с целыми числами в другом, я заставлю оба алгоритма генерировать десятичные дроби. Таким образом, я принудительно приведу значение value
, возвращённое .getRandomValues()
, к десятичной дроби (0…1):
value / ( maxValue + 1 )
Эту разницу я инкапсулирую в два метода: randFloatWithMath()
и randFloatWithCrypto()
:
/**
* Я возвращаю случайное число с плавающей точкой в диапазоне между 0 (включая) и 1 (исключая) при помощи модуля Math.
*/
function randFloatWithMath() {
return Math.random();
}
/**
* Я возвращаю случайное число с плавающей точкой в диапазоне между 0 (включая) и 1 (исключая) при помощи модуля Crypto.
*/
function randFloatWithCrypto() {
var [ randomInt ] = crypto.getRandomValues( new Uint32Array( 1 ) );
var maxInt = 4294967295;
return ( randomInt / ( maxInt + 1 ) );
}
Имея два этих метода, я затем могу присвоить одному из них ссылку randFloat()
, при помощи которой затем можно генерировать случайные значения в заданном диапазоне, попеременно используя оба алгоритма:
/**
* Я генерирую случайное целое число в заданном диапазоне (от минимума до максимума) включительно.
*/
function randRange( min, max ) {
return ( min + Math.floor( randFloat() * ( max - min + 1 ) ) );
}
Теперь поставим эксперимент. Пользовательский интерфейс у нашей программы прост, он работает на основе Alpine.js. В каждом эксперименте используется один и тот же компонент Alpine.js;, но его конструктор получает аргумент, определяющий, какая именно реализация randFloat()
будет использоваться:
Exploring Randomness In JavaScript
Math Module
Duration:
Crypto Module
Duration: ms
Как видите, каждый из компонентов x-data="Explore"
содержит две ссылки x-ref: canvas
и list
. При инициализации компонент заполняет две эти ссылки сгенерированными случайными значениями, пользуясь для этого, соответственно, методами fillCanvas()
и fillList()
.
Вот мой компонент для JavaScript / Alpine.js:
/**
* Я возвращаю случайное число с плавающей точкой в диапазоне между 0 (включая) и 1 (исключая) при помощи модуля Math.
*/
function randFloatWithMath() {
return Math.random();
}
/**
* Я возвращаю случайное число с плавающей точкой в диапазоне между 0 (включая) и 1 (исключая) при помощи модуля Crypto.
*/
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 );
}
// ---
// ПРИВАТНЫЕ МЕТОДЫ
// ---
/**
* Я заполняю холст случайными пикселями с координатами {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( " " );
}
/**
* Я генерирую случайное целое число в диапазоне между заданными значениями минимума и максимума, включительно.
*/
function randRange( min, max ) {
return ( min + Math.floor( randFloat() * ( max - min + 1 ) ) );
}
}
Выполнив этот демо-пример, получаем следующий вывод:
Как я говорил выше, случайность — это, с точки зрения человека, очень зыбкий феномен. Он больше связан с ощущениями, чем с математическим понятием о вероятности. Например, получить два одинаковых значения кряду можно с той же вероятностью, что и любые два значения, сгенерированные кряду. Но человеку кажется, что одинаковые значения чем-то выделяются, как будто они особенные.
При этом, сравнивая две вышеприведённые визуализации бок о бок, видим, что по показателю распределения они не особенно отличаются друг от друга. Конечно же, модуль Crypto
работает значительно медленнее (половина издержек уходит на выделение типизированного массива). Но «по ощущениям» сложно сказать, какой из вариантов лучше.
Таким образом, я пришёл к выводу, что при генерации цветовой палитры со случайными оттенками мне, пожалуй, не требовалось пользоваться модулем Crypto
— скорее всего, следовало придерживаться Math
. Он работает гораздо быстрее и воспринимается так, как будто даёт подлинно случайные значения. Материал по Crypto
оставлю тем, кому приходится обеспечивать криптографию на стороне клиента (мне подобным никогда заниматься не приходилось).
Хотите взять код из этого поста? Ознакомьтесь с лицензией.