Pull to refresh

Идиоматический код на Rust для тех, кто перешел с других языков программирования

Level of difficultyMedium
Reading time6 min
Views22K

Привет, дорогие читатели! В предыдущей моей статье "Как легко перейти с Java на Rust" я делился с вами советами по переходу на Rust и уменьшению количества "потерянной крови" на этом пути. Но что делать дальше, когда вы уже перешли на Rust, и ваш код хотя бы компилируется и работает? Сегодня я хочу поделиться с вами некоторыми идеями о том, как писать идиоматический код на Rust, особенно если вы привыкли к другим языкам программирования.

Нужно думать и программировать в стиле Expressions

Одной из ключевых особенностей Rust является акцент на использовании выражений (expressions). Это означает, что практически всё в Rust - это выражение, возвращающее значение. Это важно понимать и использовать в своем коде.

В других языках программирования мы часто используем инструкции (statements), которые выполняют какие-либо действия, но не возвращают значение. В Rust, мы стараемся избегать инструкций и писать код так, чтобы каждая часть возвращала какое-то значение.

Вот пример того, как можно написать код в стиле Expressions:

let x = 10;
let y = if x > 5 {
    100
} else {
    50
};

Этот код более идиоматичен, чем следующий код:

let x = 10;
let y;
if x > 5 {
    y = 100;
} else {
    y = 50;
}

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

Ещё один пример идиоматического кода в стиле Expressions:

fn factorial(n: u32) -> u32 {
    if n == 0 {
        1
    } else {
        n * factorial(n - 1)
    }
}

Этот код использует рекурсию для вычисления факториала числа. Рекурсия — это ещё один способ мышления и программирования в стиле Expressions.

Важно помнить, что практически всё в Rust может быть использовано как выражение, что делает код более выразительным и компактным.

Пишите итераторы, а не циклы

Еще одной важной идиомой в Rust является использование итераторов вместо явных циклов. Это делает код более чистым и функциональным.

Вместо того, чтобы писать классические циклы, такие как for или while, мы используем методы итераторов для обработки коллекций данных.

Например, чтобы перебрать вектор, лучше написать:

let v = vec![1, 2, 3];

for x in v {
  println!("{}", x); 
}

А еще лучше воспользоваться методами итераторов:

let v = vec![1, 2, 3]; 

v.iter().for_each(|x| println!("{}", x));

Другие полезные методы итераторов - map, filter, fold и т.д. Они позволяют писать более идиоматичный и выразительный Rust код.

// Используем итератор для фильтрации элементов в векторе
let filtered_names: Vec<_> = names.iter().filter(|x| x.starts_with("A")).collect();

Строители

Другой важный аспект идиоматического кода Rust - это использование строителей. Строители - это функции, которые принимают значения параметров и возвращают объект с этими значениями.

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

Вот пример использования строителя для создания автомобиля:

struct Car {
    name: String,
    hp: u32,
}

impl Car {
    pub fn new(name: String) -> Self {
        Car {
            name,
            hp: 100,
        }
    }

    pub fn hp(mut self, hp: u32) -> Self {
        self.hp = hp;
        self
    }

    pub fn build(self) -> Car {
        self
    }
}

let car = Car::new("Model T").hp(20).build();

Строители позволяют избежать создания объектов в недопустимом состоянии. Метод build() гарантирует, что Car будет полностью инициализирован.

Строители также позволяют настраивать объекты более гибким образом. Мы можем вызвать только .hp() и .wheels(), чтобы создать частично инициализированный Car.

В целом, Rust поощряет "правильное" решение проблем с помощью идиом вроде невозможных состояний и строителей. Изучая эти идиомы, я начал писать более идиоматичный и безопасный Rust код.

Паники

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

Например, если вы пытаетесь получить доступ к неинициализированному полю, Rust вызовет панику. Это потому, что ошибка не может быть исправлена ​​без изменения программы.

В других языках программирования, таких как Python и Java, ошибки обычно обрабатываются исключениями. Однако в Rust исключения не используются по умолчанию.

Это связано с тем, что исключения могут привести к утечкам памяти и другим проблемам. Кроме того, исключения могут быть трудными для понимания и отладки.

Вместо исключений Rust использует два типа ошибок:

  1. Option - возвращает значение типа T, если оно доступно, или None, если оно недоступно.

  2. Result<T, E> - возвращает значение типа T или ошибку типа E.

