Idiomatic Rust
and functional programming features
Go to github.com/alphagergedan/getting-rusty-bootcamp

About me

I am a doctoral researcher in scientific machine learning at Chair of scientific computing (Chair 05 at TUM School of CIT).

What is idiomatic coding?

"Idiomatic coding means following the conventions of a given language. It is the most concise, convenient, and common way of accomplishing a task in that language, rather than forcing it to work in a way the author is familiar with from a different language."
— Tim Mansfield

A basic example in JavaScript

if (input !== null && input !== undefined && input !== "") {
    console.log("Input is valid");
}
if (input) {
    console.log("Input is valid");
}

What is idiomatic Rust?

Idiomatic in Rust is:

Today's agenda

What are we learning today? Idiomatic..


  1. Ownership & Borrowing
  2. Enums & Structs
  3. Traits
  4. Error handling
  5. Iterators & Closures

Ownership & Borrowing

Ownership & Borrowing

fn greet(user: String) {
    println!("Hello, {}!", user); // `user` goes out of scope
}
fn farewell(user: String) {
    println!("Bye {}!", user);
}
fn main() {
    let user = String::from("Marty Friedman");
    greet(user); // Passes ownership to greet
    farewell(user); // Error: Use of moved value `user`
}

Reminder: Rust ensures memory safety through its ownership system:

  • Each value in Rust has an owner.
  • There can only be one owner at a time (ownership can be transferred/moved).
  • When the owner goes out of scope, the value will be dropped.

Ownership & Borrowing

fn greet(user: &String) {
    println!("Hello, {}!", user);
}
fn farewell(user: &String) {
    println!("Bye {}!", user);
}
fn main() {
    let user = String::from("Marty Friedman");
    greet(&user); // Immutable borrow
    farewell(&user); // Immutable borrow
}

Reminder: Rules of reference borrowing:

  • At any given time, you can have either one mutable reference or any number of immutable references.
  • References must always be valid (they must not outlive their underlying/pointed value).

Ownership & Borrowing

What if we have a slice instead?
Slices are views into sequence of elements
fn main() {
    let user: &str = "Guthrie Govan";
    greet(&user); // Error: Expected reference `&String`
    farewell(&user);
}
Solution: Prefer borrowed types over borrowing owned type for function arguments to support both.

Ownership & Borrowing

fn greet(user: &str) {
    println!("Hello, {}!", user);
}
fn farewell(user: &str) {
    println!("Bye {}!", user);
}
fn main() {
    let user: &str = "Guthrie Govan";
    greet(user); farewell(user);

    // This works too!
    let user = String::from("Marty Friedman");
    // Note: Use `.as_ref()` to explicitly convert to `&str`
    // Here: Compiler figures it out!
    greet(&user); farewell(&user);
}

Ownership & Borrowing

Another example

fn print_slice(slice: &[i32]) {
    println!("Slice: {:?}", slice);
}

fn main() {
    let arr: [i32; 4] = [1, 2, 3, 4];
    print_slice(&arr);

    // This works too!
    let vec: Vec<i32> = vec![5, 6, 7, 8];
    // Again, compiler figures out `&Vec<i32>` -> `&[i32]`
    print_slice(&vec);
}

Ownership & Borrowing

Use slices for sub-data borrowing

let data: Vec<i32> = vec![0, 1, 2, 3, 4];
let slice: &[i32] = &data[1..4]; // &[1,2,3]
let slice: &[i32] = &data[1..=4]; // &[1,2,3,4]

Prefer immutable references unless mutation is neccessary

let data = {
    let mut data: Vec<i32> = vec![1, 2, 3];
    data.push(4); // Requires mutation
    data
};

// Here `data` is immutable
let sum: i32 = data.iter().sum(); // sum is 10

Ownership & Borrowing

Avoid unneccessary cloning

let s = String::from("hello");
println!("Hello {}", &s); // Borrowing avoids cloning

Concatenate strings with format!

let name: &str = "Jason Becker";
let mut hello_name = String::from("Hello ");
hello_name.push_str(name);
hello_name.push_str('!');

// Better/easier with `format!`
let hello_name: String = format!("Hello {}!", name);

Enums & Structs

Enums & Structs

enum Direction {
    North, South,
    East, West,
}

fn main() {
    let dir = Direction::North;
    match dir {
        Direction::North => println!("Going North"),
        _ => println!("Not going North")
    }
}

