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

от автора

Привет, исследователи 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<T: Processable>(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, но это ограничение всё равно заставляет жёстко задавать метод для всего типа.

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

trait 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<dyn Processable>) {     item.process(); }  let a = Box::new(DataA); let b = Box::new(DataB);  dynamic_processing(a); // Выведет "DataA обрабатывается" dynamic_processing(b); // Выведет "DataB обрабатывается"

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

Если владение не требуется, можно избежать лишних аллокаций, применив &dyn Trait :

Оптимизированный вариант
trait 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: &dyn Processable) {     item.process(); }  let a = DataA; let b = DataB;  dynamic_processing(&a);  dynamic_processing(&b);

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

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

trait Processable {     fn process(&self); }  fn do_something<T: Processable>(value: T) {     value.process(); }

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


Заключение

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/857168/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *