Skip to content

Instantly share code, notes, and snippets.

@npodonnell
Last active January 19, 2023 11:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save npodonnell/d4fa94c1dcaf9812537663aae7a3890e to your computer and use it in GitHub Desktop.
Save npodonnell/d4fa94c1dcaf9812537663aae7a3890e to your computer and use it in GitHub Desktop.
Rust Crash Course

Rust Crash Course

N. P. O'Donnell, 2020 - 2023

Commands

rustup (Rust updater)

Show rustup version (not Rust version):

rustup -V

Show rustc version (and other info):

rustup show

Update the rust compiler:

rust update

rustc (Rust compiler)

Compile a Rust program:

rustc hello.rs

Packaging

cargo (Rust package manager)

Start something new:

cargo new

or

cargo init

Debug build:

cargo build

Release build:

cargo build --release

Debug run:

cargo run

Release run:

cargo run --release

Release run and pass args to program:

cargo run --release -- <program args>

Crates.io

Crates.io is a service where rust crates (a.k.a. packages) are hosted.

Language

Main Function

Every Rust program must have a main function.

fn main() {
    ...
}

Printing

println!("Hello");
println!("2 + 2 = {}", 4);
println!("My name is {} and my age is {}", "Noel", 42);

Data Types

Rust is a statically typed language with type inference.

Primitive Types

Primitive (or Scalar) types are: Int, Float, Bool, and Characters. Ints and Floats are further broken down based on signedness and bitness.

Integer Types

Name Description
i8 8-bit signed integer
u8 8-bit unsigned integer
i16 16-bit signed integer
u16 16-bit unsigned integer
i32 32-bit signed integer
u32 32-bit unsigned integer
i64 64-bit signed integer
u64 64-bit unsigned integer
i128 128-bit signed integer
u128 128-bit unsigned integer
isize architecture-dependant
usize architecture-dependant

isize and usize are equivalent to ssize_t and size_t respectively and have the same bitness as system word width - usually 64 bits.

Float Types

Name Description
f32 32-bit IEEE-754 single-precision float
f64 64-bit IEEE-754 double-precision float

Boolean Types

Name Description
bool Boolean

Character Types

Name Description
char Character (4 bytes)

Finding Out the Type of Something

The following function will print out the type of any reference (&<variable name>) passed to it:

fn print_type_of<T>(_: &T) {
    println!("{}", std::any::type_name::<T>())
}

Mutability

In Rust, variables are immutable by default. The following code won't compile:

let x: i32 = 12;
x = 13; // Illegal

To make a variable immutable, use the mut keyword:

let mut x: i32 = 12;
x = 13; // Will compile

Constants

A constant is a variable which is:

  • Bound to a name (regular variables are not).
  • Can not be made mutable using the mut keyword.
  • May only be set to a constant expression - the value must be known at compile time.
  • May be set in the global scope (regular variables can not).

Constants are declared using the const keyword and unlike regular variables their type must be specified. Constants use an all caps casing convention:

const A: i32 = 42; // Good
let const B: i32 = 43; // Illegal - let keyword not required
const mut C: i32 = 44; // Illegal - Can not make a constant mutable
const D: i32 = computeSomething(); // Illegal - can not assign to non-constant expression
const E = 10; // Illegal - type must be specified

Strings

