rukΒ·si

πŸ¦€ Rust
Basics

Updated at 2025-01-19 01:08

Compiler-driven development at its best.

Install

Rust has three important parts in a standard setup:

  • rustup, a tool for Rust version management
  • rustc, the compiler
  • cargo, 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.rs by default, this becomes the executable.
  • For a library, it is src/lib.rs by 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 in Cargo.toml
└── 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
  • 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 &str as 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 const if you have a small value that you know at compile time and that won’t ever change at runtime.

Use static if you need "constant large data" that can't simply be inlined everywhere. Or static mut if 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);
}

Sources