A primer for learning Rust

July 202410 min read
image

Content

About Rust


Rust has a reputation of being an overly protective programming language. It is a compiled language mostly used in systems programming however recently in other areas among which is web3. Statically typed, which means the types of all variables are declared at compile time. If the type of a variable is not specified, the compiler will work it out and take a guess.


fn main() { let ans:f64 = 2.0; println!("{}", ans); }

A Rust program similar to other compiled languages contains a main function and each line of code ends with a semi-colon. Variables in Rust are immutable unless explicitly specified using the mut keyword.


let mut ans:f64 = 2.0;

back to top

Memory and variable types


Variables on a stack

Given variable A stored on the stack, when variable A gets assigned to another variable B, variable A will get copied. If variable A was stored on the heap, you'll get an error. You can use .clone() for variables stored on heap. Variables stored on stack get copied under the hood because doing so is a cheap transaction.

Primitive types are stored on a stack in memory and they are popped off the stack when their existance is no longer needed. A stack is a block in memory where allocation happens in the form of first in last out (FILO). Memory allocation and de-allocation on a stack is faster than that on a heap.


Primitive types

Primitive types can be divided into scalar and compound:

1. Scalar

  • Signed integer, referred to as i<size> for example i8 or i16
  • Unsigned integer, referred to as u<size>
  • Floating point, referred to as f<size>
  • Char, which is a unicode scalar and is indicated using single quotes '' and is 4 bytes in size
  • Bool, which is 1 byte in size and can be true or false

2. Compound

  • Array, for example [1,2,3]. Objects in the array have the same type and its data is collected on a stack

  • Tuple, for example (1, true) where items can have different types (more on tuples later)

  • Slice

Slice Vs. Array

The length of an array is known at compile time however the length of a slice is known at run time.

An array signature looks like this:


let arr:[i32; 5] = [1,2,3,4,5];

where we are defining an array arr with 5 i32 items and then initialising the array.

A slice signature on the other hand looks like this:


let slice:&[i32] = &arr;

The slice is a reference to an array but without copying it. Borrowing is something that we will cover further down.

In the code below, we create a function sum outside the main program. sum takes a parameter of type slice having items of type i32 and it also returns an i32 value.

In the main function, we define an array arr and the variable res which contains the sum of all the items of the sliced array. The println! macro will print out our variable.


fn sum(vals: &[i32]) -> i32 { let mut res = 0; for i in 0..vals.len() { res += vals[i]; } res } // the main function fn main() { let arr = [1, 2, 3, 4]; let res = sum(&arr); println!("sum {}", res); }


