4 most frequently asked questions. Part 1

Obviously due to the many benefits Rust is chosen by more and more companies. In this article, we'll look at five frequently asked interview questions to help you prepare for your Rust interview.

How to manage memory in Rust without a garbage collector?

Rust uses a unique data ownership system.

Ownership system in Rust, this is the base. Every value in Rust has a variable that owns it. There can only be one owner at a time, and when the owner goes out of scope the value will be cleared. This prevents memory leaks because memory is automatically cleared when an object loses its owner.

Rust allows variables occupy data, which is done using links. Links can be immutable or mutable. Immutable references allow you to have multiple references to the same data, but do not allow them to be modified. Mutable references allow data to be modified, but Rust ensures that only one mutable reference can exist at a time.

fn main() {
    let mut x = 5;

    let y = &x; // неизменяемая ссылка
    let z = &mut x; // изменяемая ссылка

    println!("y: {}", y); // выводит: y: 5
    // println!("z: {}", z); // ошибка: не может быть использовано, так как x уже заимствовано как изменяемое

    *z = 7; // изменяем значение x через изменяемую ссылку
    println!("z: {}", z); // выводит: z: 7
    // println!("y: {}", y); // ошибка: не может быть использовано, так как x уже заимствовано как изменяемое
}

Lifetime are annotations that Rust uses to ensure that all loans are valid. The lifetime tells the compiler how long a data reference should remain valid. This avoids problems with dangling pointers, where a pointer or reference points to freed memory.

fn main() {
    let r;
    {
        let x = 5;
        r = &x;
        // println!("r: {}", r); // ошибка: x не живёт достаточно долго
    }

    // println!("r: {}", r); // ошибка: x уже освобожден, и r становится висячей ссылкой
}

// функция, которая принимает две ссылки и возвращает ссылку с длиннейшей жизнью
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() {
        s1
    } else {
        s2
    }
}

fn main2() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is '{}'", result);
    } // string2 выходит из области видимости и освобождается, но string1 все еще валидна
    // println!("The longest string is '{}'", result); // ошибка: string2 выходит из области видимости раньше, чем result
}

Rust uses a strong type system and the concept trait to control the behavior of types in the context of ownership and borrowing. For example, types that implement the trait Copy, can be automatically copied without the need for explicit memory management. In contrast, types that implement the trait Dropare required to define special deletion behavior since they cannot be copied automatically.

Our article will help you understand this issue in more detail – How memory management works in Rust without a garbage collector.

What are macros and how to use them in Rust?

Declarative macros in Rust are defined using macro_rules! and are often described as “macros by example“They allow you to create templates, which can then be used to generate code based on those templates.

Example:

macro_rules! create_struct {
    ($name:ident, $($field_name:ident: $field_type:ty),*) => {
        struct $name {
            $(pub $field_name: $field_type,)*
        }
    };
}

create_struct!(Person, name: String, age: u32);

Macro create_struct defines a structure with the name and fields specified in the macro arguments. This way you can quickly generate new data types with given fields without repeating the same type of structure definition code.

A feature of declarative macros is their ability to work directly with the syntactic constructs of the Rust language, which allows them to influence the structure of the program at the compilation stage. Macros can take various forms of input and generate code based on that input​.

Procedural macros more complex and allow you to perform operations on code, like functions. They process input data in the form of token streams and generate new code that is embedded directly where the macro is called. Procedural macros are divided into three types: derived type macros, attribute macros And function-like macros.

For example, a macro for automatically implementing a trait Debug:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(MyDebug)]
pub fn my_debug_derive(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let gen = quote! {
        impl std::fmt::Debug for #name {
            fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                write!(f, stringify!(#name))
            }
        }
    };
    gen.into()
}

MyDebug – a procedural macro that implements an interface Debug for any structure to which it is applied. Using the library syn And quote for parsing and code generation, respectively.

We also have an article about macros in Rust.

How to handle errors in Rust and what are Option and Result types?

Type Option<T> used when the value may be missing. This type is an enum enumwhich has two options: Some(T)when the value is present, and None, when the value is missing. Let's use it Option when you need to explicitly handle cases where the value may not be set, without using null pointers.

A simple example of a function that searches for a user by name and returns his age if the user is found:

fn find_age_by_name(users: &[(String, u32)], name: &str) -> Option<u32> {
    for (user_name, age) in users {
        if user_name == name {
            return Some(*age);
        }
    }
    None
}

