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

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

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

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

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

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

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

trait Graph {
    type Node;
    type Edge;

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

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

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

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

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

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

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

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

// функция для вывода всех рёбер графа
fn print_edges<G: Graph>(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<str> {
    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<str> {
    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<i32> = 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<T, E> {
    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<String, io::Error> {
    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<String, io::Error> {
    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<String, io::Error> {
    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<i32, Box<dyn std::error::Error>> {
    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<i32>) -> Option<i32> {
    Some(vec.get(0)?)
}

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

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

Комментарии (6)


  1. PrinceKorwin
    13.08.2024 05:56
    +6

    Не упомянули, но для меня полезным являются ещё trait's From и Into. У Rust нет перегрузки методов/функций и это заставляет плодить методы там, где казалось бы для этого нет особых причин.

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

    Пример:

    pub enum ArgStrings {
        None,
        String(String),
        List(Vec<String>)
    }
    
    impl From<&str> for ArgStrings {
        fn from(value: &str) -> Self {
            ArgStrings::String(value.to_string())
        }
    }
    
    impl From<Vec<&str>> for ArgStrings {
        fn from(value: Vec<&str>) -> Self {
            ArgStrings::List(value.iter().map(|x| x.to_string()).collect())
        }
    }
    
    impl From<Option<()>> for ArgStrings {
        fn from(_value: Option<()>) -> Self {
            ArgStrings::None
        }
    }
    

    Теперь объявляем нашу функцию:

        pub fn has_label<T: Into<ArgStrings>>(mut self, labels: T) -> GraphTraversalSource<'a> {
            let labels: Option<Vec<String>> = match labels.into() {
                ArgStrings::None => None,
                ArgStrings::String(v) => Some(vec![v]),
                ArgStrings::List(v) => Some(v),
            };
            ...
        }
    

    И можем вызывать ее таким образом:

    .has_label(None);
    .has_label("product");
    .has_label(ver!["product", "discount", "bundle"]);
    

    Получилось аккуратненько. Тоже самое, но в другую сторону можно проверить с помощью trait Into.


    1. m03r
      13.08.2024 05:56
      +1

      Если логика для всех трёх случаев имеет мало общего (а, скорее всего, оно так и есть), то можно объявить трейт AsLabelList с единственной функцией has_label реализовать его для всех нужных типов. Тогда можно указать для «единой» функции требование трейта и пробрасывать вызов дальше. Это отлично оптимизируется, а ещё позволяет пользователю реализовывать трейт для своих типов, которые тоже можно будет передать в нашу функцию


    1. redfox0
      13.08.2024 05:56
      +1

      Хитро! Напомню, что в расте нет перегрузки функций (как в Си), так что пришлось бы делать три функции с разными именами (или создавать zero-size структуру и писать полиморфные (?) статические методы).

      Ещё есть забавный и иногда полезный return type polymorphism - в том числе для перегрузки методов с одинаковыми именами, если я не ошибаюсь.


      1. Melirius
        13.08.2024 05:56
        +2

        Побуду занудой: в С перегрузки функций нет, только в С++.


        1. tttinnny
          13.08.2024 05:56

          Побуду еще большим занудой, с gcc магией есть не только перегрузки функций, но даже лямбды)


        1. redfox0
          13.08.2024 05:56

          *"как и в Си"