An overview of functional programming in Rust

Although Rust is not a pure functional programming language, it does have many tools that allow you to apply functional principles.

Rust supports recursionalthough without tail call optimization, which is a departure from some traditional functional languages ​​such as Haskell. However, the language provides powerful abstractions and patterns such as possession And borrowing.

Rust also encourages data immutability by default, which is generally the FP base. Variables in Rust are immutable by default, and the language requires you to explicitly specify them mutif you want to change the value.

Additionally, Rust has support for higher order functions and closures.

Functionality Basics in Rust

In Rust variables are immutable by default:

fn main() {
    let x = 5;
    println!("The value of x is: {x}");
    // x = 6; // вызовет ошибку компиляции, так как x неизменяема
}

To create a mutable variable, use the word mut:

fn main() {
    let mut x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is now: {x}");
}

enum in Rust allows you to define a type that can take one of several possible values. Together with match expressions, it turns out to be a good tool for controlling program flow based on different values enum:

enum Direction {
    Up,
    Down,
    Left,
    Right,
}

fn move_direction(direction: Direction) {
    match direction {
        Direction::Up => println!("Moving up"),
        Direction::Down => println!("Moving down"),
        Direction::Left => println!("Moving left"),
        Direction::Right => println!("Moving right"),
    }
}

Higher order functions take one or more functions as parameters or return another function as a result. Closures in Rust are anonymous functions. which can capture variables from the environment.

  • Fn allows you to borrow data from the enclosing environment in an immutable manner.

  • FnMut used when the closure must modify data by borrowing it in a mutable manner.

  • FnOnce used when a closure grabs data from the environment, moving it into itself. This short circuit can only be called once!

For example, you need to perform an operation on a number using a function passed as an argument:

fn apply<F>(value: i32, f: F) -> i32
where
    F: Fn(i32) -> i32,
{
    f(value)
}

fn main() {
    let double = |x| x * 2;
    let result = apply(5, double);
    println!("Result: {}", result); // выведет: Result: 10
}

apply takes a value and a closure that doubles that value, and then applies that closure to the value.

If you need to change the value captured in the closure, useFnMut:

fn apply_mut<F>(mut value: i32, mut f: F) -> i32
where
    F: FnMut(i32) -> i32,
{
    f(value)
}

fn main() {
    let mut accumulator = 1;
    let multiply = |x| {
        accumulator *= x;
        accumulator
    };
    let result = apply_mut(5, multiply);
    println!("Result: {}", result); // выведет: Result: 5
}

A closure modifies a captured variable accumulatormultiplying it by the passed value.

FnOnce is used when the closure captures a variable by value, which means it can only be called once.

fn apply_once<F>(value: i32, f: F) -> i32
where
    F: FnOnce(i32) -> i32,
{
    f(value)
}

fn main() {
    let add = |x| x + 5;
    let result = apply_once(5, add);
    println!("Result: {}", result); // выведет: Result: 10
}

In this case, the closure simply adds 5 to the passed value, but in theory it could grab and move values ​​from its environment.

Monads

Option<T> used when the value may be missing. It provides two options: Some(T)when the value is present, and Nonewhen there is no value. This allows you to explicitly handle missing value cases, avoiding errors associated with null:

fn find_index(needle: &str, haystack: &[&str]) -> Option<usize> {
    haystack.iter().position(|&s| s == needle)
}

The function searches for a string in an array and returns the index of the found string as Some(usize) or Noneif the string is not found.

To work with Option<T> various methods can be used such as match, if let, unwrap, expect and many others…

Result<T, E> used to handle operations that may fail. It provides two options: Ok(T)when the operation is successful, and Err(E)when the error occurred:

fn divide(numerator: f64, denominator: f64) -> Result<f64, &'static str> {
    if denominator == 0.0 {
        Err("Division by zero")
    } else {
        Ok(numerator / denominator)
    }
}

The function performs division and returns the result as Ok(f64) or error Err(&str)if the denominator is zero.

To work with Result<T, E> There are also many methods and patterns available, including match, unwrap, expect, ? operator and others.

How Option<T>so Result<T, E> support monadic operations such as map And and_thenwhich allow you to convert values ​​within these types without retrieving them:

let maybe_number: Option<i32> = Some(5);
let maybe_number_plus_one = maybe_number.map(|n| n + 1); // Результат: Some(6)

Usage example and_then With Result<T, E> to perform sequential operations, each of which may return an error:

fn try_parse_and_divide(text: &str, divider: f64) -> Result<f64, &'static str> {
    let parsed: f64 = text.parse().map_err(|_| "Parse error")?;
    divide(parsed, divider)
}

map, fold, and filter

map applies the function to each element of the iterable, creating a new collection with the results. In Rust map is an iterator method that takes a closure applied to each element:

fn main() {
    let nums = vec![1, 2, 3, 4, 5];
    let squares: Vec<_> = nums.iter().map(|&x| x * x).collect();
    println!("Squares: {:?}", squares);
    // выведет: Squares: [1, 4, 9, 16, 25]
}

Here we use map to create a new vector containing the squares of the numbers of the original vector.

Fold takes an initial value and a closure that “folds” or “reduces” elements of the collection into a single value, applying a closure sequentially to each element and accumulating the result:

fn main() {
    let nums = vec![1, 2, 3, 4, 5];
    let sum: i32 = nums.iter().fold(0, |acc, &x| acc + x);
    println!("Sum: {}", sum);
    // выведет: Sum: 15
}

We use fold to calculate the sum of numbers in a vector, starting from 0.

Filter creates an iterator that selects collection elements that match a given condition:

fn main() {
    let nums = vec![1, 2, 3, 4, 5];
    let even_nums: Vec<_> = nums.into_iter().filter(|x| x % 2 == 0).collect();
    println!("Even numbers: {:?}", even_nums);
    // выведет: Even numbers: [2, 4]
}

They can of course be combined to create complex chains:

fn main() {
    let nums = vec![1, 2, 3, 4, 5];
    let result: i32 = nums
        .into_iter()
        .filter(|x| x % 2 == 0)
        .map(|x| x * x)
        .fold(0, |acc, x| acc + x);
    println!("Sum of squares of even numbers: {}", result);
    // выведет: Sum of squares of even numbers: 20
}

We combine filter, mapAnd fold to calculate the sum of squares of only even numbers from a vector.


And while Rust wasn't built with functional programming in mind, it's surprisingly flexible in adopting that paradigm.

OTUS experts talk more about programming languages ​​in practical online courses. With a complete catalog of courses you can check out the link.

Similar Posts

Leave a Reply

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