Beyond Pointers: How Rust outshines C++ with its Borrow Checker
Tags: rust, programming, cpp
Published: 2023-07-03 10:39:03

As we all know, memory management has always been a [notorious challenge](https://www.zdnet.com/article/microsoft-70-percent-of-all-security-bugs-are-memory-safety-issues/) in languages that don't have the garbage collector, with issues such as null pointers, dangling references, and memory leaks.

Rust's borrow checker is a powerful static analysis tool that ensures memory safety of your program at runtime.

Let's explore some simple examples of how Rust's Borrow Checker shines at compile-time, covering issues that C++, simply cannot contend with.


## 1. Reference Invalidation:

Here's a C++ example that basically creates a vector, pushes an element into the vector, takes a pointer to the first element, adds some more elements  to the vector and then prints the pointer value. 

```c++
// clang++ -std=c++20  -Wall -Wextra -Wpedantic -Weverything -Wno-c++98-compat

#include <iostream>
#include <vector>

using namespace std;

int main() {
    vector<int> v;

    v.push_back(100);

    int* ptr = &v[0];
    std::cout << "Before: *ptr (v[0]) = " << *ptr << std::endl;

    // add some more elements
    v.push_back(1);
    v.push_back(2);
    v.push_back(3);
    v.push_back(4);
    v.push_back(5);

    std::cout << "After:  *ptr (v[0]) = " << *ptr << std::endl;

    return 0;
}

```

The code compiles fine with no warnings/errors and here's the output at runtime:

```
Before: *ptr (v[0]) = 100
 After: *ptr (v[0]) = -1813810918
```

What happened to the `After` value? - The pointer `ptr` got invalidated due to reallocation (for accomodating the current and possible future elements) triggered at insertion. Note that C++ compiler didn't give any warning during compilation.

Now let's see what Rust's borrow-checker finds here. Here's the equivalent Rust code snippet...
 
```rust
fn main() {
    let mut v = vec![];
    
    v.push(100);
    
    let ptr = &v[0];
    
    // add some more elements
    v.push(1);
    v.push(2);
    v.push(3);
    v.push(4);
    v.push(5);
    
    println!("{}", *ptr);
}
```
... and when trying to compile it, results in the following error:

```
 error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
  --> test.rs:9:5
   |
6  |     let ptr = &v[0];
   |                - immutable borrow occurs here
...
9  |     v.push(1);
   |     ^^^^^^^^^ mutable borrow occurs here
...
15 |     println!("{}", *ptr);
   |                    ---- immutable borrow later used here

error: aborting due to previous error

For more information about this error, try `rustc --explain E0502`.
  
```

The error message mentioned above emphasizes that the reference acquired at _line 6_ must stay valid until _line 15_. As a consequence, the compiler prohibits any mutable references that span from _line 6_ to _line 15_. Essentially, the code already has an immutable reference obtained before the mutable borrowing occurs. Later on, the same immutable reference is used, but it risks becoming invalidated due to the preceding mutation.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0lzvjfeap7yf9fkzd9k8.png)
_In the picture above, green region indicates how long the immutable reference is valid for and red region is where the mutation takes place. As you can see there is a clear overlap._

