Да, да, это мой маленький секрет - я посредственный программист. Определение слова «хакер», с которым я больше всего себя ассоциирую, звучит больше как «человек, который делает мебель топором». Я - именно такой, я пишу простой, прямолинейный, в основном, императивный код, потому что попытки разобраться в сложностях функциональных языков вызывают у меня головную боль.

По этой причине я всегда избегал более академических языков вроде 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 - наконец-то обрели для меня смысл после того, как я прочитал соответствующие главы. Рекомендую!

Комментарии (2)


  1. Dhwtj
    07.10.2025 15:38

    brain surgery for dummies


  1. segment
    07.10.2025 15:38

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