fn main() {
    let users = vec![("Alice".to_string(), 30), ("Bob".to_string(), 22)];
    let age = find_age_by_name(&users, "Alice");
    println!("Age: {:?}", age);
}

If the username is found, the function returns Some(age)otherwise – None.

Here's the guyResult<T, E> used to handle operations that may fail. This type is also an enum and has two variants: Ok(T)indicating the operation was successful, and Err(E)indicating that there is an error.

Usage Result often associated with functions that may throw errors, such as reading a file or making a network request. Rust forces developers to actively decide what to do about a possible bug using constructs match or operator ? to simplify the code.

An example of a function that tries to read a file and returns the contents of the file or an error:

use std::fs::File;
use std::io::{self, Read};

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(content) => println!("File content: {}", content),
        Err(e) => println!("Error reading file: {}", e),
    }
}

Function read_file_to_string uses the operator ? to simplify error handling. If the operation of opening a file or reading from a file fails, the function returns Err.

Sometimes you may need to handle a situation where a function may return either an error or an optional value. Here's an example:

fn find_user(id: u32) -> Result<Option<String>, String> {
    if id == 0 {
        Err("Invalid ID".to_string())
    } else if id == 1 {
        Ok(Some("Alice".to_string()))
    } else {
        Ok(None)
    }
}

fn main() {
    match find_user(1) {
        Ok(Some(user)) => println!("User found: {}", user),
        Ok(None) => println!("No user found"),
        Err(e) => println!("Error: {}", e),
    }
}

find_user can return Resultwhich either reports an error if the ID is invalid or returns Option<String>indicating whether the user was found or not.

How to set up and use cross compilation?

Cross compilation allows you to create executables for one platform while running on another. To simplify the cross-compilation process, use the utility crosswhich makes compilation easier by running Docker containers with the right tools.

Installed cross via Cargo:

cargo install cross

cross works with container engines such as Docker or Podman.

In file Cargo.toml indicates platform-specific dependencies or settings. For example, various libraries or functions that should only compile for certain systems:

[target.'cfg(target_os = "windows")'.dependencies]
winapi = "0.3"

For example, here is a dependency winapi will only be used when compiling for Windows.

The file is then created .cargo/config for additional configuration, such as specifying a specific linker for the target system:

[target.x86_64-pc-windows-gnu]
linker = "gcc"

By the way, this is important when working with C dependencies or when a special configuration for linking is required.

To start compilation, use the command cross build indicating the desired target platform:

cross build --target x86_64-pc-windows-gnu

This will create an executable for Windows, even if you are in a Linux environment.

To make sure everything is ok we use cross:

cross test --target x86_64-pc-windows-gnu

Multithreading: how to manage state?

Mutex Rust ensures that only one thread can access the protected data at any given time. When a thread wants to access data, it must capture or block Mutex. This action prevents other threads from accessing this data until the first thread exits and unlocks Mutex

Mutex example:

use std::sync::Mutex;

let m = Mutex::new(5);

{
    let mut num = m.lock().unwrap();
    *num += 1;
}

println!("m = {:?}", m.lock().unwrap());

Mutex::new used to create a new Mutex that protects the data 5. Mutex is blocked with lockand after changing the data, the lock is automatically released when MutexGuard goes out of scope.

Atomic types provide a way to perform atomic operations without the need for locking. These operations are guaranteed to be atomic at the processor level.

Example of using atomic types:

use std::sync::atomic::{AtomicUsize, Ordering};

let counter = AtomicUsize::new(0);
counter.fetch_add(1, Ordering::SeqCst);
println!("Counter: {}", counter.load(Ordering::SeqCst));

AtomicUsize::new creates an atomic variable. fetch_add atomically increments the value by 1A load used to get the current value.

Also, don't forget about the owning and borrowing system, which helps prevent many common errors in multi-threaded and parallel programs. Ownership ensures that only one thread can own the data and therefore modify it, while borrowing allows threads to safely share data to read.


Also remember that a successful interview is not just about demonstrating technical knowledge, but also an opportunity to demonstrate your analytical skills, problem-solving skills and willingness to learn.

OTUS experts tell more real-life cases as part of practical online courses. For a complete list of courses you can check out the catalog.

Similar Posts

Leave a Reply

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