Frontend Masters: The Rust Programming Language
Table of contents
Resources
- link The Rust Programming Language by Richard Feldman (4 hours 42 minutes) (May 11, 2021)
- link Course website
- link Course GitHub repository
- link Course slides
Primitives
let
- similar toconst
in jslet mut
- similar tolet
in jsas
- for type casting!
- functions ending with!
are macrospanic!(str)
- similar tothrow
in js- last expression in function (after last semicolon) used as default return value
// String
let str = "Hello";
println!("{}", str);
let same_as_print = format!("{}", str);
// Number
let num = 1.1;
let mut num2 = 1.2;
let num3: f64 = 1.3;
// Function
fn func(x: f64, y: f64) -> f64 {
return x * y;
}
// The above is same as
fn func(x: f64, y: f64) -> f64 {
x * y
}
// as
let x: f64 = 1.1;
let y: f32 = 1.2;
let z = x * y as f64;
// Integer
let int1: i8 = 1;
let unsigned_int2: u8 = 2;
// Boolean - At runtime type changed to u8
if cats > 1 {
} else if 1 == 2{
} else {
}
let ternary_operator = if something {
"val1"
} else if something2 {
"val2"
} else {
"val3"
};
Collections
unit
- similar tovoid
- tuple
- struct - Syntax sugar only. Similar to named tuples.
- array - length is fixed.
- at runtime tuple, struct, array have same memory layout and no additional overhead. Meaning they have the same performance. The read/write operations compile to the same machine code.
// Tuple
let point: (i64, i64, i64) = (0, 0, 0);
fn get_x(my_point: (i64, i64, i64)) -> i64 {
my_point.0
}
fn set_x(mut my_point: (i64, i64, i64), x: i64) {
my_point.0 = x;
}
fn destructure((x, y, z): (i64, i64, i64)) {}
fn destructure ((x, _, _): (i64, i64, i64)) {}
// Unit - Tuple with zero elements (used to return "nothing" from functions)
let unit: () = ();
// Struct
struct Point {
x: i64,
y: i64,
z: i64
}
fn point(x: i64, y: i64, z: i64) -> Point {
Point { x: x, y: y, z: z }
}
fn get_x(point: Point) -> i64 {
point.x
}
fn destructure(Point { x, y }) -> i64 {
x + y
}
fn destrcture(Point { x, .. }) -> i64 {
x
}
// Array
let arr: [i32, 3] = [2000, 2001, 2002];
for year in arr.iter() {
}
arr[0] = 1998;
let [year1, year2, year3] = arr;
Pattern matching
- Enums define one of several distinct alternative variants at runtime.
- At runtime, the variants are converted to
u8
. If number of varaints are more thenu16
is used. By default, the numbering starts from 0. You can assign a start value or values in general to any varaint usingYellow = 3
. match
similar to switch, except break is not needed. For default you can use_ => {}
, but generally avoid that, as you should be handling all variants manually. Plus if you add a variant in the future, you need to know the places it affects.- For payloads, the first memory represents the
u8
number of enum, and the additional bytes represent the payload. - Also, the size of the largest payload determines the size of each variant in enum. Since
Custom
in the example below takes 4 bytes, it meansGreen
also takes 4 bytes. impl
used for namespacing functions.self
takes the type of the thing that comes afterimpl
.
enum Color {
Green,
Yellow,
Red,
Custom {red: u8, gren: u8, blue: u8 } // Payload
}
let green: Color = Color::Green;
let blue: Color = Color::Custom {red: 0, green: 0, blue: 255 };
println!("In memory Yellow is {}", Color::Yellow as u8);
let color_str = match current_color {
Color::Green => "green",
Color::Yellow => "yellow",
Color::Red => "red",
Color::Custom {red: 0, green, blue } => format!("custom color with no red (RGB 0, {}, {})", green, blue),
Color::Custom { red, green, blue } => format!("custom (RGB {}, {}, {})", red, green, blue),
};
impl Color {
fn is_red(color: Color) -> bool {
match color {
Color::Red => true,
_ => false,
}
}
fn is_yellow(self) -> bool {
match self {
Color::Yellow => true,
_ => false,
}
}
}
let is_color_red = Color::is_red(Color::Yellow);
let is_color_yellow = Color::is_yellow(Color::Yellow);
let is_color_yellow = Color::Yellow.is_yellow(); // self allows us to uss method-calling syntax
Type parameters
- Use
Option<T>
to mimic null, undefined.Option
is available globally, so no need to doOption.Some
,Option.None
.
- Use
Result
to return from a function, where the first argument is for success and the second argument is for error.- Available globally.
enum Option<T> {
None,
Some(T),
}
let some_i64: Option<i64> = Some(41); // No need to do Option.Some
let some_i64: Option<i64> = None;
enum Result<T, E> {
Ok(T),
Err(E),
}
let failure: Result<i64, String> = Err("failure reason");
let success: Result<i64, String> = Ok(42);
Vectors
- Internally
vec
stores memory_index_of_first_element, length, capacity. Capacity is the starting size ofvec
.
let mut years: Vec<i32> = vec![2000, 2001, 2002];
years.push(2003);
let pop_val: Option<i32> = match years.pop() {
Some(val) => { val },
None => -1,
};
// vec macro shorthand for
let mut years: Vec<i32> = Vec::capacity(1);
Ownership
- Only applies to things in the heap.
- Goal of ownership is to add
dealloc
in code to free the memory. In C/C++ we have to manually add these and do memeory management. In gargabe collected languages, the garbage collector does this. In Rust this is automatic. You can useunsafe
to do manual memory management. - Ownership - Every value is “owned” by a particular scope. At first it’s owned by the scope where it was originally created, but ownership can be passed to other scope later on.
- moving - Transferring ownership of a value is called “moving” that value. Use
return
to transfer ownership. - Deallocation happens whern there is no longer any scope owning a value.
use-after-free bug
fn() -> i64 {
let heap_allocated_thing = vec![1,2,3,4]; // alloc
dealloc(heap_allocated_thing);
for item in heap_allocated_thing.iter() { ... }; // use-after-free bug
}
double free bug
fn() -> i64 {
let heap_allocated_thing = vec![1,2,3,4]; // alloc
dealloc(heap_allocated_thing);
...
dealloc(heap_allocated_thing); // double free bug
}
Solution: Rust adds dealloc when a variable goes out of scope. So at the end of function
fn() -> i64 {
let heap_allocated_thing = vec![1,2,3,4]; // alloc
...
// dealloc(heap_allocated_thing) added by rust
return something;
}
You can also control when to free memory by creating custom scopes using {}
fn() -> i64 {
{
let heap_allocated_thing = vec![1,2,3,4]; // alloc
} // dealloc(heap_allocated_thing) added by rust
...
return something;
}
Problem with the current approach
fn get_years() -> Vec<i32> {
let years = vec![2001, 2002, 2003]; // alloc
return years; // dealloc(years)
}
fn main() {
let years = get_years(); // use-after-free bug, since `years` already deallocated
}
To resolve the above problem rust created the concept of Ownership
fn get_years() -> Vec<i32> {
let years = vec![2001, 2002, 2003]; // alloc (this scope "owns" years)
return years; // transfer ownership to main
}
fn main() {
let years = get_years(); // take ownership
} // dealloc(years) because it went out of scope, without being moved elsewhere
Limitation of Ownership.
fn print_years(years: Vec<i32>) {
for year in years.iter() {
println!("Year: {}", year);
}
} // dealloc(years)
fn main() {
let years = vec![2000, 2001];
print_years(years);
print_years(years); // user-after-free bug, since years already deallocated
}
Possible solutions to the above problem
- Return
years
from theprint_years
function, as that will transfer ownership back tomain
. - Or use
.clone()
to pass a new copy to the function. This will hurt performance. - If the function takes immutable value, then you can convert it to mutable value inside the function.
- Recommended: Use Borrowing
fn main() {
let mut years = vec![2001, 2002];
years = print_years(years);
print_years(years); // this works
print_years(years.clone()); // this works as well
}
Borrowing
- To solve limitations of ownership.
- Borrowing - Obtain a reference from an owned value. Also, the thing borrowing can’t move or mutate it. Once the function returns, that reference if no longer in any scope, and there are no longer any active borrows on the original owned value.
- Borrow checker - Rust’s compilier errors around ownership and borrowing are collectively called “the borrow chekcer”.
- Borrow checker cannot be turned off, even in unsafe code.
- For immutable references, multiple functions can borrow the variable.
- For mutables references, only one function can borrow at a time, to prevent race-conditions.
- References are deallocated when the original variable is deallocated. Also, references never cause anything to get deallocated.
- Prefer reference over ownership, if a function can accomplish its goals.
- It makes the function more restricted in what it can do.
- But it makes the caller less restricted. They don’t have to clone or modify the function to return the value.
fn print_years(years: &Vec<i32>) {
for year in years.iter() {
println!("Year: {}", year);
}
} // dealloc(years)
fn main() {
let years = vec![2000, 2001];
print_years(&years); // temporarily give print_years access to years (borrow)
print_years(&years);
}
Mutable references have a limitation that as long as you have a mutable borrow active on a value, you are not allowed to have any other borrows (mutable or immutable) active on that value. This helps prevent data races.
let mut years: Vec<i32> = vec![2001, 2002];
let years2: &mut Vec<i32> = &mut years;
let years3: &mut Vec<i32> = &mut years; // error
let mut years: Vec<i32> = vec![2001, 2002];
let years2: &mut Vec<i32> = &years;
let years3: &mut Vec<i32> = &mut years; // error (also the order does not matter, it will
// still error if &mut defined before &years)
Slices
- Reference to subset of vector’s elements or strings.
- To convert entire vector to slice use
vec.as_slice()
. - To convert entire string to slice use
str.as_str()
.
let nums = vec![1,2,3];
let slices = &nums[0..3]; // specify start index and length
let str_slice: &str = string[3..7];
Lifetimes
- Lifetime is the time between when a value is allocated and when its deallocated.
- Lifetime annotations are a way to track dependencies between lifetimes.
- Lifetime annotations are required in all structs that hold references.
struct Releases<'y> {
years: &'y [i64],
eighties: &'y [i64],
nineties: &'y [i64],
}
fn jazz<'a>(years: &'a [i64]) -> Releases<'a> {
let eighties: &'a [i64] = &years[0..2];
let nineties: &'a [i64] = &years[2..4];
Releases{
years,
eighties,
nineties,
}
}
let releases: Releases<'a'> = {
let all_years: Vec<i64> = vec![...];
jazz(&all_years)
}
Lifetimes depend on how you assign data. In this example, all references are populated from the same variable years
, so in the end, all three lifetimes would end up being the same.
struct Releases<'a, 'b, 'c> {
years: &'a [i64],
eighties: &'b [i64],
nineties: &'c [i64],
}
Elision
- Lifetime annotations are not needed when there is exactly 1 reference in the return type and also exactly 1 reference in the arguments.
fn foo(arg: &String) -> &i64
// rust will rewrite above to
fn foo<'a>(arg: &'a String) -> &'a i64
- Use
_
as the lifetime name to tell rust to infer the lifetime from the context and elision rules.
let releases: Releases<'_> = {
...
}
Static
- Special lifetime to refere to values that live for the entire duration of the program (in the actual binary in RAM). They are never allocated, and deallocated.
- Strings take this lifetime by default. Reason: Since the actual string is already in the binary, it does not make sense to move it to the heap, instead just create a reference to the binary iteself.
let name: &'static str = "Default type of string";
// Rust will rewrite this to use static lifetime by default
let name: &str = "Static lifetime added by default";