Полезные фичи в Rust

07fe62e16ad026d1661dd6aa6ca80da6.jpg

Привет, Хабр!

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

Типажи с ассоциированными типами

В Rust типажи — это способ описания общих интерфейсов для различных типов данных. Типажи напоминают интерфейсы в Java или C#.

Ассоциированные типы позволяют типажу использовать типовые параметры, которые будут уточнены в конкретной реализации.

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

Пример типажа с ассоциированным типом:

trait Graph {
    type Node;
    type Edge;

    fn edges(&self, node: &Self::Node) -> Vec;
}

Определили типаж Graph, который содержит два ассоциированных типа: Node и Edge. Эти типы будут уточнены для каждого конкретного типа, реализующего этот типаж.

Реализация графа:

// определяем типаж Graph, который будет использоваться для описания графов
trait Graph {
    type Node;
    type Edge;

    // метод для получения всех рёбер, исходящих из конкретного узла
    fn edges(&self, node: &Self::Node) -> Vec;
}

// реализация типажа Graph для структуры CityGraph
struct CityGraph;

impl Graph for CityGraph {
    type Node = String;
    type Edge = (String, String);

    fn edges(&self, node: &Self::Node) -> Vec {
        // возвращаем список рёбер, исходящих из узла
        vec![
            (node.clone(), "CityA".to_string()),
            (node.clone(), "CityB".to_string()),
        ]
    }
}

// функция для вывода всех рёбер графа
fn print_edges(graph: &G, node: &G::Node) {
    let edges = graph.edges(node);
    for edge in edges {
        println!("{:?}", edge);
    }
}

fn main() {
    let city_graph = CityGraph;
    let city_node = "CityX".to_string();

    print_edges(&city_graph, &city_node);
}

Ассоциированные типы прекрасно подходят для определения интерфейсов работы с различными БД, т.к каждая база может иметь свои уникальные типы соединений и результаты запросов:

// типаж Database для описания интерфейсов работы с БД
trait Database {
    type Connection;
    type QueryResult;

    fn connect(&self) -> Self::Connection;
    fn execute_query(&self, query: &str) -> Self::QueryResult;
}

// реализация типажа Database для SQL-базы данных
struct SqlDatabase;
struct SqlConnection;
struct SqlResult;

impl Database for SqlDatabase {
    type Connection = SqlConnection;
    type QueryResult = SqlResult;

    fn connect(&self) -> Self::Connection {
        // соединение с БД
        SqlConnection
    }

    fn execute_query(&self, query: &str) -> Self::QueryResult {
        // выполнение SQL-запроса
        SqlResult
    }
}

Cow

Концепция Copy On Write, или «копирование при записи», позволяет оптимизировать операции с данными, уменьшая накладные расходы на копирование. Идея проста: данные копируются только тогда, когда они изменяются. Пока данные неизменны, все участники могут безопасно использовать одну и ту же копию.

Представим, что есть большая строка или массив, который хочется передать нескольким функциям. Без CoW пришлось бы каждый раз копировать данные, что могло бы быть накладно по времени и памяти. CoW позволяет избежать этого, создавая копию данных только тогда, когда это действительно нужно — т.е при модификации.

В Rust CoW реализуется через тип std::borrow::Cow. Он позволяет хранить данные как в заимствованном &T, так и в собственном T виде, автоматом создавая копию только при необходимости.

Рассмотрим простой пример, где идет работа со строками. Предположим, есть функция, которая принимает текст и выполняет с ним некоторые операции:

use std::borrow::Cow;

fn process_text(input: &str) -> Cow {
    if input.contains("magic") {
        // если в строке есть слово "magic", создаем копию с заменой
        Cow::Owned(input.replace("magic", "mystery"))
    } else {
        // иначе возвращаем заимствованную строку
        Cow::Borrowed(input)
    }
}

fn main() {
    let text = "This contains magic words.";
    let processed = process_text(text);

    // выводим обработанный текст
    println!("Processed text: {}", processed);
}

В этом примере, если в строке »magic» заменяется на »mystery», создаётся новая копия. В противном случае, строка просто заимствуется без создания новой копии, что экономит память.

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

