Thoughts on conducting a beginner level Rust workshop


I had the opportunity to introduce Rust to a group of undergraduate students at a nearby Engineering college on August 19th. Feedback from the audience was positive, and I believe my approach was effective in giving a taste of Rust to students who did not have extensive programming experience or exposure to systems programming concepts. This document may prove useful to Rust enthusiasts who wish to introduce the language to a similar audience.

  1. The Audience
  2. The Challenge in teaching Rust
  3. The Approach
    1. Software, Safety, Security
    2. Rust, Level 1 - Easy
    3. Rust, Level 2 - Cool
      1. Sum types, pattern matching
      2. Generics, Traits
      3. Zero cost abstractions
    4. Rust, Level 3 - Hard, and innovative
      1. Stack and Heap allocations
      2. A tricky pointer problem
      3. Ownership
      4. Move semantics
      5. Borrowing
      6. Lifetime
  4. Concluding remarks

The Audience

My audience was composed of mostly 2nd/3rd/4th year Computer Science and Engineering students. All of them had exposure to C/C++ programming (as part of a basic programming/data structures lab) - but they mostly lacked any kind of understanding of the traps and pitfalls associated with coding in C/C++. Very few of them would be capable of explaining things like what causes a segfault, why is a buffer overflow dangerous and why you should never return the address of local variables. Some of them had exposure to dynamically typed languages like Python.

This audience is typical of what you would expect at most Engineering colleges in India. This is not to say that these students are not technically skilled - you will definitely find at least a handful of them to be exceptionally talented and motivated; they simply did not have sufficient exposure to C internals or systems programming concepts 1.

The challenge in teaching Rust

The first question in the mind of your audience is: what makes this language interesting, what distinguishes it from all the other languages out there, why would you really wish to program in Rust instead of Go or Python or C++?

A key part of the answer is of course:

Memory safety without using Garbage Collection

And therein lies the trouble. You will have to first explain how the C/C++ languages are not memory safe and why memory safety is important. This will involve explaining concepts regarding memory allocation/de-allocation, stack/heap usage etc. Unless you first demonstrate how bugs like memory leaks, dangling pointers, use-after-free, null references etc can occur in C/C++, the audience will find it hard to understand how Rust solves these problems through clever use of the type system.

If you have an audience of professional C/C++ programmers, you can jump right into the core of Rust and quickly demonstrate ownership, move semantics , borrows and lifetimes. It is an entirely different situation when the audience is composed of people who do not have exposure to C internals and systems concepts.

The approach

[Note: It will take 2 full days (four sessions of 2.5 to 3.0 hour duration) to introduce the material in the way I describe it]

The idea is to go slow (initially) and build up enough of a context so that people can appreciate the really innovative ideas present in Rust. There are enough interesting ideas in Rust (besides type system based memory safety enforcement) which would be unfamiliar to many students: type inference, sum types, pattern matching and destructuring, Option/Result type based error handling, closures, iterators, generics, traits, zero cost abstractions etc. It doesn’t require any kind of systems or theory background to understand these ideas. Initial focus should be on these things … you can then be sure that each and every participant will have gained something from the workshop.

It is important not to mislead the audience by telling them things like: “I will show you how simple Rust is”. Rust is not Python, and that is something which should be made clear at the very beginning.

Participants who are more experienced in a language like Python are likely to feel Rust as being syntax-heavy. A very important advice is to focus on the ideas and not get distracted by the syntax.

Here is a more detailed description of the approach, with sample code I used during the workshop. This is written basically for Rust enthusiasts who would like to present the language to an audience unfamiliar with systems programming concepts.

Software, Safety, Security

Safety in the context of software is not something which most people appreciate fully. You can spend the first few minutes of the workshop getting this into proper perspective. The narrative can be something like this:

As Marc Andreessen puts it, software is eating the world.

Whether we like it or not (I certainly am not in favour of it), software is a dominant presence in most aspects of our daily life.

The oven in your house may talk to the refrigerator which may in turn talk to the light bulb … just for fun! Welcome to the crazy world of IoT.

It is said that some high-end cars contain millions of lines of code, even more than the code which runs an aeroplane.

The cardiac pacemaker “embedded” in the bodies of heart patients contains tens of thousands of lines of code.

Most of the code which gives life to all these devices is written in the C/C++ programming languages.

Code written in C/C++ will often fail in unpredictable ways1 - and the results can be much more dangerous than rebooting a laptop. Stories of people hacking into automobiles and taking control of them are definitely not in the realm of science fiction - nor are stories of “hackers” killing people by exploiting vulnerabilities in pacemakers.

Software safety and security is a matter of life and death.

The Rust programming language aims to do everything C and C++ can do (all the low-level stuff), but in a much safer way. You can also write code which feels more high-level (somewhat like say Python), but without any kind of performance loss.

That is the promise of Rust.

This narrative is very effective in getting students interested. Note that we are not actually talking about specific ways in which C/C++ code fails - that will be discussed later.

Rust Level 1 - Easy

After giving a very high-level perspective on Why Rust (and also talking a bit about its history, tools, installation, applications, books and learning resource etc), it is time to dive into the language.

I usually provide a collection of small programs to the audience to play with. The first set of programs demonstrate ideas which everybody is familiar with: variable declarations/data types, control structures/loops, functions, basics of strings, arrays, vectors (taking care not to have code which does copy/move or takes borrows).

The only ideas which the students may not be familiar with are type inference and immutability - but these are not too hard to understand.

A good way to get people interested early on is to show them code which controls real hardware. I had ported a Raspberry Pi GPIO programming language library from Python to Rust and was planning to show some LED blinking code; couldn’t do that because of some last minute technical issues.

Strings can be a bit tricky once you get into the Unicode encoding part. For example, I found it a bit hard to explain why we can’t do O(1) indexing.

Overall, when you prepare sample programs for this level, just keep this in mind: somebody who has written code in C/Python/Java etc should be able to easily understand the program without too much of explanation.

A good example:

fn main() {
    let s = "hello, world";
    let r = s.replace("hello", "good");

    println!("{}",r);
}

No need to explain what the code does!

A bad example:

fn main() {
    let a = vec![1,2,3,4];
    for x in a {
        println!("{}", x);
    }
    println!("{:?}", a);
}

Don’t try to explain why this doesn’t work!

Rust Level 2 - Cool

A great thing about Rust is that it has borrowed some cool ideas from statically typed functional programming languages (like ML/Ocaml). These, and a few others, make up for a great “Level 2” introduction to the language. You will now be dealing with concepts which most students have not seen (unlike Level 1); but luckily they are not very hard to understand.

The topics that can be handled at this level are: product types (struct/tuple), sum types (enums), pattern matching, generics, traits and trait bounds, Option/Result types, iterators, closures, zero cost abstractions, the cargo tool.

Going through these topics will take up a lot of time … I was unable to cover all of them during my workshop because of time constraints. That is why it is important to plan for a 2 day event.

Here is how I introduced some of the above topics.

Sum types, pattern matching

Most people who work with languages like ML/Haskell will tell you that they find it very unpleasant to code in a language without sum types. Once you learn to think in sum types, you will approach problems with a fresh perspective.

A simple example (which doesn’t compile):

enum Color {
    Red,
    Green,
    Blue,
}

fn main() {
    let c = Color::Green;
    match c {
        Color::Red   => println!("Red"),
        Color::Green => println!("Green"),
    }
}

The exhaustive nature of the matching process is an important safety net. Yaron Minsky’s Ocaml for the masses has a list “destuttering” example which demonstrates the ease with which the type system catches a subtle bug by being exhaustive during pattern matching.

Another standard example:

enum Shape {
    Circle(f64),
    Square (f64),
    Rectangle {ht: f64, wid: f64},
}

fn area(s: Shape) -> f64 {

    match s {
        Shape::Circle(x) => 3.14 * x * x,
        Shape::Square(x) => x * x,
        Shape::Rectangle {ht: x, wid: y} => x * y,
    } 
}

It will be interesting to compare this with an Object Oriented implementation (Shape as base class and Circle, Square, Rectangle as derived classes; ‘area’ will be implemented as a method - check out the expression problem).

Sum types make it possible to elegantly model many things; here is a description of a finite state machine to model an elevator moving in a two-floor building:

http://www.cs.princeton.edu/courses/archive/spr06/cos116/FSM_Tutorial.pdf

Here is the FSM expressed as Rust code:

enum Floor {
    Ground,
    First,
}
enum Button {
    Up,
    Down,
}
use Floor::{Ground, First};
use Button::{Up, Down};

fn next_floor(curr_floor: Floor, btn: Button) -> Floor {
    match (curr_floor, btn) {
        (Ground, Up)   => First,
        (Ground, Down) => Ground,
        (First,  Up)   => First,
        (First,  Down) => Ground,
    }
}

Generics, Traits

Generics/Traits may be introduced as mechanisms to gain some of the flexibility of a dynamically typed language and at the same time, maintain compile time type safety.

A good idea is to compare Python and Rust programs.

Here is a simple function in Python:

def identity(x):
    return x

And the equivalent in Rust:

fn identity<T>(x: T) -> T {
    x
}

Another example in Python:

def first(pair):
    return pair[0]

# Run time error
print first((10,20)) + "hello"

And an equivalent one in Rust which is as flexible as the Python code (in the sense it takes in any two-element tuple) but where errors are caught at compile time.

fn first<T1,T2>(pair: (T1,T2)) -> T1 {
    pair.0
}

fn main() {
    println!("{}", first((10,20)) + "hello");
}

A slightly more complex example in Python:

class Circle:
    def __init__(self, r):
        self.radius = r
    def area(self):
        return 3.14*(self.radius*self.radius)

class Square:
    def __init__(self,x):
        self.side = x
    def area(self):
        return self.side * self.side

class Dog:
    pass

# is 'a' bigger than 'b'?
def is_bigger(a, b):
    return a.area() > b.area()

a = Square(10)
b = Dog()

# comparing a square and a dog!
# Run time error!
print is_bigger(a, b)

Python’s Duck typing allows us to write functions like is_bigger quite easily; but they are prone to run time errors.

Here is the same code in Rust:


trait HasArea {
    fn area(&self) -> f64;
}
struct Circle {
    r: f64,
}
struct Square {
    side: f64,
}
struct Dog {
    age: i32,
}
impl HasArea for Circle {
    fn area(&self) -> f64 {
        3.14 * self.r * self.r
    }
}
impl HasArea for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

fn is_bigger<T1:HasArea,T2:HasArea>(a: T1,  b: T2) -> bool {
    a.area() > b.area()
}
fn main() {
    let c = Circle{r: 10.0};
    let d = Square{side: 10.0};
    let e = Dog{age: 10};

    println!("{}", is_bigger(c,e));

}

A trait can be thought of as defining some kind of an interface. The is_bigger function is now restricted to handle only those types T1 and T2 which implements the HasArea trait. You once again get compile time safety without sacrificing the flexibility that is_bigger has to operate on any type whose area can be computed.

Compile time type safety is a big idea. The Rust compiler tries to catch as many errors as possible at compile time. This aspect of the language should be emphasized strongly.

Zero cost abstractions

I have written a blog post on how you can demonstrate this idea in an interesting way.

Rust Level 3 - Hard, and innovative

Levels 1 and 2 do not have any pre-requisite other than some programming experience in any one language.

Level 3 is where we introduce the real innovation behind Rust - the idea of achieving memory safety through the type system and without using a garbage collector.

You can expect this to be difficult for the audience; but we will try to reduce the pain as much as possible by first looking at C code (which everyone is familiar with atleast at the syntax level) and understanding certain fundamental limitations. Once that is done, we have the proper context to understand ideas like ownership, borrow, copy/move semantics, lifetimes etc.

Stack and Heap allocations

Start with this:

int main()
{
    int a = 1, b = 2;

    return 0;
}

img1.ps [image shows stack layout 2]

A simplified view of the stack frame with return address and the two values 1 and 2 can be seen. This is the right time to explain how your program uses a part of RAM as a stack to store stuff like local variables, return address etc. It is essential that the audience understands this perfectly.

Diagrams are extremely important when learning both C as well as Rust. It is difficult to understand many ideas without having a clear picture as to how things are laid out in memory.

