В API нужно исключить случайную передачу идентификатора заказа вместо идентификатора пользователя. Скомпилируется ли вызов load(order) и почему?
type UserId = u64;
type OrderId = u64;
fn load(id: UserId) {
println!("{id}");
}
fn main() {
let order: OrderId = 42;
load(order);
}
Да, код скомпилируется: UserId и OrderId — это псевдонимы типов, а не отдельные типы. После разрешения псевдонимов обе переменные имеют тип u64, поэтому OrderId можно передать туда, где ожидается UserId.
Если требуется запретить такое смешивание на уровне компиляции, нужно использовать новый тип через одноэлементный кортежный struct, а не type.
Псевдонимы типов нужны, чтобы дать сложному или неочевидному типу удобное имя, сократить сигнатуры и сделать публичный API понятнее. Они также позволяют переименовывать тип без изменения его семантики и без введения неявных преобразований.
Такой подход решает задачу читаемости, но намеренно не добавляет типовой изоляции. Для изоляции значения Rust предоставляет паттерн newtype — отдельный тип-обёртку с одним полем.
В примере UserId и OrderId выглядят как разные концепции, но компилятор не различает их: оба имени раскрываются в u64. Поэтому ошибка в порядке аргументов может пройти компиляцию и проявиться только во время выполнения или при проверке данных.
Это особенно опасно в API с несколькими идентификаторами, денежными величинами или единицами измерения. Псевдоним улучшает читаемость, но не создаёт границу безопасности типов.
Объявление type UserId = u64; создаёт альтернативное имя для уже существующего типа. В местах проверки типов Rust рассматривает UserId как u64, поэтому присваивания, передача аргументов и вызовы функций между такими псевдонимами разрешены.
Минимальный вариант с отдельными типами выглядит так:
Здесь UserId и OrderId — разные номинальные типы. Компилятор не выполняет между ними неявное преобразование; преобразование нужно выразить явно, как в UserId(order.0). Это повышает безопасность, но добавляет конструкторы, доступ к внутреннему значению и необходимость явно реализовать нужные трейты или преобразования.
Псевдоним можно применять к обобщённому типу, например type UserMap = std::collections::HashMap<UserId, String>, но он всё равно не создаёт нового типа. Для семантически разных значений следует выбирать struct, а для удобного имени того же типа — type.
В сервисе есть функции get_user(UserId) и get_order(OrderId), а идентификаторы приходят из базы как u64. Рассматривались три варианта.
Сырые u64 дают простейший код, но позволяют перепутать аргументы. Псевдонимы UserId и OrderId делают сигнатуры понятнее, однако не предотвращают ошибку. Новые типы требуют небольшого дополнительного кода, зато обнаруживают перепутанные идентификаторы при компиляции.
Для публичного доменного API выбирается struct UserId(u64) и struct OrderId(u64). На границе с базой данных выполняется явное преобразование, а внутри бизнес-логики случайная подмена идентификаторов становится невозможной без намеренного извлечения и повторной упаковки значения.
Можно ли реализовать разные трейты для UserId и OrderId, если они объявлены через type?
Нет, псевдонимы не создают независимых типов. Например, попытка отдельно реализовать поведение для UserId и OrderId фактически обращается к одному и тому же базовому типу u64. Нельзя использовать псевдоним, чтобы обойти правила сиротских трейтов: реализация для псевдонима примитивного или чужого типа не становится реализацией для локального типа.
Новый тип через struct является локальным типом, поэтому для него можно реализовать собственные трейты и задать доменное поведение.
Сохраняется ли различие псевдонимов внутри обобщённых функций?
Нет. Если функция принимает T, то значения типов UserId и OrderId, объявленных как псевдонимы u64, рассматриваются как значения одного и того же типа. Псевдоним может улучшить сигнатуру или документацию, но не заставит компилятор вывести разные параметры типа для этих значений.
Для раздельного вывода параметров типа нужны независимые типы, например struct UserId(u64) и struct OrderId(u64).
Что произойдёт при замене type UserId = u64 на struct UserId(u64) в существующем API?
Код, который неявно передавал u64, перестанет компилироваться: потребуется явно создать UserId. Также могут потребоваться реализации From<u64>, Into<u64>, Display, сериализации и другие адаптеры.
Взамен API получает типовую защиту и возможность добавить инварианты, методы и трейты именно для идентификатора. Поэтому такую замену следует учитывать как изменение интерфейса, особенно если тип уже используется внешними клиентами библиотеки.