Да, да, это мой маленький секрет - я посредственный программист. Определение слова «хакер», с которым я больше всего себя ассоциирую, звучит больше как «человек, который делает мебель топором». Я - именно такой, я пишу простой, прямолинейный, в основном, императивный код, потому что попытки разобраться в сложностях функциональных языков вызывают у меня головную боль.
По этой причине я всегда избегал более академических языков вроде 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 - наконец-то обрели для меня смысл после того, как я прочитал соответствующие главы. Рекомендую!
Dhwtj
brain surgery for dummies