My Rust Journey - 10 Oct 2024

This is the 4th post about my journey learning the Rust programming language using the Rust Book. Previous posts include:

Chapter 1: Basics of Rust and Cargo

Chapter 3: Mutability and shadowing, variables and constants, scalar and compound data types, functions, control flow with conditional statements and loops

Chapter 4: Ownership, reference and borrowing, and slice type

I am documenting this as I think it is a useful thing to do for people interested in learning Rust from my non-developer perspective.

At this stage, you have already installed Rust on your machine, and you are ready to write and run your first Rust programs.

I am using VS code with the rust-analyzer extension. I am working on an M1-mac.

The following tutorial will cover Chapter 5 of the Rust Book. It is meant to be a summary and used with the book as a complementary source of information.

Structs

Similar to tuples, they hold multiple related values of different types.

struct User {
        active: bool,
        username: String,
        email: String,
        sign_in_count: u64,
    }

We use the keyword struct and name it User to define a struct. Within the struct we define names and types for each piece of data called field. Note how the struct is defined outside the main function.

Once the struct is defined, we can create multiple instances by specifying values for each field.

let user1 = User { // User instance
        active: true,
        username: String::from("user1name"),
        email: String::from("user1email"),
        sign_in_count: 1,
    };

A function to create new instances of that struct would look like this:

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username,
        email,
        sign_in_count: 1,
    }
}

where username and email are the fields to be updated. We are using the init shorthand so that we do not have to write username: username and email: email.

To utilize the function we can use the following code:

let a = String::from("user2email");
let b = String::from("user2name");

let user2 = build_user(a, b);

The struct update syntax is used to create new instances including most of the values from another instance.

let user3 = User {
        email: String::from("user3email"), // change only email
        ..user2 // rest filed like user 2
    };

Rust also supports tuple structs. Tuple structs are useful when: Give the tuple a name and make the tuple a different type from other tuples Naming each field

struct Color(i32, i32, i32)

And then in the main function you can specify a color

let black = Color(0,0,0);

Each defined struct is a different type even though fields of those structs have the same type.

Rectangle Example

Let’s build a case where we want to calculate the area of a rectangle using structs.

We defined the Rectangle struct

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

Then, we defined the area function:

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

This function is defined with one parameter named rectangle that is an immutable borrow of a Rectangle struct instance. The function accesses the width and height fields of the Rectangle instance. Accessing fields of a borrowed struct does not move the field values.

Then, within the main function, we use the following code:

let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

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

Print in Debug mode

The struct primitive can be printed in debug mode as follows:

#[derive(Debug)] // used to print struct values
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {

println!("{:#?}", rect1);

}

The {:?} prints rect1 using the' Debug' output format. The {:#?} can be used in larger structs for better readability.

Another way to print values in debug mode is to use the dbg! macro, which takes ownership over an expression and prints the file and line where the debug call occurs, along with the resultant value of the expression.

let scale = 3;
    let rect1 = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    };

dbg!(&rect1);

The dbg! is applied to the width field, takes ownership of scale, and gives it back to width (essentially is like we never used it). We then use the macro again to print rect1, but because we do not want it to take ownership of it, we just use a reference to rect1.

Methods

The area function we defined before is specific: it only computes the area of rectangles. We can ensure that the area function only works with Rectangle type, by turning the area function into area method.

Outside the main function, after specifying the Rectangle struct we specify the impl (implementation) block for Rectangle. Everything in that block will be associated with the Rectangle type. We move the area function within the block and use the self as the parameter.

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

The &self is short for self: &Self, where Self is an alias for the type the impl block is for. Methods must have a parameter named self of type Self that can be abbreviated with self. We use the reference &self because we borrow a Self instance as we did with rectangle: &Rectangle.

Then within the main function, we can print the area value as follows:

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

We use the method syntax to call the area method on our Rectangle instance. A method can have the same name as one of the struct fields. We can add another method in our block implementation:

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

    fn width(&self) -> bool {
        self.width > 0
    }
}

The second width method will return true if the width is greater than zero. Within the main function, we can use both .width or .width() and Rust will know that one will be the struct field and the other will be the method.

println!(
        "The width of the rectangle is {}.", 
        rect1.width // using the width field or .width() method
    );

Automatic Referencing and Dereferencing

When you call a method using object.method(), Rust automatically adds in &, &must, * so the object matches the signature of the method. The following are the same:

p1.distance(&p2)
&p1.distance(&p2)

Automatic referencing works well because methods have a clear receiver of the Self type. Given the receiver and method, Rust can figure out if the method is reading (&self), mutating (&mut self), or consuming (self).

Associated Functions

Functions within an impl block are called associated functions because are associated with the type named after the implementation.

Associated functions do not have to include self as the first parameter (and do not need to be methods). An example is the String::from function defined on the String type. Let’s, for example, create a function with one dimension parameter:

fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }

The Self in the return type and the body of the function are aliases for the type that appears for the impl keyword (not shown), in this case, Rectangle.

To use the the square associated function you can use the :: syntax as shown in the code below:

let sq = Rectangle::square(3);

So far we learned the basics of Structs, Methods and Associated Functions in Rust. See you in the next post!

0
filippoweb3Post author

πŸ”₯ Web3 explained from the non-developer's POV. πŸš€ Helping Polkadot users explore the ecosystem with confidence. I post daily on socials. Opinions are mine.

1 comment

Loading replies...

πŸ”₯ Web3 explained from the non-developer's POV. πŸš€ Helping Polkadot users explore the ecosystem with confidence. I post... Show More