Ну, привет.

В январе мы разбирали пять привычек, которые звучат разумно, а на деле мешают: дженерики на каждый параметр, Arc<Mutex>

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

Сегодня — ещё пять таких же «лучших» практик. Трейт на случай смены базы. Лайфтаймы в структурах ради аллокаций, которые никто не считал. Строка #[derive(...)], которую копируют не глядя и однажды сливают пароль в лог. Newtype на каждое число. И оптимизации, которые воткнули до того, как кто-нибудь вообще открыл профилировщик.

Трейт на случай, если мы поменяем базу

Начну с самого частого. В проекте один способ ходить в хранилище, и он будет один ещё три года. Но пишут так:

#[async_trait]
pub trait UserRepository: Send + Sync {
    async fn find(&self, id: u64) -> Result<Option<User>, RepoError>;
    async fn find_by_email(&self, email: &str) -> Result<Option<User>, RepoError>;
    async fn save(&self, user: &User) -> Result<(), RepoError>;
    async fn delete(&self, id: u64) -> Result<(), RepoError>;
    async fn list_active(&self, limit: u32) -> Result<Vec<User>, RepoError>;
}

pub struct PostgresUserRepository { pool: PgPool }

#[async_trait]
impl UserRepository for PostgresUserRepository {
    async fn find(&self, id: u64) -> Result<Option<User>, RepoError> {
        sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id as i64)
            .fetch_optional(&self.pool)
            .await
            .map_err(RepoError::from)
    }
    // ещё четыре метода
}

pub struct Service {
    repo: Arc<dyn UserRepository>,
}

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

Первое, что может сломаться — транзакции. У вас три метода, которые должны выполниться атомарно, а трейт про транзакции ничего не знает.

Дальше два пути:

// либо тащим транзакцию в трейт и хороним абстракцию
async fn save_tx(&self, user: &User, tx: &mut Transaction<'_, Postgres>) -> Result<(), RepoError>;

// либо пишем в обход собственного трейта
impl Service {
    async fn transfer(&self) -> Result<()> {
        let pg = self.repo.as_any().downcast_ref::<PostgresUserRepository>().unwrap();
        let mut tx = pg.pool.begin().await?;
        // ...
    }
}

В первом варианте Postgres оказывается прямо в сигнатуре абстрактного хранилища. Во втором — downcast с unwrap посреди бизнес-логики. И то и другое не очень.

Второе — dyn. С Rust 1.75 асинхронные методы в трейтах работают без макросов, и это отличная новость. Только вот такой трейт перестаёт быть dyn-совместимым, тот же Arc<dyn UserRepository> вы не напишете. Возвращаетесь к #[async_trait], а он боксит каждый вызов, то есть кладёт future в кучу на каждый поход в базу.

Для похода в Postgres эта аллокация — вообще ничто. А потом кто-то заводит CacheRepository с тем же трейтом, кладёт данные в память, и получите Box в куче на каждое чтение из хеш-таблицы.

Поэтому если реализация одна, пишите структуру:

pub struct UserRepository { pool: PgPool }

impl UserRepository {
    pub async fn find(&self, id: u64) -> Result<Option<User>> { /* ... */ }

    pub async fn tx(&self) -> Result<Transaction<'_, Postgres>> {
        Ok(self.pool.begin().await?)
    }
}

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

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

// было: чтобы протестировать правило, нужен мок репозитория
async fn can_promote(&self, id: u64) -> Result<bool> {
    let user = self.repo.find(id).await?.ok_or(NotFound)?;
    Ok(user.karma > 100 && user.days_active > 30 && !user.banned)
}

// стало: правило тестируется без всего
fn can_promote(user: &User) -> bool {
    user.karma > 100 && user.days_active > 30 && !user.banned
}

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

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

Следующая привычка тоже про будущее.

Лайфтаймы ради аллокаций, которых никто не считал

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

pub struct Config<'a> {
    name: &'a str,
    hosts: Vec<&'a str>,
    tags: HashMap<&'a str, &'a str>,
}

impl<'a> Config<'a> {
    pub fn parse(raw: &'a str) -> Result<Self, ParseError> { /* ... */ }
}

Ни одной лишней аллокации. Пока не понадобится вернуть это из функции:

fn load() -> Config<'static> {
    let s = std::fs::read_to_string("config.toml").unwrap();
    Config::parse(&s)
}
error[E0515]: cannot return value referencing local variable `s`

Тут начинается то, за что Rust чаще всего обзывают. Лайфтайм не остаётся внутри структуры.

Функция, работающая с Config<'a>, получает параметр. Структура, хранящая Config<'a>, получает параметр. Трейт, принимающий её, тоже. Через неделю лайфтаймы в половине сигнатур проекта, а положить конфиг в Arc и отдать в фоновую задачу нельзя, он живёт столько, сколько живёт исходная строка.

Дальше обычно одно из двух. Либо человек плюёт и меняет все ссылки на String, переписывая половину кода. Либо вообще тащит Box::leak ради 'static....

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

