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 Makefile
s 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 aMakefile
. It will default to the following when created withcargo 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 attarget/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
andargv
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 likeprintln()
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 howi32
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 ifvoid
could be an actual value that you can assign to a variable. Omitting the return type like we do inmain
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.