Rust и иммутабельность

8f09095c59fc08cc1d9e2f4f2a79d69b.jpg

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

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

Начнем с синтаксических особенностей.

Синтаксические особенности

В Rust переменные по умолчанию иммутабельны. То есть после их инициализации изменить значение нельзя. Это основной аспект языка, который помогает предотвратить множество видов ошибок, связанных с состоянием данных. Для объявления переменной используется ключевое слово let:

let x = 5;
// x = 6; // это вызовет ошибку компиляции, так как x неизменяемая

Если нужно изменить значение переменной, можно использовать модификатор mut, который явно указывает, что переменная может быть изменена:

let mut y = 5;
y = 6; // теперь это корректный код

Ссылки в Rust также иммутабельны по умолчанию. То есть нельзя изменить данные, на которые ссылаетесь, без явного указания:

let z = 10;
let r = &z;
// *r = 11; // ошибка, так как r — иммутабельная ссылка

Для изменения данных через ссылку нужно использовать изменяемую ссылку:

let mut a = 10;
let b = &mut a;
*b = 11; // корректно, так как b — изменяемая ссылка

Структуры данных в Rust также подчиняются правилам иммутабельности. Если создается экземпляр структуры с помощью let, все его поля будут неизменяемыми, если только каждое поле явно не объявлено как mut:

struct Point {
    x: i32,
    y: i32,
}

let point = Point { x: 0, y: 0 };
// point.x = 5; // ошибка, так как поля структуры неизменяемы

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

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn new() -> Rectangle {
        Rectangle { width: 0, height: 0 }
    }

    fn set_width(&mut self, width: u32) -> &mut Rectangle {
        self.width = width;
        self
    }

    fn set_height(&mut self, height: u32) -> &mut Rectangle {
        self.height = height;
        self
    }

    fn build(self) -> Rectangle {
        self
    }
}

let rect = Rectangle::new().set_width(10).set_height(20).build();
// rect.width = 15; // ошибка, так как rect неизменяемый после создания

Здесь Rectangle создается как изменяемый для настройки его размеров, но после вызова метода build он становится неизменяемым.

Типичные иммутабельные структуры

Иммутабельные векторы

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

use im::vector::Vector;

fn main() {
    let vec = Vector::new();
    let updated_vec = vec.push_back(42);
    println!("Original vector: {:?}", vec);
    println!("Updated vector: {:?}", updated_vec);
}

Здесь updated_vec является новым вектором, содержащим добавленные элементы, в то время как оригинальный вектор vec остается неизменным.

Структурный общий доступ

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

use rpds::Vector;

fn main() {
    let vec = Vector::new().push_back(10).push_back(20);
    let vec2 = vec.push_back(30);
    println!("vec2 shares structure with vec: {:?}", vec2);
}

vec2 использует большую часть данных из vec, добавляя только новые элементы.

Иммутабельные связные списки

Иммутабельные связные списки полезны в функциональном программировании. Пример использования персистентного связного списка:

use im::conslist::ConsList;

fn main() {
    let list = ConsList::new();
    let list = list.cons(1).cons(2).cons(3);
    println!("Persistent list: {:?}", list);
}

Каждая операция cons создает новый список, который содержит новый элемент наряду со ссылкой на предыдущий список.

Иммутабельные хэш-карты

Иммутабельные хэш-карты могут использоваться для хранения и доступа к данным по ключу:

use im::HashMap;

fn main() {
    let mut map = HashMap::new();
    map = map.update("key1", "value1");
    let map2 = map.update("key2", "value2");
    println!("Map1: {:?}", map);
    println!("Map2: {:?}", map2);
}

Здесь map2 добавляет новую пару ключ-значение, при этом map остается неизменной.

Иммутабельные деревья

Иммутабельные деревья можно использовать для создания сложных структур данных с операциями поиска и вставки:

use im::OrdMap;

fn main() {
    let tree = OrdMap::new();
    let tree = tree.update(1, "a").update(2, "b");
    let tree2 = tree.update(3, "c");
    println!("Tree1: {:?}", tree);
    println!("Tree2: {:?}", tree2);
}

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

Многопоточный доступ к конфигурации

Разработаем примеры системы, где множество потоков должны получать доступ к общей конфигурации без риска гонок данных. Иммутабельность здесь полезна тем, что гарантирует, что данные не будут случайно изменены, что, как мы знаем, очень важно в многопоточном окружении.

Определим иммутабельную структуру AppConfig, содержащую конфигурационные параметры:

#[derive(Clone, Debug)]
struct UserState {
    user_id: u32,
    preferences: Vec,
}

Создадим глобально доступный Arc для этой конфигурации, чтобы безопасно делиться между потоками:

impl UserState {
    fn add_preference(&self, preference: String) -> Self {
        let mut new_preferences = self.preferences.clone();
        new_preferences.push(preference);
        UserState {
            user_id: self.user_id,
            preferences: new_preferences,
        }
    }
}

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

Управление состоянием в функциональном веб-приложении

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

Определим иммутабельную структуру состояния пользователя:

#[derive(Clone, Debug)]
struct UserState {
    user_id: u32,
    preferences: Vec,
}

Функция обновления состояния, возвращающая новое состояние:

impl UserState {
    fn add_preference(&self, preference: String) -> Self {
        let mut new_preferences = self.preferences.clone();
        new_preferences.push(preference);
        UserState {
            user_id: self.user_id,
            preferences: new_preferences,
        }
    }
}

Пример в контексте обработки запроса:

fn handle_request(current_state: &UserState) -> UserState {
    let updated_state = current_state.add_preference("new_preference".to_string());
    updated_state
}

Здесь каждый вызов add_preference создаёт новую версию состояния UserState.

Полезные библиотеки

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

Пример иммутабельного списка:

use im::ConsList;

fn main() {
    let list = ConsList::new();
    let list = list.cons(1).cons(2).cons(3);
    println!("Persistent list: {:?}", list);
}

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

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

Пример использования иммутабельного словаря:

use rpds::HashTrieMap;

fn main() {
    let map = HashTrieMap::new();
    let map = map.insert("key1", "value1");
    let map2 = map.insert("key2", "value2");
    println!("Map1: {:?}", map);
    println!("Map2: {:?}", map2);
}

Здесь map2 создается на основе map с добавлением новой пары ключ-значение, при этом оригинальный map остается неизменным.

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

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

© Habrahabr.ru