Библиотека локализации на rust: как я разрабатывал типобезопасное решение

Как я разрабатывал библиотеку локализации на Rust

---

О чём пойдёт речь

Эта статья — рассказ о том, как из небольшого, «самодельного» решения для локализации я постепенно пришёл к полноценной библиотеке на Rust. Основной акцент — не на синтаксисе языка, а на эволюции архитектуры: какие решения принимались, какие проблемы всплывали по ходу работы и как проект из утилиты «для себя» превратился в переиспользуемый инструмент.

---

Термины и договорённости

Чтобы проще читать код и объяснения, я использую несколько терминов:

- локализация — подстановка перевода в зависимости от выбранного языка;
- локаль — конкретный язык или языковой вариант (например, `en`, `ru`, `de`);
- выражение — значение, которое меняется в зависимости от локали. На раннем этапе это был обычный `&'static str`, но далее модель усложнилась.

Эти определения нужны, чтобы не путаться между «строкой перевода», «ключом сообщения» и «локалью».

---

Как родилась идея

Началось всё с желания лучше разобраться в Rust. В качестве практического упражнения я стал писать GUI‑приложение и довольно быстро понял, что без поддержки нескольких языков оно выглядит незавершённым. Хотелось переключать язык интерфейса, не завязываясь на тяжёлые решения и форматы вроде JSON/YAML/TOML.

Первая идея была максимально простой: хранить все переводы в виде массивов, а локаль представлять как `usize`. На этапе локализации всё сводилось к обращению к нужному индексу. Примерно в таком духе:

```rust
static HELLO: [&str; 2] = ["Привет", "Hello"];
// 0 — ru, 1 — en
let locale: usize = 1;
let text = HELLO[locale];
```

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

---

Почему не JSON/YAML/TOML

Просмотр готовых решений показал, что большинство библиотек строятся вокруг внешних файлов конфигурации: JSON, YAML, TOML и т.п. Это удобно, когда проект большой, над переводами работают отдельные люди, а тексты должны жить вне бинарника.

Но у такого подхода есть несколько минусов:

- нужен парсер выбранного формата;
- тянутся дополнительные зависимости;
- в no_std‑окружении это обычно невозможно или слишком тяжело;
- при компиляции не всегда видно, что ключи корректны, а локали согласованы.

Мне же хотелось:

- максимум проверок на этапе компиляции;
- отсутствие лишних зависимостей;
- подход, который можно использовать и в no_std;
- простое и быстрое обращение по индексу, без хешей и сложных структур.

В итоге я решил оформить собственную идею в виде отдельной библиотеки.

---

Первый рабочий вариант: «минимум для себя»

Изначально библиотека была привязана к очень простому сценарию:

- локаль бралась из системных настроек один раз при старте;
- во время работы приложения язык менять было нельзя;
- путь к выражениям был жёстко зашит в коде.

Это устраивало для личного GUI-приложения, но было недостаточно для библиотеки, которую можно использовать в разных проектах. Стало ясно, что первую «публичную» версию нужно переработать по трём ключевым направлениям:

1. Сделать смену локали возможной во время работы.
2. Развязать код от жёстко прописанных путей к выражениям.
3. Добавить удобную генерацию перечисления `Locale` из списка языков.

---

Переход к no_std и архитектурные изменения

Я решил, что библиотека должна быть совместима с `no_std`. Это добавляло ограничений, но делало решение применимым, например, в встроенных системах. В процессе возникли следующие изменения:

1. `enum Locale` помечен как `repr(usize)`
Это позволило гарантировать, что каждый вариант локали можно напрямую интерпретировать как число для индексации массивов.

2. Макрос для генерации `Locale`
Появился макрос, которому достаточно передать список локалей, например:

```rust
localizer::locales! {
pub enum Locale {
Ru,
En,
}
}
```

Он генерирует само перечисление и связанный с ним код.

3. Хранение текущей локали в `AtomicUsize`
Чтобы библиотека корректно работала в многопоточной среде, текущая локаль стала храниться в атомарной переменной. Смена языка стала потокобезопасной.

4. Преобразование `Locale` ↔ `usize` через `transmute`
На раннем этапе использовался `transmute`, но чтобы не допускать выхода за диапазон, была добавлена дополнительная проверка валидности значений.

---

Макрос для выражений и гибкая локализация

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