Rust has two string types: String and str. String is a structure with a pointer to a heap-allocated buffer where the text of the string is stored, together with the number of used bytes (the length of the string if it's pure ASCII).

str is a smaller structure called a string slice which only contains the pointer to the start of the string and the length. str is immutable whereas String is mutable.

String literals are str's. str is a primitive type and is always borrowed. Borrowed variables are preceeded by a &:

let s1: &str = "Alice"; // str
let s2: String = String::from("Bob"); // String
println!("{} and {}", s1, s2); // Prints "Alice and Bob"

Internally, String and str do not use null-termination. If present, null characters are not honoured when printing:

let s1 = "Ali\0ce";

println!("{}", s1);
for c in s1.chars() {
  println!("{}", c as i32);
}

Which outputs:

Alice
65 108 105 0 99 101

Functionality is provided by Rust standard libraries for converting to and from null-terminated strings - used in C interop.

Shadowing

Shadowing allows a variable name to be re-used in the same scope with a different type. Typically shadowing is used after converting the value in some way, into a more useable type, for example a variable called customer could be passed into the function as a JSON string, but immediately converted into a struct. Most other typed languages such as Java/C++ require a different variable name for the struct customer making the string customer redundant. Rust's shadowing overwrites the string customer with the struct customer, keeping the simple variable name.

In Rust, the let keyword allows a variable name to be shadowed.

C++:

void process_customer(string customer)
{
  struct Customer customer_struct = parse_customer_json(customer);
  // At this point we still have the redundant `customer` variable and
  // we have a long, cumbersome variable name: `customer_struct`
}

Rust:

fn process_customer(customer: str) {
  let customer: Customer = parse_customer_json(customer);
  // At this point the JSON representation of customer is
  // gone and we have the same simple variable name: `customer`
}

Typecasting

The as keyword is used to typecast in Rust.

Convert an i32 into an i64:

let a: i32 = 10;
let b: i64 = a as i64;

Alternative way using .into():

let a: i32 = 10;
let b: i64 = a.into();

Unlike C++, Rust requires variables to be explicitly cast.

User Input

Most basic example:

use std::io;

fn main() {
    let mut name = String::new();
    println!("What is your name ?");
    io::stdin().read_line(&mut name).expect("Failed");
    println!("Hello {}", name);    
}

Output:

What is your name ?
Noel
Hello Noel
!

To remove the trailing \n, use .trim():

use std::io;

fn main() {
    let mut name = String::new();
    println!("What is your name ?");
    io::stdin().read_line(&mut name).expect("Failed");
    println!("Hello {}!", name.trim());    
}

Output:

What is your name ?
Noel
Hello Noel!

Conversion from String To Integer (with shadowing)

fn main() {
    let s: &str="55";
    println!("55 as a string: {}", s);

    let s: i32 = s.parse().expect("Parsing failed");
    println!("55 as a i32: {}", s);
}

Output:

5 as a string: 55
55 as a i32: 55

If-Else Construct

If-else statements are the same as C++/Java except parentheses around the predicate are not needed. The compiler will give a warning if parentheses are included.

fn main() {
    let x = 10;

    if (x == 10) {
        println!("x is 10");
    } else if x == 11 {
        println!("x is 11");
    } else {
        println!("x is something else");
    }
}

Output:

x is 10

If-Let Construct

If-let allows assignment using let to a variable based on an if-else clause:

fn main() {
    let x = 10;
    let y = if x > 5 {
        let a = 2;
        let b = 3;
        a + b
    } else if x > 3 {
        let c = 3;
        let d = 4;
        c + d 
    } else {
        let e = 4;
        let f = 5;
        e + f 
    };

    println!("y is {}", y);
}

Output:

y is 5

This improves slightly on Java/C++ where to do the above would require y to be temporarily unassigned:

int x = 10;
int y;

if (x > 5) {
    int a = 2;
    int b = 3;
    y = a + b;
} else if (x > 3) {
    int c = 3;
    int d = 4;
    y = c + d;
} else {
    int e = 4;
    int f = 5;
    y = e + f;
}

Loops

Rust has 3 kinds of loops: loop, while, and for.

loop Loop

loop is the simplest type of loop and is the preferred way of creating an infinite loop:

loop {
    ...
}

A loop loop can be broken out of using the break keyword:

loop {
    ...
    if <condition> {
        break;
    }
}

while Loop

A while loop combines the condition into the loop construct and is similar to C++/Java's while loop:

while <condition> {
    ...
}

for Loop

A for loop is used to iterate over a collection. Under the hood it uses an iterator.

Simple forward loop - i takes on the values 1, 2, 3, 4, 5, 6, 7, 8, and 9:

for i in 1 .. 10 {
    ...
}

Reverse for-loop - i takes on the values 9, 8, 7, 6, 5, 4, 3, 2, and 1:

for i in (10 .. 1).rev() {
    ...
}

For-loop with step - i takes on the values 1, 3, 5, 7, and 9:

for i in (1 .. 10).step_by(2) {
    ...
}

Reverse for-loop with step - i takes on the values 9, 7, 5, 3, and 1:

for i in (1 .. 10).rev().step_by(2) {
    ...
}

Functions

Functions in Rust are defined using the fn keyword. A Rust function may consist of the following properties:

  • Function name (Required)
  • Return type (Optional)
  • Arguments (Optional)
  • Body (Optional)

By convention, Rust functions are written using snake_case. A minimal Rust function with only a function name:

fn foo() {}

A Rust function with all 4 properties:

fn bar(x: i32, y: i32) -> i64 {
    (x * y) as i64
}

This function may be called and the result printed with:

println!("{}", bar(10, 20));

Returning Values

There are two ways to return a value from a Rust function:

// Traditional way - return keyword.
fn foo() -> i32 {
    return 42;
}

or

// Minimal Rust way - expression on a single line with no semicolon.
fn foo() -> i32 {
    42
}

Function Ordering

Unlike C++ and Python, ordering of functions in Rust does not matter. All functions are visible to all other functions in the same .rs file regardless of order:

// Works

fn foo() {}

fn main() {
    foo()
}
// Also works

fn main() {
    foo()
}

fn foo() {}

Nested Functions

Rust allows nested functions:

fn main() {
    fn foo() -> i32 {
        63
    }

    println!("{}", foo())
}

Recursion and Tail Recursion

Rust functions support recursion and tail recursion is likely if the return value is the last statement. However tail recursion is not guaranteed by the Rust standard.

Tuples and Arrays

Tuples

Tuples have a fixed size and may contain values of different types. To create a tuple, use parentheses:

let tup1: (i32, f64, u8) = (-1241256, -0.053, 211);

Alternatively types within tuple may be inferred:

let tup2 = (-1241256, -0.053, 211);

Tuples are zero indexed. To access individual elements, use the syntax <tuple>.<index>. For example:

let tup: (i32, f64, u8) = (-1241256, -0.053, 211);
println!("First tuple element is {:?} and last element is {:?}", tup.0, tup.2);

The individual elements of a tuple can be assigned into variables. This is known as destructing:

let tup = (-1241256, -0.053, 211);
let (a, b, c) = tup;
println!("{:?} {:?} {:?}", b, c, a);

Output:

-0.053 211 -1241256

Arrays

Arrays are similar to tuples - they have a fixed size, can contain many elements, and are zero indexed, but all elements must be of the same type. To create an array, square brackets are used:

let arr1: [i32; 5] = [1, 2, 3, 4, 5]; // Creates the array [1, 2, 3, 4, 5]

More succinct notation using type inference:

let arr2 = [1, 2, 3, 4, 5]; // Creates the array [1, 2, 3, 4, 5]

Create an array of five 9s:

let arr3: [i32; 5] = [9; 5];

Or more succinctly:

let arr4 = [9; 5];

Unlike tuples, individual elements are accessed using square brackets:

println!("{:?}", arr1[0]); // Prints the first element.

Unlike C++, all values in Rust arrays must be initialized. If any arrays containing uninitialized values are present in a Rust program, that program will not compile. This is one of Rust's memory safety features.

An array may be iterated over using the .iter() method:

for i in arr.iter() {
    ...
}

The length of an array may be found using the .len() method:

let arr = [1, 2, 3];
println!("arr has length {}", arr.len()); // arr has length 3

Ownership

In Rust, a block of memory may only be associated with 1 variable name (owner) at a time. For example the following code will not compile:

fn main() {
    let s = String::from("Hello");
    let s1 = s;
    println!("{} {}", s, s1);
}

because s1 and s both point to the same block of memory. Changing the line

let s1 = s;

to

let s1 = &s;

makes it compile, but s1 is now an immutable reference to the data pointed to by s, in other words s1 is read-only. If you need s1 to be mutable, use clone:

let mut s1 = s.clone();

References & Borrowing

The reference operator (&) converts a non-primitive value into a reference to that value:

let s2: String = &s1;

Assigning a variable to a reference of another variable is known as borrowing. In the example above, s2 borrows s1's data, which means s2 may read s1's data but can not write to or delete it. If a function takes a reference as a parameter, the reference may not be modified in the body of the function and the memory it points will not be freed when the reference goes out of scope:

fn takes_ref(s: &String) {
    // s is a string reference, so we can do read-only stuff with s such as print:
    println!("{}", s);
    
    // End of the function -- s does not get freed either, because somebody else owns its data.
}

Mutable References

Mutable references are declared with the &mut operator instead of the & operator. Only a mutable variable may be referenced by a mutable reference. Functions may also take mutable references as arguments, meaning they can be used to modify an object without returning it:

fn exclaim(s: &mut String) {
    s.push('!');
}

let mut s1 = String::from("hello");
exclaim(&mut s1);
println!("{}", s1); // Prints "hello!"

Limits of References & Immutable References

  • There is no limit to the number of references to a single piece of data per scope.
  • There is a limit of one mutable reference to a single piece of data per scope.
  • No piece of data may be refered to by both a mutable reference and an immutable reference within a scope.

Slices

A slice allows a contiguous sequence of elements to be referenced efficiently. For example, a large string containing several words can have each work referred to by a slice. Slices can be mutable or immutable references and are declared with the & (or &mut), [, .. and ] operators:

let s = String::from("Hello World");
let slice1 = &s[0 .. 5];      // Start at index 0, end at index 5-1 ... "Hello"
let slice2 = &s[6 .. 11];     // Start at index 6, end at index 11-1 ... "World"
let slice3 = &s[6 .. =5];     // Start at index 6, make slice of length 5 ... "World"
let slice4 = &s[ .. 5];       // Start index is omitted and defaults to 0 ... "Hello"
let slice5 = &s[6 ..];        // End index is omitted and defaults to 11 ... "World"
let slice6 = &s[..];          // Start and end indexes omitted ... "Hello World"

Structs

Use the struct keyword to create a structure:

struct Person {
    name: String,
    age: i32
}

Structs use camel case.

Then to instantiate a Person:

let rosie = Person {
    name: String::from("Rosie"),
    age: 41
}; // <- Don't forget semicolon

Debugging

dbg! Macro

Use the dbg! macro to get the file name and line number of debug output:

dbg!(y)

Modules & Libraries

Rust modules are similar to C++ namespaces.

Imporing modules

You can import a module into your source file by using the use keyword:

use some::module;

Declaring modules

Declare a module using the mod keyboard:

mod foo;

Make it public:

pub mod foo;

Libraries

Creating a library module called foo:

cargo new foo --lib

Containers

Vector

Vector is better known as a list. Vectors can be created with the Vec container.

Create an empty vector which can hold i32s:

let mut v: Vec<i32> = Vec::new();

Use push to add new elements to the end of the vector:

v.push(1);
v.push(2);
v.push(3);

println!("{:?}", v); // Prints [1, 2, 3]

The vec! Macro

A (non mutable) vector can be declared and populated in one line with the vec! macro:

let v: Vec<i32> = vec![1, 2, 3];  // Not mutable, so efficient.

The type of v will be inferred when using vec! so the code above can be shortened to:

let v = vec![1, 2, 3];

Accessing Elements

Elements can be accessed in 2 ways:

  1. Square brackets notation
  2. get method

The get method is safer since it returns an Option<T> so if the index is out of range it will be None. With square brackets, the program will panic if the index is out of range.

let v = vec![1, 2, 3];

println!("{:?}", v.get(3));  // Prints 'None'
println!("{:?}", v[3]); // Crashes!

Storing Elements of Multiple Types

A vector can be made to hold elements of different types by using an enum:

enum SpreadSheet {
    Integer(i32),
    Float(f64),
    Text(String)
}

let row = vec![
    SpreadSheet::Integer(3),
    SpreadSheet::Float(3.4),
    SpreadSheet::Text(String::from("Hello"))
];

String

Converting other types to string

Use to_string to convert other types (e.g. i32) to String:

let x: i32 = 42;
let s = x.to_string();

Use push_str to append to a String:

let mut s = String::from("Hello");
s.push_str(" World");
// s is now "Hello World"

Concatenating Strings

Using the + operator:

let a = String::from("hello");
let b = String::from(" world");
let c = a + &b;

a can not be used anymore because it's been moved. If you want to keep using it, use a clone instead:

let a = String::from("hello");
let b = String::from(" world");
let c = a.clone() + &b;

format! Macro

let a = String::from("hello");
let b = String::from("world");
let c = format!("{}, {}!", a, b);
println!(c) // Prints 'hello, world!'

Accessing Individual Characters

Use the String::chars method to access individual characters of a String:

let c = String::from("Hello");

for c in s.chars() {
    println!("{}", c);
}

Get the n-th character:

s.chars().nth(4); // Returns the 4th character.

HashMap

To use HashMap, first use it:

use std::collections::HashMap;

Create a new HashMap:

let mut scores = HashMap::new();

Insert some values:

scores.insert("Blue", 10);
scores.insert("Red", 20);

Constructing a HashMap with collect:

let teams = vec!["Blue", "Red"];
let scores = vec![10, 20];

let team_scores: HashMap<_, _> = team.iter().zip(score.iter()).collect();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment