Class 1: Starting on Rust

Why Rust?

In this course, we'll be programming in a language called Rust. Rust is a relatively young programming language that officially reached 1.0 in 2015, making it decades younger than most other popular languages. Although it's young, Rust isn't a particularly new language: it has many influences and is somewhat of a melting pot of popular ideas from the past 50 years. For example, Rust has a lot of C++ influences making it particularly good for low-level programming, but it also takes a lot of ideas from functional programming languages like OCaml and Haskell, which are more popular in academia.

If that doesn't get you excited, consider this. You've taken CS31 and CS35, and now have experience writing C and C++ programs. While those languages are cool because they give you so much control, how many times did that end up backfiring by allowing you to shoot yourself in the foot? How many times did you think you finished a lab, but then end up spending another hour trying to satisfy Valgrind? How many segmentation faults did you get from accidentally dereferencing null pointers?

Rust is a programming language that parallels C and C++ in terms on control over memory management, but also guarantees memory safety at compile time. That means you can still write all the fun, optimized programs in Rust that you could in C or C++, but without ever having to worry about Valgrind or segfaults. For this reason, companies like Google, Microsoft, Amazon, Meta, and more are all using Rust in some areas as a replacement for where they would otherwise use C++.

Cargo, the Rust Package Manager

You're probably completely unfamiliar with Rust, and that's totally okay. The goal of this class is to get you up to speed on the very fundamentals so that you can get through the first lab.

Rust, like C++, is a compiled language. That means you need to run the compiler to convert your program into a binary before you can run it. If you've taken CS35 recently, you'll probably remember clang++, which is the command line tool used to invoke the compiler. Rust has a tool called rustc, which is similar in that it can be used to invoke the Rust compiler with a whole lot of configuration options.

However, just like how you almost never touched clang++ directly and used Makefiles instead, we'll never be using rustc directly because it's a pain to manually use. Instead, we'll be using Cargo. Cargo is the official Rust package manager, and will allow us to create projects, compile projects, download dependencies, and is capable of much more that we won't be exploring. It should be installed on all the lab machines already.

To use Cargo, you need a project, which is just a directory containing a Cargo.toml configuration file and a src/ directory containing source code files. There are two kinds of projects: applications and libraries. Applications have a main function just like C++, while libraries do not but expose a public interface of functions and types that can be used by other libraries or applications. If the project is an application, then the src/ directory must contain a main.rs file, where .rs is the file extension for Rust source code. If it's a library, then the src/ directory must contain a lib.rs file.

Running your first "Hello, world!" program

Let's start with a quick "hello world" program. We'll first run the cargo new command to create a new Rust project that's an application:

$ cargo new hello-world

This command will then create the project which looks like this right now:

hello-world/
├─ src/
│  ├─ main.rs
├─ Cargo.toml

In your labs, the files you git clone from the starter code repositories should have a file structure similar to this so you shouldn't need to use cargo new yourself.

You might be wondering what Cargo.toml is, and it can be summarized as a configuration file that serves a similar purpose to a Makefile. It will default to the following when created with cargo new:

[package]
name = "hello-world"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

It's used for specifiying dependencies and configuring compiler options, as well as providing project metadata when publishing a project to the internet. Since we're working on such a simple program right now, we can leave it with all the defaults.

Then, we'll cd into the project directory:

$ cd hello-world

... and open up src/main.rs in our favorite editor:

fn main() {
    println!("Hello, world!");
}

Pro tip: for most blocks of Rust code on this website, you can hover your cursor over the block which should make some buttons appear at the top right corner. If you press the play button, it will compile and run the code and show you the result!

In fact, Cargo will already default to the hello world program for new applications. Since this is an application and not a library, we can run the program using cargo run from inside the top level of the project directory:

$ cargo run
   Compiling hello-world v0.1.0 (/Users/quinn/Documents/hello-world)
    Finished dev [unoptimized + debuginfo] target(s) in 2.44s
     Running `target/debug/hello-world`
Hello, world!

You may be wondering where the binary is. After all, running make in CS35 spits out a binary that you have to run yourself.

The answer is that Cargo will put all files related to the compilation process, including the resulting binary, in a target/ directory in your project. For example, you can find the binary at target/debug/hello-world as hinted in the above output.

Syntax of "Hello, world!" program

Now we're going to spend the rest of the class talking about basic Rust syntax for doing things that you should already be familiar with from other languages.

Let's start by walking through some syntax by starting with the "Hello, world" example:

fn main() {
    println!("Hello, world!");
}

Let's break it down line by line, starting with the first where we declare the main function:

fn main() {
}

Unlike C and C++, function declarations start with the fn keyword instead the the return type. Then comes the name, main, followed by the arguments that the function takes, which are always empty for the main function.

In C and C++, we've seen argc and argv used here, but Rust has an alternate way of accessing command line arguments (std::env::args()) which we won't discuss now.

Then there's an open brace to indicate the start of the function, which takes us to the next line:

fn main() {
    println!("Hello, world!");
}

In Rust, we use println!() to print to the console.

Notice that it has ! between the name and the arguments: this indicates that it is a macro, not a function. If it were a regular function, we would call it like println() instead without the !. Macros aren't as common as functions and we won't discuss the differences too much, but know that one of their use cases is for acting like a function that can take a variable number of arguments.

After the println!() statement, we end the function with a semicolon. Just like C++, Rust doesn't care about whitespace and needs the ; to determine when a statement ends.

Comments

Comments in Rust are identical to C and C++, where we use double slash // to denote a comment:

// The main function
fn main() {
    println!("Hello, world!"); // This let's us print to the console
}

You can also use /* */ for multiline comments, but // is almost always used in practice.

In most editors, you can highlight multiple lines and press ctrl + / to automatically comment and uncomment all highlighted lines. Note that you don't even need to highlight an entire line; any line that is even partially highlighted will toggle between commented or uncommented. You can try this out for yourself on this editable code block!

fn main() {
    println!("Hello");
    println!("Good morning");
    println!("Greetings");
}

See Chapter 3.4 for more details.

Variables and Mutability

In languages like C++, we're used to defining variables like so:

int x = 4;

In Rust, the equivalent would be written as:

#![allow(unused)]
fn main() {
let x: i32 = 4;
}

In the Rust example, we start with the keyword let, followed by the variable name, followed by : and the type, followed by an assignment. It should also be noted that instead of int for a 32-bit signed integer, it's called i32 in Rust instead.

And just like C++, Rust is statically typed meaning that the compiler needs to know the types of all variables when the program is compiled, and values can't change type halfway through a program. However, that doesn't mean that we have to explicitly write the types of all variables, and that's thanks to type inference, a feature built into the compiler. This means that we can omit writing the type altogether most of the time, like

#![allow(unused)]
fn main() {
let x = 4;
}

... and the language will figure out that x should have the type i32 when the program is compiled because 4 is a number.

One thing to note is that all variables are immutable by default. This means that once assigned, their values cannot change:

fn main() {
    let x = 4;
    println!("The value of x is {}", x);
    // Trying to reassign `x`
    x = 6;
    println!("The value of x is {}", x);
}

Oh yeah, this is how you print values. Think of it as exactly the same as C's printf function, but using {} as a placeholder for variables (regardless of type) instead of the weird mixture of %s and friends that you use in C.

Running this example, we get a compilation error: cannot assign twice to immutable variable `x` . In order to make x mutable, we can add the mut keyword to the declaration:

fn main() {
    let mut x = 4;
    //  ^^^
    println!("The value of x is {}", x);
    // Now we can reassign `x` and it will compile!
    x = 6;
    println!("The value of x is {}", x);
}

See Chapter 3.1 for more details.

Primitive Types

Since Rust is a systems programming la Just like C++, Rust has several primitive types that are built into the language. Here's a table with some of them:

Description Rust name
Signed 8-bit integer i8
Signed 16-bit integer i16
Signed 32-bit integer i32
Signed 64-bit integer i64
Signed pointer-sized integer isize
Unsigned 8-bit integer (byte) u8
Unsigned 16-bit integer u16
Unsigned 32-bit integer u32
Unsigned 64-bit integer u64
Unsigned pointer-sized integer usize
32-bit floating point number f32
64-bit floating point number f64
Boolean bool
UTF-8 encoded character char

See Chapter 3.2 for more details.

Functions

We've already seen the main function, but what about functions that have inputs and outputs? Let's write a function to add two numbers:

#![allow(unused)]
fn main() {
fn add(a: i32, b: i32) -> i32 {
    a + b
}
}

There's a lot going on here, so lets break it down.

The first thing to notice is that unlike C++, parameters in Rust are the name, followed by :, followed by the type. In this case, this means writing a: i32, b: i32. Unlike assigning variables, the type annotation is mandatory in functions.

