Idiomatic Rust code for those who have migrated from other programming languages

Hello dear readers! In my previous article, How to Switch from Java to Rust Easily, I shared with you tips for switching to Rust and reducing the amount of “blood wasted” along the way. But what to do next when you have already switched to Rust, and your code at least compiles and works? Today I want to share with you some ideas on how to write idiomatic code in Rust, especially if you are used to other programming languages.

You need to think and program in the style of Expressions

One of the key features of Rust is the emphasis on the use of expressions. This means that almost everything in Rust is an expression that returns a value. This is important to understand and use in your code.

In other programming languages, we often use statements (statements) that perform some action, but do not return a value. In Rust, we try to avoid statements and write code in such a way that each part returns some value.

Here is an example of how you can write Expressions-style code:

let x = 10;
let y = if x > 5 {
    100
} else {
    50
};

This code is more idiomatic than the following code:

let x = 10;
let y;
if x > 5 {
    y = 100;
} else {
    y = 50;
}

The second code is less idiomatic because it uses assignment operators to dynamically calculate the value of a variable.

Another example of idiomatic Expressions-style code:

fn factorial(n: u32) -> u32 {
    if n == 0 {
        1
    } else {
        n * factorial(n - 1)
    }
}

This code uses recursion to calculate the factorial of a number. Recursion is another way of thinking and programming in the style of Expressions.

It is important to remember that almost everything in Rust can be used as an expression, which makes the code more expressive and compact.

Write iterators, not loops

Another important idiom in Rust is the use of iterators instead of explicit loops. This makes the code cleaner and more functional.

Instead of writing classic loops like for or while, we use iterator methods to process collections of data.

For example, to iterate over a vector, it would be better to write:

let v = vec![1, 2, 3];

for x in v {
  println!("{}", x); 
}

Better yet, use iterator methods:

let v = vec![1, 2, 3]; 

v.iter().for_each(|x| println!("{}", x));

Other useful iterator methods are map, filter, fold, etc. They allow you to write more idiomatic and expressive Rust code.

// Используем итератор для фильтрации элементов в векторе
let filtered_names: Vec<_> = names.iter().filter(|x| x.starts_with("A")).collect();

Builders

Another important aspect of idiomatic Rust code is the use of builders. Builders are functions that take parameter values ​​and return an object with those values.

Builders are useful for creating complex objects that have many parameters. They also help ensure that parameter types and values ​​are consistent.

Here is an example of using the builder to create a car:

struct Car {
    name: String,
    hp: u32,
}

impl Car {
    pub fn new(name: String) -> Self {
        Car {
            name,
            hp: 100,
        }
    }

    pub fn hp(mut self, hp: u32) -> Self {
        self.hp = hp;
        self
    }

    pub fn build(self) -> Car {
        self
    }
}

let car = Car::new("Model T").hp(20).build();

Builders allow you to avoid creating objects in an invalid state. The build() method ensures that Car is fully initialized.

Builders also allow you to customize objects in a more flexible way. We can only call .hp() and .wheels() to create a partially initialized Car.

In general, Rust encourages “correct” problem solving with idioms like impossible states and builders. By learning these idioms, I started writing more idiomatic and safer Rust code.

Panic

A panic is a special type of error that can cause a process to terminate immediately. In Rust, panics are meant to indicate errors that cannot be fixed or worked around.

For example, if you try to access an uninitialized field, Rust will panic. This is because the error cannot be fixed without changing the program.

In other programming languages ​​such as Python and Java, errors are usually handled with exceptions. However, Rust does not use exceptions by default.

This is because exceptions can lead to memory leaks and other problems. In addition, exceptions can be difficult to understand and debug.

Instead of exceptions, Rust uses two types of errors:

  1. Option – Returns a value of type T if available, or None if not available.

  2. Result – returns a value of type T or an error of type E.

Option and Result are used to handle errors in a safer and more transparent way than exceptions.

Let’s look at an example:

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("Division by zero is not allowed!"); // Паника при делении на ноль
    }
    a / b
}

In this example, if b is zero, then the function will panic, indicating a programming error. However, if you can anticipate error situations and want to handle them without terminating the program, it’s better to use Result:

fn divide(a: i32, b: i32) -> Result<i32, &'static str> {
    if b == 0 {
        Err("Division by zero is not allowed!") // Возвращаем ошибку как Result
    } else {
        Ok(a / b)
    }
}

This approach makes your code more predictable and safer.

Generics

Generics are a way of writing code that can work with any type of data. Rust uses generics to create types like Option and Result.

Generalizations can be a powerful tool, but they should be used with care. Complex generics can make code difficult to understand and debug.

Here is an example of a simple generalization:

fn print_value<T>(value: T) {
    println!("{}", value);
}

This function can take any value of type T and print it to the console.

Here is an example of a complex generalization:

fn compare_values<T: PartialEq>(value1: T, value2: T) -> bool {
    value1 == value2
}

This function compares two values ​​of type T that implement the PartialEq interface.

If you’re not sure whether to use a generalization, it’s best to avoid it. In most cases, you can use a more specific type, which will make your code clearer and easier to debug.

Separation of implementations

Generics allow you to write code that can be used with different types of data. This is a very powerful tool, but it can also be misused.

One way to misuse generics is to try to write code that works for all data types. This can lead to code that is difficult to maintain and extend.

The best way to use generics is to separate implementations into separate modules. For example, suppose you have a Point structure that can contain x and y coordinates of any type. You can write a generic Point implementation that will contain generic methods like getX() and getY(). You can then write separate implementations of Point for specific data types such as Point and Point.

Here is a code example demonstrating how it works:

// Общая реализация Point
struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    pub fn get_x(&self) -> &T {
        &self.x
    }

    pub fn get_y(&self) -> &T {
        &self.y
    }
}

// Реализация Point для f32
impl Point<f32> {
    pub fn distance_to(&self, other: &Point<f32>) -> f32 {
        let dx = self.x - other.x;
        let dy = self.y - other.y;

        return (dx * dx + dy * dy).sqrt();
    }
}

// Реализация Point для i32
impl Point<i32> {
    pub fn distance_to(&self, other: &Point<i32>) -> i32 {
        let dx = self.x - other.x;
        let dy = self.y - other.y;

        return (dx * dx + dy * dy).sqrt();
    }
}

This code allows us to write code that works for both floating point and integer coordinates. If we want to add a new Point implementation for a different data type, we just need to add a new module.

Avoid unsafe {}

Rust is known for its focus on security. It provides powerful facilities for working with low-level memory, but sometimes beginners may be tempted to use unsafe {} blocks to bypass the type system and safety. However, there are often safer and faster alternatives that don’t require unsafe.

Let’s look at an example. Suppose we have a vector of numbers and we want to get the sum of all elements. We could do this using unsafe {}:

fn unsafe_sum(numbers: &[i32]) -> i32 {
    let mut sum = 0;
    for i in 0..numbers.len() {
        unsafe {
            sum += *numbers.get_unchecked(i);
        }
    }
    sum
}

But a safer and more idiomatic way to do it is:

fn safe_sum(numbers: &[i32]) -> i32 {
    numbers.iter().sum()
}

In conclusion, moving to Rust can be tricky, especially if you come from imperative languages ​​like Java or Python. But if you take the time to learn the idioms and best practices of Rust, you will soon begin to enjoy writing safe and expressive code in this language.

Remember that Rust is not just a tool, but an entire programming philosophy focused on reliability and performance. The more you think and code in a functional style using expressions, iterators, and generics, the more natural Rust will feel to you.

I hope this article has helped you understand some of the key Rust idioms and how to write more idiomatic code in this wonderful language. Good luck with learning Rust and building reliable and secure applications!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *