Rust без прикрас: где мы ошибаемся

0666e092ac9c4290170f8c84918de07a.jpg

Привет, исследователи Rust! Сегодня хочу поделиться своим опытом (не всегда радужным) работы с Rust. Да, язык классный, безопасный, быстрый — все мы это знаем. Но, как и в любом инструменте, здесь есть свои подводные камни, на которые я благополучно наступал.

Начнем с первой проблемы — злоупотребление unwrap() и expect().

Злоупотребление unwrap () и expect ()

Да, Rust нас оберегает от многих ошибок благодаря типам Option и Result, и это супер! Но вот эти милые unwrap() и expect() — это как оружие: можно по-умному, а можно себе же в ногу. В большинстве случаев такая привычка возникает, когда мы торопимся. А потом, на продакшене — бах! Программа крашится в самом неожиданном месте, и ты тратишь время на отлавливание этого падучего unwrap().

let data = some_option.unwrap(); // Бум! Если some_option -- None

Если случается None — программа крашится.

Альтернатива? Используем match или хотя бы unwrap_or(), чтобы задать дефолтное значение, если что-то пошло не так. Например:

let value = some_option.unwrap_or("default_value".to_string());

Если хочется гибкости, используем unwrap_or_else():

let value = some_option.unwrap_or_else(|| expensive_fallback_calculation());

Но! unwrap_or и unwrap_or_else следует использовать с осторожностью.

Также подумайте о map и and_then — это ещё более безопасный способ обработать значения, не полагаясь на жесткое развертывание.

Переходим к следующей проблеме.

Игнорирование ошибок с помощью let _ =

Когда работаешь с Result, иногда не хочется обрабатывать ошибки — кажется, что сейчас это не важно. Быстро засовываем результат в let _ =, и вроде как всё ок. Но на самом деле мы теряем важную информацию, а именно: что же пошло не так?

let _ = some_function_that_may_fail();

Это как заклеить чек-энжин в машине изолентой и думать, что всё нормально. Проблема в том, что при отладке не получится увидеть причины ошибки, особенно если произошла цепочка сбоев.

Лучшее решение — использовать if let с логированием ошибки:

if let Err(e) = some_function_that_may_fail() {
    eprintln!("Ошибка: {:?}", e);
}

Или использовать ?, если функция поддерживает Result:

some_function_that_may_fail()?; // компилятор обработает ошибки за вас

Так ошибка станет более прозрачной. А главное, не придется потом гадать, почему что-то пошло не так.

Клонирование всего и вся

Когда хочется быстрого результата, клонирование кажется хорошей идеей. Наивное клонирование часто заканчивается массивными затратами памяти и снижением производительности.

let data = expensive_data.clone(); // А вдруг это был гигантский объект?

Каждое клонирование — это глубокое копирование, которое может существенно замедлить работу. На больших структурах клонирование приводит к дублированию данных в памяти.

Как избежать? Используем Rc (если работа с одним потоком) или Arc (если многопоточный режим). Вместо клонирования будет передана ссылка:

use std::rc::Rc;

let data = Rc::new(expensive_data);
let data_clone = Rc::clone(&data);

Таким образом сократим использование памяти.

Использование &str вместо String (или наоборот)

Кажется, какая разница — &str или String? На самом деле разница колоссальная, особенно когда дело касается времени жизни и владения данными. Использование String там, где можно обойтись &str, приводит к лишнему выделению памяти и копированию.

fn process(data: String) {
    // ...
}

let text = "Hello, world!";
process(text); // Ошибка компиляции

В чём тут загвоздка? &str указывает на данные, которые хранятся где-то ещё (например, строковый литерал), тогда как String — это самостоятельный объект в куче. Если функция требует String, а у нас &str, придётся создать новый String, тратя лишние ресурсы.

Как сделать правильно? Если функция не нуждается в изменении строки, можно принимать &str — так можно позволить функции работать как со строковыми литералами, так и со String.

fn process(data: &str) {
    // ...
}

let text = "Hello, world!";
process(text); // Работает с &str

let string_data = String::from("Owned string");
process(&string_data); // Работает и с String

С &str можно избежать лишних преобразований и копирований, позволяя функции обрабатывать как строковые литералы, так и String.

Бесконечные рекурсии без хвостовой оптимизации

В Rust нет автоматической хвостовой оптимизации, так что написание глубоких рекурсивных функций может легко привести к переполнению стека. Например:

fn recursive_function(n: u32) {
    if n > 0 {
        recursive_function(n - 1);
    }
}

Каждый вызов функции сохраняется в стеке, и если у нас большой n, то стек просто переполняется.

Как это исправить? Используйте цикл или стройте алгоритмы без вложенной рекурсии:

// Альтернативное решение без рекурсии
fn iterative_function(mut n: u32) {
    while n > 0 {
        n -= 1;
    }
}

Отсутствие ограничений в обобщениях

Казалось бы, зачем нам ограничения? Всё ж и так компилируется! Но дело в том, что Rust в таких случаях становится неуловимым. Например:

fn do_something(value: T) {
    value.process();
}

Компилятор тут же начнет ругаться: «Что за process?» Всё потому, что мы не указали, что T должен реализовывать трейт с методом process. Rust строго следит за тем, чтобы каждое обращение к методу было обосновано, и, если мы не добавим ограничение, компилятор не сможет сопоставить метод с типом.

Решение — добавить нужный трейт в ограничения. Например, если есть трейт Processable, который реализует метод process, можно потребовать его реализации для всех типов T, передаваемых в do_something:

trait Processable {
 fn process(&self);
 }

fn do_something(value: T) {
 value.process();
 }

Использование глобальных переменных с static mut

Глобальные переменные в Rust — это всё ещё тема для осторожных. static mut — это неконтролируемый доступ к данным, что несёт риск так называемых гонок данных и нарушает потокобезопасность.

static mut COUNTER: u32 = 0;

Многопоточная работа с такой переменной может привести к трудноуловимым багам.

Как это обойти? Используйте AtomicU32:

use std::sync::atomic::{AtomicU32, Ordering};

static COUNTER: AtomicU32 = AtomicU32::new(0);

COUNTER.fetch_add(1, Ordering::SeqCst);

Такой подход сохраняет данные, позволяет работать в многопоточной среде и убирает проблему с unsafe-кодом.

Трейты и их ограничения

Одно из ограничений Rust — невозможность менять реализацию трейт-методов для конкретных экземпляров структур. В отличие от некоторых других языков, в Rust трейт-реализация применяется к типу целиком, без возможности динамически переопределить поведение для отдельных объектов. То есть один и тот же метод, реализованный через трейт, будет вести себя одинаково для всех экземпляров данного типа.

Пример:

trait Processable {
    fn process(&self);
}

struct Data {
    value: i32,
}

impl Processable for Data {
    fn process(&self) {
        println!("Обработка данных: {}", self.value);
    }
}

let data1 = Data { value: 10 };
let data2 = Data { value: 20 };

data1.process(); // Выведет "Обработка данных: 10"
data2.process(); // Выведет "Обработка данных: 20"

Здесь process() реализован для типа Data и будет одинаково работать для всех экземпляров Data. Нельзя реализовать Processable с изменяемым поведением только для data1, а для data2 сделать что-то другое.

Помимо этого, в Rust отсутствует полноценная поддержка динамического связывания, которую можно найти, например, в языках с наследованием. В Rust трейты, как правило, являются статическими: компилятор решает, какие методы будут вызваны, на этапе компиляции. Однако иногда требуется динамическое разрешение методов в зависимости от типа. Для этого в Rust можно использовать указатели на трейты Box, но это ограничение всё равно заставляет жёстко задавать метод для всего типа.

Пример динамического диспетчера:

rait Processable {
    fn process(&self);
}

struct DataA;
struct DataB;

impl Processable for DataA {
    fn process(&self) {
        println!("DataA обрабатывается");
    }
}

impl Processable for DataB {
    fn process(&self) {
        println!("DataB обрабатывается");
    }
}

fn dynamic_processing(item: Box) {
    item.process();
}

let a = Box::new(DataA);
let b = Box::new(DataB);

dynamic_processing(a); // Выведет "DataA обрабатывается"
dynamic_processing(b); // Выведет "DataB обрабатывается"

Box позволяет работать с трейтом как с объектом, но только в рамках одного набора методов, реализованных для разных типов.

Помимо этого, когда мы создаём универсальную функцию с трейтом, компилятор требует, чтобы каждый тип, передаваемый в такую функцию, полностью соответствовал требованиям трейта. Это не всегда удобно, особенно если трейт не содержит нужного метода или если требуется обработка, которая отсутствует в текущей реализации.

Допустим, мы пишем функцию, которая должна работать с любыми типами, реализующими Processable. Однако в реализации трейта Processable может отсутствовать метод process, что вынуждает нас искать обходные пути.

trait Processable {
    fn process(&self);
}

fn do_something(value: T) {
    value.process();
}

В этом примере do_something ожидает, что T реализует Processable, и требует наличия метода process. Если необходимо расширить функциональность для некоторых типов, придётся либо изменять сам трейт (что может нарушить контракты), либо вводить дополнительные обобщённые параметры и трейт-ограничения.

Заключение

Ну что ж, если вас не испугали ни мьютексы, ни атомики, поздравляю — вы официально прошли посвящение в конкурентный Rust. Теперь закрепим успех.

Во-первых, нужно завести дружбу с Clippy — мудрое решение. Этот линтер безжалостно укажет на сомнительные ходы. Также не забываем и о регулярных тестах, желательно с CI/CD.

© Habrahabr.ru