- принимает список локалей и соответствующие строки;
- гарантирует, что количество строк соответствует числу локалей;
- автоматически выстраивает массивы по нужному порядку.

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

На этом этапе библиотека избавилась от любых внешних зависимостей, а в таком виде была выпущена версия 1.0.0.

---

Какие задачи всплыли после первой версии

После того как библиотека стала хоть сколько-нибудь стабильной, стало понятно, какие сценарии она покрывает, а какие — нет. Появился список задач, которые требовалось решить в первую очередь:

1. Передача локали явно при локализации
До этого текущая локаль была глобальной. Для серверных сценариев, где у каждого запроса может быть свой язык, нужен был способ указывать локаль вручную, не полагаясь на глобальное состояние.

2. Подстановка значений в выражения
Хотелось не только выводить статичные строки, но и форматировать сообщения: подставлять имена, числа, параметры. Эту возможность я и планировал изначально, но откладывал до первой стабильной версии.

3. Удаление `unsafe`‑кода
Использование `transmute` вызывало сомнения с самого начала. Нужно было заменить его на более безопасный и прозрачный механизм, сохранив при этом эффективность.

4. Создание выражений «пачками»
Стало очевидно, что определять каждое выражение отдельно не слишком удобно. Логичнее объявлять целые группы фраз для одной локали и строить из них структуры автоматически.

При этом я осознанно решил не двигаться в сторону сложных языковых правил: склонения по родам, числительные, контекстные формы и т.п. Это отдельная большая тема, и цель библиотеки — скорее давать лёгкий, предсказуемый механизм, чем превращаться в полный движок форматирования естественного языка.

---

Внутренний список улучшений

Параллельно я составил и свой набор целей по развитию:

- Полная поддержка стандартных трейт‑классов для `Locale`.
На тот момент некоторых реализаций не хватало, например, `Display`.

- Гибкое преобразование между `Locale`, `usize` и `&str`.
Хотелось, чтобы можно было легко превращать локаль в строку и обратно, в том числе с учётом регистра, алиасов и т.п.

- Защита от ошибок при создании выражений.
Все потенциальные несоответствия (разный размер массивов, неверное количество локалей и т.д.) должны отлавливаться на этапе компиляции.

- Поддержка сериализации / десериализации.
Многие приложения хранят настройки пользователя (в том числе выбранную локаль) в файлах или БД. Нужно было дать возможность подключать derive‑макросы, в том числе для сериализации.

- Подписи для вариантов локали.
Пользователю важнее видеть «Русский» или «English», чем внутренние идентификаторы `Ru`, `En`. При этом не хотелось жёстко навязывать наличие таких подписей.

---

Что появилось в итоговой библиотеке

После переработки библиотека существенно изменилась, но при этом сохранила ключевой принцип: локаль — это индекс, а выражение — массив вариантов переводов.

Функционально добавилось следующее:

1. Подписи для локалей
Теперь можно задавать удобочитаемые названия языков тем же синтаксисом, который используется для создания выражений. Этот функционал опционален: если подписи не нужны, код не загромождается лишними структурами.

2. Подключение своих derive‑макросов
При объявлении `Locale` можно передать дополнительные derive — например, для поддержки сериализации и десериализации через `serde`.

3. Реализации `Default`, `Display`, `FromStr`
- `Default` выбирает первую локаль в перечислении как значение по умолчанию.
- `Display` отвечает за человекочитаемое представление (обычно строковый идентификатор или подпись).
- `FromStr` даёт возможность создавать `Locale` из текстового кода, что удобно для чтения настроек.

4. Константы для доступа к вариантам и default‑локали
Поскольку `Default::default` не доступен на этапе компиляции, была добавлена специальная константа, генерируемая вспомогательным макросом, которая указывает на первую локаль. Аналогичным образом доступны константы для вариантов и их подписей.

5. Итераторы по локалям и подписям
Реализованы итераторы:
- по самим вариантам `Locale`;
- по подписям;
- по паре `(Locale, подпись)` одновременно.
Это удобно, например, когда нужно вывести список доступных языков в настройках приложения.

6. Все нужные варианты преобразований `usize` ↔ `Locale`, `&str` ↔ `Locale`
Поддерживаются разные способы конвертации, в том числе с игнорированием регистра или с проверкой на допустимый диапазон. Это уменьшает количество ручного кода и потенциальных ошибок.

