Rust Tutorial

Saravanan Vijayakumaran

Department of Electrical Engineering, IIT Bombay

February 3, 2024

Hello World

fn main() {
    println!("Hello, world!");
}
  • fn specifies a function
  • println! prints the string with a newline at the end
  • Semicolon ; needed at the end of a statement
  • Compilation: rustc hello.rs

cargo

  • Rust’s build system and package manager

  • Creating a new project: cargo new hello_world

  • Project contents

    hello_world/
    ├── Cargo.toml
    └── src
        └── main.rs
  • Cargo.toml

    [package]
    name = "hello_world"
    version = "0.1.0"
    edition = "2021"
    
    [dependencies]
  • main.rs

    fn main() {
        println!("Hello, world!");
    }

Using cargo to compile and execute

  • Run cargo build in the project directory

    • Output

      $ cargo build
       Compiling hello_world v0.1.0 (/home/sarva/rust/hello_world)
        Finished dev [unoptimized + debuginfo] target(s) in 0.15s
    • Can be shortened to cargo b

    • By default, debug symbols are embedded

  • Use cargo build --release to get faster executable

    • Output

      $ cargo build --release
       Compiling hello_world v0.1.0 (/home/sarva/rust/hello_world)
        Finished release [optimized] target(s) in 0.12s
    • Can be shortened to cargo b -r

  • Execute with cargo run or cargo run -r

Contents of project directory

hello_world/
├── Cargo.lock
├── Cargo.toml
├── src
│   └── main.rs
└── target
    ├── CACHEDIR.TAG
    ├── debug
    │   ├── build
    │   ├── deps
    │   ├── examples
    │   ├── hello_world
    │   ├── hello_world.d
    │   └── incremental
    └── release
        ├── build
        ├── deps
        ├── examples
        ├── hello_world
        ├── hello_world.d
        └── incremental

Multiple programs in a single project

  • Project structure

    multibin/
    ├── Cargo.toml
    └── src
        └── bin
            ├── alpha.rs
            └── beta.rs
  • Run alpha.rs with cargo run --bin alpha

  • Run beta.rs with cargo run --bin beta

Variables

Variable Declaration

  • Rust is a statically typed language

    • Compiler must know the types of all variables at compile time
  • Declaring variables

    • let x: u32 = 5;

    • The type of the variable is specified after the colon :

    • If the type is not specified, Rust will try inferring it

      fn main() {
          let x = 5;
          let y: u32 = 10;
          println!("{}", x+y);
      }
    • The type of x is inferred as u32

Variables are Immutable by Default

  • The following code does not compile

    fn main() {
        let x = 4;
        println!("{x}");
        x = 5;
        println!("{x}");
    }
  • Mutable variables need to be labeled with mut

    fn main() {
        let mut x = 4;
        println!("{x}");
        x = 5;
        println!("{x}");
    }
  • Note: println!("{}", x) and println!("{x}") are equivalent

    • But the latter form does not allow expressions like x+y inside the {}

Data Types

Integer Types

Length Signed Unsigned
8-bit i8 u8
16-bit i16 u16
32-bit i32 u32
64-bit i64 u64
128-bit i128 u128
arch isize usize
  • arch implies architecture dependent; usually 64-bit

  • usize is usually used to indexing arrays or other collections

  • An integer literal of a specific length can be specified by appending the type

    • For example, 5u8 or 25i32

Floating-Point Types

  • f32 and f64 are the single-precision and double-precision floating-point types

  • Operations between different types fail

  • The following code does not compile

    fn main() {
        let x = 5.5f32;
        let y = 4.5f64;
        println!("{}", x+y); // Adding an f32 and an f64
    }
  • The following code works

    fn main() {
        let x = 5.5f32;
        let y = 4.5f64;
        println!("{}", (x as f64) + y);
        println!("{}", x + (y as f32));
    }

Boolean and Character Types

  • bool represents the boolean type which can take values true and false

    fn main() {
        let x = false;
        let y = true;
        println!("{}", x & y);
        println!("{}", x | y);
    }
  • char is a 4-byte type which can represent any Unicode code point

    fn main() {
        let x = 'a';
        let exploding_head = '🤯';
        println!("{x} {exploding_head}");
    }

Compound Types

  • Tuple types are useful for grouping a fixed number of variables with different types

    fn main() {
        let x: (u8, f32, bool) = (38, 5.5, true);
    
        // Note the {:?}
        println!("{:?}", x);
        println!("{} {} {}", x.0, x.1, x.2);
    }
    • Later we will use structures to group disparate types
  • Arrays are useful for grouping a fixed number of variables of the same type

    fn main() {
        let a = [1, 2, 3, 4, 5];
    
        let first = a[0];
        let second = a[1];
        println!("{first} {second}");
    }

Functions

Defining and Calling Functions

  • Function syntax

    fn function_name(parameters) -> return_type {
       // Body
    }
  • Example

    fn main() {
        let x = increment(5);
    
        println!("The value of x is: {x}");
    }
    
    fn increment(x: i32) -> i32 {
        return x + 1;
    }
    • The function can be defined before or after calling location

Returning without return

  • If a function ends with an expression and has no semicolon at the end, the expression value is returned

  • Example

    fn main() {
        let x = increment(5);
    
        println!("The value of x is: {x}");
    }
    
    fn increment(x: i32) -> i32 {
        x + 1
    }
  • An expression that ends with a semicolon evaluates to (), the unit type

Control Flow

if statements

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

Using if in a let statement

  • If the semicolons at the end of the if blocks are omitted, it can be used in a let assignment

    fn main() {
        let condition = true;
        let number = if condition { 5 } else { 6 };
    
        println!("The value of number is: {number}");
    }
  • All branches should return the same type

  • The following code does not compile

    fn main() {
        let condition = true;
        let number = if condition { 5 } else { "six" };
    
        println!("The value of number is: {number}");
    }

while loop

fn main() {
    let mut number = 10;

    while number != 0 {
        println!("{number}");

        number -= 1;
    }

    println!("Lift Off!");
}

for loop

  • Simple for loop

    fn main() {
        for i in 0..4 {
            println!("{i}");
        }
    }
  • Syntax for including upper range limit

    fn main() {
        for i in 0..=4 {
            println!("{i}");
        }
    }
  • Iterating over the elements of a collection

    fn main() {
        let a = [10, 20, 30, 40, 50];
    
        for element in a {
            println!("the value is: {element}");
        }
    }

Fibonacci Example

Fibonacci sequence

  • Sequence of numbers f(n) for n \in \mathbb{N} such that f(n) = \begin{cases} 1 & \text{ if } n=1,2\\ f(n-1)+f(n-2) & \text{ if } n > 2. \end{cases}

  • 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, …

  • First attempt

    fn main() {
        let n = 10;
        println!("{}", fibonacci_rec(n));
    }
    
    fn fibonacci_rec(n: u64) -> u64 {
        if n == 1 || n == 2 {
            1
        } else {
            fibonacci_rec(n-1) + fibonacci_rec(n-2)
        }
    }

Using match instead of if

  • Second attempt

    fn main() {
        let n = 10;
        println!("{}", fibonacci_rec(n));
    }
    
    fn fibonacci_rec(n: u64) -> u64 {
        match n {
            1 | 2 => 1,
            _ => fibonacci_rec(n - 1) + fibonacci_rec(n - 2),
        }
    }
  • Both the if and match implementations are easy to check but repeat computations

Fibonaccci without recursion

fn fibonacci_nonrec(n: u64) -> u64 {
    match n {
        1 | 2 => 1,
        _ => {
            let mut curr = 1;
            let mut prev = 1;
            let mut sum = 0;
            
            for _i in 2..n {
                sum = curr + prev;
                prev = curr;
                curr = sum;
            }
            sum
        }
    }
}
  • Suppose we wanted to test this implementation

Unit Tests

  • Simple tests for checking functionality of small parts of code

  • In Rust, unit tests can be included in the same file

  • Tests are run using cargo test

  • Unit test syntax

    fn main() {
      <snip>
    }
    
    fn fibonacci_nonrec(n: u64) -> u64 {
      <snip>
    }
    
    #[cfg(test)]
    mod fibtests {
        use super::*;
    
        #[test]
        fn test_basecases() {
            assert_eq!(fibonacci_nonrec(1), 1);
            assert_eq!(fibonacci_nonrec(2), 1);
        }
    }

Multiple Unit Tests

  • The same test module can have multiple unit tests

    #[cfg(test)]
    mod fibtests {
        use super::*;
    
        #[test]
        fn test_basecases() {
            assert_eq!(fibonacci_nonrec(1), 1);
            assert_eq!(fibonacci_nonrec(2), 1);
        }
    
        #[test]
        fn test_fib10() {
            assert_eq!(fibonacci_nonrec(10), 55);
        }
    }
  • Tests can be selectively run by specifying their names (fully or partially)

    $ cargo test base --bin fibmultitest
    $ cargo test fib10 --bin fibmultitest

