2. Basic Synthax

One of the reason why we use Rust is for a memory-safe efficient programming. It is because that rust compiler strictly yells the programmer to follow their memory-safe instructions.

Table of contents

2.1. Immutables and mutables

In rust, let is used for declaration of a variable, without mut keyword, rust generally declare the variables be immutable by default.

Basically, rust supports three ways to assign a value as follows :

Variable Type Advantages Disadvantages Syntax
Mutable Flexibility, efficiency Thread safety, complexity let x = 3;
Immutable Thread safety, predictability, simplicity Memory usage, performance let mut x = 3;
Constant Readability, predictability, optimization Limited flexibility, potential for code duplication const x = 3;

The following code snippet is from the book but with my commentary

2.1.1. mutable vs immutable

// the following code occurs compile error, this example is from `the book` of rust-lang.org

fn main(){
    let x = 5; // rust sets value be immutable by default. (1)
    println!("the value of x is {x}");
    x = 6; // here the mutation occurs however as we did not set x be mutable, this occurs compile error in rust.
    println!("the value of x is {x}"); //compiler cannot reach here.
}

  1. To avoid the error, we need to write (1) as let mut x = 5;

2.1.2. constant

fn main(){
    const mut X = 5; // compile error occurs here, unlike `let` expression which initializes variables, `const` does not allow `mut` expression.
}
  1. Compiler might tell you change const to static. However, even if you change it, the expression of static mut is unsafe, so you get compiler error again with : error[E0133]: use of mutable static is unsafe and requires unsafe function or block

  2. This error ([E0133]) can be avoided with unsafe block (which allows memory-unsafe coding) like the following, however, you might not need this usages right now. (not recommended)

fn main() {
    unsafe{
        static mut A:i32 = 1024; // static needs the concrete type like i32 here.
        println!("Hello, world!");
        println!("{A}");
    }
}

2.1.3. shadowing

Shadowing means that a variable is declared with the same name of previous variable. I posted the usage because it is in the book of rust-lang, however, shadowing is not recommended in general cases.

fn main(){
    let x = 5;
    let x = x + 1; // shadow variable x to be x (prev) + 1 : 6
    {
        let x = x * 2; // inner shadow of x : 
    } // shadowing scope ends here. so the value of the variable x is now 6,
}
  1. Unlike mutable variable, we can change the value of variable in compile-time.

  2. Unlike mutable variable, shadowing allows to use the same name when we change the data type. This sounds like powerful. However, type-changing situation sometimes leads a serious dynamic errors that compiler cannot discern. Therefore, shadowing should be avoided in general cases.

2.2. Rust is a statically-typed language.

Like modern programming languages like python or javascript, Rust supports type inference.
However, rust generally requires concrete (static) types during compilation for memory-safe efficient programming. We call this type compliance as statically-typed language, which means the types of variables and expressions are checked at compile-time rather than at runtime.

2.2.1. Basic Scalar Types

Here are the table for Rust’s basic types

Type Description Literal
i8 ~ i128 8 ~ 128 bit integer  
u8 ~ i128 8 ~ 128 bit unsigned integer  
isize integer depends on the architecture  
usize unsigned integer depends on the architecture  
f32 ~ f64 32 ~ 64 bit floating points 3.1415
bool boolean type true / false
char letter type ‘a’
(type, type, … ) tuple (true, 5)
[type; integer value ] array [3; 10]

Here the following codes are about declaration of types:

fn main(){
    let five = 5 ; // Rust basically infers integer value as i32.
    let five_i32 : i32 = 5; // i32
    let pi = 3.14; // Rust baically infers float value as f64
    let pi_f32 : f32 = 3.14; // f32
    let t = true; // bool
    let f : bool =  false; // bool
    let ch = 'c';
    let ch_char : char = 'c'; // char is 32 bit length

    // about tuple
    let compound_tup : (i32, f64, u16) = (400, 6.28, 2);
    let four_hundred = compound_tup.0;
    let tau = compound_tup.1;
    let two = compound_tup.2;

    // about array
    let arr_1 = [1,2,3,4,5];
    let arr_2 : [i32; 5] = [1,2,3,4,5]; // 
    let arr_3 = [3; 5]; // [3,3,3,3,3]
    let arr_first = arr_1[0];
    let arr_second = arr_1[1];
}

2.2.2. Custom Types

Rust also supports algebraic types. struct can be used as a multiple type, enum can be used as a sum type.

Struct

Like C programming language, Rust has a struct type that user can define a custom data. While rust’s struct is more expansive than C struct, it enables users to use flexible and memory-safe custom data.

struct Person{
    name: String,
    age: u8,
}

fn main(){
    let person = Person{
        name:"Sangdo Han".to_string(),
        age:33,
    };
}

Enums

