π¦ Rust - Basics
Compiler-driven development at its best.
Install
Rust has three important parts in a standard setup:
rustup, a tool for Rust version managementrustc, the compilercargo, the package manager
I personally use mise to manage my programming environments, but you might want to just use rustup to install Rust.
rustup show
rustup install stable
rustup default stable
rustup update
// hello.rs
fn main() {
println!("Hello World!");
}
rustc hello.rs; ./hello
# Hello World!
Cargo
Cargo is the Rust package manager; similar to pip in Python or npm in Node. You use it to manage your Rust projects and their dependencies.
mkdir my-project
cd my-project
cargo init --bin
# you would use --lib if you were creating a library
# cargo init --lib
tree -a .
# .
# βββ Cargo.toml
# βββ .git
# βββ .gitignore
# βββ src
# βββ main.rs
cat Cargo.toml
# [package]
# name = "my-project"
# version = "0.1.0"
# authors = ["ruksi <[email protected]>"]
# edition = "2021"
#
# [dependencies]
cat src/main.rs
# fn main() {
# println!("Hello, world!");
# }
cargo run
# Compiling my-project v0.1.0 (/home/ruksi/my-project)
# Finished dev [unoptimized + debuginfo] target(s) in 0.19s
# Running `target/debug/my-project`
# Hello, world!
# you would run tests while developing...
cargo test
# and the finally compile a releasable binary
cargo build --release
./target/release/my-project
# Hello, world!
It is idiomatic to include unit tests next to the implementation.
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*; // import everything from outer scope, even private stuff
#[test]
fn test_add() {
assert_eq!(add(1, 2), 3);
}
}
Crate
Rust crate is a compilation unit, a complete package of code. Crate can be a binary (i.e., executable), a library (i.e., reusable code) or both.
Each crate has one or more build targets.
- For a binary, it is
src/main.rsby default, this becomes the executable. - For a library, it is
src/lib.rsby default, this becomes the library. - You can have multiple entry points e.g.:
- Each file under
src/bin/becomes a binary - You can be more explicit with
[[bin]]definitions inCargo.toml
- Each file under
βββ src
βββ main.rs // (optional) the default binary target
βββ lib.rs // (optional) if you have a library target
βββ bin
βββ tool.rs // builds a `tool` executable
βββ helper.rs // builds a `helper` executable
cargo build --bin tool
cargo run --bin tool
cargo build --bin helper
cargo run --bin helper
Build target is not to be confused with platform target like
x86_64-unknown-linux-gnu
- Build target is what is being build
- Platform target is where it will be run, thus how it will be built
Module
Within each crate, you have multiple modules to organize your code.
Starting from the crate root (or parent module), you define your modules:
// main.rs
mod foo; // resolves to "foo.rs" or "foo/mod.rs"
This is different from Python where each file is a "module" by default. In Rust, you have to explicitly define modules, and are even allowed to nest multiple module namespaces in a single file.
Namespaces
Namespaces are derived from crates and modules.
- There is no namespace keyword
- Crate creates the top-level namespace
- Within the crate, you use
crate:: - Outside the crate, you use the crate name
- Within the crate, you use
- Modules create nested namespaces under the crate
The double-colon :: traverses namespaces.
fn main() {
// `std` is a crate namespaces
// `cmp` is a module namespace
// `min` is a function in that module
let least = std::cmp::min(2, 8);
assert_eq!(least, 2);
}
use introduces names to the current namespace.
use std::cmp::min;
// or e.g. `use std::cmp::{min, max};`
// or e.g. `use std::cmp::*;`
fn main() {
let least = min(6, 1);
assert_eq!(least, 0);
}
Rust implicitly inserts use std::prelude::v0::*; on each module. This introduces some common types like Vec, String, Option and Result.
Some libraries also have a prelude, which is a set of common types and functions, but you still need to import them with
use.
Variables
You define variables with let. The compiler must always know the type of each variable, but you don't always need to specify it as the compiler can infer it from the context. You have the usual primitive types like i8, f64, u32 and the rest. If you need to redefine the variable later, you must use let mut (mutable). Also, variable definitions can be the result of a block like shown below.
fn main() {
let number1 = 8;
assert_eq!(number1, 8);
let mut number2 = 8;
number2 = 10;
assert_eq!(number2, 10);
let number3 = {
let number4 = 1;
number4 + 2
};
assert_eq!(number3, 3);
}
Rust has two main types of strings: String and &str. String literal &str (a reference to a static array of letters) and String (a dynamic array of letters) work differently. String is already a (smart) pointer to the actual text.
NB: if you simply write
"Text"in Rust, it's of type&stras it's a (static) reference to a static array of letters in the binary.
fn main() {
let name1: &str = "Ruksi";
let name2: String = String::from("Ruksi");
assert_eq!(name1, name2); // many functions play nice with both
}
In general, String is less performant but benchmark yourself to see if it matters. Usually, it does not, but depends on the use case.
Prefixing a string literal with b makes it a byte string. You'd use byte strings when 1) working with raw data that might not be valid UTF-8, or 2) if you know you are working with strictly ASCII data and want to avoid the overhead of UTF-8.
fn main() {
let greeting = b"Hello!";
println!("{:?}", greeting); // => [72, 101, 108, 108, 111, 33]
}
b"..."is of type&[u8]; literally an array of unsigned 8-bit integers.
Casting is asymmetrical and "primitive" that can cause silent errors. x as u8 as char essentially means "pretend that his unsigned 8-bit integer x is a character". In Rust, char is a number-to-letter mapping and 0β255 contain the most used letters, but char does go over full Unicode range so much larger than max u8; so it's fine to do u8 as char but unwise to do char as u8.
fn main() {
let a_char: char = 'A';
println!("{}", a_char as u8); // => 65
let a_number = 65;
println!("{}", a_number as u8 as char); // => A; but can give surprises
}
Tuples (,) work like you'd expect them to. You access the members with the dot notation, or you can destructure them.
fn main() {
let pair = ('a', 17);
assert_eq!(pair.0, 'a');
assert_eq!(pair.1, 17);
let (some_char, some_num) = pair;
assert_eq!(some_char, 'a');
assert_eq!(some_num, 17);
}
Shadowing variables is encouraged when it makes the code more readable.
fn times_two(number: i32) -> i32 {
number * 2
}
fn main() {
let final_number = {
let y = 10;
let x = 9; // x starts at 9
let x = times_two(x); // shadow with new x: 18
let x = x + y; // shadow with new x: 28
x // return x: final_number is now the value of x
};
println!("The number is now: {}", final_number)
}
There are constants and static variables.
- constants must be defined compile-time
- constants are always immutable
- constants are inlined when used
- static variables represent a fixed location in program memory
- static variables can be mutable; although that is an unsafe operation
const PI: f64 = 3.14159;
static mut COUNTER: i32 = 0;
Use
constif you have a small value that you know at compile time and that wonβt ever change at runtime.
Usestaticif you need "constant large data" that can't simply be inlined everywhere. Orstatic mutif you need to mutate it runtime.
Ranges
.. means range. Used in slicing.
fn main() {
let v = vec![1, 2, 3, 4, 5];
let v2 = &v[2..4];
assert_eq!(v2, [3, 4]);
}
fn main() {
assert_eq!((0..).contains(&100), true); // 0 or greater
assert_eq!((..20).contains(&20), false); // strictly less than 20
assert_eq!((..=20).contains(&20), true); // 20 or less than 20
assert_eq!((3..6).contains(&4), true); // only 3, 4, 5
}
Type Aliases
Type aliases define a new name for an existing type. They are used to reduce repetition, and keep the code more readable and maintainable.
// aliasing to make the code more readable
type Kilometers = i32;
// aliasing to reduce repetition
type IoResult<T> = Result <T, std::io::Error>;
type JsonResult<T> = Result<T, serde_json::Error>;
// aliasing to... make the code remotely writable by humans π
type MyBox = Box<Fn() + Send + 'static>;
Yes, types can get quite complex in Rust.
Debugging
dbg! macro is the best way of print debugging. But println! is fine too.
fn main() {
let name = "Ruksi";
println!("Name: {}", name); // Name: Ruksi
println!("Name: {:?}", name); // Name: "Ruksi"
println!("Name: {name:?}"); // Name: "Ruksi"
// dbg! is nicer as it includes the variable name too
dbg!(name) // name = "Ruksi"
}
format!() is the same as print!() but just returns the String.
fn main() {
let toy = "Lego";
let text = format!("Toy Name: {}", toy);
assert_eq!(text, "Toy Name: Lego");
}
{} calls std::fmt::Display and {:?} calls std::fmt::Debug. Here are the rest of the most common helpers:
{} = display format, the same as String::from()
{:?} = debugging format
{:#?} = pretty debugging print
{:p} = the pointer memory address
{:b} = binary
{:x} = hexadecimal
{:o} = octal
You can give indices and names to format! placeholders. Can help future-proof String constructions a bit.
fn main() {
let one = "1";
let two = "2";
let three = "3";
let text = format!("Numbers are: {what}, {1}, {0}", one, two, what=three);
assert_eq!(text, "Numbers are: 3, 2, 1");
}
panic! macro violently stops the program with an error message. Very violent, but sometimes useful for debugging.
fn main() {
panic!("This panics");
}
Structs
Structs are like classes; types you attach methods into.
struct Vec2 {
x: f64,
y: f64,
}
impl Vec2 {
fn is_positive(self) -> bool {
self.x > 0.0 && self.y > 0.0
}
}
fn main() {
let v1 = Vec2 { x: 1.0, y: 3.0 };
assert_eq!(v1.x, 1.0);
assert_eq!(v1.y, 3.0);
let v2 = Vec2 { x: -2.0, y: 1.5 };
assert_eq!(v1.is_positive(), true);
assert_eq!(v2.is_positive(), false);
}