Ownership

Ownership in Rust

  • Rust decides when to free unused memory during program compilation

  • Python and Go make this decision during program execution

  • Ownership rules in Rust (from Rust book Chapter 4)

    • Each value in Rust has an owner
    • There can only be one owner at a time
    • When the owner goes out of scope, the value will be dropped

Ownership is Tricky for Allocated Variables

  • The following program compiles

    fn main() {
        let x1 = 5;
        let x2 = x1;
        println!("{x1} {x2}");
    }
  • The following program also compiles

    fn main() {
        let s1 = "hello";
        let s2 = s1;
        println!("{s1} {s2}");
    }
  • The following program does not compile

    fn main() {
        let s1 = String::from("hello");
        let s2 = s1;
        println!("{s1} {s2}");
    }

The compilation error

$ cargo run --bin string
   Compiling ownership v0.1.0 (/home/sarva/rust/prog/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/bin/string.rs:4:15
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does
              not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |     println!("{s1} {s2}");
  |               ^^^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

Questions

  • What does it mean to implement the Copy trait?

    • Assignment x = y results in x being an exact copy of y
  • Why does String not implement Copy?

    • Because copying is expensive; not done by default
    • Can be explicitly done using let s2 = s1.clone()
  • Why did the following program not give an error?

    fn main() {
        let s1 = "hello";
        let s2 = s1;
        println!("{s1} {s2}");
    }
    • "hello" is a string literal; size is known at compile time
    • String literals are hardcoded into the program binary; no memory allocated during execution

String Assignment Changes Ownership

What does the following code do?

let s1 = String::from("hello");
let s2 = s1;

let s1 = String::from("hello");

Image credit: Rust book Chapter 4

let s2 = s1;

Image credit: Rust book Chapter 4

Avoiding Ownership Errors

  • Cloning the value is one solution

    fn main() {
        let s1 = String::from("hello");
        let s2 = s1.clone();
        println!("{s1} {s2}");
    }
  • Cloning is expensive as it performs a deep copy

  • In many cases, references can be used to avoid ownership errors

Another Ownership Error

  • The following program does not compile

    fn main() {
        let s1 = String::from("hello");
        let len = calculate_length(s1);
        println!("The length of '{}' is {}.", s1, len);
    }
    
    fn calculate_length(s: String) -> usize {
        s.len()
    }
  • The parameter s of the function calculate_length takes ownership of s1

  • s1 cannot be used in the println statement

References

  • References allow access to data values without taking ownership

  • Prefixing a variable with & creates a reference to it

  • The following program works

    fn main() {
        let s1 = String::from("hello");
        let len = calculate_length(&s1);
        println!("The length of '{}' is {}.", s1, len);
    }
    
    fn calculate_length(s: &String) -> usize {
        s.len()
    }
  • Creating a reference is called borrowing

Mutable References

  • Mutable references enable modifications to borrowed values

  • Prefixing a variable with &mut creates a mutable reference to it

  • The following program works

    fn main() {
        let mut s = String::from("hello");
    
        change(&mut s);
    }
    
    fn change(some_string: &mut String) {
        some_string.push_str(", world");
    }

Structs

Structs

  • Structs allow grouping of related data

  • Example

    struct Rectangle {
        width: u32,
        height: u32,
    }
    
    fn main() {
        let rect1 = Rectangle {
            width: 30,
            height: 50,
        };
    
        println!(
            "The area of the rectangle is {} square pixels.",
            rect1.width * rect1.height
        );
    }

Methods

  • Methods are functions which are associated with a struct
  • Rust does not have classes
  • Methods can be used to link data and functions
  • The first argument to a method is always self or &self, which refers to the associated struct

Rectangle struct with methods

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}

Multiple methods in an impl block

  • Multiple methods can be defined in the same impl block

    impl Rectangle {
        fn area(&self) -> u32 {
            self.width * self.height
        }
    
        fn is_square(&self) -> bool {
            self.width == self.height
        }
    }

Multiple impl blocks

  • Multiple impl blocks can define methods of a struct

    impl Rectangle {
        fn area(&self) -> u32 {
            self.width * self.height
        }
    }
    
    impl Rectangle {
        fn is_square(&self) -> bool {
            self.width == self.height
        }
    }

Enums

Enums

  • Enums (aka enumerations) allow defining a type by enumerating its possible variants

  • Example

    enum Degree {
        BTech,
        MTech,
        PhD,
    }

Example Usage of Enums

enum Degree {
    BTech,
    MTech,
    PhD,
}

struct Student {
    name: String,
    program: Degree,
}

fn main() {
    let s = Student {
        name: String::from("John Doe"),
        program: Degree::BTech,
    };

    match s.program {
        Degree::BTech => println!("{} is a UG student", s.name),
        Degree::MTech | Degree::PhD =>
            println!("{} is a PG student", s.name),
    }
}

Enums can hold data

enum CampusAddress {
    Hostel(u8),
    Building(String),
}

struct CampusResident {
    name: String,
    address: CampusAddress,
}

fn main() {
    let c = CampusResident {
        name: String::from("John Doe"),
        address: CampusAddress::Hostel(16),
    };

    match c.address {
        CampusAddress::Hostel(n) =>
            println!("{} lives in hostel {n}", c.name),
        CampusAddress::Building(s) =>
            println!("{} lives in building {s}", c.name),
    }
}

Generic Types

Generics enable code resuse

  • Consider the following structs for a 2D point

    struct IntPoint {
      x: i64,
      y: i64,
    }
    
    struct FloatPoint {
      x: f64,
      y: f64,
    }
  • They can be combined into a generic type

    struct Point<T> {
        x: T,
        y: T,
    }

Methods for generic types

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T>
{
    fn square_distance_from_origin(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}

fn main() {
    let p = Point { x: 1, y: 5 };
    println!("Distance = {}", p.square_distance_from_origin());
}
  • The type T needs to support additions and multiplications

  • The Num trait exactly captures this behavior

    • Implemented in the num-traits crate
  • Add the crate using cargo add num-traits

  • Cargo.toml

    [package]
    name = "generics"
    version = "0.1.0"
    edition = "2021"
    
    [dependencies]
    num-traits = "0.2.17"
use num_traits::Num;

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T>
where
    T: Num,
{
    fn square_distance_from_origin(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}

fn main() {
    let p = Point { x: 1, y: 5 };
    println!("Distance = {}", p.square_distance_from_origin());
}
use num_traits::Num;

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T>
where
    T: Num + Copy,
{
    fn square_distance_from_origin(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}

fn main() {
    let p = Point { x: 1, y: 5 };
    println!("Distance = {}", p.square_distance_from_origin());
}

Option

  • Option is defined in the Rust standard library

    enum Option<T> {
        None,
        Some(T),
    }
  • Rust does not have a null pointer or reference

  • Option captures the notion of variables that are in one of two states: null or not null

Result

  • Result is also defined in the Rust standard library

    enum Result<T, E> {
        Ok(T),
        Err(E),
    }
  • It is generic over two types: T and E

  • Used to model the result of an operation which might fail

    • T is the return value on success
    • E is the return value on error

Result example

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };
}
  • greeting_file_result has type Result<std::fs::File, std::io::Error>

Propagating Errors

Propagating Errors

  • Suppose a function implementation can encounter errors

  • In many cases, it is better to let the function caller handle the error

    • Because the function caller may have more context about how to handle the error
  • This design principle is called propagating the error

Example of Error Propagation

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

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}

A Shortcut for Propagating Errors

  • The ? operator is shorter syntax for error propagation

    use std::fs::File;
    use std::io::{self, Read};
    
    fn read_username_from_file() -> Result<String, io::Error> {
    
        let mut username_file = File::open("hello.txt")?;
    
        let mut username = String::new();
    
        username_file.read_to_string(&mut username)?;
        Ok(username)
    }

Traits

Traits

  • Traits are a way for different types to have the same behavior

  • A trait is a collection of function declarations

  • Example

    pub trait Area {
      fn area(&self) -> f64;
    }

Implementing Traits

pub trait Area {
    fn area(&self) -> f64;
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Area for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

fn main() {
    let r = Rectangle {
        width: 3.0,
        height: 4.0,
    };
    println!("Rectangle has area {}", r.area());
}

Multiple Trait Implementations

pub trait Area {
    fn area(&self) -> f64;
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Area for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

struct Circle {
    radius: f64,
}

impl Area for Circle {
    fn area(&self) -> f64 {
        3.14 * self.radius * self.radius
    }
}

fn main() {
    let r = Rectangle {
        width: 3.0,
        height: 4.0,
    };
    println!("Rectangle has area {}", r.area());

    let c = Circle { radius: 1.0 };
    println!("Circle has area {}", c.area());
}

Vectors

Vectors

  • Vectors are resizable lists of same type objects

  • Vector initialization

    fn main() {
        let mut v: Vec<i32> = Vec::new();
    
        v.push(10);
        v.push(11);
        println!("{:?}", v);
    
        v = vec![4, 5, 6];
        println!("{:?}", v);
    
        v = vec![10; 5];
        println!("{:?}", v);
    }

Accessing vector elements

  • Vector elements can be accessed using square brackets

    let v = vec![4, 5, 6];
    println!("{} {}", v[0], v[1]);
  • Iterating over a vector using a reference

    let v = vec![100, 32, 57];
    for i in &v {
        println!("{i}");
    }
  • Iterating over a vector using a mutable reference

    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }

Many useful methods available

Iterators

Iterators

  • Iterators allow us to perform task on a sequence of items

  • Any object that implements the Iterator trait can have an iterator

    pub trait Iterator {
      type Item;
    
      fn next(&mut self) -> Option<Self::Item>;
      // methods with default implementations elided
    }
  • Vectors have iterators

    fn main() {
        let v = vec![1, 2, 3];
        let v_iter = v.iter();
    
        let s: u32 = v_iter.sum();
        println!("Sum: {}", s);
    }

Example: Course Credits

  • Consider the Course struct

    #[derive(PartialEq)]
    enum CourseType {
        Core,
        Elective,
    }
    
    struct Course {
        credits: u32,
        category: CourseType,
    }
  • Given a vector Vec<Course>, suppose we want to add up the credits in the core courses

Example: Course Credits

fn main() {
    let mut courses: Vec<Course> = vec![];
    for i in 0..10 {
        let c = if i % 3 == 0 {
            Course {
                credits: 8,
                category: CourseType::Core,
            }
        } else {
            Course {
                credits: 6,
                category: CourseType::Elective,
            }
        };
        courses.push(c);
    }

    let core_credits: u32 = courses
        .iter()
        .filter(|c| c.category == CourseType::Core)
        .map(|c| c.credits)
        .sum();
    println!("Core credits = {core_credits}");
}

Example: Counting Identical Components

  • Suppose we want to count the number of identical components in two vectors

    let v1 = vec![15, 23, 44, 19, 56, 83, 33, 19, 76, 10];
    let v2 = vec![76, 23, 27, 20, 56, 83, 39, 19, 92, 60]; 
  • Solution using zip and map

    let matches: u32 = v1
        .iter()
        .zip(v2.iter())
        .map(|(a, b)| if a == b { 1 } else { 0 })
        .sum();

Many useful methods available

Organizing Projects

Crates

  • A crate is the smallest amount of code the Rust compiler can compile

  • Crates can be binary crates or library crates

  • Binary crates have executables

  • Library crates have code that can be shared with multiple projects

  • Simple binary crate

    .
    ├── Cargo.toml
    └── src
        └── main.rs

Binary crate with modules

  • Modules are logical partitions of the code in the project

  • Example of a binary crate with modules

    backyard
    ├── Cargo.toml
    └── src
        ├── garden
        │   └── vegetables.rs
        ├── garden.rs
        └── main.rs
  • main.rs defines a module garden

    use crate::garden::vegetables::Asparagus;
    
    mod garden;
    
    fn main() {
        let plant = Asparagus {};
        println!("I'm growing {:?}!", plant);
    }
  • garden.rs defines a submodule vegetables

    pub mod vegetables;
  • vegetables.rs defines a struct Asparagus

    pub struct Asparagus {}

Location of modules

  • backyard crate

    backyard
    ├── Cargo.toml
    └── src
        ├── garden
        │   └── vegetables.rs
        ├── garden.rs
        └── main.rs
  • Seeing the line mod garden in main.rs, the compiler will look for module’s code in the following places

    • In a { } block immediately after mod garden
    • In the file src/garden.rs
    • In the file src/garden/mod.rs

Location of modules

  • backyard crate

    backyard
    ├── Cargo.toml
    └── src
        ├── garden
        │   └── vegetables.rs
        ├── garden.rs
        └── main.rs
  • Seeing the line pub mod vegetables in garden.rs, the compiler will look for module’s code in the following places

    • In a { } block immediately after mod vegetables
    • In the file src/vegetables.rs
    • In the file src/vegetables/mod.rs

Visibility of modules and their contents

  • Code in a module is private from its parent modules by default

  • Seeing the line pub mod vegetables, the compiler interprets vegetables as a public module

  • Individual functions and types in a public module can be exposed using pub prefix

    pub struct Asparagus {}

Resources