Non-primitive types
  • Vector. Vec<T> is part of the std library. It is a resizable array and its size is not known at compile time. It behaves like a slice but unlike a slice, you can append more values to it. A vector is dynamically allocated which means it is growable and is stored on a heap.


    fn main() { // an array let arr = [1, 2, 3, 4, 5]; // a slice let s = &arr[1..3]; // a vector let mut v = Vec::new(); // add 1 to vector v.push(1); // copy items from slice to vector v.extend_from_slice(s); // v is [1, 2, 3] println!("v is {:?}", v); }

  • String. It has 2 types. String which is stored as a vector and &str which is stored as a slice. The latter is a string slice. A slice in this case is a reference to part of a string.


    fn main() { // define a string let s = String::from("Hello world"); // string slice let s_lice = &s[..5]; // Hello! println!("{}", s_lice); }

back to top

Tuples, Structs and Enums


Tuples

A tuple is useful for functions returning objects of different types. The tuple is fixed in size and elements can't be added or removed.

Indexing a tuple looks something like this:


let tup:(char, i32, bool) = ('A', 7, true); tup.0 tup.1


Structs

A struct allows you to create custom types. By using structs, you can keep associated pieces of data connected to each other. You can also define functions that are associated with your type as well as methods that specify a behavior that your struct has in the impl block of the struct.

The name of the struct is capitalized and the struct itself is usually defined outside the main function.

Structs have 2 types:

  • Classic struct. This one is defined with named fields. A semicolon ; is not used at the end of a classic struct definition.


    struct Student {name: String, grade: u8}

  • Tuple struct. The difference between a tuple struct and a classic struct is that the tuple struct fields are not named.


    struct Grades (u32, u32, u32);

    The difference between a tuple struct and a tuple is that the tuple struct has a custom type while a regular tuple doesn't.


Printing a struct

The println! macro used with {} uses formatting known as std::fmt::Display. Primitive types implement the Display trait by default but structs don't. That's because printing a struct can have many possible displays.

println! used with {:?} uses the Debug format useful for developers. However, the Debug format is not implemented by default for structs. To make use of it, place the #[derive (Debug)] attribute above your struct definition.

For a visually better struct display on stdout, you can use {:#?} with println!.


#[derive (Debug)] struct Grades (u32, u32, u32); fn main() { let int = 3; let arr:[i32; 2] = [1, 2]; let my_struct = Grades(32, 34, 36); println!("int is {}", int); println!("arr is {:?}", arr); println!("my_struct is {:#?}", my_struct); }


The struct's impl block

This is the implementation block. The struct can have more than 1 impl block. The impl block is used to define methods and functions within the context of a struct.

Associated function Vs. Struct method

An associated function takes arguments/parameters. A struct method takes &self as the argument. An associated function is called using new() for example, Rectangle::new() where Rectangle is the Struct type while the Struct method is called as follows rect.area() where rect is a struct instance.

The method is called on an instance and the associated function is called on the type.

Methods look a lot like functions however their first argument is always self similar to what exists in other popular programming languages. It follows that self represents an instance of the struct.

The functions defined within the impl block are called associated functions. They don't take self as their first argument. Associated functions are mostly used as struct constructors.


// define a struct struct Employee { name: String, age: i32, } // write the impl block impl Employee { // define a constructor (associated function) fn new_one(name: String, age: i32) -> Employee { Employee {name: name, age: age} } // define a method fn get_name(&self) -> String { self.name.clone() } // another method fn get_age(&self) -> i32 { self.age } } fn main() { // initialise an Employee variable let employee = Employee::new_one("Alex".to_string(), 40); // print things out println!("{} is {} years old", employee.get_name(), employee.get_age()); }


Enums

Enumerations (or Enums) are a data struct that allow you to define a type by enumerating its possible variants.

Those are used to define several types that are related to each other and will be grouped together in the same enum type. The most common example of enums is an IP enum with types v4 and v6 IP addresses.

You can use structs with enums. For example the type of an enum variant IPv4 can be a predefined struct type.

There is a good example of this in the Rust documentation Enums example.

back to top

Option and Iterators


Option

There's another type to learn and that's Option. It is used to catch errors. Similar to methods in other languages where you try to .get() an item from an object and if this item does not exist, None is returned instead of the code crashing.

You can see Option when you use the get method with a slice slice.get(12). The get method can catch an out-of-bound error when the slice does not have an item at index 12. And so it will return None. However if the slice does have an item at index 12, it will return a value wrapped in Some.

So an Option type is either Some(type) or None.

There are some methods that go along with the Option type, for example .is_some() and .is_none() that return booleans. And .unwrap() which will return the target value for us without the Some().

So, Option can contain a value or nothing at all. The value can be any type hence Option<&[i32]>.


// Define a function that prints if the variable passed is true or false fn on_off(btn: Option<bool>) { match btn { Some(_) => println!("The button is ON"), None => println!("The button is OFF"), } } fn main() { let btn_1: Option<bool> = Some(true); let btn_2: Option<bool> = None; on_off(btn_1); on_off(btn_2); }

Iterators

Iterators are common in programming languages, and they are objects that generate values using the next method. Iterators in Rust return an Option.


fn main() { let mut iter = 0..3; assert_eq!(iter.next(), Some(0)); }

.next() will return a Some as long as there's a value left, otherwise it will return None.

back to top

Borrowing and Ownership

Some languages implement a garbage collector while others give this responsibility to the programmer. Rust takes another approach where memory is automatically reclaimed once the variable it belongs to goes out of scope. This is called Ownership.

A variable is owned by the scope it exists in, if the scope ends, the variable is dropped i.e. memory is freed. So if a variable outside a function is used as an argument for that function, when the function exits, the variable will be dropped because the scope of the new owner (the function) ended.

It's worth mentioning that println! does not take ownership of the object it is printing but uses a reference.

You can reassign a new variable to the value of that function variable outside the function and have it returned if you still need to use it outside the function. But what is usually applied instead is Borrowing.

You can borrow the variable by using a reference to it as a function argument. This way, ownership of the variable is not transferred to the function and the variable still exists in its scope outside the function.

Borrowing has some rules that are useful to know such as:

  • an object can have many immutable references to it or exactly 1 mutable reference but not both.

back to top

Traits

Rust defines a trait called Copy that determines that a type may be copied when being moved.
Primitive types are mostly Copy eg. i32, f64, char, bool, etc. However types like Vector and String are not Copy. This might be useful to know for performance purposes.

In the example code below, we'll create a custom trait and implement it for 2 different structs.


struct Sheep; struct Cow; trait Animal { fn sound(&self) -> String; } impl Animal for Sheep { fn sound(&self) -> String { String::from("Maah") } } impl Animal for Cow { fn sound(&self) -> String { String::from("Mooh") } }

Derive

The derive macro is used to provide basic implementations of certain traits. In the example below we give the custom struct MyNumb 2 traits PartialEq and PartialOrd from the standard library. This will enable float comparison operations.


#[derive(PartialEq, PartialOrd)] struct MyNumb(f64); fn main() { let _n1: MyNumb = MyNumb(10.0); let _n2: MyNumb = MyNumb(11.0); println!("{:?}", _n1 < _n2); }

back to top

A newsletter for web3 developers

Subscribe to get the latest posts.