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