The next program scribbles all over the stack - possibly changing value of other variables, modifying the return address or accessing memory beyond the program’s limits (resulting in a segfault) - the infamous buffer overflow.

int main()
{
    int i=1;
    int a[4];
    int j=2;

    a[4] = 5; // bug
    a[5] = 6; // bug
    a[10000] = 7; // bug
}

img2.ps

The purpose of the next program is to demonstrate allocations/ deallocations on the stack:

void fun2()
{
    int e=5, f=6;
}

void fun1()
{
    int c=3, d=4;
}

int main()
{
    int a=1, b=2;
    
    fun1();
    fun2();

    return 0;
}

img4.ps

It should be clearly explained how the stack frame allocated to a function (say fun1) gets de-allocated when the function returns and how the same space is used by fun2 for storing its stack frame. The idea that “deallocation” simply means the space has been made available for reuse is something which has to be made very clear.

The program below can be used for explaining null pointer dereferencing:

int main()
{
    char s[100] = "hello";
    char *p;

    p = index(s, 'f');
    *p = 'a'; // bug!

    printf("%s\n", s);

    return 0;
}

Here is a program which demonstrates a common mistake in C code, dangling pointers.

void fun2()
{
    int m = 1; 
    int n = 2;
}
int* fun1()
{
    int *p;
    int q = 0;
    p = &q;
    return p; // bug
}
int main()
{
    int *a, b;

    a = fun1();
    *a = 10;
    fun2();
    b = *a; //??
}

img6.ps

These are the kind of bugs Rust prevents through its type system … it is essential that a clear picture is provided as to why the above program is buggy.

It is now time to discuss heap allocation and how it is different from stack allocation:

void fun()
{
    char *c;
    c = malloc(10*sizeof(char));

    /* do some stuff here */

    free(c);
}
int main()
{
    fun(); 
}

img7.ps

The pointer variable ‘c’ stays on the stack, the space it consumes (say 4 bytes) is de-allocated automatically when the function returns. But the pointed-to block is freed only when you call ‘free’ explicitly.

Many students have weird ideas regarding the working of the “free” function; it should be made clear that the basic objective is to make the block available for re-use at a later point in time.

Here comes a program with a memory leak:

void fun()
{
    char *c;
    c = malloc(10*sizeof(char));

    /* do some stuff here */

}

int main()
{
    fun(); // bug! memory leak.
}

The local variable ‘c’ containing the malloc’d block’s address goes out of scope when the function ends and the space it occupies (say 4 bytes) gets automatically deallocated; but the malloc’d block is not de-allocated (because you are not calling free).

The malloc’d block can neither be used nor can it be freed. It effectively become ‘garbage’; if this happens a large number of times in your program, it is going to result in a huge amount of memory becoming unusable. Memory leaks are common in large non-trivial C code bases and are notoriously hard to track down and fix.

Now we have a program with a use-after-free bug:

void fun(char *t)
{
    /* do some stuff here */
    
    free(t);

}

int main()
{
    char *c;

    c =  malloc(10 * sizeof(char));
    fun(c);

    c[0] = 'A'; //bug! user-after-free
}

The 10 byte block has been freed by the time you do c[0]=’A’, which means it can be re-used at a later point in time. It doesn’t make sense to store data at a memory location whose contents may change unpredictably any time in the future.

Memory leaks, use-after-free, double-free - these are problems which Rust prevents at compile time itself through clever use of the type system. Now that we have provided a proper context for these problems through a set of simple C programs, it will be easier for the audience to understand exactly how Rust solves these issues!

A tricky pointer problem

A problem known as iterator invalidation can be used to demonstrate the logic behind one of the “borrow” rules in Rust.

Let’s look at a C++ vector:


#include <vector>
#include <iostream>

using namespace std;

int main()
{
    vector<int> v;

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

    for(int i = 0; i < 3; i++) {
        cout << v[i] << endl;
    }
} 

The C++ vector will automatically grow to accommodate new elements. The elements themselves will be stored at consecutive addresses.

When you store the first element in the vector, you can imagine “v” pointing to a block of memory which can hold a single integer.

When you store the next element 2, a new block of memory capable of holding two integers is allocated, the number 1 gets copied to the new block from the previous block, 2 gets stored immediately after 1, the old block is de-allocated and “v” is made to point to the new block.