Ссылки в структурах хороши там, где структура живёт недолго и никуда не уезжает. Временный вид на чужие данные внутри функции:

// нормально: живёт ровно один разбор
struct Token<'a> { kind: TokenKind, text: &'a str }

// а конфиг пусть владеет своим
pub struct Config {
    name: String,
    hosts: Vec<String>,
    tags: HashMap<String, String>,
}

Если аллокации правда мешают, между этими крайностями есть Cow — он хранит либо ссылку, либо копию и решает по ситуации. Есть Arc<str> для строк, которые расшариваются между потоками и не меняются. Ни то, ни другое не требует тащить лайфтайм наружу.

Ну а если вы пишете парсер протокола, который обязан выдавать миллион сообщений в секунду, ссылки в структурах на своём месте. Разница в том, что там цену кто-то посчитал.

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

Строка derive, которую копируют не глядя

В каждом втором проекте есть структуры, украшенные полным набором:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize)]

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

Первая — время сборки. Я собрал шестьдесят структур по двенадцать полей в трёх вариантах и померил:

без derive                    25 мс
Debug + Clone                 95 мс
полный набор из восьми       250 мс

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

Умножьте на число структур в живом проекте и на то, сколько раз вы пересобираете за день.

Вторая проблема серьёзнее:

#[derive(Debug)]
struct DbConfig { host: String, port: u16, password: String }

#[derive(Debug)]
struct User { id: u64, email: String, password_hash: String, session_token: String }

Обычный код и обычное логирование:

eprintln!("[error] не удалось подключиться: {:?}", cfg);
eprintln!("[debug] пользователь: {u:?}");

Что уезжает в лог:

[error] не удалось подключиться: DbConfig { host: "db.internal", port: 5432, password: "hunter2" }
[debug] пользователь: User { id: 7, email: "a@b.c", password_hash: "$2b$12$abc", session_token: "eyJhbGci" }

Пароль от базы, хеш пароля пользователя, токен сессии. Всё открытым текстом. Дальше этот лог попадает в общее хранилище, где его читает вся команда, а нередко и подрядчики.

И главное, никто этого специально не делал. Один поставил #[derive(Debug)], потому что без него в отладчике неудобно. Второй через год добавил поле с секретом. Третий залогировал структуру целиком, так быстрее, чем перечислять поля руками.

Исправляем так:

impl fmt::Debug for DbConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("DbConfig")
            .field("host", &self.host)
            .field("port", &self.port)
            .field("password", &"[скрыто]")
            .finish()
    }
}

Пять строк, и секрет никуда не утечёт, даже если кто-то залогирует структуру целиком. Если секретов в проекте много, проще взять secrecy с готовым типом-обёрткой.

По остальным derive критерий тот же: ставьте то, чем пользуетесь. Clone на структуре, которую никто не клонирует, ничего не ломает, но и не даёт. А вот PartialEq на типе с f64 внутри уже не очень. Сравнивать числа с плавающей точкой на равенство почти всегда ошибка, но derive её разрешает, и компилятор больше не ругается.

Newtype на каждое число

Идея, вроде, звучит классно — заворачиваем примитив в свой тип, компилятор перестаёт пускать идентификатор заказа туда, где ждали идентификатор пользователя.

Стоит это мало, что легко проверить. Две функции, одна на голых u64, другая на обёртках, смотрим в промежуточное представление:

sum_nt(i64 noundef %a, i64 noundef %b)
@sum_raw = alias ... @sum_nt

Обёртка испарилась, в сигнатуре обычные целые. Компилятор к тому же схлопнул обе функции в одну и оставил второе имя псевдонимом.

По скорости вопросов нет. Вопросы начинаются, когда правило лепят вообще ко всему:

pub struct UserId(u64);
pub struct OrderId(u64);
pub struct Email(String);
pub struct Username(String);
pub struct PasswordHash(String);
pub struct CreatedAt(DateTime<Utc>);
pub struct RetryCount(u8);
pub struct TimeoutMs(u64);
pub struct Percentage(f64);

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

Дальше на эти типы вешают Deref, чтобы не мучиться:

impl Deref for Username {
    type Target = str;
    fn deref(&self) -> &str { &self.0 }
}

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

newtype нужен только там, где путаница возможна и дорого стоит. Два идентификатора одного типа рядом в одной сигнатуре — да, обязательно. Миллисекунды и секунды в одном проекте — тут вообще без вариантов. А Username(String), который едет из формы в базу и обратно, не защищает ни от чего, его не с чем путать.

И взглянем на этот код:

impl Percentage {
    pub fn new(v: f64) -> Result<Self, RangeError> {
        if (0.0..=100.0).contains(&v) { Ok(Self(v)) } else { Err(RangeError) }
    }
}

Вот это осмысленный newtype. Он несёт гарантию, которую примитив нести не может: если у вас на руках Percentage, значение точно в диапазоне. А если конструктор просто заворачивает и всё, вы добавили себе работы и ничего не выиграли.

