Ошибки и их решения

Генерация случайных чисел в C#

Обновлено: · 5 мин чтения

Случайное целое число в C# получают методом Next класса Random: например, Random.Shared.Next(1, 101) вернёт число от 1 до 100. В статье разобраны диапазоны, дробные и длинные числа, повторяющиеся последовательности, потокобезопасность и безопасная генерация для паролей и токенов.

Генерация случайных чисел в C#
Содержание
  1. Быстрый способ получить случайное число
  2. Перегрузки метода Next
    1. Диапазон с включённой верхней границей
    2. Дробное число в диапазоне
  3. Почему числа повторяются: ошибка с new Random в цикле
  4. Seed: повторяемая последовательность
  5. Случайные числа в многопоточной программе
  6. Уникальные случайные числа без повторов
  7. Случайный элемент коллекции
  8. Выбор с разными вероятностями
  9. Криптографически стойкие числа
  10. Случайные числа в Unity
  11. Типичные ошибки
  12. Частые вопросы

Быстрый способ получить случайное число

Начиная с .NET 6 у класса Random есть готовый общий экземпляр Random.Shared. Его не нужно создавать, он потокобезопасен и подходит для большинства задач: игр, тестовых данных, выбора случайного элемента.

int a = Random.Shared.Next();          // от 0 до int.MaxValue - 1
int b = Random.Shared.Next(10);        // от 0 до 9
int c = Random.Shared.Next(1, 101);    // от 1 до 100 включительно
double d = Random.Shared.NextDouble(); // от 0.0 до 1.0 (1.0 не входит)

Console.WriteLine($"{a} {b} {c} {d}");

Если проект собирается под .NET Framework или более старую версию, Random.Shared недоступен. Тогда создают один экземпляр new Random() и повторно используют его, как описано ниже.

Перегрузки метода Next

Метод Next имеет три формы. Главное правило одно: нижняя граница входит в диапазон, верхняя нет.

ВызовДиапазон результата
Next()От 0 до int.MaxValue, не включая максимум
Next(max)От 0 до max - 1
Next(min, max)От min до max - 1
NextInt64(min, max)Длинное число от min до max - 1
NextDouble()Дробное от 0 до 1, единица не включается
NextSingle()Число типа float от 0 до 1
NextBytes(byte[])Заполняет массив случайными байтами

Диапазон с включённой верхней границей

Чтобы получить число от 1 до 10 включительно, верхнюю границу увеличивают на единицу. Для игральной кости это выглядит так:

int dice = Random.Shared.Next(1, 7);   // 1, 2, 3, 4, 5 или 6
int percent = Random.Shared.Next(0, 101);   // от 0 до 100

Частая ошибка начинающих - написать Next(1, 6) и удивиться, что шестёрка никогда не выпадает. Метод исключает верхнюю границу, поэтому нужен вызов Next(1, 7).

Дробное число в диапазоне

Для дробей диапазон растягивают умножением. Так получают, например, температуру от -10.5 до 35.0 или координату на экране.

static double NextDouble(Random rnd, double min, double max) =>
    min + rnd.NextDouble() * (max - min);

double temperature = NextDouble(Random.Shared, -10.5, 35.0);
Console.WriteLine(Math.Round(temperature, 1));

Почему числа повторяются: ошибка с new Random в цикле

Самая известная проблема связана с созданием Random внутри цикла. В .NET Framework генератор инициализировался текущим временем с точностью до миллисекунд, и все экземпляры, созданные в течение одной миллисекунды, выдавали одинаковую последовательность. В .NET Core и новее начальное значение берётся из системного источника, и эта проблема почти ушла, но привычка создавать генератор в цикле остаётся плохой: это лишние выделения памяти.

// плохо: новый генератор на каждой итерации
for (int i = 0; i < 10; i++)
{
    var rnd = new Random();
    Console.WriteLine(rnd.Next(1, 11));
}

// хорошо: один экземпляр на все вызовы
var shared = Random.Shared;
for (int i = 0; i < 10; i++)
    Console.WriteLine(shared.Next(1, 11));

Если вам нужно хранить генератор в поле класса, создайте его один раз как static readonly или используйте Random.Shared. Ту же ошибку часто допускают, когда создают Random внутри метода, который вызывается из цикла.

Seed: повторяемая последовательность

Конструктор принимает начальное значение (seed). Два генератора с одинаковым seed выдают одну и ту же последовательность. Это полезно в тестах, симуляциях и играх, где нужно воспроизвести уровень по номеру.

var r1 = new Random(12345);
var r2 = new Random(12345);

for (int i = 0; i < 3; i++)
    Console.WriteLine($"{r1.Next(100)} {r2.Next(100)}");   // пары одинаковы

Важно: при заданном seed алгоритм остаётся стабильным, но между версиями .NET его реализация может меняться, поэтому не стоит хранить такие последовательности как долгосрочные данные. А вот для обычного кода без seed используется другой, более быстрый алгоритм.

Случайные числа в многопоточной программе

Экземпляр new Random() не потокобезопасен. При одновременном доступе из нескольких потоков внутреннее состояние портится, и генератор может начать выдавать нули. Решений три:

  • использовать Random.Shared, который уже защищён от этой проблемы;
  • завести отдельный генератор на каждый поток через [ThreadStatic] или ThreadLocal<Random>;
  • блокировать доступ через lock, что медленнее.
