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 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!
π₯ 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