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

Иммутабельность данных в 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<String>,
}

Создадим глобально доступный 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<String>,
}

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

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 и для блокчейн-систем. Регистрация доступна по ссылке.

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


  1. bromzh
    08.08.2024 18:21
    +10

    impl Rectangle {
        fn new() -> Rectangle {
            Rectangle { width: 0, height: 0 }
        }
    
        fn set_width(&mut self, width: u32) -> &mut Rectangle {
            self.width = width;
            self
        }
        
        fn build(self) -> Rectangle {
            self
        }
    }
    

    Жесть, на курсах такой же говнокод учите писать? Самое смешное, что вы даже не проверили код, он не рабочий:

    let rect = Rectangle::new().set_width(10).set_height(20).build();
       |                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ------- value moved due to this method call
       |                |
       |                move occurs because value has type `Rectangle`, which does not implement the `Copy` trait

    Ну и до кучи, чтобы мутировать поля достаточно просто добавить mut, к чему эти пляски с недобилдером были?

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


    1. redfox0
      08.08.2024 18:21
      +1

      Тут скорее хотели показать приватные поля в структуре. Но надо было структуру переместить в другой файл/модуль (ключевое слово mod):

      // сама структура публичная, а поля - приватные
      #[derive(Debug)]
      pub struct Rectangle {
          width: u32,
          height: u32,
      }
      
      impl Rectangle {
          /// Других способов создать объект не существует
          pub fn new() -> Self {
              Self { width: 0, height: 0 }
          }
      }
      


      1. bromzh
        08.08.2024 18:21
        +2

        Если бы хотели - так бы и написали. Тут скорее хотели порекламировать свои курсы, но забыли, что раст они не знают. Как можно воспринимать это всерьёз, если они пишут вот такое:

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


  1. ZyXI
    08.08.2024 18:21
    +5

    Явно видно непонимание иммутабельности. К примеру,

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

    Во‐первых, код не работает. Вы не можете скормить .build() &mut self вместо self, а именно первое возвращает .set_height.
    Во‐вторых, build полностью бесполезна. Иммутабельность относится к конкретному месту размещения, если вы владеете объектом, то вы всегда можете сделать его мутабельным. Реальная иммутабельность достигается инкапсуляцией и удалением из общего доступа методов, которые могут изменить состояние объектов. Соответственно в шаблоне создатель у вас должен был быть тип RectangleBuilder и тип Rectangle, и только первый должен иметь методы set_width.
    Соответственно, говорить «создадим иммутабельную структуру», не объясняя нигде, что она иммутабельная только пока в общем доступе нет методов, её изменяющих, некорректно. Напомню, что вот это вполне себе корректный код:

    let mut rect = Rectangle::new();
    rect.set_width(10);
    let rect = rect.build();
    let rect = rect;  // То же, что и строчка выше.
    // rect.set_width(20);  // Не скомпилируется.
    let mut rect = rect;
    rect.set_width(30);  // А теперь мы опять можем менять rect.
    

    Остальная часть не лучше. Например:

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

    Можете показать, как вы объявляете поля структуры как mut? Только так, чтобы результат компилировался.

    Или «создадим глобально доступный Arc»: «код, в котором Arc нигде не упоминается».

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

    1. Если вы не объявили что‐то, как mut, то взятие исключающей (&mut) ссылки вам недоступно, как и всё, что требует её взятия.

    2. Изменение значения требует такой ссылки (или возможности её взятия) или внутренней мутабельности в каком‐то виде.

    3. Исключающая ссылка гарантирует только, что объект больше никому не доступен. Разделяемая ссылка (&) гарантирует только, что на объект нет исключающих ссылок. Иммутабельность здесь вообще не причём, ссылки исторически назвали неправильно, но большинство типов компилятор позволит менять только по исключающей ссылке.


    1. Cheater
      08.08.2024 18:21
      +4

      // А теперь мы опять можем менять rect.

      В смысле "можем менять rect"?? Вы же в курсе, что let mut rect = rect создаёт новый объект и мувает в него старый, а не снимает с объекта константность? Выведите на печать &rect до и после выражения с let, они будут разными.


      1. ZyXI
        08.08.2024 18:21

        Это с точки зрения семантики языка. С точки зрения программиста это часто один и тот же объект, т.к. нет видимой разницы (разные адреса, если оптимизатор почему‐то не смог убрать перемещение, не считаются). Это особенно верно, если под rect у нас не простая структура данных, а что‐то вроде Box<Rectangle>: кого волнует, где в стеке указатель на Rectangle, если данные всё равно за указателем и никуда не двигаются?


      1. ZyXI
        08.08.2024 18:21

        После некоторого размышления у меня появилось сильное подозрение, что семантика let foo = bar; — это «создать новое место foo и переместить в него объект из bar» (объект один и тот же, но его переместили), а не «создать новый объект foo и переместить в него данные из bar, bar удалить» (есть два разных объекта с непересекающимися временами жизни).
        Я не могу найти конкретных подтверждений в документации (но то, как про перемещения объектов значений рассказывает документация std::pin оставляет ощущение, что я скорее прав), но let foo = foo точно не запускает Drop::drop и при этом никаких конструкторов в Rust нет. Можете обосновать утверждение «let mut rect = rect создаёт новый объект»?


  1. PrinceKorwin
    08.08.2024 18:21

    А зачем fn new() в таком виде если есть трейт Default?


  1. vlad4kr7
    08.08.2024 18:21

    let rect = Rectangle{width: 10, height: 20}; // короче и понятнее 
    let rect = Rectangle{width: 20, ..rect}; // не мутировали, но в тему статье