Да, да, это мой маленький секрет - я посредственный программист. Определение слова «хакер», с которым я больше всего себя ассоциирую, звучит больше как «человек, который делает мебель топором». Я - именно такой, я пишу простой, прямолинейный, в основном, императивный код, потому что попытки разобраться в сложностях функциональных языков вызывают у меня головную боль.
По этой причине я всегда избегал более академических языков вроде OCaml, Haskell, Clojure и тому подобных. Я знаю, что это хорошие языки - люди намного умнее меня строят с их помощью потрясающие вещи, - но к тому моменту, когда я слышу слово «эндофунктор», я теряю всё внимание (и большую часть интереса к вопросу). Мои любимые языки - те, что требуют меньше интеллектуальных усилий: C, PHP, Python и Ruby.
Так что довольно занятно, что я с большим рвением принялся за Rust. Это, безусловно, самый сложный язык, с которым я чувствую себя хотя бы отдалённо комфортно «в бою». Отчасти потому, что я собрал набор принципов, позволяющих почти полностью избегать ссор с ужасающим механизмом контроля заимствования, временем жизни и прочими тёмными, пугающими уголками Rust. А ещё потому, что Rust помогает мне писать лучшее ПО, и я это чувствую (почти) всё время.
В духе помощи моим товарищам-посредственным программистам, которые пытаются освоить Rust, ниже я представляю принципы, которые я собрал на данный момент. Поехали!
Ни занимаем, ни кредитуем
Если вы немного знакомы с Rust, то вы, вероятно, слышали о страшном borrow checker. Это механизм времени компиляции, который следит, чтобы две части кода не пытались одновременно модифицировать одни и те же данные или использовать значение, когда его уже нельзя использовать.
Хотя семантика заимствований в Rust позволяет достичь высокой производительности без ущерба безопасности, но для нас, посредственных программистов, всё это быстро становится очень сложным. Поэтому в тот момент, когда компилятор начинает говорить о «явных временах жизни» (explicit lifetimes), я просто сдаюсь и использую значения с владельцем (owned vaules).
Это не значит, что я никогда ничего не заимствую. Напротив, есть несколько ситуаций, которые я знаю как «безопасные для заимствования» (о них позже). Но каждый раз, когда я не уверен, как всё будет, я сразу выбираю владение значением.
Например: если мне нужно хранить какой-то текст в структуре или enum, я сразу помещаю его в String. Я не собираюсь думать о временах жизни и &'a str - это я оставлю для более умных людей. Аналогично: если нужен список чего-то - это всегда Vec<T>; никаких &'b [T] в моих структурах, спасибо.
Атака клонов
Следуя принципу из предыдущего параграфа, я также перестал бояться .clone(). Я разбрасываю их по коду, как семечки подсолнуха по весеннему полю. Жизнь слишком коротка, чтобы тратить её на то, чтобы понять, кто у кого что заимствует, если я могу просто дать каждому его собственную вещь.
Да, в книгах по Rust предупреждают, что клонирование может быть дорогим. И это правда: создание копий структур потребляет процессорное время и память. Но почти всегда это не имеет большого значения. Тактовые частоты высоки и растут каждый год, а оперативная память относительно дешева. Зато умственные усилия посредственного программиста дороги и не должны расходоваться на преждевременную оптимизацию.
К тому же, если вы пришли из какого-либо другого современного языка, Rust уже даёт вам настолько больше производительности, что вы, скорее всего, всё равно выигрываете, даже если клонируете всё подряд.
Если же каким-то чудом мой код станет настолько популярным, что «стоимость» этих ненужных клонов станет проблемой, будет разумнее нанять кого-то умнее меня, чтобы он превратил программу в шедевр эффективности без единого копирования.
А пока… я говорю: клонируй рано и клонируй часто!
Макросы derive - могущественная магия
Если начать клонировать всё подряд, довольно быстро вы столкнётесь с такой ошибкой:
error[E0599]: no method named `clone` found for struct `Foo` in the current scope
Это потому, что не всё можно клонировать. А если вы хотите, чтобы ваш тип можно было клонировать, то метод придётся реализовать… ну, почти.
Одна из вещей, которые я нахожу совершенно потрясающими в Rust - это derive-макросы.
Они позволяют добавить маленький маркер к структуре или enum, и компилятор сам напишет за вас кучу кода!
Clone - один из так называемых выводимых трейтов, так что достаточно добавить #[derive(Clone)] к вашей структуре, и - вуаля! - можно клонировать сколько душе угодно.
Но есть и другие полезные derive-трейты, и у меня есть набор, который я добавляю практически ко всем моим структурам:
#[derive(Clone, Debug, Default)]
struct Foo {
// ...
}
Каждый раз, когда я пишу определение структуры или enum, эта строка #[derive(Clone, Debug, Default)] идёт у меня наверху.
Debug позволяет вывести «отладочное» представление структуры, будь то через макрос
dbg!(), или с помощью{:?}вformat!()(и везде, где принимается формат-строка). Возможность спросить у программы «а что это у меня тут вообще?» полезна настолько часто, что отсутствиеDebug- это как программировать с одной рукой, привязанной к креслу.Default позволяет создать «пустой» экземпляр структуры, где все поля будут инициализированы своими дефолтными значениями. Это работает, если все поля тоже реализуют
Default. К счастью, многие стандартные типы это умеют, так что редко встретишь структуру, у которой нельзя сгенерироватьDefault-ом.
Для enum это тоже не проблема - достаточно указать один из вариантов как "по умолчанию":
#[derive(Clone, Debug, Default)]
enum Bar {
Something(String),
SomethingElse(i32),
#[default] // <== пакость предотвращена
Nothing,
}
Заимствование — это нормально (иногда)
Ранее я сказал, что обычно предпочитаю владение значениями, но есть несколько ситуаций, когда я точно знаю, что можно одолжить данные, не вызывая гнева богов borrow checker’а. В таких случаях я спокойно это делаю.
Первая ситуация - когда я передаю значение в функцию, которой нужно просто взглянуть на данные, чтобы принять решение. Например, если я хочу проверить, есть ли в Vec<u32> чётные числа, я мог бы передать Vec напрямую:
fn main() {
let numbers = vec![0u32, 1, 2, 3, 4, 5];
if has_evens(numbers) {
println!("EVENS!");
}
}
fn has_evens(numbers: Vec<u32>) -> bool {
numbers.iter().any(|n| n % 2 == 0)
}
Однако это становится проблемой, если я собираюсь использовать numbers позже:
fn main() {
let numbers = vec![0u32, 1, 2, 3, 4, 5];
if has_evens(numbers) {
println!("EVENS!");
}
// Компилятор жалуется: "value borrowed here after move"
println!("Sum: {}", numbers.iter().sum::<u32>());
}
fn has_evens(numbers: Vec<u32>) -> bool {
numbers.iter().any(|n| n % 2 == 0)
}
Компилятор великодушно предложит использовать мой старый приём - .clone(), чтобы исправить это. Но я знаю, что borrow checker не будет против, если я одолжу Vec<u32> как срез &[u32]:
fn main() {
let numbers = vec![0u32, 1, 2, 3, 4, 5];
if has_evens(&numbers) {
println!("EVENS!");
}
}
fn has_evens(numbers: &[u32]) -> bool {
numbers.iter().any(|n| n % 2 == 0)
}
Моё общее правило: если я могу воспользоваться lifetime elision (научное название для «компилятор сам разберётся»), то всё, скорее всего, нормально. Говоря проще: пока компилятор не требует от меня писать 'a где-то в коде - я в безопасности. А вот как только он начинает употреблять слова «explicit lifetime», я тут же всё клонирую подряд и не задумываюсь.
Другой пример безопасного заимствования - это когда я возвращаю значение поля из структуры или enum. В таких случаях я обычно могу вернуть ссылку, зная, что вызывающий код, скорее всего, просто посмотрит на это значение и выбросит его, прежде чем сама структура выйдет из области видимости.
struct Foo {
id: u32,
desc: String,
}
impl Foo {
fn description(&self) -> &str {
&self.desc
}
}
Возвращать ссылку из функции - почти всегда смертный грех для посредственного программиста, но возвращать её из метода структуры - вполне нормально.
А если вдруг вызывающий код действительно хочет, чтобы ссылка жила дольше - он всегда может превратить её в собственное значение с помощью .to_owned().
Избегайте путаницы со строками
В Rust есть несколько разных типов для работы со строками - чаще всего встречаются String и &str. У этого есть хорошие причины, но они начинают усложнять сигнатуры функций в тех случаях, когда нам просто нужен «кусок текста», и не так важны все эти детали реализации.
Например, допустим, у нас есть функция, которая проверяет, чётная ли длина строки. Поскольку мы всего лишь «подсматриваем» в переданное значение, то логично, что функция будет принимать ссылку на строку, &str:
fn is_even_length(s: &str) -> bool {
s.len() % 2 == 0
}
Вроде бы всё работает, пока кто-то не решит проверить форматированную строку:
fn main() {
// Компилятор жалуется: "expected `&str`, found `String`"
if is_even_length(format!("my string is {}", std::env::args().next().unwrap())) {
println!("Even length string");
}
}
Проблема в том, что format! возвращает обладаемую строку String, а не строковую ссылку &str. Конечно, легко превратить String в &str, просто добавив & перед ней. Но как посредственные программисты мы не можем помнить, какой именно тип строк ожидают все наши функции, и постоянно исправлять код после жалоб компилятора - это утомительно.
Обратная ситуация тоже иногда случается: функция ожидает String, а у нас есть &str (например, строковый литерал "Hello, world!"). В этом случае приходится использовать один из множества способов превращения ссылки в строку (.to_string(), .to_owned(), String::from() и ещё парочку, про которые я забыл). Всё это быстро становится уродливым.
По этим причинам я никогда не принимаю String или &str в аргументах напрямую. Вместо этого я использую силу трейтов, чтобы позволить вызывающему коду передавать всё, что уже является строкой или может быть ею преобразовано.
Например, в тех местах, где я бы по привычке использовал &str, сейчас я пишу так:
fn is_even_length(s: impl AsRef<str>) -> bool {
s.as_ref().len() % 2 == 0
}
Да, приходится добавить вызов .as_ref(), но зато теперь можно вызывать функцию и с String, и с &str.
А если мне нужна именно String (например, чтобы забрать владение строкой в структуру), я использую impl Into<String>:
struct Foo {
id: u32,
desc: String,
}
impl Foo {
fn new(id: u32, desc: impl Into<String>) -> Self {
Self { id, desc: desc.into() }
}
}
Теперь я могу писать и Foo::new(1, "this is a thing"), и Foo::new(2, format!("This is a thing named {name}")), не заботясь о том, какой именно у меня тип строки.
Всегда имейте Enum для ошибок
Механизм обработки ошибок в Rust (Result повсюду), вместе с приятным синтаксическим сахаром (например, оператор ?), - это удивительно эргономичный способ работать с ошибками. Чтобы облегчить жизнь посредственным программистам, я рекомендую начинать каждый проект с перечисления ошибок (Error enum), которое выводит трейты из thiserror::Error, и использовать его во всех функциях и методах, возвращающих Result.
Как именно структурировать свой тип ошибок - вопрос не всегда однозначный. Но обычно я создаю отдельный вариант enum для каждого типа ошибки, который должен иметь своё описание. С помощью thiserror легко прикрутить такие описания прямо к вариантам:
#[derive(Clone, Debug, thiserror::Error)]
enum Error {
#[error("{0} caught fire")]
Combustion(String),
#[error("{0} exploded")]
Explosion(String),
}
Я также реализую для такого enum функции-конструкторы. Это позволяет использовать трюк с Into<String>, а ещё удобно, когда приходится конструировать ошибки в .map_err() (об этом чуть позже). Например, для приведённого выше Error я бы написал:
impl Error {
fn combustion(desc: impl Into<String>) -> Self {
Self::Combustion(desc.into())
}
fn explosion(desc: impl Into<String>) -> Self {
Self::Explosion(desc.into())
}
}
Да, это немного скучный шаблонный код, но можно использовать крейт thiserror-ext, который предоставляет derive-макрос thiserror_ext::Construct и сделает эту работу за вас. Он тоже умеет применять трюк с Into<String>.
Выкиньте map_err (ну, почти)
Новичок-посредственный программист, только начинающий пробовать Rust, может написать код работы с файлами примерно так:
fn read_u32_from_file(name: impl AsRef<str>) -> Result<u32, Error> {
let mut f = File::open(name.as_ref())
.map_err(|e| Error::FileOpenError(name.as_ref().to_string(), e))?;
let mut buf = vec![0u8; 30];
f.read(&mut buf)
.map_err(|e| Error::ReadError(e))?;
String::from_utf8(buf)
.map_err(|e| Error::EncodingError(e))?
.parse::<u32>()
.map_err(|e| Error::ParseError(e))
}
Это работает (ну, наверное, я даже не проверял), но map_err занимает больше половины функции. Слишком много шума. К счастью, с помощью трейта From и магии оператора ? можно сделать всё намного чище.
Сначала предположим, что мы уже написали функции-конструкторы ошибок (или позволили thiserror_ext::Construct сделать это за нас). Тогда код открытия файла можно слегка упростить:
fn read_u32_from_file(name: impl AsRef<str>) -> Result<u32, Error> {
let mut f = File::open(name.as_ref())
// убрали .to_string()
.map_err(|e| Error::file_open_error(name.as_ref(), e))?;
let mut buf = vec![0u8; 30];
f.read(&mut buf)
// передаём функцию напрямую
.map_err(Error::read_error)?;
// ...
Обратите внимание на последний вызов: .map_err(Error::read_error). Это трюк с «функцией как замыканием» - просто экономит немного символов. Мы ведь посредственные программисты, но и лениться никто не запрещал.
Дальше - ещё интереснее. Если реализовать From для других ошибок, можно вообще избавиться от явных map_err. Например:
impl From<std::string::FromUtf8Error> for Error {
fn from(e: std::string::FromUtf8Error) -> Self {
Self::EncodingError(e)
}
}
impl From<std::num::ParseIntError> for Error {
fn from(e: std::num::ParseIntError) -> Self {
Self::ParseError(e)
}
}
(Опять же, с thiserror всё это можно сгенерировать автоматически, добавив #[from] к нужным вариантам.)
После этого код превращается в почти полностью «чистый»:
Ok(
String::from_utf8(buf)?
.parse::<u32>()?
)
Оператор ? сам конвертирует ошибки из конкретных типов в наш Error, используя From.
Единственный маленький минус - ? убирает Result, так что нужно обернуть итоговое значение в Ok(...). Но это ничто по сравнению с избавлением от кучи map_err.
Вообще, мой процесс часто выглядит так: я ставлю ? после каждого вызова, который возвращает Result, а когда компилятор жалуется, что он не может сконвертировать какой-то тип ошибки, я просто добавляю новый вариант в Error. Это требует почти нулевых усилий - отличный результат для посредственного программиста.
То, что ты посредственный, не значит, что нельзя стать лучше
В завершение хочу подчеркнуть: посредственность не означает халтуру, и это не значит, что не стоит учиться и постоянно совершенствовать своё мастерство.
Недавно я нашёл одну книгу, которая оказалась для меня очень полезной - Effective Rust Дэвида Драйсдейла. Автор любезно выложил её для бесплатного чтения онлайн, но, конечно, будет признателен, если вы купите бумажную или электронную версию.
Особенность этой книги в том, что она написана очень доступно, даже для нас, посредственных программистов. Разделы изложены так, что они буквально «щёлкают» в голове. Некоторые аспекты Rust, которые я долго не мог понять - такие как времена жизни и borrow checker, а особенно lifetime elision - наконец-то обрели для меня смысл после того, как я прочитал соответствующие главы. Рекомендую!
Комментарии (16)

segment
07.10.2025 15:38Дело привычки конечно, но код на rust читается так себе.

domix32
07.10.2025 15:38Смотря с чем сравнивать и какой код сравнивать. Шаблоны и прочее метапрограмирование нигде не читается хорошо. Линейный код в виде цепочек обычно чище, будь то Java или плюсы или как тут - в Rust.

IUIUIUIUIUIUIUI
07.10.2025 15:38Шаблоны и прочее метапрограмирование
Шаблоны в смысле параметрического полиморфизма отлично читаются в любом ML-подобном языке. В нормальных завтипизированных языках это вообще обычно просто частный случай передачи аргумента в функцию (просто аргумент — тип).
Метапрограммирование — ну, дженерики норм (особенно если навесить pattern synonyms, если вам вдруг однобуквенные конструкторы не оч).

domix32
07.10.2025 15:38Метапрограммирование — ну, дженерики норм
не считая, что оно вообще стало выглядеть как лисп, это ни разу не читаемее.
Метапрограммирование на основе рефлексии в Haskell емнип можно сделать нормальным, но хаскелл среднестатистическому программисту в принципе читать сложнее. Даже условная Scala или Erlang в этом плане попроще будут. Но в среднем без предварительного переобучения в ФП языки довольно сложно, т.к. классические приёмы управления потоком программы растворяются в других концептах.

IUIUIUIUIUIUIUI
07.10.2025 15:38Метапрограммирование на основе рефлексии в Haskell емнип можно сделать нормальным, но хаскелл среднестатистическому программисту в принципе читать сложнее.
Чем сложнее? Как раз минимальный синтаксис, но не слишком минимальный (то есть (не (лисп))).
Даже условная Scala или Erlang в этом плане попроще будут.
С чтением эрланга у меня мало опыта, implicit val а[_] от(скалы): _ => { у[меня] = new глаза[меня](вытекают[меня]) }. ИМХО, конечно.

Dhwtj
07.10.2025 15:38Кстати, не нашёл ни одного полезного случая когда нужны завтипы. Ведь они могут что-то доказать только когда число (длина массива и т.п.) известна на стадии компиляции. А это прям редко, чаще это то что пришло снаружи.

vdudouyt
07.10.2025 15:38Для человека, в целом знакомым с программированием но малознакомым конкретно с Rust - да.
Если же добавить в определение задачи условие о том, что человек должен быть знаком с технологий, код на которой пытается понять то сравнение получится сильно не в пользу того же C++. Особенно с хайповым в последние 10-15 лет template-only подходом и его наигрязнейшими хаками.

Dhwtj
07.10.2025 15:38По мне, так очень хорошо Rust читается.
Главное, что сигнатуры функций не врут и ошибка входит в контракт (сигнатуру). Жаль, IO эффекты не проверяются компилятором на уровне сигнатур, но хорошим тоном весь IO делать асинхронным что видно в сигнатуре.
Мусорить в контрактах (лайфтаймы всякие) не надо без необходимости и они будут читаемы.

greypo
07.10.2025 15:38+1 я бы на месте разработчиков Раста изменил немного синтаксис/стмволы заимствования, вместо &mut , использовать другой символ…

lem0nify
07.10.2025 15:38Конечно, не хочется обижать автора статьи, но первая её часть читается как «Вредные советы» Г. Остера. Когда осознаёшь, что автор всерьёз предлагает клонировать всё подряд, кроме пары базовых случаев, становится страшно.
Это делает Ваши программы на Rust не просто менее производительными, чем они могли быть. Это делает их значительно менее производительными, чем программы на языках со сборщиком мусора. Постоянные и бездумные копирования данных более, чем нивелируют тот бонус производительности, который даёт AOT-компиляция в машинный код. Играть в кошки-мышки с borrow checker'ом постоянно и ненужно: написать мало-мальски сложную программу на одних только заимствованиях едва ли получится, но есть ведь способы успокоить borrow checker, избежав копирования данных: всякие там RefCell и счётчики ссылок.Аргумент про растущие тактовые частоты и дешёвую оперативную память – очень странный. Оптимизация до сих пор имеет место и до сих пор полезна и важна. Другой вопрос, который меня беспокоит: зачем вообще Вы пытаетесь использовать инструмент, которым не только не умеете эффективно пользоваться, но и отказываетесь учиться, опуская руки и прикрываясь за смирением с тем, что Вы посредственный программист? Раз уж Вы так хотите прибедняться и называете себя посредственностью, почему не возьмёте инструмент под стать – что-нибудь попроще, без borrow checker'а, без лайфтаймов, со сборщиком мусора?

alan_dani Автор
07.10.2025 15:38Спасибо за развернутый комментарий! Обратите внимание на теги к статье.

Dhwtj
07.10.2025 15:38Нет. Копировать структуры в раст легко и дешево. Внутри функции компилятор сам выведет без копирования. А параметры в функции лучше передавать по значению, как в Паскале. Потом разберётесь и будете оптимизировать.
Раст полезен не только производительностью. Он помогает доказывать утверждения (бизнес правила) в типах и корректность выражений используя pattern match и конечные автоматы.
Делал 3 пета в этом году. И каждый раз когда выкручиваешь на максимум одно из требований, то лучшим оказывается Раст
Высокая параллельность обработки и вообще high load
Критичность ошибок, необходимость более надёжных средств, чем просто тесты
Жёсткие сроки: на раст больше уйдёт на проектирование (прогнозируемое время), но доказательство корректности / отладка (самое непредсказуемое по времени) будет гораздо короче
Dhwtj
brain surgery for dummies