Rust enums are:

  • useful for defining a type with a fixed set of variants
  • enums are structs, i.e., they can implement traits and methods
  • enums can carry data
  • perfect for state machines and error handling

Enums & Structs

Enums can carry data

enum Color {
    RGB(u8, u8, u8),
    RGBA(u8, u8, u8, u8),
}

fn main() {
    let red = Color::RGB(255, 0, 0);

    // Error! Use tuple structs or pattern matching!
    println!("Color: RGB({red.0}, {red.1}, {red.2})");
}

Use pattern matching ⇒

Enums & Structs

Accessing fields of enums

enum Color {
    RGB(u8, u8, u8),
    RGBA(u8, u8, u8, u8),
}
fn main() {
    let red = Color::RGB(255, 0, 0);
    match red {
        Color::RGB(r, g, b) => println!(...),
        Color::RGBA(r, g, b, a) => println!(...),
    }

    // To only match `Color::RGB`
    if let Color::RGB(r, g, b) = red {
        println!("Color: RGB({r}, {g}, {b})");
    }
}

Enums can also carry other structs ⇒

Enums & Structs

Example: bevy game engine (bevy-0.15.0)

enum Color {
    Srgba(Srgba),
    LinearRgba(LinearRgba),
    // [...]
}

struct Srgba {
    red: f32,
    green: f32,
    blue: f32,
    alpha: f32,
}

Enums can hold a struct type inside
⇒ makes your code more readable when your struct becomes more complex

Enums & Structs

enum AccountState {
    Active { user: String, balance: u32 },
    Suspended { user: String },
}
What if we want to convert Active variant to Suspended variant if balance is zero?

We can do this without cloning

fn deactivate(accs: &mut AccountState) {
    if let AccountState::Active { user, balance: 0 } = accs {
        // Note: We do not own `user`
        *accs = AccountState::Suspended {
            user: std::mem::take(user)
        };
    }
}

Enums & Structs

The newtype pattern (wrapper around a type)

// Tuple struct
struct Meters(f64); // The newtype pattern

// Type alias
type Meters = f64; // Does not create a custom type

Enums & Structs

Use explicit field names in enums and structs if it makes more sense

struct Point { x: f64, y: f64, }

struct Rectangle {
    top_left: Point,
    bottom_right: Point,
}

enum Message {
    Quit,
    Shift { x: f64, y: f64 },
    ChangeColor(Color),
    Write(String),
    // [...]
}

Enums & Structs

impl Rectangle {
    fn shift(&mut self, dx: f64, dy: f64) {
        self.top_left.x += dx;
        self.top_left.y += dy;
        self.bottom_right.x += dx;
        self.bottom_right.y += dy;
    }
}

fn main() {
    let top_left = Point { x: 0., y: 1. };
    let bottom_right = Point { x: 1., y: 0. };
    let mut rect = Rectangle { top_left, bottom_right };
    let msg = Message::Shift { x: 1., y: 1. };
    if let Message::Shift { x: dx, y: dy } = msg {
        rect.shift(dx, dy);
    }
}

Enums & Structs

Constructor convention with new

impl Rectangle {
    fn new(top_left: Point, bottom_right: Point) -> Self {
        Self { top_left, bottom_right }
    }
}

fn main() {
    let top_left = Point { x: 0., y: 1. };
    let bottom_right = Point { x: 1., y: 0. };
    let rect = Rectangle::new(top_left, bottom_right);
}

Traits

Traits

Traits define shared behavior that types can implement.
Similar to interfaces in other languages. They 'enable' polymorphism in Rust.
trait Area {
    fn area(&self) -> f64;
}

impl Area for Rectangle {
    fn area(&self) -> f64 {
        let width = self.bottom_right.x - self.top_left.x;
        let height = self.top_left.y - self.bottom_right.y;
        width * height
    }
}

// Other structs can implement this trait

Traits

Derive useful traits (derivable traits)

#[derive(Debug, Default, Copy, Clone, PartialEq, PartialOrd)]
struct Point { x: f64, y: f64 }

Traits

Default constructors can be implemented with the Default trait

impl Default for Rectangle {
    fn default() -> Self {
        Self {
            top_left: Point { x: 0., y: 1. },
            bottom_right: Point { x: 1., y: 0. }
        }
    }
}

Traits

Partial initialization with the Default trait

fn main() {
    let rect = Rectangle {
        bottom_right: Point { x: 2., y: 0. },
        ..Default::default()
    };
}

Traits