The compiler upholds the principle that mutability is exclusive and cannot be shared within the specified range. In essence, it saves your program from crashing due to a dangling reference. (`ptr` can turn dangling, since `v.push()`, like it's C++ counterpart also triggers a reallocation, whenever required). 

While it is true that C++ offers smart pointers like `unique_ptr`, `shared_ptr`, etc, similar to Rust's `Box`, `Rc`, etc, it is important to note that the focus of our discussion here is specifically on the borrow checker in Rust. The concept of smart pointers, although related, is [distinct from the borrow checker](https://www.quora.com/Why-arent-C-shared_ptr-unique_ptr-and-weak_ptr-a-complete-answer-to-Rusts-borrow-checker-memory-management-model). 

## 2. Out-of-Scope Variable Access

In its native behavior, C++ compilers such as clang++/g++ do offer warnings when returning a reference to a local variable from a function. However, it's important to note they may not issue warnings for more advanced scenarios, unless I have inadvertently overlooked some details here. I am no C++ expert.

Here's an example:

```c++
// clang++ -std=c++20  -Wall -Wextra -Wpedantic -Weverything -Wno-c++98-compat

#include <iostream>

using namespace std;

class Rectangle {
  size_t& width;
  size_t& height;

public:
  Rectangle (size_t& w, size_t& h): width(w), height(h) {}

  size_t area () const {
    return width * height;
  }
};

static Rectangle
make_rectangle(size_t width, size_t height) {
  return Rectangle(width, height);
}

int main() {
  Rectangle rec = make_rectangle(52, 10);
  std::cout << rec.area() << std::endl;
}

```

In the provided code snippet, `make_rectangle()` returns an object with its fields aliasing the local variables, and surprisingly, the compiler does not generate any warnings/errors. However, it's worth noting that static analysis tools like cppcheck are capable of detecting this issue. Therefore, relying on static analysis tools becomes necessary in C++ to catch such occurrences.

Let's look at the equivalent example in Rust.


```rust
struct Rectangle<'a> {
    width: &'a usize,
    height: &'a usize,
}

impl<'a> Rectangle<'a> {
    fn area(&self) -> usize {
        self.width * self.height
    }
}

fn make_rectangle(width: usize, height: usize) -> Rectangle {
    Rectangle {
        width: &width,
        height: &height,
    }
}

fn main() {
    let rec = make_rectangle(52, 10);
    println!("{}", rec.area());
}

``` 

Compiling the code, throws the following error:

```
error[E0106]: missing lifetime specifier
  --> test.rs:12:61
   |
12 | fn make_rectangle(width: usize, height: usize) -> Rectangle<'_> {
   |                                                             ^^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime

For more information about this error, try `rustc --explain E0106`.
```

The compiler talks about lifetimes, which by the way, is one of the primary reasons why Rust doesn't need a garbage collector. It's a bit advanced but a very powerful concept. Coming back to the error, it basically says _"I see that `Rectangle` here is borrowing certain things, but you need to tell me for how long?. I am sorry, I can't proceed without knowing that!"_

Lifetime is an attribute of a reference in Rust that tells the compiler how long the reference is supposed to be valid for. It helps compiler statically analyze how safely the references are used in the code. If the compiler sees that specified lifetime is meant to be longer than the data it points to, it cries foul! So mapping this understanding to our error message above, we can see the data (local variables living on the stack) that the returned object is pointing to, will be invalidated once the function call is over. 

Lifetime denotions are prefixed by a `'` and are defined in the same as way as generic types. Let's specify a lifetime.

```rust

fn make_rectangle<'a>(width: usize, height: usize) -> 
    Rectangle<'a> {
    Rectangle {
        width: &width,
        height: &height,
    }
}

```
and here's the compiler output:

```

error[E0515]: cannot return value referencing function parameter `width`
  --> a1.rs:13:5
   |
13 | /     Rectangle {
14 | |         width: &width,
   | |                ------ `width` is borrowed here
15 | |         height: &height,
16 | |     }
   | |_____^ returns a value referencing data owned by the current function

error[E0515]: cannot return value referencing function parameter `height`
  --> a1.rs:13:5
   |
13 | /     Rectangle {
14 | |         width: &width,
15 | |         height: &height,
   | |                 ------- `height` is borrowed here
16 | |     }
   | |_____^ returns a value referencing data owned by the current function

error: aborting due to 2 previous errors

For more information about this error, try `rustc --explain E0515`.

```
We now have defined a lifetime `'a`, but `'a` (i.e a borrow of local variables) is supposed to live beyond `make_rectangle()`'s execution which obviously isn't correct, hence the above error.

But what does `<'a>` on the `Rectangle` really mean here? - It's a part of the `Rectangle`'s definiton and it means `Rectangle` will borrow stuff for "some" lifetime `'a`. 

If we make `make_rectangle()` accept references instead of values, the compiler will be happy as far as the defintion of `make_rectangle()` goes.

```rust

fn make_rectangle<'a>(width: &'a usize, height: &'a usize) -> 
    Rectangle<'a> {
    Rectangle {
        width: width,
        height: height,
    }
}

```

We assure the compiler here that `make_rectangle()` will be receiving references that live for "some" lifetime `'a` which at the very least are guaranteed to live longer than `make_rectangle()`'s execution. Note this denotion is similar to how we define generic types eg. `Rectangle<T>` or `make_rectangle<T>()` where `T` could be "some" concrete type such an integer, string, vector, etc.

Understanding lifetimes might be a bit daunting to begin with, but as you write and test different code it will grow on you :). The Rust compiler messages have become very  friendly and easy to understand.

Let's deepen our understanding of lifetimes. Here's another C++ example that demonstrates a scenario where the compiler fails to identify the issue, but cppcheck successfully detects it.

```c++

// clang++ -std=c++20  -Wall -Wextra -Wpedantic -Weverything -Wno-c++98-compat

#include <iostream>

using namespace std;

static int& 
bar(int &x, int &y) { 
  if (x > 10) { 
    return x;
  } 

  return y;
}

static int& 
foo(int &x) { 
  int y = 10;

  return bar(x, y);
}

int main() { 
  int val = 1;
  std::cout << foo(val) << std::endl;
}

```

As observed in the above example, `foo()` conditionally returns a reference to its local variable. While a tool like cppcheck can identify this issue, if `foo()` belonged to a library and we don't have control over its implementation, we would have limited options to address the problem directly.

Rust tackles this issue by providing the ability to express the constraint directly within the function definition using lifetimes. Here's what I mean

```rust
 
fn bar(x: &i32, y: &i32) -> &i32 {
    if (*x > 10)
        return x;
    y
}

```

The compiler won't compile this function. It throws the following error:

```

error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:29
  |
1 | fn bar(x: &i32, y: &i32) -> &i32 {
  |           ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
1 | fn bar<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
  |       ++++     ++          ++          ++

For more information about this error, try `rustc --explain E0106`.

```

There isn't much need for further explanation of the error message since it is self-explanatory. Following the compiler suggestion of adding lifetimes to `bar()`, any ambiguity regarding its return value is resolved. As a consumer of `bar()`, it is reassuring to know that there will be no dangling references after calling it and the error message would completely point towards the issue at my callsite i.e inside `foo()`

```rust

error[E0515]: cannot return value referencing local variable `y`
   | fn foo(x:&i32) -> &i32 {
   |
   |     let y = 2;
   |
   |     bar(&x, &y)
   |     ^^^^^^^^--^
   |     |       |
   |     |       `y` is borrowed here
   |     returns a value referencing data owned by the current function

For more information about this error, try `rustc --explain E0515`.

```

It is worth noting that if `bar()` had to return the same input, specifying the same lifetimes for both arguments would serve little purpose and would be unnessarily constraining for the caller. Let's look at a full example:

```rust

fn bar<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
    if *x < 10 {
        //some_func(x, y);
        println!("{:?}", y);
    }

    x
}

fn foo(x: &i32) -> &i32 {
    let y = 2;
    bar(&x, &y)
}

fn main() {
    let x = 1;
    println!("{}", foo(&x));
}

```

Since there is a return lifetime from `bar()` and `y` is tied to the same input lifetime as `x`, the compiler expects `y` to live beyond the `foo()` which is not possible.

```

error[E0515]: cannot return value referencing local variable `y`
  --> src/main.rs:12:5
   |
12 |     bar(&x, &y)
   |     ^^^^^^^^--^
   |     |       |
   |     |       `y` is borrowed here
   |     returns a value referencing data owned by the current function

For more information about this error, try `rustc --explain E0515`.

```

The fix would be to have `bar()` receive disjoint input lifetimes.

```rust

fn bar<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
    if *x < 10 {
        //some_func(x, y);
        println!("{:?}", y);
    }

    x
}

```

## 3. Out-of-Scope Variable Access - Multithreading

Another situation where accessing out-of-scope data can occur is in the context of multithreading. Here's an example in C++:

```c++

// clang++ -std=c++20  -Wall -Wextra -Wpedantic -Weverything -Wno-c++98-compat 

#include <iostream>
#include <thread>
#include <unistd.h>

using namespace std;

static
std::thread fire_a_job(int id) {
	std::thread t([&]() {
		// here there's no guarantee that `id` will be valid 
		std::cout << "job running " << id << std::endl;
	});

	return t;
}

int main() {
	std::thread t = fire_a_job(10);
	t.join();
}

```

In this scenario, `fire_a_job()` initiates a thread `t` and `fire_a_job()` promptly returns. However, it's important to note that thread `t` may continue running for an extended period or possibly even start executing at a later time after the completion of `fire_a_job()`. This uncertainty arises because there is no guarantee or control over when the thread `t` will be scheduled to run by the underlying scheduler.

As a consequence, there is a risk of accessing the variable `id` after it has been destroyed. The variable `id` lives on the stack and is borrowed by thread `t` indicated by the `[&]` syntax. The compiler again, doesn't catch this nor does cppcheck (as of the time of writing).Hence your code becomes concurrency-unsafe, as it may lead to accessing invalidated data.

Rust's borrow-checker tackles such data access invalidation issues between the threads too. Let's look at the Rust equivalent of `fire_a_job()`:

```rust

use std::thread::*;

fn fire_a_job(id: i32) -> JoinHandle<()> {
    spawn(|| {
        println!("job running {}", id);
    })
}

```

