Rust and Immutability
Let's start with syntactic features.
Syntactic features
In Rust, variables are immutable by default. That is, once they are initialized, their value cannot be changed. This is a fundamental aspect of the language that helps prevent many types of errors related to data state. To declare a variable, use the keyword let
:
let x = 5;
// x = 6; // это вызовет ошибку компиляции, так как x неизменяемая
If you need to change the value of a variable, you can use a modifier mut
which explicitly indicates that the variable can be changed:
let mut y = 5;
y = 6; // теперь это корректный код
References in Rust are also immutable by default. That is it is forbidden change the data you are referencing without explicitly stating:
let z = 10;
let r = &z;
// *r = 11; // ошибка, так как r — иммутабельная ссылка
To change data via a link, you need to use a changeable link:
let mut a = 10;
let b = &mut a;
*b = 11; // корректно, так как b — изменяемая ссылка
Data structures in Rust also obey the rules of immutability. If you create an instance of a structure using let
all of its fields will be immutable unless each field is explicitly declared as mut
:
struct Point {
x: i32,
y: i32,
}
let point = Point { x: 0, y: 0 };
// point.x = 5; // ошибка, так как поля структуры неизменяемы
In addition to all this, there are a number of functionalities that facilitate working with immutable data structures. One such feature is the template Creatorwhich allows the structure's data to be modified during its creation, but provides an immutable object as a result:
#[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 неизменяемый после создания
Here Rectangle
is created as resizable to adjust its size, but after calling the method build
it becomes immutable.
Typical immutable structures
Immutable vectors
In Rust, vectors are mutable by default, but you can use a library like im
which provides immutable collections. An example of creating and using an immutable vector:
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);
}
Here updated_vec
is a new vector containing the added elements, while the original vector vec
remains unchanged.
Structural Sharing
Structural sharing allows immutable data structures to share parts of their state with other structures, thereby minimizing the need to copy data. An example can be implemented using the library rpds
which has persistent data structures:
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
uses most of the data from vec
adding only new elements.
Immutable Linked Lists
Immutable linked lists are useful in functional programming. An example of using a persistent linked list:
use im::conslist::ConsList;
fn main() {
let list = ConsList::new();
let list = list.cons(1).cons(2).cons(3);
println!("Persistent list: {:?}", list);
}
Every operation cons
creates a new list that contains the new item along with a reference to the previous list.
Immutable hash maps
Immutable hash maps can be used to store and access data by key:
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);
}
Here map2
adds a new key-value pair, while map
remains unchanged.
Immutable trees
Immutable trees can be used to create complex data structures with search and insert operations:
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);
}
Examples of using
Multithreaded configuration access
Let's develop examples of a system where multiple threads need to access a common configuration without the risk of data races. Immutability is useful here because it ensures that data is not accidentally modified, which we know is very important in a multi-threaded environment.
Let's define an immutable structure AppConfig
containing configuration parameters:
#[derive(Clone, Debug)]
struct UserState {
user_id: u32,
preferences: Vec<String>,
}
Let's create a globally accessible Arc
for this configuration to safely share between threads:
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,
}
}
}
Here, each thread gets secure access to the configuration, which eliminates the possibility of changing it, since the data is protected by immutability and Arc
.
Managing State in a Functional Web Application
The second case is a web application where user state is updated without mutations, using FP concepts to improve state manageability and simplify testing.
Let's define an immutable user state structure:
#[derive(Clone, Debug)]
struct UserState {
user_id: u32,
preferences: Vec<String>,
}
A state update function that returns a new state:
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,
}
}
}
Example in the context of request processing:
fn handle_request(current_state: &UserState) -> UserState {
let updated_state = current_state.add_preference("new_preference".to_string());
updated_state
}
Here every challenge add_preference
creates a new version of the state UserState
.
Useful libraries
im
— is a high-performance library for working with immutable data structures in Rust. It has a full set of persistent data structures: listsvectors, cards And setswhich preserve previous versions of themselves when modified and allow data to be shared between states without having to copy them entirely.
Example of an immutable list:
use im::ConsList;
fn main() {
let list = ConsList::new();
let list = list.cons(1).cons(2).cons(3);
println!("Persistent list: {:?}", list);
}
Create a list using the method cons
which adds an element to the beginning of a list while preserving the previous version of the list. This is great for functional programs where data immutability is important.
rpds
which we used just above, provides a collection of immutable and persistent data structures. The library supports the functional style, offering structures that automatically save the history of changes.
An example of using an immutable dictionary:
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);
}
Here map2
is created on the basis of map
with the addition of a new key-value pair, while the original map
remains unchanged.
Thanks to immutability in Rust, you can manage application state without the complexities associated with mutable data structures.
In conclusion, I would like to invite you to free webinarwhere we will take a detailed look at the differences and features of development in Rust for classic backend and for blockchain systems. Registration is available at the link.