To specify the return type, we put -> followed by the type after the list of parameters. In this case, we write -> i32, and this is also mandatory for functions that return values.

Now you may be wondering: where is the return statement, and why is there no ; at the end? Similar to functional languages like OCaml, Rust is an expression based language. At a high level, this means that functions always return whatever expression they evaluate to, so there's no need to write return most of the time when you can just say the last expression without a semicolon and have it implicitly returned.

Note that this is only the case if the expression is at the end of the function. For returning early, the return keyword can be used similarly to C++.

At this point, you may be wondering how functions that return "nothing", like main, work. These work thanks to another primitive type in Rust: the () type (pronounced "unit"). Just like how i32 is a type with 232 possible values, () is a type with exactly one value: (). It contains no state, and it may help to think of it as if void could be an actual value that you can assign to a variable. Omitting the return type like we do in main is just syntactic sugar for -> (), and ending an expression with ; will make it evaluate to ().

See Chapter 3.3 for more details.

Control Flow

There are three pieces of control flow you will need for the first lab: for loops, while loops, and if-else statements.

As you know, for loops let you iterate through some values. Fortunately, Rust's for loop syntax is quite similar to Pythons. To loop from 0 (inclusive) to 10 (exclusive), we write:

#![allow(unused)]
fn main() {
for i in 0..10 {
    // Do stuff with `i`.
    // For example, print it!
    println!("i = {}", i);
}
}

We can write for <variable> in <iterator> { ... }, where unlike C++, the braces are mandatory for the body of the loop.

while loops are also very straight-forward:

#![allow(unused)]
fn main() {
let mut n = 10;

// Decrement `n` each time until it's 0.
while n != 0 {
    n -= 1;
}
}

This is basically the same as a while loop in C++, but again we don't need parentheses around the conditional, and braces for the body of the loop are required. As a side note, the math operators and comparison operators as shown above are pretty much the same as C++, except you can't do i++ or i-- for incrementing or decrementing a variable.

The last tool that you'll need are conditional expressions. Building from what we've seen so far, they look exactly how you'd expect them to:

#![allow(unused)]
fn main() {
let age = 21;

if age > 18 {
    println!("You're an adult!");
}
}

You can also add an else block, just like in C++:

#![allow(unused)]
fn main() {
let price = 200;

if price < 20 {
    println!("Cheap");
} else {
    println!("Expensive");
}
}

And lastly, you can have multiple branching else if blocks:

#![allow(unused)]
fn main() {
let age = 32;

if age < 18 {
    println!("Child");
} else if age > 65 {
    println!("Senior");
} else {
    println!("Adult");
}
}

These should all feel very familiar from C++, with the only difference being that parentheses are not required.

Remember in the above section, where we said everything is an expression? This also applies to if-else expressions, where the expression as a whole will evaluate to whatever either of its blocks evaluate to. For this reason, the bodies of the if and the else parts must evaluate to the same type.

Here's an example:

#![allow(unused)]
fn main() {
let a = 21;
let b = 24;

let max = if a < b {
    b
} else {
    a
};
}

Since the if-else block is an expression and a and b have the same type, then this will assign max the value of whichever is larger. The reason it wouldn't work if a and b have different types is because Rust needs to figure out ahead of time the type of max, and it cannot do this if the condition is based on something at runtime like user input.

We could also make this a function:

#![allow(unused)]
fn main() {
fn max(a: i32, b: i32) -> i32 {
    if a < b {
        b
    } else {
        a
    }
}
}

Since the function only contains one expression, the if-else expression, it will evaluate that. Then, based off of whether a < b, the conditional will evaluate to either a or b. Notice how there are no semicolons terminating any lines of this function.

Writing functions like the above are considered idiomatic and clean in Rust, and you should aim to write code that takes advantage of expressions like this in your labs.

See Chapter 3.5 for more details.

~

Summary

  • Rust guarantees memory safety while targetting a similar domain as C++.
  • Cargo is used to manage Rust projects, like compiling and running with cargo run
  • The main function is where Rust programs start.
  • Comments use // like C++.
  • Rust is statically typed like C++, but variable types can be infered by the compiler.
  • Variables are immutable by default.
  • Functions implicitly return the last expression.
  • if-else statements evaluate to expressions.
  • Control flow has similar syntax to Python, except with curly braces.

For a more in depth exploration of the above concepts, check out Chapter 3 of the Rust Book.