Compiler fails with the following message:

```

error[E0373]: closure may outlive the current function, but it borrows `id`, which is owned by the current function
 --> src/lib.rs:4:11
  |
4 |     spawn(|| {
  |           ^^ may outlive borrowed value `id`
5 |         println!("job running {}", id);
  |                                    -- `id` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/lib.rs:4:5
  |
4 | /     spawn(|| {
5 | |         println!("job running {}", id);
6 | |     })
  | |______^
help: to force the closure to take ownership of `id` (and any other referenced variables), use the `move` keyword
  |
4 |     spawn(move || {

For more information about this error, try `rustc --explain E0373`.

```

The message indicates that thread `t` may exist beyond the lifetime of `id`. To address this, it is recommended to either ensure that `id` remains valid throughout the entire program execution (referred to as the static lifetime and denoted by `'static`), or ["move" `id` (copy or transfer ownership) into thread `t`](https://hashrust.com/blog/moves-copies-and-clones-in-rust/), similar to the `[=]` syntax in C++. While the first recommendation is not possible since `id` is a stack variable, the second one provides a solution to the problem. By moving `id` (copying or transferring ownership) into thread `t`, we can ensure that the data remains accessible and valid throughout the thread's execution. This resolves the issue of accessing potentially destroyed or out-of-scope data. 

```rust

use std::thread::*;

fn fire_a_job(id: i32) -> JoinHandle<()> {
    spawn(move || {
        println!("job running {}", id);
    })
}

```

## 4. Shared Mutability
Unlike Rust, C++ does not have native support for preventing shared mutability at compile-time. Here's an example in C++

```c++

// clang++ -std=c++20  -Wall -Wextra -Wpedantic -Weverything -Wno-c++98-compat 

#include <iostream>
#include <thread>
#include <vector>

using namespace std;

int main() {
  vector<int> v;
  v.push_back(0);

  std::thread t1([&](){
    v.push_back(1);
    std::cout << "t1 running " << std::endl;
  });

  std::thread t2([&](){
    v.push_back(2);
    std::cout << "t2 running " << std::endl;
  });

  t1.join();
  t2.join();

  for (auto element : v) {
    std::cout << element << " ";
  }
}

```

In the above example, `v` is captured by reference and is simultaneously mutated from two different threads. The code compiles fine but as expected, yields inconsistent results during runtime.. Here's what happens at runtime:

![4 different runs](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h2vacid4emy1t2g6wx8y.png)

_4 different runs - 2 passed, 2 failed!_

Certainly, the appropriate solution in this scenario is to utilize synchronization primitives or thread-safe data structures, which applies not only to C++ but also to Rust. However, Rust's borrow checker detects such issues at compile-time, preventing your application from crashing at runtime.

Here's the Rust equivalent example:

```rust
use std::thread::*;

fn main() {
    let mut v = vec![];
    v.push(0);
    
    scope(|s| {
        s.spawn(|| {
            v.push(1);
            println!("t1 running");
        });
        
        s.spawn(|| {
            v.push(2);
            println!("t2 running");
        });
    });
}
```

and here's the compiler's error message: 

```
error[E0499]: cannot borrow `v` as mutable more than once at a time
  --> src/main.rs:13:17
   |
7  |       scope(|s| {
   |              - has type `&'1 Scope<'1, '_>`
8  |           s.spawn(|| {
   |           -       -- first mutable borrow occurs here
   |  _________|
   | |
9  | |             v.push(1);
   | |             - first borrow occurs due to use of `v` in closure
10 | |             println!("t1 running");
11 | |         });
   | |__________- argument requires that `v` is borrowed for `'1`
12 |           
13 |           s.spawn(|| {
   |                   ^^ second mutable borrow occurs here
14 |               v.push(2);
   |               - second borrow occurs due to use of `v` in closure

For more information about this error, try `rustc --explain E0499`.

```

## Closing Points

In contrast to C++, where it falls upon the programmer to implement adequate synchronization and prevent dangling references, Rust takes a different approach. Rust's borrow checker thoroughly examines the code, verifying that shared references and mutable access are handled safely. This not only prevents accessing invalidated data but also eliminates issues like data races and other concurrency-related problems. By detecting these concerns during compilation, Rust enhances the overall safety of the codebase, effectively mitigating certain categories of bugs associated with memory access.

While it may require some additional annotations like lifetimes or code restructuring at times, the borrow-checker's thorough analysis provides a valuable layer of protection and contributes to the overall reliability and efficiency of programs.