Rust

Memory safety without garbage collection! Concurrency without Data Races! Abstraction without Overhead!

Why Rust?

Rust was designed to give the programmer as much direct control as C++, allowing:

...but without all the bugs that plague C++ programmers. Rust is safe! There are:

Rust compiles to the metal and doesn't need a runtime nor a garbage collector, so:

...although:

But if you do need unsafe features, they are available:

Impressive, right? A lot of people think so. From the StackOverflow 2022 Developer Survey:

mostlovedlangs.png

Rust wins the “most loved“ category — by quite a bit.

Here’s Jeff’s introduction:

Getting Started

Rust is a systems programming language, so you compile source files into machine executables. Say hello:

hello.rs
fn main() {
    println!("hello");
}

While yes, yes, you can run this online at The Rust Playground, TIO, or Replit, you’ll probably want to install Rust yourself and run it on the command line:

$ rustc hello.rs && ./hello
hello

Like C, C++, and Go, you need that darn main.

Here is what simple for-loops, if-statements, and string formatting look like:

triple.rs
fn main() {
    for c in 1..41 {
        for b in 1..c {
            for a in 1..b {
                if a * a + b * b == c * c {
                    println!("({a}, {b}, {c})");
                }
            }
        }
    }
}
$ rustc triple.rs && ./triple
(3, 4, 5)
(6, 8, 10)
(5, 12, 13)
(9, 12, 15)
(8, 15, 17)
(12, 16, 20)
(15, 20, 25)
(7, 24, 25)
(10, 24, 26)
(20, 21, 29)
(18, 24, 30)
(16, 30, 34)
(21, 28, 35)
(12, 35, 37)
(15, 36, 39)
(24, 32, 40)

Another one. Here is Phil Dorin's 180° clock hands problem:

clockhands.rs
fn main() {
    for i in 0..11 {
        let t = (((i as f64) + 0.5) * 43200.0 / 11.0) as i32;
        let (hours, remaining_seconds) = (t / 3600, t % 3600);
        let (minutes, seconds) = (remaining_seconds / 60, remaining_seconds % 60);
        println!("{:02}:{minutes:02}:{seconds:02}", if hours == 0 {12} else {hours});
    }
}
$ rustc clockhands.rs&& ./clockhands
12:32:43
01:38:10
02:43:38
03:49:05
04:54:32
06:00:00
07:05:27
08:10:54
09:16:21
10:21:49
11:27:16

Yes, that conditional expression (if hours == 0 {12} else {hours}) really looks like an if-statement. And yes, that is how it is written. There is no ?: operator.

One more introductory example. Here is how to use command line arguments. And vectors. And result objects. And a lot of other stuff. Don’t worry about what this program does right now. Just get a feel for what Rust “looks like” for now.

fib.rs
fn main() {
    let args: Vec<String> = std::env::args().collect();
    if args.len() != 2 {
        println!("Exactly one command line argument required");
        return;
    }
    let Ok(n) = args[1].parse() else {
        println!("Must be positive integer");
        return;
    };
    let (mut a, mut b) = (0, 1);
    while b <= n {
        print!("{b} ");
        (a, b) = (b, a + b);
    }
    println!("");
}
$ rustc fib.rs && ./fib
Exactly one command line argument required
$ ./fib 20000
1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765 10946 17711
$./fib dog
Must be positive integer

Learning Rust

Now that we have seen what Rust programs look like, it’s time to learn (a lot of) the language. Where can we do this? Here are some of the important reads:

Getting advanced? Try:

Let’s take our own language tour now.

Variables

Introduce new variables with let. If a variable is not used, that’s an error, unless the variable’s name starts with an underscore.

fn main() { 
    let x = 1;
    println!("{x}");    // used it
    let y = 2;          // ERROR: y is not used
    let _z = 3;         // Ok, don't have to use this one
}

Subsequent declarations of variables with the same name shadow previous ones.

fn main() { 
    let x = 1;          // introduces a variable
    println!("{x}");    // prints 1
    let x = 2;          // completely different variable, shadows previous x
    println!("{x}");    // prints 2
}
Exercise: Try this example, and as many of these on this page as you can, in the Rust Playground.

Variables are immutable by default. Use mut to make the variable mutable.

fn main() { 
  let x = 1;
  x = 2;                // cannot assign twice to immutable variable `x`
  println!("{x}");      // prints 1
  let mut y = 3;        // mut makes it mutable
  println!("{y}");      // prints 3
  y = 5;                // second assignment is ok
  println!("{y}");      // prints 5
}

Blocks create new scopes. Variables introduced in blocks are in scope only from their declaration to the end of the block.

fn main() { 
    let x = 1;
    {
        println!("{x}");    // prints 1 (value of x from outside)
        let x = 2;          // a completely new var, shadows outer x
        println!("{x}");    // prints 2
    }
    println!("{x}");        // prints 1 (we are back outside)
}

Rust always constrains the types of values allowed for a variable. Normally the constraint is inferred from the values assigned to it (including an initializer, if present). The constraints are checked at compile-time.

fn main() {
    let mut x = 2;      // x constrained to an integer type
    println!("{x}");
    x = 5;              // assignment is ok!
    println!("{x}");
    x = 3.0;            // mismatched types: expected integer found floating-point number
}

If there is no initializer, you can’t use the variable until you’ve assigned to it. Then it gets its type constraint.

fn main() {
    let x;              // Perfectly ok
    println!("{x}");    // used binding `x` is possibly-uninitialized
    x = 5;              // Finally initialized constrained to integer type
    println!("{x}");    // Happily prints 5
}

You can explicitly supply a constraint even if an initializer is present.

fn main() {
    let a: i32 = 5;
    let b: i8 = 3;
    let c: char = '😀';
    let d: [i32; 3] = [10, 20, -30];
    let e: bool = false;
    let f: f64 = 3.8E-5;
    let g: String = String::from("hello");
    println!("{a} {b} {c} {d:?} {e} {f} {g}");
    // prints 5 3 😀 [10, 20, -30] false 0.000038 hello
}

The let statement introduces variables via patterns. Examples:

fn main() { 
    struct Point { x: f64, y: f64 }
    let p = Point { x: 3.3, y: -1.0 };
    
    let (a, b) = (3.5, "interesting");
    let [c, d] = [false, true];
    let Point {x: e, y: f} = p;    
    println!("{a} {b} {c} {d} {e} {f}");
    // prints 3.5 interesting false true 3.3 -1

    let Point {x, y} = p;       // x is short for x:x
    let [z, ..] = [1, 2, 3];    // .. stands in for "all remaining"
    println!("{x} {y} {z}");    // prints 3.3 -1 1
}

A pattern that might not match is called refutable. You can define with “let-else” to handle a refutable pattern.

fn main() { 
    let mut v = vec![1, 2, 3];
    let Some(t) = v.pop() else {
        return;
    };
    println!("{t}");    // prints 3
}

Statements

There are basically three kinds of statements.

KindPurposeNotes
Let StatementsIntroduces one or more new variables(Many examples above)
Item DeclarationsDeclares an itemItems can be: modules, extern crates, use declarations, functions, type aliases, structs, enums, unions, constants, statics, traits, implementations, extern blocks, macros
Expression StatementsEvaluates an expression and “throws away” the result, evaluating the expression only for its side effect(s)Expression statements can be: literals, paths, oeprators, groups, arrays, awaits, indexings, tuples, tuple indexings, structs, calls, method calls, field accesses, closures, async blocks, continues, breaks, ranges, returns, underscores, macros, blocks, unsafe blocks, infinite loops, predicate loops (whiles), predicate pattern loops (while-let) iterator loops (for), ifs, if-lets, matches, labeled expressions

It’s rather interesting that expressions are statements, and many things you normally think of as statements in other languages (if, while, match, etc.) are actually expressions.



There is some power in this idea. Blocks are sequences of statements, but the blocks themselves are expressions! The value of the block is the value of the last expression in the block, but be careful with semicolons: the empty expression is an expression, so you probably don’t want a final semicolon if you want to the block expression to have a value.



Blocks are actually expressions!

But if the last thing in a block is an expression, then the block itself is an expression, and the value of the block expression is the value of the last expression. Wild!



This works well for functions bodies, which are also blocks.



Types

What types are available to you? A lot! Here are the built-in ones. All of them have values of a fixed size, so we know exactly how much space each of their values need at compile time. This is necessary to make programs run as fast as possible:

TypeExamplesNotes
i824i8-128...127
i16-30000i16-32768...32767
i3299310855i32-2147483648...2147483647
i64-89i64-9223372036854775808...9223372036854775807
isize3500022isizeSigned integer with size of a pointer on the host architecture
u8255u80..255
u164095u160...65535
u32999999999u320..4294967295
u642u640..18446744073709551615
usize89553421138usizeUnsigned integer with size of a pointer on the host architecture
f323.2e-7f3232-bit IEEE Float
f641f6464-bit IEEE Float
booltruetrue or false
char'å'32-bit Unicode scalar value in range 0x0000–0xD7FF, 0xE000–0x10FFFF
str(Cannot write out values of this type)Basically a [u8], that is, a slice (see below) of 8-bit unsigned bytes, though whose elements constitute a valid UTF-8 sequence. (Like all slice types, you never see instances of this type written on their own.)
!(This type has no values)This is called the Never type. There are no values of this type. It is the return type of functions that don’t return.

And if you want to make your own types, there are a crazy amount of ways to do this. Here are a few of them:

Kind of typeExamplesNotes
tuple types()
(u16, char, f64)
array types[i32; 5]Yes, length is part of the type. [1,1,2,3,5]
slice types[i32]
struct typesstruct {x: f64, y: f64}
enum types
union types
function item types
closure types
pointer types
trait object types

Not only can you make your own types, but so have thousands of other programmers. These are available to you in various crates. Many of the crates are part of the Rust standard library. Here are a few:

TODO

Why so many types?

As a safe, non garbage-collected, zero-abstraction systems language, Rust needs to have numeric types of known sizes, have explicit pointers, and make sure there are ways to control the sizes of what are normally thought of as dynamically sized entities like array slices and strings. Making sure everything is memory safe is handled in the language, often in the type system.

Booleans

Numbers

Tuples

Tuples are cool! Access components with .0, .1, etc. Destructure as needed, too:

TODO

Structs

Structs are pretty similar to tuples, but while tuple components are positional, struct components are named.

Arrays and Slices

Functions

Strings

rust-string-meme.jpg

Ownership and Borrowing

Data is owned by default. But there is region-based borrowing. Individual types can be marked as copy types. And there are (C++-style) destructors.

The big problem is the combination of aliasing with mutation. Can we separate those things?

More on Pointers

Impls and Traits

Enums

Functional Programming

Concurrency

Unsafe

Architecture

Rust programs are made up of crates. Each crate is a collection of items. Because creates can contain thousands of items, the items may be grouped into modules. The modules are arranged in a hierarchy within the crate. You can think of the the “top-level” of the crate as an unnamed module.

To refer to items, use ::. Some examples from above:

But if you use the use keyword, you don’t have to give the full path to the item.

TODO