Option и Result<T, E> используются для обработки ошибок в более безопасном и прозрачном способе, чем исключения.

Давайте рассмотрим пример:

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("Division by zero is not allowed!"); // Паника при делении на ноль
    }
    a / b
}

В этом примере, если b равно нулю, то функция вызовет панику, указывая на программную ошибку. Однако, если вы можете предвидеть ошибочные ситуации и хотите обработать их без завершения программы, лучше использовать Result:

fn divide(a: i32, b: i32) -> Result<i32, &'static str> {
    if b == 0 {
        Err("Division by zero is not allowed!") // Возвращаем ошибку как Result
    } else {
        Ok(a / b)
    }
}

Такой подход делает ваш код более предсказуемым и безопасным.

Обобщения (generics)

Обобщения - это способ написания кода, который может работать с любым типом данных. В Rust обобщения используются для создания типов, таких как Option и Result<T, E>.

Обобщения могут быть мощным инструментом, но их следует использовать с осторожностью. Сложные обобщения могут сделать код трудным для понимания и отладки.

Вот пример простого обобщения:

fn print_value<T>(value: T) {
    println!("{}", value);
}

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

Вот пример сложного обобщения:

fn compare_values<T: PartialEq>(value1: T, value2: T) -> bool {
    value1 == value2
}

Эта функция сравнивает два значения типа T, которые реализуют интерфейс PartialEq.

Если вы не уверены, стоит ли использовать обобщение, лучше избегать этого. В большинстве случаев вы можете использовать более конкретный тип, который сделает ваш код более понятным и легким для отладки.

Разделение реализаций

Generics позволяют вам писать код, который может использоваться с различными типами данных. Это очень мощный инструмент, но его также можно использовать неправильно.

Один из способов неправильного использования generics - это пытаться написать код, который работает для всех типов данных. Это может привести к коду, который сложно поддерживать и расширять.

Лучший способ использовать generics - это разделить реализации на отдельные модули. Например, предположим, что у вас есть структура Point, которая может содержать координаты x и y любого типа. Вы можете написать общую реализацию Point, которая будет содержать общие методы, такие как getX() и getY(). Затем вы можете написать отдельные реализации Point для конкретных типов данных, таких как Point и Point.

Вот пример кода, демонстрирующий, как это работает:

// Общая реализация Point
struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    pub fn get_x(&self) -> &T {
        &self.x
    }

    pub fn get_y(&self) -> &T {
        &self.y
    }
}

// Реализация Point для f32
impl Point<f32> {
    pub fn distance_to(&self, other: &Point<f32>) -> f32 {
        let dx = self.x - other.x;
        let dy = self.y - other.y;

        return (dx * dx + dy * dy).sqrt();
    }
}

// Реализация Point для i32
impl Point<i32> {
    pub fn distance_to(&self, other: &Point<i32>) -> i32 {
        let dx = self.x - other.x;
        let dy = self.y - other.y;

        return (dx * dx + dy * dy).sqrt();
    }
}

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

Избегать unsafe {}

Rust известен своей фокусировкой на безопасности. Он предоставляет мощные средства для работы с низкоуровневой памятью, но иногда новички могут быть склонны использовать unsafe {} блоки, чтобы обойти систему типов и безопасности. Однако часто существуют более безопасные и быстрые альтернативы, которые не требуют unsafe.

Давайте рассмотрим пример. Предположим, у нас есть вектор чисел, и мы хотим получить сумму всех элементов. Мы могли бы сделать это с использованием unsafe {}:

fn unsafe_sum(numbers: &[i32]) -> i32 {
    let mut sum = 0;
    for i in 0..numbers.len() {
        unsafe {
            sum += *numbers.get_unchecked(i);
        }
    }
    sum
}

Но более безопасный и идиоматический способ сделать это:

fn safe_sum(numbers: &[i32]) -> i32 {
    numbers.iter().sum()
}

В заключение хочу сказать, что переход на Rust может показаться непростым, особенно если вы пришли из императивных языков вроде Java или Python. Но если вы уделите время изучению идиом и лучших практик Rust, то вскоре начнете получать удовольствие от написания безопасного и выразительного кода на этом языке.

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

Я надеюсь, эта статья помогла вам понять некоторые ключевые идиомы Rust и как писать более идиоматичный код на этом замечательном языке. Удачи вам в освоении Rust и создании надежных и безопасных приложений!

Tags:
Hubs:
Total votes 44: ↑30 and ↓14+23
Comments39

Articles