When you store 3, a new block capable of storing 4 integers is allocated and the above story gets repeated.

If you try to store 4, no new allocation happens as the block has enough space to hold four integers.

If you try to store 5, a new block double the size of the previous block, ie, a block capable of holding 8 integers, is allocated and the copy-to-new-block, de-allocate-old-block story gets repeated.

And so on …

The following diagram shows this clearly:

img12.ps[the numbers 2000, 3400, 5600 etc represent block addresses]

The next program stores an integer 1 in the vector, makes a pointer variable point to the location holding 1 and uses that pointer to change the value stored at the location from 1 to 100. It works without any problem.

#include <vector>
#include <iostream>

using namespace std;

int main()
{
    vector<int> v;
    int *p;
    v.push_back(1);
    p = &v[0];
    *p = 100;
    cout << v[0] << endl;
} 

Now look at this program:

#include <vector>
#include <iostream>

using namespace std;

int main()
{
    vector<int> v;
    int *p;
    v.push_back(1);
    p = &v[0];
    v.push_back(2);
    *p = 100;  // BUG
    cout << v[0] << endl;
} 

We know have a statement which adds 2 to the vector in between the statement which store the address of v[0] in “p” and the statement which writes 100 to the location pointed to by “p”.

The problem is obvious - the addition of 2 has resulted in the vector getting restructured; both the old content 1 and the new value 2 have been copied to a new block and that is what “v” is now pointing to. Unfortunately, “p” is pointing to the old block which is no longer in use!

The problem basically occurs because we have two pointers pointing to the same data structure and both of them are trying to modify it. We will see how the Rust type system does not let you do things like these, thereby preventing such subtle errors from happening.

Ownership

The most interesting thing about Rust is that it manages to eliminate memory related problems through clever use of the type system.

Here is a Rust function which creates a vector:

fn foo() {
    let v = vec![1,2,3,4];
}

This looks a bit like the following C function:

void foo() 
{
    int *v = malloc(4*sizeof(int));
    v[0] = 1, v[1]  = 2; 
    v[2] = 3, v[3] = 4;
}

The C code is buggy; the malloc’d block is not getting freed.

There is no such bug in the Rust code because when the symbol “v” goes out of scope (which happens when the function ends, “v” is simply a pointer variable stored on the stack), Rust automatically de-allocates the space on the heap allocated for the vector.

We say that “v” owns the vector; when the owner goes out of scope, the owned object (the vector in this case) is completely de-allocated.

The actual representation of the vector v is a bit more complex; the data block used for storing the contents of the vector (in this case 1,2,3,4) is allocated on the heap. A pointer to this block, together with its length and capacity (we know that a vector of 5 integers may have space in it for holding 8 integers; in this case, the length of the vector is 5 and its capacity is 8) is stored on the stack.

The figure below shows this clearly:

img14.ps

Move semantics

Here is another program involving a vector:

fn main() {
    let  v1 = vec![1,2,3];
    
    let mut v2 = v1;
    v2.truncate(2); // truncate to two elements

    println!("{:?}", v2); vector is now [1,2]
}

What happens when we copy v1 to v2?

Rust performs only a shallow copy.

The variable v1 represents a triple of values stored on the stack: (length, capacity, pointer) where the pointer points to the heap allocated block which holds the contents of the vector (the numbers 1,2,3).

The copying operation (v2=v1) does not create a new data block containing 1,2,3.

Instead, what it does is simply copy the (length,capacity,pointer) triple to a new location on the stack; this will be referred to by the symbol “v2”.

The figure below shows this clearly:

img15.ps

Here is a slightly modified form of the above program:

fn main() {
    let  v1 = vec![1,2,3];
    
    let mut v2 = v1;
    v2.truncate(2); // truncate to two elements

    println!("{:?}", v1);
}

The v2.truncate(2) operation will change the value of the length field (associated with v2) to two; the vector has effectively become one of length 2.

But the length field associated with v1 still has the value 3.

So if you try to access the vector through v1, Rust will incorrectly try to print 3 elements from a vector which has been truncated to length 2.

This should not happen.

The above code will not compile.