7. Полный отказ от `unsafe`
В процессе рефакторинга выяснилось, что `transmute` легко заменить на обычный `match` и аккуратный код преобразования. Компилятор умеет так оптимизировать эти конструкции, что накладные расходы исчезающе малы, а безопасность при этом возрастает.

---

Инициализация без хранилища локали

Один из поддерживаемых сценариев — когда текущее значение локали не хранится глобально, а всегда передаётся явно. Такой подход особенно удобен в серверных приложениях:

- у каждого запроса свой контекст;
- локаль может зависеть от заголовков, параметров, токенов;
- не требуется глобальное состояние.

В этом режиме библиотека фактически предоставляет компактный слой над массивами строк, давая при этом типобезопасный интерфейс и гарантии согласованности выражений с локалями.

---

Вариант с глобальным хранилищем локали

Другой режим — когда есть глобальное хранилище текущей локали, реализованное поверх `AtomicUsize`. Он больше подходит для:

- десктопных приложений;
- игр;
- утилит, где локаль меняется редко и применяется ко всему интерфейсу сразу.

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

---

Создание выражений и пакетная генерация

Существенное упрощение работы дала пакетная генерация выражений. Вместо того чтобы вручную объявлять десятки статических массивов, можно:

- описать локали;
- затем в одной или нескольких структурах перечислить выражения, сгруппированные логически (например, по модулям интерфейса: «меню», «настройки», «ошибки»).

Макрос следит, чтобы:

- для каждой локали было задано значение;
- порядок локалей не нарушался;
- не появлялись «дырки» и несоответствия в массивах.

В результате код локализации становится более читаемым, а ошибки — заметными ещё на этапе компиляции.

---

Локализация с параметрами

Следующий важный этап — поддержка подстановки значений в шаблоны. Вместо статичной строки теперь можно задавать выражения с плейсхолдерами и передавать набор аргументов для форматирования.

Например:

```rust
localize!(expr::WELCOME_USER, locale, name = user_name);
```

Внутри реализуется своя лёгкая схема форматирования, которая не пытается конкурировать с полноценными механизмами интернационализации, но покрывает типовые нужды:

- подстановка имени пользователя;
- вывод чисел, счетчиков, версий;
- подстановка коротких динамических параметров.

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

---

Практические сценарии использования

На практике библиотека хорошо вписывается в несколько типовых сценариев:

1. Небольшие десктопные приложения
- код локализации живёт рядом с UI‑кодом;
- переводы компилируются вместе с бинарником;
- смена локали — через глобальный переключатель.

2. Серверы и веб‑сервисы
- локаль вычисляется индивидуально для каждого запроса;
- локализация вызывается с явной передачей локали;
- нет глобального состояния, мешающего масштабированию.

3. Встроенные системы и no_std‑окружение
- отсутствие динамических аллокаций;
- минимальные зависимости;
- компактное хранение строк в виде статических массивов.

---

Ограничения и сознательные решения

В процессе разработки пришлось чётко очертить границы ответственности библиотеки:

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

- Нет загрузки переводов в рантайме.
Все выражения задаются на этапе компиляции. Это позволяет поймать максимум ошибок заранее и остаётся в духе Rust — больше гарантий, меньше сюрпризов на продакшене.

- Фокус на скорости и предсказуемости.
Обращение к выражению — это обращение по индексу, без хеш-таблиц, поиска по строкам и т.п.

---

Итоги и дальнейшие планы

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

- работает в `no_std`;
- не тянет внешние зависимости;
- предоставляет безопасный интерфейс без `unsafe`;
- поддерживает глобальное и явное управление локалью;
- позволяет удобно определять локали, их подписи и выражения;
- даёт гибкие преобразования между `Locale`, `usize` и `&str`.

В планах на будущее:

- ещё упростить синтаксис макросов, сделав объявления локалей и выражений более естественными;
- расширить возможности форматирования с параметрами, не превращая библиотеку при этом в тяжёлый движок интернационализации;
- улучшить интеграцию с типичными экосистемами (GUI‑фреймворки, веб‑фреймворки) через адаптеры и вспомогательные модули;
- добавить больше compile-time‑проверок, чтобы поймать любые несоответствия ещё на стадии сборки.

Главная идея при этом остаётся прежней: локализация должна быть такой же типобезопасной и предсказуемой, как остальной Rust‑код, а библиотека — помогать в этом, не навязывая лишней магии и не заставляя тянуть за собой тяжёлую инфраструктуру.

1
2
Прокрутить вверх