Learning Rust
Posted March 13, 2021 by ‐ 10 min read
Reading and working through the exercises provided in The Rust Programming Language book.
This project is a collection of notes I’ve taken while making my way through The Rust Programming Language, aka. The Book. The goal is to summarize what I learn to help me consolidate what I’ve learned. I did not finish the book since I decided that to progress I’d need to write Rust for several weeks to overcome the hurdles of fluency.
Link to the Github source code. You can also find my take-aways on Rust here.
Introduction
time
for simple"Hello, world."
app on my machine. (Rust=0.001) and (Node.js=0.026)- “The Book” is approachable
- Rust is on to something: High-level ergonomics and low-level control are often at odds in programming language design; Rust challenges that conflict. (Introduction, first paragraph)
- Immutability by default!
- Such a nice developer experience (seems to have a unified community)
- rustup, rustup update
- cargo new, cargo run, cargo build, cargo build –release, cargo doc, cargo update
- Cargo.toml, Cargo.lock, semver
- While rust is flexible, it also leverages the benfits of conventions such as
src/bin/*.rs
,src/main.rs
,src/lib.rs
, and code privacy by default
Guessing Game
- use, loop, fn, println! (macro), let, match
- Shadowing is very cool, I wish I had this in other languages
Data Types
- Scalar Types: integer=u32, floating-point numbers=f64, Booleans=bool, and characters=char
- Compound Types
tup
(tuples) fixed size of different typeslet tup: (i32, u32, str) = (-1, 2, "hello");
array
fixed size of same typelet a = [1, 2, 3, 4, 5];
- Vectors (dynamic size)
- integer types default to
i32
Functions
- fn (snakecase and lowercase: my_awesome_function)
- Rust is an expression-based language
Statements
are instructions that perform some action and do not return a value.Expressions
evaluate to a resulting value. Expressions do not have semicolons{ }
are expressions
let y = {
let x = 3
x + 1
};
- the return types are signified by an arrow
->
fn foo() -> u32 {
let bar: u32 = 10;
bar
}
- return is synonymous with the value of the final expression in the body of a function, therefore,
return
is optional
Control flow
- if, loop, while, for (no parens used)
- they are expressions which means they can be assigned to varibles
Ownership
Three rules of ownership:
- each value in rust has a variable owner
- there can only be one owner
- when the owner goes out of scope, the value is dropped
Notes:
- Rust does not use garbage colleciton or manual memory allocation
- Rust uses a third approach: memory is managed through a system of ownership with a set of rules that the compiler checks at compile time.
move
passes ownership from one variable to anotherdrop
cleans up memory when variables go out of scope- Two parts of memory: stack (of plates, fixed size, last in/first out), heap (less organized, free allocated memory)
- double assignment invalidates the original variable
copy
creates shallow copy on the stackclone
duplicates memory on the heep- simple scalar values use copy annotation (but nothing that uses allocation)
- passing a value to a function changes it’s ownership to that function scope (important)
References
Rules:
- At any given time, you can have either one mutable reference or any number of immutable references.
- References must always be valid (no
dangling
refs)
Borrowing:
- having a reference as a function parameter is called
borrowing
&
ampersands are references, and they allow you to refer to some value without taking ownership of it.*
is used for dereferencing
Notes:
- reference data is immutable since we do not own the referenced data
- we can mutate references if we use
&mut some_var
andfn(foo: &mut String)
- can only have one mutable reference to the same data in the same scope (new Rustaceans struggle with this because most languages let you mutate whenever you’d like but it’s used to prevent
data races
at compile time) - Note that a reference’s scope starts from where it is introduced and continues through the last time that reference is used
Slices
- slices do not have ownership just like simple scalars
- [n..x] or [n..] or [..n] or [..]
let s = String::from("hello, world.");
let hello = &s[..5]; // slice
- string literals are slices and immutable
- there are other slices as well
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
Structs
- structs are like tuples but with keyed indexes so they are more flexible because we do not need to rely on order
struct User {
email: String,
first_name: String,
last_name: String,
}
impl
- structs can have methods as well
impl User {
fn name(&self) -> String {
let mut s = String::from(&self.first_name);
s.push_str(" ");
s.push_str(&self.last_name);
s
}
}
Enums
- enums are similar to structs but with several differences
- enums can hold different amounts of data
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
- in rust the
Option<T>
enum replaces the need fornull
withSome<T>
andNone
if let
is similar tomatches
.if let
allows you to match on a subset of possible enums
Manage growing projects
src/main.rs
,src/lib.rs
,src/bin/*.rs
are conventions for defining the starting places in a crate- Helpful concepts from resturants:
front of house
(public) andback of house
(private) module tree
is how modules are organized in rust projects, they are similar to file system directoriespaths
are how we accessmodules
, two formsabsolute
orrelative
(self
orsuper
), nesting is separated by the use of::
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
// Absolute path
crate::front_of_house::hosting::add_to_waitlist();
// Relative path
front_of_house::hosting::add_to_waitlist();
}
- all code is private to the outside by default, you make it public with
pub
keyword before definition use
is similar to symlinks for the file system allowing us to bring code into scope- you can alias a module with
as
, for example:use std::io::Result as IoResult
- for modules
use
the parent containing scope, for structs use full path as convention re-exporting
is how we bring a module into scope and allow code which calls us to use it as well withpub use
- importing multiple modules shorthand
use std::io::{self, Write};
- import glob shorthand
use std::io::*
- import module in a different file with
mod name
which loadsname.rs
Common Collections
- All common collections are stored on the
heap
- Methods exist for collections such as
new
among others
Vectors
Vec<T>
is a dynamic sized array, requires elments to be the same typelet v: Vec<i32> = Vec::new();
let v = vec![1, 2, 3];
is a vec macro with type inference- getting values from Vectors is easy
&v[0]
orv.get(0)
- adding values is easy too
v.push(4)
alsopop
is available - iterating over a vector
for i in &v { //snip }
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
Strings
Strings
are types of collections as well.String::new
,String::from("Hello, world.")
format!
is a macro for string concatenation, for example:println!("{}", format!("{} {}!", String::from("Hello"), String::from("world")));
&s[0..1]
is used to access substrings because&s[0]
will not compile since Strings are complicateds.chars()
to iterate over a string
Hash maps
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
.insert()
to add a new key/value entry.entry(key).or_insert(value)
to insert if key doesn’t already have a value, (entry returns a enum)
Handling errors
- Rust does not have Exceptions like other languages
- Instead Rust has
panic!
macro orResult<T, E>
which distinguishes between recoverable and unrecoverable errors
panic!
panic!
is a macro which is unrecoverable. It cleans up memory and exits the program.RUST_BACKTRACE=1 cargo run
to view the backtrace from a panic
To improve performance, we can ask the compiler to not unwind the memory via:
[profile.release]
panic = 'abort'
Result<T, E>
Result<T, E>
the T and E are generic types- You can view the return type by reading the api docs or by assigning it an invalid type and letting the compiler tell us
Ok
andErr
are brought into scope by prelude, so we don’t need to specifyResult::
- To handle returning multiple types of errors from a function use the trait object
Box<dyn Error>
, for example:Result<T, Box<dyn Error>>
Using match
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};
}
Propagating errors
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let f = File::open("hello.txt");
let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut s = String::new();
match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(e) => Err(e),
}
}
Propagating errors via shorthand ?
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?; // <-- shorthand here
Ok(s)
}
Chaining error shorthands
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();
File::open("hello.txt")?.read_to_string(&mut s)?; // <-- shorthand chaining here
Ok(s)
}
Generic Types, Traits, and Lifetimes
Generics
- Generics are used to remove code duplication. Consider two function which do the exact same thing but need to take in different types as parameters, this is where generics come in.
- Functions:
fn some_name<T>(item: T) -> &T
, notice<T>
goes between the name and parameters - Structs:
struct SomeName<T> { x: T }
orstruct some_name<T, U> { x: T, y: U }
- Enums:
enum SomeName<T> { Some(T), None }
- Implement:
impl<T> Point<T> { fn x(&self) -> &T { &self.x } }
- To solve for runtime performance, Rust’s compiler uses
monomorphization
to search through the code and fine all the uses to put in the specific types used so that the code is compiled with specific types. Therefore, there is no runtime cost to use generics.
Option
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
Traits
Traits
are likeinterfaces
in other languages which some differences, they define behavior that types must implement.- You can implment default traits for a type by using one method which calls others
- Traits may be used as parameters
pub fn notify(item: &impl Summary) {
, notice the&impl
Trait bound
syntax looks like this:pub fn notify<T: Summary>(item: &T) {
- Another example with multiple traits:
pub fn notify<T: Summary + Display>(item: &T) {
Basic Trait
pub trait Summary {
fn summarize(&self) -> String;
}
Implement a trait on a type
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
Multiple function traits with where
fn some_function<T, U>(t: &T, u: &U) -> i32
where T: Display + Clone,
U: Clone + Debug
{
// snip
}
Return types which implement traits
fn returns_summarizable() -> impl Summary { // notice impl
// snip
}
Lifetimes
- Every reference in Rust has a
lifetime
, most of the time, lifetimes are implicit and inferred - But we need to annotate lifetimes when multiple lifetimes are possible and cannot be inferred
- Lifetimes are somewhat different from tools in other programming languages, arguably making lifetimes Rust’s most distinctive feature
- The main aim of lifetimes is to prevent dangling references, which cause a program to reference data other than the data it’s intended to reference
- The borrow checker automatically assignes lifetimes
Automated Tests
- Tests are annotated with
#[test]
- Tests follow module scoping, often we use
use super::*
within the test module - Assertions: bool ->
assert!(left, right, [message])
, equality ->assert_eq!(left, right)
, inequality ->assert_ne!(left, right)
- Custom error messages with
assert!(left, right, message)
- Capture
panic!
with annotation#[should_panic]
- Use
Result<T, E>
in tests byfn it_works() -> Result<(), String> {
Test organization
- Convention is to put the unit tests in the same file in a
tests
module and#[cfg(test)]
annotation - The
cfg
annotation stands forconfiguration
and should only be included given certain configuration option,test
is the configuration option for unit tests. - Integration tests are stored in the root directory under
./tests
directory