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.
}
- 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.
}
-
Compiler might tell you change
consttostatic. However, even if you change it, the expression ofstatic mutis unsafe, so you get compiler error again with :error[E0133]: use of mutable static is unsafe and requires unsafe function or block -
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,
}
-
Unlike mutable variable, we can change the value of variable in compile-time.
-
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
- loop
loopis basically meanswhile truein rust, unlikewhileorfor, however, it can be used as expression withbreak. 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 } } } - while
whileis a repitition keyword as widely used in other programming language. With a condition phrase besidewhile, we can control the repetition.whileneeds discrimitive condition to break the repetition.while some_condition { // ... if other_condition { break; } } - for
forcould 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};
}