The code that results from monomorphization is doing static dispatch. Monomorphization: The process of turning generic code into specified code by filling in the concrete types that are used when compiled for runtime efficiency.

⇒ creating a copy of the code for each type it is used with bloats the code, costs compile time and cache usage.


Instead we can use dynamic dispatch via dyn

let readable: &mut dyn std::io::Read = if arg == "-" {
    &mut std::io::stdin() // of `Stdin` type
} else {
    &mut std::fs::File::open(arg)? // of `File` type
};
let mut buffer = String::new();
readable.read_to_string(&mut buffer)?;

The actual type of readable is resolved during runtime.

Rust will figure out which concrete type's read implementation to invoke

Traits

enum Shape {
    Circle { center: Point, radius: f64 },
    Rectangle { top_left: Point, bottom_right: Point }
}
impl Area for Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle { center, radius } => {
                std::f64::consts::PI * radius * radius
            },
            Shape::Rectangle { top_left, bottom_right } => {
                let width = bottom_right.x - top_left.x;
                let height = top_left.y - bottom_right.y;
                width * height
            }
        }
    }
}

Traits

The shape type can be resolved during runtime

struct Rectangle { top_left: Point, bottom_right: Point, }
struct Circle { center: Point, radius: f64, }

impl Area for Rectangle {
    fn area(&self) -> f64 {
        let width = self.bottom_right.x - self.top_left.x;
        let height = self.top_left.y - self.bottom_right.y;
        width * height
    }
}
impl Area for Circle {
    fn area(&self) -> f64 {
        f64::consts::PI * self.radius * self.radius
    }
}
Example: let shapes: Vec<&dyn Area> = vec![&circle, &rect];

Traits

Conversion traits

let name = String::from("ata");        // From<&str>
let name_slice: &str = name.as_ref();  // AsRef<&str>
let name_bytes: &[u8] = name.as_ref(); // AsRef<&[u8]>
println!("{}", name_slice);   // ata
println!("{:?}", name_bytes); // [97, 116, 97]

Traits

Examples from the standard library

Traits

Reminder: Deriving the Debug trait provides printing via {:?}

#[derive(Debug)]
struct Point { x: f64, y: f64 }

fn main() {
    let p = Point { x: 0., y: 1. };
    println!("{:?}", p); // Point { x: 0.0, y: 1.0 }
    println!("{}", p);   // Error: Implement `Display` trait
}

How can we `display` objects in a more human-readable format?


⇒ implement the Display trait

Traits

Implementing Display for Point

impl std::fmt::Display for Point {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>)
        -> std::fmt::Result {
        write!(f, "({},{})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 0., y: 1. };
    println!("{:?}", p); // Point { x: 0.0, y: 1.0 }
    println!("{}", p);   // (0,1)
}

Outlook: See the derive_more crate to workaround boilerplate code.

Traits

Overridable trait implementation

trait Area {
    fn area(&self) -> f64 {
        0.
    }
}

struct Point { x: f64, y: f64, }

impl Area for Point {} // Uses the default implementation

Structs can still implement the trait and override the default implementation (i.e., this does not affect the implementation for the Rectangle and Circle structs).

Traits

Traits as parameters

fn print_area<T: Area>(shape: &T) {
    let area = shape.area();
    println!("Area: {}", area);
}

Prefer the trait bound syntax sugar for fewer parameters

fn print_area(shape: &impl Area) {
    let area = shape.area();
    println!("Area: {}", area);
}

⇒ Similarly you can also return types that implement certain traits as long as you are returning a single type

⇒ For returning possibly different types that implement the same trait you can dynamic dispatch

Traits

For multiple trait bounds use where clauses

fn display_and_print_areas<T, U>(shape1: &T, shape2: &U)
where
    T: Area + std::fmt::Display,
    U: Area + std::fmt::Display,
{
    let area1 = shape1.area();
    let area2 = shape2.area();
    println!("Shape {} has area {}", shape1, area1);
    println!("Shape {} has area {}", shape2, area2);
}

⇒ Trait bound information is separated from the function signature

⇒ The function signature is easier to read!

Error handling

Error handling

In rust we have no undefined, null, exception...


In Rust:

Error handling

Understanding Option<T>

fn find_user(id: u32) -> Option<String> {
    if id == 1 {
        Some(String::from("Alice"))
    } else {
        None
    }
}

Error handling

Handling Option<T>


Error handling

Optional function arguments

fn foo(a: Option<i32>, b: Option<i32>) {
    if let Some(a) = a {
        println!("a {}", a);
    }
    if let Some(b) = b {
        println!("b {}", b);
    }
}

fn main() {
    foo(None, None);
    foo(Some(1), None); // Some(_) is required
}

How to get rid of the Some? It may too verbose for the user.

Error handling

Optional function arguments with generics and trait bounds

fn foo2<T, U>(a: T, b: U)
where
    T: Into<Option<i32>>,
    U: Into<Option<i32>>,
{
    if let Some(a) = a.into() {
        println!("a {}", a);
    }
    if let Some(b) = b.into() {
        println!("b {}", b);
    }
}
fn main() {
    foo(None, None);
    foo(1, None);   // Some(_) is not explicitly required
}

Error handling

Understanding Error<T, E>

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0. {
        Err(String::from("Cannot divide by zero"))
    } else {
        Ok(a / b)
    }
}