Enums, which stands for enumerations, is one of the powerful custom type that enables to make custom types in modern programming language. Briefly speaking, enums gives user to selecting a value of a possible set of values. With this concept, users can write more safe and expressive codes.

enum OrderStatus{
    Pending,
    Approved,
    Processing,
    Shipped,
    Delievered,
    Canceled,
}
struct Order{
    id: u32,
    customer_name: String,
    items: Vec<String>,
    status: OrderStatus,
}
fn main(){
    let mut order = Order{
        id:1,
        customer_name: "Sangdo Han".to_string(),
        items: vec!["TV".to_string(), "Laptop".to_string()],
        status:OrderStatus::Pending,
    };

    order.status = OrderStatus::Processing;
}

Like the example above, programmer can set the Order’s status using enum OrderStatus with the follwing valid variables : Pending, Approved, Procesing, Shipped, Delivered and Canceled.

These options might have different types and amounts of associated data. Enums with inline-struct can make more properous types as followings:

use chrono::{DateTime, Local, TimeZone, Utc};

#[derive(Debug)]
enum OrderStatus{
    Pending,
    Approved{start_date:DateTime<Utc>, approver:String},
    Processing{start_date:DateTime<Utc>, provider:String},
    Shipped{start_date:DateTime<Utc>, ship_no:u32},
    Delievered{start_date:DateTime<Utc>, expected_date:DateTime<Utc>},
    Canceled
}

#[derive(Debug)]
struct Order{
    id: u32,
    customer_name: String,
    items: Vec<String>,
    status: OrderStatus,
}

fn main(){
    let mut order = Order{
        id:1,
        customer_name: "Sangdo Han".to_string(),
        items: vec!["TV".to_string(), "Laptop".to_string()],
        status:OrderStatus::Pending,
    };

    order.status = OrderStatus::Processing{start_date:Utc::now(), provider:"sangdo".to_string()};
    println!("{:?}", order)
}

2.2.3. Common collections

Rust’s standard library supports useful data structures called collections. It supports Vec (vector), VecDeque (queue), HashMap and so on. see the details in the official documentation of std::collections

2.3. Control Flow

In any programming language, if you know if-else and loop, you can write any program even if it is too hard to read or too slow.

2.3.1. if expression

Rust’s if is an expression, so it returns value. If there is no explicit return statement, it automatically returns Unit Type (()), which represents an empty tuple.

As if is an expression, we can assign value as the following:

let result = if condition { 
        value_1 ;
    } else if condition_2 {
        value_2 ;
    } else {
        value_3 ;
}

about the conditions, rust only supports boolean type.

2.3.2. pattern matching

Rust has a powerful control flow construct keyword : match expression. Basically, match works as the following:

match expression {
    pattern1 => { code_block1 },
    pattern2 => { code_block2 },
    // ..
}

For a simple example, you can register patterns with literals and wildcard (_) as the following:

let dice_roll = 3;
match dice_roll {
    3 => println!("you got the prize : candies !"),
    5 => println!("you got the prize : chocolate !"),
    _ => println!("try again")
}

Generally, enum is widely used for pattern constraints. the following example is originated from the book.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

2.3.3. loop, while, for

  1. loop
    loop is basically means while true in rust, unlike while or for, however, it can be used as expression with break. In addition, rust can assign a label to a loop as followings:
    'outer_loop: loop {
     'inner_loop: loop {
         // ...
         if some_condition {
             break 'outer_loop;  // with label, loop can exit directly to outer loop
         }
     }
    }
    
  2. while
    while is a repitition keyword as widely used in other programming language. With a condition phrase beside while, we can control the repetition. while needs discrimitive condition to break the repetition.
    while some_condition {
     // ... 
     if other_condition {
         break;
     }
    }
    
  3. for
    for could be the best option that handling the repetition with fixed range.
    let mut factorial = 1;
     for idx in 1..10 { // starts 1 to 9, if you want to include 10, use 1..=10
         factorial *= idx;
         println!("{idx}! = {factorial}");
    }
    

2.4. functions

We’ve already used a function : main. As you might know already, to make a function, the keyword fn is needed. Also, the function named main is a crucial function, that rust compiler identifies the project. So far, we don’t put the parameters for the function. To give a parameters to a function, we have some rules as follows:

fn make_2d_circle(x:f64, y:f64, r:f64){
    assert!( r>0.0 , "r needs to be larger than 0 but you put : {}",r);
    println!("circle created at ({x}, {y}) with radius {r}");
}

fn main(){
    let inputs : (f64, f64, f64) = (2.0, 3.0, 2.0);
    make_2d_circle(inputs.0, inputs.1, inputs.2);
}

with the outputs, we should declare return type.

fn calculate_polar_coordinates(original_x: f64, original_y: f64) -> (f64, f64) {
    // Calculate radius using the Pythagorean theorem
    let polar_radius = (original_x.powf(2.0) + original_y.powf(2.0)).sqrt();

    // Calculate theta using atan2 (handles 0/0 case)
    let polar_theta = original_y.atan2(original_x);

    (polar_radius, polar_theta) // return (polar_radius, polar_theta);
}