var results = new int[1000];
Parallel.For(0, results.Length, i =>
{
    results[i] = Random.Shared.Next(1, 7);
});

Об общей схеме работы с потоками можно прочитать в материалах блога по многопоточности, а для обычных консольных программ Random.Shared достаточно.

Уникальные случайные числа без повторов

Задача "вытащить 6 разных чисел из 49" возникает в лотереях и розыгрышах. Нельзя просто генерировать числа в цикле и надеяться на удачу, лучше перемешать набор и взять начало. Начиная с .NET 8 для этого есть метод Random.Shuffle.

int[] pool = Enumerable.Range(1, 49).ToArray();
Random.Shared.Shuffle(pool);              // .NET 8 и новее
int[] ticket = pool.Take(6).OrderBy(x => x).ToArray();

Console.WriteLine(string.Join(", ", ticket));

В более старых версиях применяют алгоритм Фишера-Йетса или вариант с HashSet, пока набор не достигнет нужного размера. Подробный разбор шаффла есть в статье как перемешать список в C#.

Случайный элемент коллекции

Чтобы выбрать случайный элемент массива или списка, достаточно получить случайный индекс. В .NET 8 появился метод GetItems, который выбирает несколько элементов с повторами.

string[] colors = { "красный", "зелёный", "синий", "жёлтый" };

string one = colors[Random.Shared.Next(colors.Length)];
string[] three = Random.Shared.GetItems(colors, 3);   // .NET 8, возможны повторы

Console.WriteLine(one);
Console.WriteLine(string.Join(", ", three));

Выбор с разными вероятностями

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

static string WeightedPick(Dictionary<string, int> weights)
{
    int total = weights.Values.Sum();
    int roll = Random.Shared.Next(total);

    foreach (var (name, weight) in weights)
    {
        if (roll < weight) return name;
        roll -= weight;
    }
    throw new InvalidOperationException();
}

var loot = new Dictionary<string, int>
{
    ["Обычный"] = 70, ["Редкий"] = 25, ["Легендарный"] = 5
};
Console.WriteLine(WeightedPick(loot));

Криптографически стойкие числа

Класс Random предсказуем: зная несколько выданных значений, можно восстановить состояние генератора. Для паролей, токенов сессии, ключей и кодов подтверждения он не подходит. Используйте RandomNumberGenerator из пространства имён System.Security.Cryptography.

using System.Security.Cryptography;

int pin = RandomNumberGenerator.GetInt32(100000, 1000000);   // шестизначный код
byte[] key = RandomNumberGenerator.GetBytes(32);              // 256-битный ключ
string token = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));

Console.WriteLine($"{pin} {token}");

Метод GetInt32 доступен с .NET Core 3.0 и учитывает смещение распределения, поэтому результат равномерный. Работает такой генератор медленнее, чем Random, но для безопасности это оправданная цена.

ЗадачаЧто использовать
Игры, симуляции, тестовые данныеRandom.Shared
Воспроизводимая последовательностьnew Random(seed)
Пароли, токены, коды, ключиRandomNumberGenerator
Перемешивание коллекцииRandom.Shuffle (.NET 8) или Фишер-Йетс
Многопоточный кодRandom.Shared или генератор на поток

Случайные числа в Unity

В играх на Unity обычно используют собственный класс UnityEngine.Random. У него метод Range(min, max) для целых чисел исключает верхнюю границу, а для дробных, наоборот, включает. Не смешивайте его с System.Random в одном файле без явного указания пространства имён, иначе компилятор сообщит о неоднозначности.

Типичные ошибки

  • Создание нового Random при каждом вызове метода.
  • Ожидание включённой верхней границы в Next(min, max).
  • Использование Random для паролей и токенов.
  • Общий экземпляр new Random() в нескольких потоках.
  • Расчёт случайного индекса через Next() % n: из-за остатка небольшой перекос в вероятностях остаётся, лучше вызывать Next(n).

Частые вопросы

Как получить случайное число от 1 до 100 в C#?

Вызовите Random.Shared.Next(1, 101). Верхняя граница не входит в диапазон, поэтому указывается 101.

Как получить случайное дробное число?

Используйте NextDouble(), который возвращает число от 0 до 1. Для произвольного диапазона умножьте результат на ширину диапазона и прибавьте минимум.

Почему Random выдаёт одинаковые числа?

Обычно причина в том, что генератор создаётся заново рядом по времени или используется один и тот же seed. Заведите один экземпляр или используйте Random.Shared, и последовательность станет разнообразной.

Можно ли использовать Random для паролей?

Нет. Для паролей, токенов и ключей применяйте RandomNumberGenerator, так как обычный Random можно предсказать.

Как получить случайные числа без повторов?

Создайте массив всех допустимых значений, перемешайте его через Random.Shuffle или алгоритм Фишера-Йетса и возьмите нужное количество первых элементов.

Как выбрать случайный элемент из списка?

Получите случайный индекс методом Next(list.Count) и обратитесь к элементу по индексу. Для нескольких элементов с повторами в .NET 8 есть GetItems.