The copying operation v2=v1 will result in ownership of the vector getting transferred to v2; v1 is no longer usable in our program! The compiler will generate an error if you try to use v1 at any place in the program.

We say that the vector’s ownership has moved from v1 to v2.

What about the following program?

fn foo() {
    let v1 = vec![1,2,3];
    let v2 = v1;
}
    
fn main() {
    foo();   
}

We know that both v2 and v1 are pointing to the same location on heap which holds the data (1,2,3) associated with the vector.

We also know that when v2 goes out of scope, the heap allocated space referred to by v2 also gets de-allocated.

But “v1” will also go out of scope; if Rust tries to free the heap allocated space associated with “v1” (same as the one pointed to by “v2”), that will be a bug (a double free). That space has already been freed.

As “v1” is no longer the owner of the vector, Rust will not de-allocate the block pointed to by “v1” when “v1” goes out of scope. Thus, the double-free error will never happen.

Now let’s look at another situation:

fn fun1(v: Vec<u32>) {

    println!("{:?}", v);
}

fn main() {
    let  v1 = vec![1,2,3];
    
    fun1(v1);

    println!("{}", v1[0]);

}

Imagine that Rust doesn’t have “move” semantics. This program will then have a ‘user-after-free’ bug.

fun1(v1) does a shallow copy of v1 to v; so “v” in fun1 is also pointing to the heap allocated block pointed to by “v1” in main.

When “v” goes out of scope, the heap allocated block which stores (1,2,3) gets de-allocated.

After you come back to main, you are trying to access this 0 block through “v1”.

This will not happen.

When you do fun1(v1), “v1” gets moved to “v” in fun1; the ownership of the vector gets transferred to “v” and because of move semantics, the symbol “v1” can no longer be used in the program.

Borrowing

Ownership and move semantics makes your program free from all kinds of memory safety issues present in C/C++ programs. But there are some issues.

Check out:

fn vector_sum(v: Vec<i32>) -> i32 {
    //assume v is always a 3 0 vector
    v[0] + v[1] + v[2]
}
fn vector_product(v: Vec<i32>) -> i32 {
    //assume v is always a 3 element vector 
    v[0] * v[1] * v[2]
}
fn main() {
    let v = vec![1,2,3];
    
    let s = vector_sum(v);
    let p = vector_product(v);

    println!("{}",p);
    
}

Calling vector_sum(v) will result in ‘v’ getting moved; it is now impossible to access ‘v’ at any other point in the program. The call vector_product(v) will not work.

One solution is for vector_sum to return the ownership back to “v” in main; but this can very soon become inelegant.

The solution is for vector_sum to “borrow” the vector:

fn vector_sum(v: &Vec<i32>) -> i32 {

    //assume v is always a 3 element vector
    v[0] + v[1] + v[2]

}
fn vector_product(v: Vec<i32>) -> i32 {
    //assume v is always a 3 element vector 
    v[0] * v[1] * v[2]
}
fn main() {
    let  v = vec![1,2,3];
    
    let s = vector_sum(&v);
    let p = vector_product(v);

    println!("{}",p);
}

We say that ‘v’ in vector_sum is a reference, or that ‘v’ has borrowed the vector. It can simply be thought of as a handle through which you can access the original vector in ‘main’. The key point here is that this ‘v’ doesn’t own the vector - the vector is not de-allocated when ‘v’ goes out of scope. Also, move semantics doesn’t apply when you call vector_sum(&v); it is still possible to access ‘v’ in “main”.

The next two programs illustrate the difference between mutable and immutable references.

fn change(v: &Vec<i32>) {
    v[0] = 10;
}

fn main() {
    let mut v = vec![1,2,3];

    change(&v);
}

