Привет, Хабр!
В первой части мы разобрали never-тип !, макрос matches!, std::hint::black_box, прозрачные обёртки с repr(transparent) и transmute_copy. Если не читали — загляните, там фундаментальные штуки. Сегодня продолжаем: ещё шесть возможностей.
Вы аллоцируете строку, которую тут же выбрасываете
Представьте функцию, которая получает путь к файлу как &str, нормализует разделители под текущую платформу и возвращает результат. В девяноста процентах случаев путь уже правильный, делать то ничего не нужно, просто верни обратно то, что пришло. Но в оставшихся десяти процентах нужно заменить пару символов, и тут начинается классическая неловкость.
Если принять String — аллоцируем всегда, даже когда данные и без того правильные. Если принять &str и вернуть &str — что возвращать, когда нужно модифицировать? Ссылку на временный String компилятор не пропустит.
Многие в этом месте меняют возвращаемый тип на String и смиряются с тем, что теперь аллоцируют всегда. Или заводят Cow вручную, не зная, что он уже есть в стандартной библиотеке.
std::borrow::Cow<'a, B> — это перечисление с двумя вариантами: Borrowed(&'a B), когда данные чужие и мы их просто держим по ссылке, и Owned(<B as ToOwned>::Owned), когда мы владеем собственной копией. Для строк — &str или String в одном типе. Ключевой метод здесь to_mut(): он клонирует данные ровно один раз, только если внутри сейчас Borrowed, и переходит в Owned. Если уже Owned — просто отдаёт изменяемую ссылку и ничего лишнего не делает.
use std::borrow::Cow; fn normalize_separators(path: &str) -> Cow<str> { if path.contains('\\') { Cow::Owned(path.replace('\\', "/")) } else { Cow::Borrowed(path) // нулевых копий, нулевых аллокаций } } fn main() { let unix_path = "home/user/docs"; let win_path = "home\\user\\docs"; // Первый вызов: просто ссылка на оригинал, никакой памяти не выделяется let result1 = normalize_separators(unix_path); // Второй вызов: создаётся новая String только здесь let result2 = normalize_separators(win_path); // Работаем с обоими одинаково — через Deref в &str println!("{} {}", result1, result2); }
Вызывающий код работает с Cow<str> через Deref как с обычным &str — разница снаружи прозрачна. Если где-то нужна именно String, достаточно вызвать .into_owned(): он либо вернёт уже существующий String, либо создаст новый, но никогда не создаст без причины.
Cow работает не только со строками — это часто упускают. Cow<'_, [u8]> — это &[u8] или Vec<u8>, Cow<'_, Path> — это &Path или PathBuf. Любой тип, реализующий ToOwned, подходит.
Есть один тонкий момент с сигнатурами. Когда пишут fn f(s: Cow<str>), вызывающий вынужден оборачивать вручную: f(Cow::Borrowed("hello")) — некрасиво. Правильнее:
fn process<'a>(input: impl Into<Cow<'a, str>>) -> Cow<'a, str> { let s = input.into(); // если нужна модификация — вызываем to_mut() и получаем &mut String // если нет — s выходит как Borrowed, без аллокации s } // Теперь вызывать можно как угодно: process("hello"); // &str process(String::from("hi")); // String
Про Cow знают многие, но используют редко, на самом деле это шикарный инструмент.
Паника показывает вашу строку кода, хотя виноват тот, кто вызвал
Пишешь утилитарную функцию с предусловием, допустим тот же индекс не должен выходить за пределы массива, или строка не должна быть пустой. Нарушение сигнализируется паникой.
Коллега вызывает функцию с неправильными данными, тест падает, в стектрейсе написано что-то вроде utils.rs:47. Он идёт в utils.rs, начинает разбираться, почему сломалась ваша функция, хотя сломал её он сам.
#[track_caller] это решит. Атрибут добавляет функции неявный параметр — местоположение вызывающей стороны, и делает его доступным через std::panic::Location::caller(). Паника внутри такой функции показывает файл и строку того, кто её вызвал, а не то место, где написан assert! или panic!.
#[track_caller] fn get_item(slice: &[i32], index: usize) -> i32 { if index >= slice.len() { panic!("индекс {} вне границ (длина: {})", index, slice.len()); } slice[index] } fn main() { let data = vec![1, 2, 3]; get_item(&data, 10); // паника укажет на эту строку в main.rs }
Так устроены стандартные unwrap() и expect(), они помечены #[track_caller], поэтому при падении мы видим место вызова, а не кишки стандартной библиотеки.
Можно получить локацию не только для паники, но и для любой другой логики, например, для трассировки:
#[track_caller] fn traced_insert(map: &mut std::collections::HashMap<String, i32>, key: &str, val: i32) { let loc = std::panic::Location::caller(); eprintln!("[debug] insert({:?}, {}) из {}:{}", key, val, loc.file(), loc.line()); map.insert(key.to_owned(), val); }
Тут тоже не без нюансов. Если написать метод с #[track_caller] в трейте и вызвать его через dyn Trait, Location::caller() укажет не туда. Динамическая диспетчеризация по природе своей не передаёт информацию о вызывающей стороне, виртуальный вызов не знает, откуда он был инициирован.
Вы возвращаете bool из обходчика, хотя на самом деле хотите вернуть значение
Обход дерева, рекурсивный поиск по вложенной структуре, итерация с возможностью досрочного выхода, все эти задачи встречаются постоянно. Самый базовый паттерн: функция-обходчик принимает замыкание, которое возвращает bool. true — продолжаем, false — останавливаемся.
Проблема с bool в том, что он ничего не несёт. Почему остановились? Нашли нужный элемент? Встретили ошибку? Достигли лимита?
Вся семантика теряется. Кто-то улучшает это через Option ,замыкание возвращает Option<T>, None значит «продолжай». Уже лучше, но ограниченно, ведь как передать промежуточное накопленное состояние? Как вернуть одновременно «нашли» и «сколько прошли до этого»?
std::ops::ControlFlow<B, C> стабилизировали в Rust 1.55. Перечисление с двумя вариантами: Continue(C) несёт промежуточное состояние и говорит «смотрим дальше», Break(B) несёт итоговый результат и говорит «стоп». Типы B и C полностью независимы.
В стандартной библиотеке он работает через Iterator::try_fold и Iterator::try_for_each, которые понимают ControlFlow напрямую и останавливаются при первом Break:
use std::ops::ControlFlow; // Ищем первую пару соседних элементов, дающих нужную сумму. // Возвращаем и индекс, и саму сумму — без повторного обхода. fn first_adjacent_with_sum(nums: &[i32], target: i32) -> Option<(usize, i32)> { let result = nums.windows(2).enumerate().try_fold((), |_, (i, w)| { let sum = w[0] + w[1]; if sum == target { ControlFlow::Break((i, sum)) } else { ControlFlow::Continue(()) } }); match result { ControlFlow::Break(found) => Some(found), ControlFlow::Continue(_) => None, } }
Без ControlFlow пришлось бы либо делать два прохода, сначала position, потом подсчёт суммы по найденному индексу, либо тащить изменяемую переменную снаружи итератора, что ломает всю цепочку. Здесь один обход, один результат и ноль лишних аллокаций.
А самая сочная ценность открывается, когда пишешь собственный API обхода. Сравним две сигнатуры:
// Как делают часто — молчаливый договор про true/false fn visit_nodes<F: FnMut(&Node) -> bool>(&self, visitor: F); // Как лучше — контракт вшит в типы fn visit_nodes<R, F>(&self, visitor: F) -> ControlFlow<R> where F: FnMut(&Node) -> ControlFlow<R>;
Вторая сигнатура самодокументирована: Break(value) явно несёт найденное значение, Continue(()) явно означает «идём дальше». Возвращаемый ControlFlow<R> тоже информативен: вызывающий знает, завершился ли обход досрочно или дошёл до конца.
Rust запускает деструктор именно в тот момент, когда C уже владеет памятью
С FFI-кодом эта проблема возникает быстро и весьма неочевидно. Создаёшь Rust-объект, передаёшь сырой указатель в C-библиотеку. C берёт владение, сохраняет указатель во внутренней структуре и сам освободит его позже. Всё выглядит как будто бы ок, пока не вспоминаешь, что Rust-переменная при выходе из области видимости запустит деструктор автоматически и освободит ту же самую память. После этого у C на руках висячий указатель, и при следующем обращении получаем неопределённое поведение.
std::mem::ManuallyDrop<T> подавляет запуск деструктора, не откладывает, а именно подавляет. Обёртка не реализует Drop, поэтому при выходе из области видимости компилятор просто не генерирует код освобождения. Если не вызвать ManuallyDrop::drop() явно, ресурс утечёт навсегда. Короче говоря вы явно берёте на себя ответственность за время жизни значения.
Типичная FFI-связка с правильным управлением временем жизни:
use std::mem::ManuallyDrop; // C вызывает create_context(), получает указатель и владеет им #[no_mangle] pub extern "C" fn create_context() -> *mut MyContext { let ctx = Box::new(MyContext::new()); Box::into_raw(ctx) // Box::into_raw забирает владение у Box — деструктор не запустится } // C вызывает destroy_context(), когда больше не нужен объект #[no_mangle] pub extern "C" fn destroy_context(ptr: *mut MyContext) { if !ptr.is_null() { // Box::from_raw возвращает владение Rust-у — теперь Box уничтожит объект unsafe { drop(Box::from_raw(ptr)) }; } }
ManuallyDrop нужен, когда нельзя обойтись Box::into_raw, например, при работе с кастомными аллокаторами или при размещении объекта в уже выделенной памяти. Стандартная библиотека использует его внутри MaybeUninit<T>: неинициализированная память не должна вызывать деструкторы для полей, которые ещё не существуют, и именно ManuallyDrop это гарантирует.
Есть и весьма необычное применение за пределами FFI: реализация аренных аллокаторов, где объекты уничтожаются разом в конце арены, а не по одному, и поэтому индивидуальные деструкторы запускать или не нужно, или нужно строго вручную. ManuallyDrop::into_inner() — безопасный метод без unsafe, потребляет обёртку и возвращает T со стандартным жизненным циклом: используем его, когда хочется вернуть значение обратно под управление Rust.
Паника через FFI-границу
Если Rust-функция, вызываемая из C, паникует внутри — паника начинает разворачивать стек, а Rust ничего не знает о стековых фреймах C. Формально это неопределённое поведение. На практике получаем падение процесса в случайном месте, либо тихую порча стека. В последнем случае везёт меньше всего.
std::panic::catch_unwind ловит панику и возвращает Result: Ok(T) если функция выполнилась нормально, Err(Box<dyn Any + Send>) если была паника, где внутри Box лежит то, что передали в panic!(), обычно там строка с сообщением.
use std::panic; #[no_mangle] pub extern "C" fn safe_divide(a: i32, b: i32) -> i32 { let result = panic::catch_unwind(|| { if b == 0 { panic!("деление на ноль"); } a / b }); match result { Ok(value) => value, Err(err) => { // Пытаемся достать сообщение из паники для логирования let msg = err .downcast_ref::<&str>() .copied() .or_else(|| err.downcast_ref::<String>().map(|s| s.as_str())) .unwrap_or("неизвестная паника"); eprintln!("Rust: поймали панику — {}", msg); i32::MIN // sentinel-значение, понятное C-стороне } } }
Змыкание должно быть UnwindSafe. Это трейт-маркер, который Rust проверяет статически. Если внутри замыкания есть изменяемые ссылки на внешние данные, то компилятор выдаст ошибку, потому что после пойманной паники эти данные могут оказаться в инвалидном состоянии: что-то записали в середине, не дописали до конца, инвариант нарушен. Обойти проверку можно через AssertUnwindSafe, но это уже ваша ответстенность: вы утверждаете, что инварианты будут восстановлены вручную.
use std::panic::{self, AssertUnwindSafe}; let mut shared_state = SomeState::new(); let result = panic::catch_unwind(AssertUnwindSafe(|| { // Работаем с &mut shared_state. // Если запаникуем — сами отвечаем за корректность shared_state после. shared_state.do_something_risky(); }));
catch_unwind не ловит сигналы (SIGSEGV, SIGABRT) и не ловит process::abort().
Это правильно. За пределами FFI catch_unwind используют в тест-раннерах: стандартный #[test] работает через него, чтобы один упавший тест не убивал весь процесс.
Вы сравниваете варианты перечисления и каждый раз теряете значение внутри
Есть перечисление с данными в вариантах:
enum Message { Move { x: i32, y: i32 }, Write(String), Resize(u32, u32), Quit, }
Задача: проверить, что два значения у нас одного варианта, не сравнивая сами данные внутри.
Например, оба Write, но с разным текстом. Интуитивный путь видится через match с игнорированием содержимого, работает, но как будто перегруженно. С matches! немного лучше, но всё равно нужно перечислять варианты вручную.
std::mem::discriminant возвращает Discriminant<T> — непрозрачное значение, представляющее вариант перечисления без его содержимого. Два дискриминанта равны тогда и только тогда, когда оба значения одного варианта.
use std::mem::discriminant; fn same_kind(a: &Message, b: &Message) -> bool { discriminant(a) == discriminant(b) } let m1 = Message::Write(String::from("привет")); let m2 = Message::Write(String::from("пока")); let m3 = Message::Move { x: 10, y: 20 }; assert!(same_kind(&m1, &m2)); // оба Write — true assert!(!same_kind(&m1, &m3)); // разные варианты — false
Где это вообще используется? Чаще всего при дедупликации событий или уведомлений.
Представим очередь событий UI: если уже есть Resize в очереди и приходит ещё один Resize, то нет смысла обрабатывать оба, актуален только последний. Проверить наличие события того же вида без распаковки содержимого ровно то, что делает discriminant.
use std::mem::discriminant; use std::collections::VecDeque; fn enqueue_deduplicated(queue: &mut VecDeque<Message>, new_event: Message) { // Убираем все события того же вида — они устарели queue.retain(|existing| discriminant(existing) != discriminant(&new_event)); queue.push_back(new_event); }
Для перечислений с явно заданными дискриминантами (enum Status { Active = 1, Inactive = 2 }), тех, что состоят только из вариантов без данных проще привести к целому через as. discriminant нужен именно когда варианты несут данные и сравнивать их не хочется или нельзя.
Вы всё ещё тянете once_cell для ленивой глобальной инициализации
Скомпилированное регулярное выражение, конфигурация, загруженный из файла словарь — всё, что нужно создать один раз при первом обращении и потом просто читать. До Rust 1.70 стандартным решением были крейты once_cell или lazy_static. Оба хорошие, оба широко используются, но по факту это доп зависимости со своими версиями, потенциальными конфликтами и необходимостью объяснять новым людям в команде, почему их нет в стандартной библиотеке.
Начиная с Rust 1.70 в std есть OnceLock, а с 1.80 LazyLock. Для большинства задач внешние зависимости больше не нужны.
OnceLock<T> — потокобезопасная ячейка, в которую можно записать значение ровно один раз. После первой инициализации чтение не требует ни мьютекса, ни атомарной операции, просто чтение указателя, что в горячем пути практически бесплатно:
use std::sync::OnceLock; static CONFIG: OnceLock<AppConfig> = OnceLock::new(); fn get_config() -> &'static AppConfig { CONFIG.get_or_init(|| { AppConfig::load_from_env() .expect("не удалось загрузить конфигурацию") }) }
LazyLock<T> — то же самое, но замыкание привязывается прямо при объявлении и выполняется автоматически при первом разыменовании. Для глобальных констант времени выполнения это удобнее:
use std::sync::LazyLock; use regex::Regex; static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap() }); fn is_valid_email(s: &str) -> bool { EMAIL_RE.is_match(s) // Regex создаётся один раз при первом вызове }
Разница между ними очень большая. OnceLock гибче, его можно заполнить из разных мест программы, проверить через get() без инициализации, а в тестах удобно подменять значение через set(). LazyLock уже для глобальных значений, где инициализатор известен заранее и единственен.
С тестовой изоляцией есть тонкость: если тест заполняет OnceLock, значение остаётся там до конца процесса, то между тестами глобальное состояние не сбрасывается. Тесты выполняются в одном процессе, и второй тест, который попытается заполнить тот же OnceLock, получит старое значение. Для тестируемых конфигураций лучше передавать зависимости явно или использовать thread_local!. OnceLock хорош именно для по-настоящему неизменяемых глобалей.
К тому же get_or_init не поддерживает рекурсивную инициализацию. Если внутри замыкания обратиться к тому же OnceLock то получим панику.
Для однопоточного кода без требования Sync есть несинхронизированные аналоги: std::cell::OnceCell и std::cell::LazyCell, стабилизированные в тех же версиях. Они легче по накладным расходам и хорошо подходят для ленивой инициализации полей внутри структур данных.
Люблю Rust.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