fn main() {
    let original_x = 2.0;
    let original_y = 3.0;

    let (polar_radius, polar_theta) = calculate_polar_coordinates(original_x, original_y);

    println!("Original x: {}, y: {}", original_x, original_y);
    println!("Converted to Polar Coordinates:");
    println!("Radius: {}, Theta: {}", polar_radius, polar_theta);
}

2.5. Method

In rust, there is no class keyword, however, we can use enum and struct for OOP.
If you are not familiar with reference (&) or dereference(*), I hope you to visit chapter 3 first then come back to this chapter.
For instance, we can assign method with impl keyword as follows:

// based on an example from `the book`
struct Rectangle {
    width : u32,
    height: u32,
}
impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
    fn radius_inscribe(&self) -> f64{
        let _width:f64 = self.width as f64;
        let _height:f64 = self.height as f64;

        f64::sqrt(_width.powf(2.0) + _height.powf(2.0))
    }
    fn has_larger_width_than(&self, other:&Rectangle) -> bool {
        // this function can get another argument : rectangle instance.
        self.width > other.width
    }

    // associated functions, we can instantiate with other way
    fn square(size:u32) -> Self {
        Self {
            width:size,
            height:size,
        }
    }
}
fn main(){
    let rect1 = Rectangle{
        width:30,
        height:40,
    };
    println!("the area of rectangle 1 is {}", rect1.area());
    println!("the radius of outer circle of rectangle {}", rect1.radius_inscribe());

    let rect2 = Rectangle{
        width: 10,
        height: 100,
    };

    println!("rect1 has larger width than rect2? : {}", 
            rect1.has_larger_width_than(&rect2));

    let square1 = Rectangle::square(30);
    println!("the area of square1 is {}", square1.area());
}

enum also can have methods, the following example is generated by copilot, which is also a good example that shows pattern matching.

enum Direction {
    Up,
    Down,
    Left,
    Right,
}

impl Direction {
    fn is_vertical(&self) -> bool {
        match *self { // *self means dereference Direction, that gives a value.
            Direction::Up | Direction::Down => true,
            _ => false,
        }
    }

    fn is_horizontal(&self) -> bool {
        !self.is_vertical()
    }
}

fn main() {
    let up = Direction::Up;
    let left = Direction::Left;

    println!("Is 'up' vertical? {}", up.is_vertical()); // Prints: Is 'up' vertical? true
    println!("Is 'left' horizontal? {}", left.is_horizontal()); // Prints: Is 'left' horizontal? true
}

2.6. Generics / Traits

So far, we construct functions, enums and structs with strong type signatures or members. However, sometimes these strict way may induce duplications. For instance, let us define a function that give result of addtion of two numbers.

fn add(a:f64, b:f64)->f64{
    a+b
}
fn main(){
    let a32:f32 = 3.0;
    let b32:f32 = 5.0;
    println!("{}", add(a32,b32)); 
    // we have error cuz we defined function `add` only with `f64`, not `f32` 
}

The above code causes compiler error. This is because the function can only takes f64. Even if we know that addition works with the same way with i32 or f32, as we only defined add function only works for f64, the code has issues. To solve this problem we might need more add functions respect to each type. However this approach could result in redundancies in your code and it makes hard for you (or your team) to maintain the code. One of the idea to solve these redundant-prone coding is using generics. With generics, we can set a generic defintion of a function that can handles multiple types as followings:

fn add<T: std::ops::Add<Output= T>> (a :T, b : T ) -> T {
    a + b
}
fn main(){
    let a32:f32 = 3.0;
    let b32:f32 = 5.0;
    println!("{}", add(a32,b32));

    let a64:f64 = 4.0;
    let b64:f64 = 53.0;
    println!("{}", add(a64, b64));
}

Here, we set T as a abstract type, by putting angle bracket < > right next to the name of function. In this example, since we use standard add operator + we need to say that T has a special trait (or a constraint or characteristic) that this generic type T uses standard add operator’s output. However, if we don’t have those constraints (in this case, use of add-operator), The code snippet would be compiled.

The trait std::ops::Add<Output=T> is a powerful concept in Rust. Traits define functionality that types can implement. In this case, std::ops::Add<Output=T> constraint guarantees type T can be properly added within the function.

One of the interesting thing is that rust’s generics don’t impact performance. The compiler generates specific code for each type at compile time, ensuring efficiency, resulting in performance equivalent to writing separate functions for each type.

Sometimes, we assume that the generalized members / signatures would not always be the same types. In this case, the generic type placeholder needs to be distinguished as followings:

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

fn main(){
    let x: i32 = 5;
    let y: f64 = 3.0;
    let pointxy = Point{x,y};
}