Error handling

Handling Error<T, E> (again using utility functions)


Error handling

Depending on The error type E in Result<T, E> you can propagate the error

fn read_user(path: &str) -> Result<String, std::io::Error> {
    let f = std::fs::File::open(path);
    let mut f = match f {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut buffer = String::new();
    match f.read_to_string(&mut buffer) {
        Ok(_) => Ok(buffer),
        Err(e) => Err(e)
    }
}

Error handling

Use the ? operator to propagate the error

more developer friendly than explicitly doing the pattern matching
fn read_user(path: &str) -> Result<String, std::io::Error> {
    let mut f = std::fs::File::open(path)?;

    let mut buffer = String::new();
    f.read_to_string(&mut buffer)?;
    Ok(buffer)
}

⇒ Deal with the error one level above

Error handling

Example: Implementing FromStr for Point struct

impl std::str::FromStr for Point {
    type Err = std::num::ParseFloatError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let coords: Vec<&str> = s
            .trim_matches(|p| p == '(' || p == ')')
            .split(',')
            .collect();

        let x: f64 = coords[0].parse()?;
        let y: f64 = coords[1].parse()?;

        Ok(Self { x, y })
    }
}

FromStr is often used through str's parse method

Error handling

Problem: Reading a file's content and parsing it into a number.

Errors because parse returns Result<_, std::num::ParseIntError>

fn read_number(path: &str) -> Result<i32, std::io::Error> {
    let mut f = std::fs::File::open(path)?;

    let mut buffer = String::new();
    f.read_to_string(&mut buffer)?;

    let parsed_num = buffer.trim().parse::<i32>()?; // Error
    Ok(parsed_num)
}

⇒ Solution: Map different Err types to String

Error handling

Map different Err types to String

fn read_number(path: &str) -> Result<i32, String> {
    let mut f = std::fs::File::open(path)
        .map_err(|e| format!("Failed to open file: {}", e))?;

    let mut buffer = String::new();
    f.read_to_string(&mut buffer)
        .map_err(|e| format!("Failed to read file: {}", e))?;

    let parsed_num = buffer.trim().parse::<i32>()
        .map_err(|e| format!("Failed to parse: {}", e))?;
    Ok(parsed_num)
}
Better: Define custom error types for your application!

Error handling

You can define custom errors using enums, structs, nested types...

Enum example

#[derive(Debug)]
enum FileReadError {
    FileOpen(std::io::Error),
    Parse(std::num::ParseIntError),
}

// To be compatible with the ecosystem
impl std::error::Error for FileReadError { }

Implementing the std::error::Error trait makes your error type idiomatic and compatible with Rust's standard library.

Error handling

Implement Display

impl std::fmt::Display for FileReadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>)
        -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}

Error handling

Implement From<T> for all the wrapped error types

impl From<std::io::Error> for FileReadError {
    fn from(e: std::io::Error) -> Self {
        FileReadError::FileOpen(e)
    }
}

impl From<std::num::ParseIntError> for FileReadError {
    fn from(e: std::num::ParseIntError) -> Self {
        FileReadError::Parse(e)
    }
}

Error handling

Using your custom error type

fn read_number(path: &str) -> Result<i32, FileReadError> {
    let mut f = std::fs::File::open(path)?;

    let mut buffer = String::new();
    f.read_to_string(&mut buffer)?;

    let parsed_num = buffer.trim().parse()?;
    Ok(parsed_num)
}

⇒ Great, but too much boilerplate code we had to go through...

⇒ Use thiserror to skip the headache

Error handling

thiserror provides you useful macros

