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

В январе мы разбирали пять привычек, которые звучат разумно, а на деле мешают: дженерики на каждый параметр, 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% при первом пополнении.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.