PunchWike remains an asset to APC, shouldn’t be embarrassed — LawmakerInquirer8 ex-NPA rebels surrender in Northern SamarCNN TürkBORSA NEDEN DÜŞTÜ? BİST 100’de sert düşüş | Borsa İstanbul bugün neden düşüyor, son durum ne?וואלההמשטרה נערכת לדרבי התל אביבי בכדורסל: מאות שוטרים יתפרסו באזור היכל מנורהThe Jerusalem PostJustice Minister allows Shin Bet to decide on security for party leaders after full panel no-showInquirer EntertainmentTeenage fan dies after medical emergency before Stray Kids concert in ArgentinaRTP DesportoLiga Europa. Benfica vence AC Milan de Rúben Amorim por 2-0UOLPeter Max, conhecido pela arte símbolo dos anos 1960, morre aos 88 anosХабрОтказоустойчивость в мультиклауде: архитектура и реальные кейсыObservador DesportoCanadá vai defender aproximação à UE no Parlamento EuropeuThe RegisterJudge orders Microsoft to spill internal docs and scour execs' comms in secondhand licensing caseIl Fatto Quotidiano“Ce l’avete fatta a farmi scendere la lacrimuccia”: Edelfa Chiara Masciotta rompe il silenzio sull’incidente che le ha cambiato la vita con cinque operazioni
The Daily Newsstand · Free, Always
Thursday, September 17, 2026

«Лучшие» практики Rust, которые вас подведут. Часть 2

Translate

В январе мы разбирали пять привычек, которые звучат разумно, а на деле мешают: дженерики на каждый параметр, 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% при первом пополнении.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.