In this post, I’ll explore more Rust features that beat C++ for compile-time memory safety. But before we dive in, we need to understand a few fundamental Rust concepts- ownership, lifetimes, and borrowing- that are necessary for the discussion. This discussion will not be comprehensive but rather a beginner’s guide to learning Rust. We will compare Rust concepts with their C++ counterparts on and off so that they are relatable and easy to understand.

Ownership: Rust has a concept of single ownership. For example, if a non-Copy value is passed to a function by value, the ownership is moved to the function. The value here can be anything, e.g., an instance of a struct. Again, this is not a comprehensive explanation of the ownership concept, but this will give you some idea, which is needed for further discussion in this post.

Lifetime: the owner of a value has a lifetime, and when it goes out of scope, the lifetime of the owner ends, and the value is dropped unless the ownership has been moved to another owner. Dropping a value essentially means the underlying resource is cleaned up by Rust. For example, any allocated heap memory will be released when the owner goes out of scope.

Borrowing: if code accesses a value through a reference without taking ownership of it, the code is said to be borrowing the value, as per Rust terminology. The concept is much broader than this simplistic explanation. But to start with, this will give you some conceptual perspective on what we are talking about.

Rust resource handling example

Let’s take a look at the following example where a struct Device instance is being created in the main function, and then the instance is being passed to another function which prints the device details on the console.

Note

I have discussed the basic steps to create a Rust project and other necessary steps to compile and run the executable in the previous blog.

The main.rs for this example is as follows:

Compile and Run

Alternatively, you can also build and run the same with the following command:

This code is simple and self-explanatory, so I won’t waste time explaining it. But now we are going to modify the code and add a line after the function call print_device_details(), as follows:

So the whole listing stands like the following:

Compile and Run

We have hit a series of error messages from the rustc compiler. The errors are giving away quite a lot of information. Let’s understand these one by one.

In simple words, the above message says the data type of the variable device is Device, which is a user-defined data type (a struct). It doesn’t implement the Copy trait. That essentially means that when we try to pass this value (device) by value to the function print_device_details(), there is no mechanism to implicitly copy the value (device). As a result, the ownership of the value (device) has to be moved. The next line in the error log explicitly shows where the move actually happens.

The moment the main() calls print_device_details() and passes the value device by value, the ownership of the value (device) is moved to the function print_device_details().

Note

Ownership rule: there must be a single owner of a value at a time, and when the owner goes out of scope, the value will be dropped unless ownership has been moved to another entity. Once the value is dropped, it cannot be referenced (borrowed) from anywhere in the code. This will be checked at compile time, and if the rule is violated, the compilation will fail.

As ownership has been moved, it is a violation in main to try to borrow the device instance using the same handle (device) in the following line:

The compilation breaks, and now you know why.

Bringin C++ into the picture

Now, if we turn our attention to our good old friend C++. Let’s have a look at the following C++ skeleton code and see how we could have handled a situation like the above in C++:

In the above code, main() is passing device by value, but unlike in the Rust example we saw previously, the ownership of device is not transferred in this case. However, passing the structure instance by const reference will be a better option here as it avoids copying involved in pass by value. So we will modify print_device_details() as below:

Everything else in the code remains the same. The function print_device_details() gets a const reference to the variable device and works on it. In this case, till now there has been no ownership involved in this, nor did we have the need for that.

C++ Ownership and transfer of ownership

Now, let’s introduce some more complexity to our C++ example, and let’s introduce a class called Registry that takes the Device handle and stores it in its internal data store for future reference. The following is such an implementation:

The main() function first creates an instance of the Registry class called registry that stores pointers to Device instances. Then it creates a unique_ptr<Device> called device. In this design, the intention is to make the registry the only entity to have exclusive ownership of all the pointers to the Device instances created in the system. In C++, unique_ptr < T> provides exclusive ownership. And hence the choice of unique_ptr<T> is probably justified. Also, as the registry is supposed to be the only owner of all the pointers to Device instances in the system, we have to move the unique_ptr<Device>, i.e., the device, from main() to the registry object using the std::move() function.

After this point, the ownership of the device has been transferred to the registry object, and device is no longer pointing to a valid object in the main() function. So main() shouldn’t be trying to dereference it anymore. But C++ doesn’t have a mechanism to check this at compile time. Even if main() tries to access it after this line, it goes undetected at compile time, and the code crashes at runtime. Let’s modify the main() function and add a line to try accessing the device pointer after the ownership has been transferred:

If you compile and run the program, the compiler won’t complain about it, but the program will crash with an exception.

Screenshot of a C++ code editor displaying a program with a class named 'Registry', a method 'register_device', and a 'main' function that demonstrates device registration. An error message indicates a segmentation fault.

Takeaway

  • Rust enforces single ownership. Passing our non-Copy Device instance by value transfers ownership. As we saw, rustc reports an error when main() tries to print the device details after ownership has been transferred.
  • We can borrow a value through a reference when we need to access it without taking ownership.
  • But once ownership has been transferred, we cannot borrow the value through the original variable. Rust checks this at compile time, preventing this ownership error without adding runtime overhead for the check.
  • In our C++ example, passing Device by value copies the instance. Passing it by const reference avoids that copy.
  • Moving a unique_ptr into the registry transfers ownership and leaves the original smart pointer empty.
  • Unlike Rust, C++ has no built-in ownership checker to prevent this use after moving ownership.
  • The added std::cout statement in our C++ example still compiles, even though device is now empty. Dereferencing it causes undefined behavior, which may result in a crash at runtime.

Discover more from Tech For Talk

Subscribe to get the latest posts sent to your email.

Leave a Reply