После внедрения CoW производительность значительно улучшилась:

use std::borrow::Cow;

fn process_message(message: &str) -> Cow {
    if message.contains("urgent") {
        // заменяем "urgent" на "high priority"
        Cow::Owned(message.replace("urgent", "high priority"))
    } else {
        // возвращаем оригинальную строку
        Cow::Borrowed(message)
    }
}

fn handle_incoming_data(data: &str) {
    let processed_data = process_message(data);
    save_to_database(&processed_data);
}

fn save_to_database(data: &str) {
    // логика сохранения в БД
    println!("Saving to database: {}", data);
}

CoW полезен не только для строк, но и для других коллекций. Предположим, есть массив чисел, который иногда нужно модифицировать:

use std::borrow::Cow;

fn process_numbers(numbers: &[i32]) -> Cow<[i32]> {
    if numbers.iter().any(|&n| n % 2 == 0) {
        let modified: Vec = numbers.iter().map(|&n| n * 2).collect();
        Cow::Owned(modified)
    } else {
        Cow::Borrowed(numbers)
    }
}

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

Cow весьма полезен, но если его использовать в ситуациях, где данные всегда изменяются, выгода от него будет минимальна.

Обработка ошибок с помощью ? и Result

В традиционных языках, таких как C++ или Java, ошибки часто обрабатываются через исключения. В Rust же часто используют Result и Option. Эти конструкции позволяют явно указывать и обрабатывать возможные ошибки.

Result — это enum в Rust, которое используется для обозначения успешного или ошибочного результата операции. Оно определяется следующим образом:

enum Result {
    Ok(T),
    Err(E),
}
  • Ok(T): обозначает успешный результат, содержащий значение типа T.

  • Err(E): обозначает ошибочный результат, содержащий значение ошибки типа E.

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

use std::fs::File;
use std::io::{self, Read};

fn read_file(filename: &str) -> Result {
    let mut file = match File::open(filename) {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut contents = String::new();
    match file.read_to_string(&mut contents) {
        Ok(_) => Ok(contents),
        Err(e) => Err(e),
    }
}

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

Вот тут и может помочь оператор ?, который упрощает обработку ошибок, автоматом распространяя ошибку наружу функции, если она возникает. Упростим предыдущий пример с его помощью:

use std::fs::File;
use std::io::{self, Read};

fn read_file(filename: &str) -> Result {
    let mut file = File::open(filename)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

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

Когда ? используется после вызова, возвращающего Result, он делает следующее:

  1. Если результат Ok(T), то выражение продолжает выполнение с извлеченным значением T.

  2. Если результат Err(E), то текущая функция немедленно возвращает Err(E), завершая свое выполнение.

Примеры использования

Допустим, хочется прочитать файл и обработать потенциальные ошибки:

fn read_file_to_string(filename: &str) -> Result {
    let mut file = File::open(filename)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

fn main() {
    match read_file_to_string("example.txt") {
        Ok(contents) => println!("File content: {}", contents),
        Err(e) => println!("Error reading file: {}", e),
    }
}

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

fn process_file(filename: &str) -> Result<(), io::Error> {
    let mut file = File::open(filename)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    println!("File length: {}", contents.len());
    Ok(())
}

Часто возникает необходимость преобразовать одну ошибку в другую. Для этого используется метод map_err:

fn read_number_from_file(filename: &str) -> Result> {
    let contents = read_file_to_string(filename)?;
    let number: i32 = contents.trim().parse().map_err(|e| format!("Parse error: {}", e))?;
    Ok(number)
}

Пару нюансов:

Оператор ? можно использовать только в функциях, которые возвращают Result или Option. Если функция возвращает другой тип, придется использовать обычный match или изменить возвращаемый тип.

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

Оператор ? также работает с Option:

fn get_first_element(vec: Vec) -> Option {
    Some(vec.get(0)?)
}

В заключение приглашаем Rust-разработчиков на открытый урок 14 августа «Backend vs Blockchain на Rust».

На нём мы подробно рассмотрим различия и особенности разработки на Rust для классического backend и для блокчейн-систем. Записаться можно по ссылке.

© Habrahabr.ru