Разберем задачу, которая часто встречается на собеседованиях и в реальных проектах. Она простая, но именно на таких задачах проверяют, как вы мыслите и что знаете помимо базового синтаксиса.
Задача: Напишите функцию, которая принимает целое число n и возвращает строку длиной n из символов a-z, A-Z и 0-9. По сути — генератор паролей.
Казалось бы, чего проще? Но давайте представим, что мы на собеседовании. Мы не просто напишем код, а пройдем путь от самого простого решения к профессиональному, обсудив все подводные камни.
- Шаг 1. Самый простой вариант
- Что здесь происходит?
- Минусы этого решения
- Шаг 2. Разбираем проблемы по порядку
- Проблема 1. Устаревший способ инициализации
- Проблема 2. Предсказуемость
- Проблема 3: Нет проверки входных данных
- Проблема 4: Нет обработки ошибок
- Шаг 3. Пишем улучшенную версию
- Разбираем код по строкам
- Шаг 4. Как тестировать случайность?
- Тест 1: Проверка длины
- Тест 2: Проверка допустимых символов
- Тест 3: Проверка уникальности
- Тест 4: Проверка обработки ошибок
- Шаг 5. Можно ли предсказать пароль для тестов?
- Ключевой вывод
Шаг 1. Самый простой вариант
Вот самое прямолинейное решение, которое приходит в голову:
package main
import (
"fmt"
"math/rand"
"time"
)
func GeneratePassword(n int) string {
// задаем все возможные символы для пароля
symbols := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// "перемешиваем" генератор случайных чисел,
// чтобы каждый раз получать разные пароли
rand.Seed(time.Now().UnixNano())
// создаем пустой срез байтов нужной длины
result := make([]byte, n)
// заполняем каждый байт случайным символом из symbols
for i := 0; i < n; i++ {
// rand.Intn(62) дает число от 0 до 61
// symbols[число] берет символ под этим номером
result[i] = symbols[rand.Intn(len(symbols))]
}
// превращаем срез байтов в строку
return string(result)
}
func main() {
// генерируем пароль из 8 символов
password := GeneratePassword(8)
fmt.Println(password)
}
Что здесь происходит?
symbols— это строка со всеми разрешенными символами. Их тут 62: 26 маленьких букв + 26 больших + 10 цифр.rand.Seed(time.Now().UnixNano())— инициализируем генератор случайных чисел текущим временем в наносекундах. Если этого не сделать,randбудет выдавать одни и те же числа при каждом запуске.make([]byte, n)— создаем срез изnбайтов. Почему не строку? Потому что строки в Go не изменяемы, а с байтами работать удобнее.- В цикле берем случайное число от 0 до 61 и используем его как индекс в строке
symbols, получая случайный символ.
Минусы этого решения
Базовое решение работает, но у него есть несколько недочетов, которые следует учитывать в production-коде. Давайте разберем их по порядку.
Шаг 2. Разбираем проблемы по порядку
Проблема 1. Устаревший способ инициализации
В Go 1.20 (вышел в 2023 году) перестали требовать ручной вызов rand.Seed(). Теперь при импорте пакета math/rand генератор инициализируется автоматически один раз. Если вызываем Seed внутри функции, то мы переинициализируем его при каждом вызове. Это медленно и бессмысленно.
Что делать? Просто убрать эту строку. Но тогда возникает следующая проблема…
Проблема 2. Предсказуемость
math/rand — это не настоящий случайный генератор. Это псевдослучайный генератор, который использует математическую формулу. Если знать seed (начальное число), можно предсказать все следующие «случайные» числа.
Но главная проблема даже не в этом. Представьте, что вы вызываете функцию несколько раз подряд:
pwd1 := GeneratePassword(10) pwd2 := GeneratePassword(10) pwd3 := GeneratePassword(10)
На быстрых компьютерах эти вызовы могут произойти в одну и ту же наносекунду. И тогда time.Now().UnixNano() вернет одинаковое значение для всех трех вызовов! А значит, все три пароля будут идентичными.
Для паролей (даже для тестовых) это недопустимо!
Проблема 3: Нет проверки входных данных
Что будет, если передать n = 0? Получим пустую строку. Может, так и задумано? А если передать отрицательное число? В цикле условие i < n никогда не выполнится (потому что отрицательное число меньше нуля), и мы снова получим пустую строку.
Но главное — пароль длиной 0 или -5 — это ошибка. Функция должна явно сообщать об этом.
Проблема 4: Нет обработки ошибок
rand.Intn() всегда работает без ошибок. Но что, если мы захотим использовать криптографически безопасный генератор? Там ошибки могут возникнуть (например, закончилась энтропия в системе). Наш код к этому не готов.
Шаг 3. Пишем улучшенную версию
Теперь, зная все эти проблемы, давайте перепишем функцию правильно:
package main
import (
"crypto/rand"
"errors"
"math/big"
)
// charset содержит все разрешенные символы
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// GeneratePassword создает криптостойкий пароль длиной n.
// Возвращает ошибку, если длина некорректна или генератор отказал.
func GeneratePassword(n int) (string, error) {
// Проверяем входные данные
if n <= 0 {
return "", errors.New("длина пароля должна быть положительным числом")
}
// Узнаем длину набора символов как *big.Int
// big.Int нужен для работы с crypto/rand
charsetLen := big.NewInt(int64(len(charset)))
// Создаем срез байтов нужной длины
result := make([]byte, n)
// Заполняем срез случайными символами
for i := 0; i < n; i++ {
// Получаем случайное число от 0 до charsetLen-1
// Используем криптографически безопасный генератор
index, err := rand.Int(rand.Reader, charsetLen)
if err != nil {
// Если случилась ошибка (например, нет энтропии),
// возвращаем пустую строку и ошибку
return "", err
}
// index.Int64() превращает *big.Int в обычный int64
// подставляем полученный индекс в charset
result[i] = charset[index.Int64()]
}
// Превращаем срез байтов в строку и возвращаем
return string(result), nil
}
func main() {
// генерируем пароль из 8 символов
password := GeneratePassword(8)
fmt.Println(password)
}
Разбираем код по строкам
Строка 4-6: Импортируем нужные пакеты:
crypto/rand— криптографически безопасный генератор случайных чиселerrors— для создания ошибокmath/big— для работы с большими числами (нужен для crypto/rand)
Строка 10: charset — это набор символов. Вынесли в константу, чтобы:
- Не пересоздавать строку при каждом вызове
- Легко было изменить набор символов в будущем
Строка 16: Проверяем длину. Если n ≤ 0 — возвращаем ошибку. В Go принято возвращать ошибку последним параметром.
Строка 22: big.NewInt() создает большое число. Почему не обычный int? Потому что rand.Int() из пакета crypto/rand работает только с *big.Int. Это особенность API.
Строка 25: make([]byte, n) — выделяем память сразу под n байтов. Это эффективно: мы не будем расширять срез в процессе.
Строка 28-41: Главный цикл:
rand.Int(rand.Reader, charsetLen)— получаем случайное число от 0 до charsetLen-1.rand.Reader— это глобальный источник случайных чисел из пакетаcrypto/rand.- Если произошла ошибка (например, системе не хватило «случайности» для генерации числа), возвращаем ошибку.
index.Int64()— конвертируем*big.Intв обычныйint64, чтобы использовать как индекс.
Строка 40: charset[index.Int64()] — берем символ по индексу. Это безопасно, потому что индекс гарантированно меньше длины строки.
Строка 44: Возвращаем готовую строку и nil (нет ошибки).
Шаг 4. Как тестировать случайность?
Отлично, функция готова. Но как ее тестировать? Ведь каждый раз она возвращает разный результат! Тут нам помогут тесты свойств (property-based tests). Мы не проверяем конкретные значения, а проверяем, что функция соблюдает свои обещания.
Тест 1: Проверка длины
Самый простой тест: убедимся, что пароль имеет нужную длину:
func TestGeneratePasswordLength(t *testing.T) {
// задаем нужную длину
length := 10
// генерируем пароль
pwd, err := GeneratePassword(length)
// проверяем, что нет ошибки
if err != nil {
t.Fatal("Не ожидали ошибку, но получили:", err)
}
// проверяем длину
if len(pwd) != length {
t.Errorf("Хотели пароль длиной %d, получили %d", length, len(pwd))
}
}
Тест 2: Проверка допустимых символов
Убедимся, что в пароле нет посторонних символов:
func TestGeneratePasswordCharset(t *testing.T) {
// генерируем длинный пароль, чтобы проверить много символов
pwd, err := GeneratePassword(1000)
if err != nil {
t.Fatal("Ошибка генерации:", err)
}
// проверяем каждый символ
for _, char := range pwd {
// strings.ContainsRune проверяет, есть ли символ в строке
if !strings.ContainsRune(charset, char) {
t.Errorf("Найден недопустимый символ: %с", char)
}
}
}
Важно: Используем strings.ContainsRune, потому что символы в Go — это руны (Unicode-коды), а байты могут не вместить весь символ, если добавим, например, кириллицу.
Тест 3: Проверка уникальности
Хороший генератор не должен повторяться (по крайней мере, часто):
func TestGeneratePasswordUniqueness(t *testing.T) {
// карта для отслеженных уже увиденных паролей
seen := make(map[string]bool)
// генерируем 1000 паролей
for i := 0; i < 1000; i++ {
pwd, err := GeneratePassword(10)
if err != nil {
t.Fatal("Ошибка генерации:", err)
}
// если пароль уже был - это проблема
if seen[pwd] {
t.Errorf("Найден повторяющийся пароль: %s", pwd)
}
seen[pwd] = true
}
}
Заметка: Технически повторы возможны (это же случайность), но вероятность получить повтор среди 1000 паролей длиной 10 символов из 62-символьного алфавита ничтожна мала. Если тест упал — значит, генератор работает неправильно.
Тест 4: Проверка обработки ошибок
Убедимся, что на некорректные данные функция реагирует правильно:
func TestGeneratePasswordErrors(t *testing.T) {
// тестируем разные варианты неправильной длины
testCases := []int{0, -1, -100}
for _, length := range testCases {
_, err := GeneratePassword(length)
if err == nil {
t.Errorf("Для длины %d ожидали ошибку, но ее нет", length)
}
}
}
Шаг 5. Можно ли предсказать пароль для тестов?
Это хороший вопрос. Если бы мы использовали math/rand с фиксированным seed’ом, мы могли бы точно предсказать, какой пароль сгенерируется. Например:
rand.Seed(42) // фиксируем seed pwd := GeneratePassword(5) // всегда будет "aB3xK"
Это было бы удобно для тестов, но опасно для реального использования. В продакшене пароли должны быть непредсказуемыми.
Поэтому здесь выбор пал на crypto/rand — он использует системный источник энтропии (шумы от устройств, движения мыши, нажатия клавиш). Это делает генерацию по-настоящему случайной и непредсказуемой. Для тестов это минус (сложнее проверять), но для безопасности — огромный плюс.
Ключевой вывод
Главное, что нужно вынести из этой задачи: простота реализации не равна качеству кода. Даже в такой базовой функции, как генератор паролей, важно учитывать безопасность (выбор crypto/rand вместо math/rand), корректную обработку ошибок и проверку входных данных. Именно такие детали отличают код, который просто работает, от кода, который можно использовать в реальных проектах.
А как вы решаете подобные задачи на своих проектах? Используете готовые библиотеки или пишете свои генераторы? Сталкивались с неочевидными проблемами при генерации случайных данных? Поделитесь опытом в комментариях — интересно обсудить!