Оптимизация до профилировщика

Это я видел чаще всего. Человек прочитал, что стандартный HashMap медленный из-за криптостойкого хеша, и меняет его на FxHashMap по всему проекту. Потом узнаёт про SmallVec и заменяет им все векторы, где элементов обычно мало. Потом везде расставляет #[inline(always)].

Каждый шаг может быть правильным.

Про #[inline(always)] у меня есть отдельная статья, поэтому коротко: он не ускоряет код, а отбирает у LLVM право решать. В половине случаев делает хуже, потому что раздувает секцию кода и выбивает горячий цикл из кэша инструкций.

С хеш-таблицей интереснее, там замена быстрым хешем может быть не оптимизацией, а серьезной проблемой. Стандартный HashMap использует SipHash, который медленнее FxHash.

Но у этого выбора есть причина:

// ключи приходят от клиента — оставляем стандартный
let sessions: HashMap<String, Session> = HashMap::new();

// ключи свои, из кода — можно и быстрый
let type_names: FxHashMap<TypeId, &'static str> = FxHashMap::default();

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

SmallVec устроен похоже. Он держит несколько элементов на стеке и переезжает в кучу, когда их становится больше. Выигрыш есть, когда векторов много и они реально короткие. А если элементов обычно двадцать, а на стеке зарезервировано четыре, вы получили обычный Vec, да еще и плюсом лишнюю ветку на каждой операции. А еще структура с ним внутри стала больше, а значит подорожало всё, что её копирует и передаёт.

Короче, сначала нужно все мерить, потом менять, потом померить снова. И на своих данных, а не на синтетике из README крейта — там условия подобраны так, чтобы разница была видна.

Все три инструмента хорошие.


В итоге

Если собрать всё вместе: сами по себе все эти привычки нормальные, проблема только в том, что их внедряют слишком рано. В итоге платишь за то, что почти наверняка не понадобится: сложнее читать, дольше собирается. Поэтому вместо списка правил лучше спросить себя: а мне это реально нужно сейчас? Если да и ответ конкретный — вторая реализация уже есть, замеры есть — бери, не думай. А если «ну, так принято» — это просто привычка, не более.

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

А у вас как? С чем ловили себя на таких «разумных» практиках? В прошлый раз из ваших комментариев выросла половина этой статьи, так что рассказывайте, почитаю с удовольствием.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться

Комментарии (3)


  1. domix32
    17.09.2026 12:16

    Трейт на случай, если мы поменяем базу

    Начну с самого частого. В проекте один способ ходить в хранилище, и он будет один ещё три года. Но пишут так:

    Странно конечно проблемы собственной архитектуры перекладывать на язык и зачем-то звать это "лучшими" практиками. Кто агитировал за эти практики? Что мешало из транзакции сделать машину состояний - "сделайте ваши ошибки непредставимыми в коде" довольно известная фраза, которую почему-то исключили из лучших практики. Может даже разделить read-only интерфейсы от модифицирующих, чтобы по SOLID было разделение отвественностей.

    В первом варианте Postgres оказывается прямо в сигнатуре абстрактного хранилища. Во втором — downcast с unwrap посреди бизнес-логики. И то и другое не очень.

    Какие альтернативы в других языках, если мы снимает ограничения ржавчины?

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

    вот тут не понял. если оно только читает, то писать оно не может по определению. Непрямой доступ из-за косвенной адресации замедлит исполнение, но это кажется меньшая из проблем. В конце концов переключитесь на обычные КЧ-дерево, если вас смущают боксированные значения.

    Правило такое: трейт стоит заводить, когда у вас уже есть две реализации

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

    Лайфтаймы ради аллокаций, которых никто не считал

    Опять же - кто агитировал за подобный подход? Одно дело писать парсеры, другое читать конфиг. У парсеров появляется ещё и приколы про выравнивание памяти, чтобы лишний раз не оставлять дыры в памяти, но ещё ни разу не встречал, чтобы кто-то заморачивался с чтением конфигураций подобным образом - toml, yaml, json ли. Тут по идее нужен тот же подход, что и с прошлым пунктом - рефакторить только по мере необходимости, когда оно всплывает бутылочным горлышком.


    1. Boneyan
      17.09.2026 12:16

      Не знаю почему у вас такая негативная реакция на статью. По-моему очевидно, что автор не критикует раст, и даже не критикует описанные практики (автор описал где их разумно применять). Критикуется бездумное применение этих паттернов. Мне наоборот понравилось что даётся критика, свежий взгляд всяких устоявшихся вещей (по типу “делаем интерфейс на любой чих”, “слабая связанность” и вот это всё) и всяких новомодных вещей (по типу “ньютайп” паттерна)


  1. v_0ver
    17.09.2026 12:16

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

    Ну так надо ввести алгебру над типами, благо с LLM-кой это не потребует много времени.