#[derive(Debug, thiserror::Error)]
enum FileReadError {
    #[error("Failed to open file: {0}")]
    FileError(#[from] std::io::Error),
    #[error("Failed to parse number: {0}")]
    ParseError(#[from] std::num::ParseIntError),
}

thiserror is transparent: It behaves like a handwritten error implementation.

Iterators & Closures

Iterators & Closures

Remember for loop in Rust

for x in 0..4 {     // x in {0, 1, 2, 3}
    println!("{}", x);
}

Range 0..4 are 'iterators'. Iterators provide the .next method

let mut range = 0..4;
loop {
    match range.next() {
        Some(x) => println!("{}", x),
        None => break,
    }
}

⇒ Writing your own iterator involves implementing the Iterator trait

Iterators & Closures

Iterators are abstractions for sequentially accessing elements of a collection.

Iterators & Closures

Implementing an iterator

struct Counter { count: usize }
impl Iterator for Counter {
    type Item = usize;
    fn next(&mut self) -> Option<Self::Item> {
        self.count += 1;
        Some(self.count)
    }
}
let counter = Counter { count: 0 };
let mut it = counter.into_iter();
it.next(); // Some(1)

let counter = Counter { count: 0 };
// Consumes `counter` and calls .next() till None is returned
for c in counter { ... }

Iterators & Closures

Do not use 'C-style' for loop

let nums = vec![1, 2, 3];
for idx in 0..nums.len() {
    println!("{}", nums[idx]); // unnecessary bounds check
}

Using iterators

for num in &nums {
    println!("{}", *num); // no bounds check but still safe
}

Use .enumerate if you need the index

for (idx, num) in nums.iter().enumerate() {
    println!("idx {}: {}", idx, num);
}

Iterators & Closures

Consumers: Functions that consumes an iterator and produces something else
let one_to_hundred: Vec<u8> = (1..=100).collect();
let nums: Vec<i32> = vec![1, 2, 3, 4];
let even: Option<&i32> = nums
    .iter()
    .find(|&x| x % 2 == 0); // 2
let sum = nums
    .iter()
    .fold(0, |acc, &x| acc + x); // 15

And more: reduce, product, min, max, any, all...

Iterators & Closures

Adapters (transformation): Functions that take an iterator and return a new iterator
let squares: Vec<i32> = nums.iter().map(|&x| x*x).collect();
let first_three: Vec<&i32> = nums.iter().take(3).collect();
let evens: Vec<&i32> = nums
    .iter()
    .filter(|&x| x % 2 == 0)
    .collect();

And more: take, skip, peekable...

Iterators & Closures

What are closures?
Functions that capture their environment
let add: &dyn Fn(&i32) -> i32 = &|&x| x + outer_var;

Three types:

  • Fn requires immutable borrow of the environment and can be called multiple times
  • FnMut requires mutable borrow of the environment and can be called multiple times (sequentially)
  • FnOnce requires ownership of the environment and can only be called once because they consume their environment.
let a = 1;
nums
    .iter()
    .for_each(|&x| println!("{}", x + a));

Closures provide concise and inline logic

Iterators & Closures

Examples

// impl Fn
let a = 1;
let add_a = |x: i32| x + a; // add_a(x)
// FnMut
let mut a: u32 = 1;
let mut inc_a = || a += 1; // inc_a()
// FnOnce
let name = String::from("Aylin");
let say_hello = || {
    let s = name;
    println!("Hello, {}", s);
}; // say_hello();

Iterators & Closures

The move keyword

let data = vec![1, 2, 3];
let closure = move || println!("captured {data:?} by value");

// Error
println!("{:?}", data); // data is not available,
                        // it is owned by the closure
Note: move closures may still implement Fn or FnMut

⇒ The traits implemented by a closure type are determined by what the closure does with captured values, not how it captures them

Iterators & Closures

Closures as function arguments

fn apply_closure<F>(f: F, x: i32) -> i32
where
    F: Fn(i32) -> i32,
    {
        f(x)
    }

let double = |x| x * 2;
println!("{}", apply_closure(double, 5)); // 10

Iterators & Closures

Returning closures from functions

fn create_multiplier(factor: i32) -> impl Fn(i32) -> i32 {
    move |x| x * factor
}

let multiply_by_3 = create_multiplier(3);
println!("{}", multiply_by_3(5)); // 15

move is used because the closure outlives the function's scope

Wrap-Up


Practice is essential for fluency in idiomatic Rust

Resources

Next steps