The variable `v’ in “change” is an immutable reference; it is not possible to modify the object you are borrowing through an immutable reference.

What if you do wish to modify the borrowed object?

fn change(v: &mut Vec<i32>) {
    v[0] = 10;
}

fn main() {
    let mut v = vec![1,2,3];

    change(&mut v);

    println!("{}", v[0]); // prints 10
}

When you call change(&mut v), what you are passing to “change” is a mutable reference - it is possible to modify the borrowed object through a mutable reference.

It is possible to have any number of immutable references to the same object:

fn main() {
    let v = vec![1,2,3];

    let p1 = &v;
    let p2 = &v;
    let p3 = &v;

}

But if you have a mutable reference to an object, then it is impossible to have another mutable or immutable reference to the same object as long as the first mutable borrow is active.

The following code will not compile:

fn main() {
    let mut v = vec![1,2,3];

    let p1 = &mut v;
    let p2 = &v;

}

The next program also will not compile:

fn main() {
    let mut v = vec![1,2,3];

    let p1 = &mut v;
    let p2 = &mut v;

}

This is a very important idea; but it is not easy to explain the logic behind it.

If you feel really adventurous, you can try explaining with the help of the next program:

fn main() {
    let mut v = vec![1,2];
    let p = &v[0];

    v.push(3);
}

This program is similar to the buggy program we examined in the context of C++ vectors.

Initially, ‘p’ is made to refer to the 0th item of the vector (ie, the memory location containing the number 1).

Now, we are pushing 3 to the vector. This will trigger a re-allocation; a new block (capable of holding 4 integers) is allocated on the heap and its address is stored in the ‘pointer’ field of the (length,capacity,pointer) triple which represents the vector. The old values 1 and 2 as well as the new value 3 gets copied to this new block and the old block is freed. Unfortunately, ‘p’ is still referring to the old block - this is a bug.

Bugs like these are impossible in Rust, the above program simply will not compile.

The idea is that the “push” function needs to take a mutable reference to ‘v’ as a parameter; but Rust will not allow that because there already exists an immutable borrow of v[0]. A mutable borrow can’t co-exist with either immutable or other mutable borrows to the same object (either the whole object or some part of it).

Lifetime

fn main() {
    let v1 = vec![1,2,3];
    let v2: &Vec<i32>;

    v2 = &v1;
    println!("{:?}", v2);
}

The above program works perfectly; we are defining a reference ‘v2’ and making it borrow v1.

What about the next program?

fn main() {
    let v2: &Vec<i32>;//alive till end-of-main
    {
        let v1 = vec![1,2,3]; //alive till end-of-block
        v2 = &v1;
    }
    println!("{:?}", v2);
}

We now have an inner block within main (enclosed in { and } braces). The vector ‘v1’ gets de-allocated once this inner block ends. The reference ‘v2’ is accessible outside of the inner block. If this code gets compiled, it is going to result in a run-time error because ‘v2’ is pointing to an object which has been de-allocated.

Such errors are not possible in Rust. The Rust compiler keeps track of the lifetime of each variable; the compiler sees that ‘v2’ has a longer lifetime than ‘v1’ - so the assignment v2=&v1 will not be allowed. ‘v1’ should live atleast as long as ‘v2’.

There are some really complex ideas involving lifetime annotations in Rust - better not present those ideas in a beginner level session.

The ideas we have seen so far: ownership, move semantics, borrowing and lifetime are central to memory safety in Rust. These are basically restrictions on the way pointers are to be used in our programs. In a C program, you are free to pass around and manipulate pointers in whatever way you like; Rust doesn’t let you do that. This can feel very restrictive in the beginning - but it is said that with enough experience, you will feel this to be less of a problem than it looks in the beginning. I am still a Rust newbie and have not reached that level of experience … so like all Rust newbies I frequently engage in that sport which all Rust programmers are forced to play: wrestling with the borrow checker!

Concluding Remarks

Rust is not an easy language to learn (or teach). We need more people to come forward and conduct workshops and interactive sessions. We need to focus more on students as they are the ones who shape the future. If you are a Rust enthusiast, don’t wait to become an expert - you can start conducting meetups and workshops at nearby colleges (I am a few-weeks-old Rust newbie and I have already conducted one workshop and planning to do many more). Your enthusiasm for the language is more important than your expertise.

Happy Rust hacking!


  1. Even professional systems programmers are unlikely to have a complete understanding of the problems with C - the writings of John Regehr, for example, on undefined behaviour and on teaching C are extremely interesting in this context. 2

  2. Images are .ps files generated by the pic/troff programs; conversion to png results in loss of clarity. That is why I am not showing them inline.