Rust
Memory Safety Recap
Remember the heap commandments?
When programming in C (and C++), you need to be careful to avoid using memory after it’s freed, calling free twice on the same pointer, leaking memory, and doing pointer arithmetic that strays out of bounds.
You have doubtless noticed that this is not true in other programming languages you have used. C and C++ are the most popular examples of memory unsafe languages; memory safe languages (including Java, Python, and OCaml) don’t have this problem because they make it impossible to write this kind of bug.
Recall also that sometimes, a low-level language is unavoidable. When performance, predictability, and direct hardware control are important, something like C is the only option. The consequences, however, are dire. Memory safety bugs in C and C++ code are the cause of an enormous share of all bugs, especially security problems. I have mentioned the Microsoft report saying that 70% of their CVEs (officially numbered security vulnerabilities) come from memory-safety problems.
All this seems to introduce a dichotomy: programming languages are either memory safe or they have low-level hardware control, but generally not both at once. To explain why these goals are in competition, we will need to look a little closer at the mechanism that familiar memory-safe languages use to get their memory safety.
Garbage Collection
The common thread among most memory-safe languages (again, including Java, Python, and OCaml) is that they build in extra code, alongside the code you write, to automatically free memory for you.
In other words, you never write free.
An additional library runs continuously, alongside your code; its sole purpose in life is to detect when your code is done using a chunk of memory and to call free for you.
That system is called a garbage collector. Garbage collection (GC) is a major topic in computer science research because it is both (a) extremely important for most programming languages in the world and (b) hard to do efficiently.
The mechanics of GC are out of scope for CS 3410, but here’s a brief preview to give you an intuition for the costs and challenges involved. A common kind of garbage collector is mark–sweep collection. Here’s an overview of how that works:
- Occasionally, pause the program. This usually works by having the compiler insert code into your program that calls into the GC library. The library can then decide whether enough time has elapsed that it’s time to intervene and run GC.
- Mark all the pointer-reachable data on the heap. This works like a graph search (like BFS or DFS). Start from all the variables on the stack, and follow any pointers they contain to memory in the heap. Then, if that stuff on the heap contains pointers, follow all those pointers too. Keep doing this recursively, and mark everything you traverse. The point is that we will not free anything we mark.
- Sweep away all unmarked memory regions. We now know that anything that isn’t marked in the previous phase must be unreachable: it is impossible for the program to ever access that data, because there is no chain of pointers it could dereference to get to it. So we just call
freeon all of that stuff.
Modern garbage collectors are extremely sophisticated variations on that theme. Even the most advanced GCs, however, come with fundamental costs:
- By design, they take low-level control of memory away from the programmer. That control is unnecessary for many applications, but it’s absolutely critical for low-level system software.
- They consume time and space. A 2022 study by Cai et al. found that, even when provisioned with 2.4× more memory than the program actually needs, GCs make programs take 7–82% more time than an ideal, GC-free execution. (If you’re curious about the details, I recommend reading both that paper and another by Hertz and Berger in 2005.)
For many categories of software (although surely not a majority), those costs are too great.
Memory Safety Without Garbage Collection?
All this should raise the question: is it possible to have the best of both worlds? Is there such a thing as a programming language that enforces memory safety without a garbage collector? Or does one imply the other?
Imagine a Venn diagram containing all the programming languages in the world. We’ll draw two circles: one labeled “garbage collected” and one labeled “memory safe.” C and C++ are in neither circle. Most other languages in the universe are inside both circles. So the diagram is pretty close to just being a single circle. The historical record, therefore, seems to suggest that it’s hard to design a memory-safe language that doesn’t rely on GC to achieve that safety.
In the modern era, there is one language aiming for that narrow, almost-empty slice in our Venn diagram: Rust. Rust is the technology industry’s current best hope for achieving memory safety in the important category of system software that cannot bear the cost of GC.
Rust 1.0 came out in 2015. Since then, Rust’s nearly unique combination of memory safety and low-level control have gathered a torrent of attention. A February 2024 report from the Biden administration lays out the case for moving important software to memory-safe languages and explicitly names Rust as an important route to doing that. The idea of transitioning software to Rust is already so commonplace that ti has become a cliché; the phrase “rewrite it in Rust” (RIIR) is officially a programmer meme.
There is a catch, however. There has to be a catch: GC-less memory safety does not come without some sort of trade-off. (If it were “free,” all languages would do it!) The trade-off that Rust makes is a complex, restrictive type system. Or, to put it bluntly (probably too bluntly), Rust is hard to learn and hard to use. That’s the cost you pay as a Rust programmer: you climb the steep learning curve, and you reach the summit of memory safety without GC that is (more or less) exclusive to Rust.
Getting Started
If you want to follow along, I recommend installing Rust using the rustup tool.
It’s pretty quick.
Alternatively, you can try it out in your browser by typing code into the Rust Playground.
If you installed Rust, create a project by typing cargo init in an empty directory.
Then type cargo run to build and execute the generated “hello world” program.
(Cargo is a general-purpose command-line tool for managing Rust projects.)
Open src/main.rs in your editor and we’ll start writing some code together.
Basic Syntax
Here is the initial program that cargo init generated for us:
fn main() {
println!("Hello, world!");
}
Let’s notice a few syntactic features that are evident already:
- There’s a
mainfunction, just like in C. - You declare functions with the
fnkeyword. println!(yes, with an exclamation point) is our alternative toprintf, and it doesn’t require including any headers.
Let’s do something super simple to learn a little more syntax:
fn square(num: f64) -> f64 {
return num * num;
}
fn main() {
let x = 34.10;
println!("Here's a number: {}", square(x));
}
Some things to notice here are:
f64is the type of 64-bit floating-point numbers: the equivalent of C’sdouble.- In function signatures (and, in fact, everywhere), you write types after the variable name, separated by a
:. So we writenum: f64where C or Java would usedouble num. Return types for functions go after a->symbol. - You declare variables with
let. Most of the time, you don’t need to write a type. We could also have writtenlet x: f64 = ...here to make the type explicit. println!uses{}as a placeholder in its template strings, and you pass values afterward to interpolate them. This is a little like Python’s format strings, if you’ve used those.
Here’s something about Rust that may look a little funny if you’re coming from C, Java, or Python but which will feel totally natural if you’re coming from OCaml.
The syntax follows an “everything is an expression” philosophy, meaning that code generally evaluates to a value that you can use anywhere you would write a variable name like x or an expression like num * num.
One reflection of this philosophy is that we don’t actually need the return keyword here; you can instead just write the expression you want the function to evaluate to:
fn square(num: f64) -> f64 {
num * num
}
fn main() {
let x = 34.10;
println!("Here's a number: {}", square(x));
}
This approach also means that some constructs that would be statements in other languages are expressions in Rust.
A prominent example is if.
You can use if as if it were a statement, but it works just as well in “expression position”:
fn square(num: f64) -> f64 {
num * num
}
fn main() {
let x = 34.10;
println!("Here's a number: {}", square(x));
println!("That number is {}.", if square(x) > 50.0 {
"big"
} else {
"small"
});
}
In this lesson, we’ll also want to use Rust’s equivalent of C’s struct, which is also named struct.
It looks like this:
struct Point {
x: i64,
y: i64,
}
fn print_point(p: Point) {
println!("({}, {})", p.x, p.y);
}
fn main() {
let location = Point { x: 4, y: 10 };
print_point(location);
}
Here are some syntax points to notice:
- Declaring structs uses the same
name: typesyntax as function arguments. - Accessing fields in a struct uses dots, just like C (and Java and Python).
- To create a value of a struct type, use
MyType { my_field: some_value, ... }.
It Feels Like Everything is On the Stack
When we created a Point value above, we never had to do anything explicit (such as call free) to deallocate the memory.
If you think about the equivalent C, you wouldn’t need to call free there either,
because the value would go on the stack, not the heap.
So, just so we’re clear, the fact that you don’t explicitly call free on this stuff doesn’t mean that C (or Rust) has a garbage collector.
C can automatically manage the memory for the stack without a GC because it always knows exactly when it’s safe to free all local variables:
when the function returns.
Perhaps the most important thing I want you to know about Rust is that it takes that idea to the extreme. Almost everything in Rust follows that same “stack discipline”: when you allocate memory for something, it automatically “lives” as long as the function call that did the allocation is active. That includes stuff allocated on the heap! When a function allocates something on the heap, it typically automatically frees that heap allocation when it returns, without any explicit intervention from you.
The consequence is that, in Rust, it feels like every value is on the stack, even when it’s actually on the heap. By default, all data “belongs” to the function that allocated it; Rust automatically frees the memory when the function returns.
To see this in action, let’s try the built-in feature Rust has for explicitly putting stuff on the heap.
It’s called Box (named after the general concept of boxing).
We’ll create a heap allocation and get a pointer to it using the syntax Box::new,
and we can pass that pointer around using the type Box<Point> instead of plain old Point:
fn print_point(p: Box<Point>) {
println!("({}, {})", p.x, p.y);
}
fn main() {
let location = Box::new(Point { x: 4, y: 10 });
print_point(location);
}
To interpret this code, you may read Box<Point> as expressing the same thing as Point* in C: i.e., a pointer to a value of type Point.
(On a minor syntax note, Rust lets you “follow pointers” automatically, so p.x and p.y don’t need an explicit dereference.
In C, you would need to write p->x and p->y.)
Even though our point is now allocated on the heap,
its lifetime remains the same as if it were on the stack instead.
Under the hood, Rust will insert a call to free to deallocate the heap memory when the relevant variable goes out of scope (i.e., when the relevant function returns).
The effect is that, regardless of whether we use a stack-allocated Point or a heap-allocated Box<Point>, the memory gets deallocated at the same moment.
That’s what I mean by saying that it feels like everything is on the stack, even when it’s on the heap.
This approach leads to the two most important facts about Rust:
- Rust always knows at compile time exactly when to free memory that you allocate. It does that without any heavyweight code (i.e., a GC) executing at run time.
- Rust is very restrictive about when you can use values, to ensure that you can never try to use something after its lifetime ends. These restrictions mean that many things you can easily do in other languages are compile-time errors in Rust.
Those two facts embody the trade-off that I previewed above: GC-free memory safety at the expense of programming complexity.
Movement
Let’s revert to the stack-allocated version for simplicity and try doing something else with our point:
fn main() {
let location = Point { x: 4, y: 10 };
print_point(location);
println!("L1 norm: {}", location.x + location.y);
}
Rust will emit an error, complaining that location has been moved.
This is a lifetime error, and it embodies the kind of restrictiveness that characterizes Rust’s central trade-off.
The problem is that the function call, print_point(location), does something to location that is different from other programming languages.
In most programming languages, passing a variable as an argument to a function usually does one of two things:
it copies the value (pass by value),
or it creates a pointer alias to the same value (pass by reference).
In Rust, passing location to print_point instead moves the value from main to print_point.
This move semantics does not imply any copying or aliasing.
The consequence is that, after the function call, main can no longer access location—it’s gone!
Clone
One surefire way to fix lifetime errors is to copy stuff.
We can use the clone() method to do that:
fn main() {
let location = Point { x: 4, y: 10 };
print_point(location.clone());
println!("L1 norm: {}", location.x + location.y);
}
This approach essentially recovers the pass-by-value semantics common in other languages. The problem, of course, is that copying stuff can get expensive, especially when values are large.
(This listing omits an annotation I had to add to conjure a clone() method for Point.
It’s written #[derive(Clone)], and it’s not important for what we’re learning here.)
Ownership
Let’s take a step back and think about why Rust uses this unconventional “move” semantics for function calls.
Remember that Rust wants to know exactly where in your code to deallocate everything to allocate.
Conceptually, you can think of the compiler inserting calls to free into your code.
So when main allocates location and then passes it to print_point, Rust needs to know whether it should insert the free at the end of main or the end of print_point.
(Only one of them can do it; otherwise, we’d have a double free!)
This general principle is called ownership:
variables in the code own the values they point to,
and they free the associated memory when the variable goes out of scope (at the end of the function call that contains them).
Function calls in Rust transfer that ownership from the variable in the caller to the parameter in the callee.
The end result in our example is that the compiler knows to insert the (conceptual) call to free(p) at the end of print_point.
Rust prevents main from accessing that data after the function call because it has transferred ownership of the data away.
Move and Move Back?
Can we avoid the need for clone() and still let main do stuff with location after the call?
In Rust, that would require moving the Point back again when the print_point call ends.
We can do that by returning the Point back from the function:
fn print_point(p: Point) -> Point {
println!("({}, {})", p.x, p.y);
p
}
fn main() {
let location = Point { x: 4, y: 10 };
let location2 = print_point(location);
println!("L1 norm: {}", location2.x + location2.y);
}
In this version, print_point takes ownership of a Point, uses it for a little while, and then hands it back to its caller.
By putting it into another variable, main receives the point and can use it again.
Everything works!
It may already be clear, however, that this “move and move back” pattern would get inconvenient quickly. It’s pretty common to need to pass something into a function temporarily and then continue using it. Fortunately, Rust has a whole set of features to support this borrowing.
Borrowing and References
The syntax &T means “a borrowed reference to a value of type T,”
or, more briefly, “a T reference.”
A reference is like a pointer in C imbued with Rusty restrictions.
So we can achieve the same result the move-and-move-back strategy above like this:
fn print_point(p: &Point) {
println!("({}, {})", p.x, p.y);
}
fn main() {
let location = Point { x: 4, y: 10 };
print_point(&location);
println!("L1 norm: {}", location.x + location.y);
}
Here, main uses the & operator, which works like the & reference-of operator in C.
In Rust, it borrows a value of type T and produces a reference of type &T.
As the name implies, borrowing is temporary: it only lasts as long as the function call.
So after the print_point call,
main owns our Point again and can use it in exactly the same way as before the call.
There’s no need for print_point to return anything, nor for a second location2 variable in main.
You can think of borrowing with & as a more convenient alternative to the move-and-move-back strategy.
Importantly, though, it doesn’t let you work around any of the fundamental restrictions:
all memory is still owned by one specific variable,
and it will still get freed when that variable goes out of scope.
(Here, the conceptual call to free goes at the end of main.)
Borrowed vs. Owned Arguments
Here’s one example of something you can’t do.
You can’t pass a borrowed &T into a function that expects to own a T.
Like this:
fn black_hole(p: Point) {
println!("I own `p` and will now deallocate it");
}
fn print_point(p: &Point) {
println!("({}, {})", p.x, p.y);
black_hole(p);
}
Because black_hole takes a parameter of type Point and not &Point, it needs to take ownership of its argument.
(That means it will conceptually contain free(p) at the end of the function.)
print_point is only borrowing p, however, so it can’t transfer ownership to black_hole.
Returning a Reference to a Local
A classic mistake in C is returning a pointer to a local variable:
int* newint() {
int x = 42;
return &x;
}
This kind of mistake is so easy to commit that we explicitly warned you about it in the lectures about pointers. Let’s try the equivalent in Rust:
fn newint() -> &i64 {
let x = 42;
&x
}
Unlike C, Rust prevents us from shooting ourselves in the foot.
The error message tells us that the variable x owns the memory containing 42, and that variable will cease to exist when the function returns,
so nobody is allowed to borrow it past that point.
Mutability
In Rust, everything is immutable by default. If you want mutation, you need to ask for it explicitly. (This is again something that seems foreign coming from C, Python, or Java but perfectly natural coming from OCaml.)
Declaring Mutability
Let’s add a function that reflects a point across a diagonal (i.e., swaps its coordinates):
fn print_point(p: &Point) {
println!("({}, {})", p.x, p.y);
}
fn reflect(p: &Point) {
let tmp = p.x;
p.x = p.y;
p.y = tmp;
}
fn main() {
let location = Point { x: 4, y: 10 };
print_point(&location);
reflect(&location);
print_point(&location);
}
The error from Rust confirms that we’re not allowed to modify p in reflect.
To allow that, we need to sprinkle in the mut keyword:
fn print_point(p: &Point) {
println!("({}, {})", p.x, p.y);
}
fn reflect(p: &mut Point) {
let tmp = p.x;
p.x = p.y;
p.y = tmp;
}
fn main() {
let mut location = Point { x: 4, y: 10 };
print_point(&location);
reflect(&mut location);
print_point(&location);
}
It goes in three places:
- We use
let mutto declarelocationso we can mutate it. - The argument type to
reflectis now&mut Pointinstead of just&Point. The syntax&mut Tmeans “a mutably borrowed reference to a value of typeT.” - When calling
reflect, we use&mutinstead of&.&mutis a reference-of operator, just like&, but it produces a mutable reference.
The Trouble with Mutability and Aliasing
In C, two features are more-or-less unrestricted everywhere: the ability to freely modify data (mutability) and the ability to create multiple pointers to the same data (aliasing). Mutability and aliasing may seem innocuous on their own, but their combination is at the root of many of C’s problems.
Think about this classic C gotcha:
void do_stuff(int* x, int* y) {
*x = 34;
*y = 10;
printf("x is now %d\n", *x);
}
It’s tempting to say that this program will always print 34, but we can’t know that for sure just by looking at this function!
If x and y point to the same location in memory (if they are aliases), this will print 10.
Mutable aliases can also cause memory-safety bugs.
I’ll illustrate this using a little bit of C++ because it makes the example a little more succinct than plain C, but the same principle applies in both languages.
The C++ standard library has a resizable array type called vector.
When a vector grows and shrinks, the library is responsible for automatically allocating differently-sized underlying arrays and copying the data round between them.
Here’s a quick demo:
#include <stdio.h>
#include <vector>
using namespace std;
int main() {
vector<int> data = {3, 4};
printf("length is %zu\n", data.size());
data.push_back(1);
data.push_back(0);
data.push_back(42);
printf("length is %zu\n", data.size());
data.pop_back();
printf("length is %zu\n", data.size());
return 0;
}
Each call to push_back() and pop_back() may trigger a reallocation behind the scenes.
We can also get a pointer to an element of the resizable array by writing &data[i].
This creates an alias: we can reference the same data through our new pointer or through the data vector itself.
By combining aliasing with mutation, we can cause a serious problem:
int main() {
// `data` is a resizable array containing 4 elements initially.
vector<int> data = {3, 4, 1, 0};
// Get a reference to the second-to-last item.
int* item = &data[2];
printf("item 2 is %d\n", *item);
// Remove 3 elements from the resizable array.
data.pop_back();
data.pop_back();
// Now `item` points past the end of the array!
printf("item 2 is %d\n", *item);
return 0;
}
At the time of the last printf, item is a dangling pointer: it points to the third entry in a 2-element resizable array.
Concretely, the implementation of vector is allowed to free the old 4-element backing array and allocate a new one, meaning that item points to unallocated memory.
This example illustrates how aliasing and mutability can conspire to violate memory safety.
Mutability XOR Aliasing
Rust addresses this problem with additional rules on mutable references. Here’s the goal: you can have aliases or mutation, but not both for the same data at the same time. People summarize this idea using the slogan “mutability XOR aliasing.”
As a consequence, mutable references in Rust are more restrictive than ordinary, immutable references. If you have a mutable reference to a value, that must be the only reference in existence anywhere. You can’t have multiple multiple simultaneous mutable references to the same data, and you can’t simultaneously have mutable and ordinary references to the same data.
That means it’s perfectly fine to create several different variables that all reference location immutably, so we can print them:
fn main() {
let mut location = Point { x: 4, y: 10 };
print_point(&location);
reflect(&mut location);
let foo = &location;
let bar = &location;
print_point(foo);
print_point(bar);
}
But Rust will stop you from creating two mutable references:
fn main() {
let mut location = Point { x: 4, y: 10 };
print_point(&location);
reflect(&mut location);
let foo = &mut location;
let bar = &mut location;
reflect(foo);
reflect(bar);
}
Or even from creating a mutable and ordinary references at the same time:
fn main() {
let mut location = Point { x: 4, y: 10 };
print_point(&location);
reflect(&mut location);
let foo = &mut location;
let bar = &location;
reflect(foo);
print_point(bar);
}
Safety Consequences
This restriction yields an important property you can rely on: if you have permission to mutate some data, you can be sure that you are modifying it privately. No other code can be simultaneously reading or writing that data through a second reference. Or, conversely, if you have an immutable reference to something, you can be sure that you will not “accidentally” mutate it using some other reference.
For example, here’s a Rust translation of the simple C program from above:
fn do_stuff(x: &mut usize, y: &mut usize) {
*x = 34;
*y = 10;
println!("x is now {}", *x);
}
The “mutability XOR aliasing” guarantee ensures that these two references cannot alias. So unlike in C, you can be sure that this function prints 34.
Let’s also translate our C++ vector example above into Rust.
Our translation uses Vec, Rust’s resizable array type:
fn main() {
// `data` is a resizable array containing 4 elements initially.
let mut data = vec![3, 4, 1, 0];
// Get a reference to the second-to-last item.
let item = &data[2];
println!("item 2 is {}", *item);
// Remove 3 elements from the resizable array.
data.pop();
data.pop();
// Now `item` points past the end of the array!
println!("item 2 is {}", *item);
}
This is a compile-time error because data and item would alias while we use data.pop() to mutate the underlying data.
The “mutability XOR aliasing” guarantee prevents this mistake.
Threads
In addition to enforcing memory safety, Rust’s core guarantees about ownership and mutability also rule out data races. Rust has a built-in threading library that is reminiscent of pthreads, and its restrictions mean that you will get an error for any multithreaded code that has a data race.
Let’s use that library to implement our mutex-based primality checker that we previously implemented in C:
use std::sync::Mutex;
use std::thread;
const NUMBERS: usize = 1024;
const THREADS: usize = 8;
fn is_prime(n: usize) -> bool {
for i in 2..n {
if n.is_multiple_of(i) {
return false;
}
}
true
}
struct PrimeThreadArgs<'a> {
start_number: usize,
end_number: usize,
prime_count: &'a Mutex<usize>,
}
fn prime_thread(args: PrimeThreadArgs) {
for n in args.start_number..args.end_number {
if is_prime(n) {
// The lock acquisition here gets automatically released when this
// variable goes out of scope.
let mut count = args.prime_count.lock().unwrap();
*count += 1;
}
}
}
fn main() {
// This is a lock-protected integer variable.
let prime_count = Mutex::new(0);
// Launch some threads and then wait for them to finish. The `scope`
// construct here automatically takes care of all the threads created with
// `s.spawn`.
thread::scope(|s| {
let numbers_per_thread = NUMBERS / THREADS;
for i in 0..THREADS {
let args = PrimeThreadArgs {
start_number: if i == 0 { 1 } else { i * numbers_per_thread },
end_number: (i + 1) * numbers_per_thread,
prime_count: &prime_count,
};
s.spawn(move || prime_thread(args));
}
});
// Get the final value of the count and print it.
let count = prime_count.into_inner().unwrap();
println!("{} numbers in the range 1-{} are prime", count, NUMBERS - 1);
}
We’ll gloss over a few details here, but here are some important observations about how this works:
- If we write this code by attempting to pass a plain
&mut usizeinstead of aMutex<usize>, Rust will complain. This is Rust preventing us from writing a data race. - In general, the “right way” to use a mutex is to think of it as “protecting” some kind of data. You then promise to always acquire the lock while accessing that data. In C, the association between the data and the lock is in your mind. In Rust, it’s explicit. In this code
Mutex::new(0), allocates space for an integer variable containing 0 and wraps it in a mutual exclusion lock. The only way to access this data is by callinglock()on that mutex. - Rust’s ownership system automatically takes care of two “cleanup” tasks that are manual in C. It releases locks (when the locked variable goes out of scope) and it joins threads (when the
thread::scopeblock ends).
Even More Rust
We have only scratched the surface, but I hope this introduction is enough for you to appreciate those two important facts I mentioned above:
- Rust knows when to free all data without running a garbage collector.
- It accomplishes that feat by imposing a lot of onerous-seeming restrictions on when you can use values.
Despite all the restrictions (which are a real trade-off!), the technology world has decided that Rust’s safety guarantees are worth it for many software domains. Rust is, at this point, a real-world language used in real-world projects. Some examples of projects that have some Rust code include Firefox, Android, Google Chrome, and Microsoft Windows. Perhaps the most significant milestone is that the Linux kernel, after decades as a pure-C project, now includes Rust modules.
If you want to learn more about Rust, I recommend these resources:
- The official Rust book, The Rust Programming Language, is a comprehensive and readable tour of the whole programming language.
- Comprehensive Rust is Google’s structured course for learning Rust. It can be a good fit if you want a class-like learning experience.
- Rust By Example offers a code-heavy, English-light introduction to a specific feature you want to learn.