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.
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:
struct Device {
device_id: i32,
serial_number: String,
}
impl Device {
fn new(device_id: i32, serial_number: String) -> Self {
Self {
device_id,
serial_number,
}
}
}
fn print_device_details(device: Device) {
println!(
"Device id: {:#?} serial number: {:#?}",
device.device_id, device.serial_number
);
}
fn main() {
let device = Device::new(10, String::from("XYZ"));
print_device_details(device);
}Compile and Run
~/cpp_vs_rust/rust/device_example$ cargo build -q
~/cpp_vs_rust/rust/device_example$ cargo run -q
Device id: 10 serial number: "XYZ"Alternatively, you can also build and run the same with the following command:
~/cpp_vs_rust/rust/device_example$ cargo run -q
Device id: 10 serial number: "XYZ"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:
println!(
"Device id: {:#?} serial number: {:#?}",
device.device_id, device.serial_number
);So the whole listing stands like the following:
struct Device {
device_id: i32,
serial_number: String,
}
impl Device {
fn new(device_id: i32, serial_number: String) -> Self {
Self {
device_id,
serial_number,
}
}
}
fn print_device_details(device: Device) {
println!(
"Device id: {:#?} serial number: {:#?}",
device.device_id, device.serial_number
);
}
fn main() {
let device = Device::new(10, String::from("XYZ"));
print_device_details(device);
println!(
"Device id: {:#?} serial number: {:#?}",
device.device_id, device.serial_number
);
}Compile and Run
~/cpp_vs_rust/rust/device_example$ cargo run -q
error[E0382]: borrow of moved value: `device`
--> src/main.rs:26:27
|
22 | let device = Device::new(10, String::from("XYZ"));
| ------ move occurs because `device` has type `Device`,
which does not implement the `Copy` trait
23 | print_device_details(device);
| ------ value moved here
...
26 | device.device_id, device.serial_number
| ^^^^^^^^^^^^^^^^^^^^ value borrowed
here after move
|
note: consider changing this parameter type in
function `print_device_details` to borrow instead if
owning the value isn't necessary
--> src/main.rs:15:33
|
15 | fn print_device_details(device: Device) {
| -------------------- ^^^^^^ this parameter
takes ownership of the value
| |
| in this function
note: if `Device` implemented `Clone`, you could clone the value
--> src/main.rs:1:1
|
1 | struct Device {
| ^^^^^^^^^^^^^ consider implementing `Clone` for this type
...
23 | print_device_details(device);
| ------ you could clone this value
For more information about this error, try `rustc --explain E0382`.
error: could not compile `device_example` (bin "device_example")
due to 1 previous errorWe 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.
let device = Device::new(10, String::from("XYZ"));
------ move occurs because `device` has type `Device`,
which does not implement the `Copy` traitIn 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.
fn print_device_details(device: Device) {
-------------------- ^^^^^^ this parameter
takes ownership of the valueThe 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().
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:
println!(
"Device id: {:#?} serial number: {:#?}",
device.device_id, device.serial_number
);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++:
#include <iostream>
#include <memory>
#include <string>
#include <utility>
struct Device
{
int device_id;
std::string serial_number;
Device() = delete;
Device(const int device_id, const std::string& serial_number)
: device_id{device_id},
serial_number{serial_number} {};
};
void print_device_details(const Device device)
{
std::cout << "Device id: " << device.device_id << " serial number: " << device.serial_number
<< "\n";
}
int main()
{
Device device(10, "XYZ");
print_device_details(device);
return 0;
}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:
void print_device_details(const Device& device)
{
std::cout << "Device id: " << device.device_id
<< " serial number: " << device.serial_number
<< "\n";
}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:
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
struct Device
{
int device_id;
std::string serial_number;
Device() = delete;
Device(int device_id, const std::string& serial_number)
: device_id{device_id},
serial_number{serial_number}
{
}
};
class Registry
{
public:
void register_device(std::unique_ptr<Device> device)
{
_devices.push_back(std::move(device));
}
private:
std::vector<std::unique_ptr<Device>> _devices;
};
int main()
{
Registry registry;
std::string serial_number{"XYZ"};
std::unique_ptr<Device> device{
std::make_unique<Device>(10, serial_number)};
registry.register_device(std::move(device));
return 0;
}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.
registry.register_device(std::move(device));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:
int main()
{
Registry registry;
std::string serial_number{"XYZ"};
std::unique_ptr<Device> device{std::make_unique<Device>(10, serial_number)};
registry.register_device(std::move(device));
std::cout << "Device id: " << device->device_id << " serial number: " << device->serial_number
<< "\n";
return 0;
}If you compile and run the program, the compiler won’t complain about it, but the program will crash with an exception.
Takeaway
- Rust enforces single ownership. Passing our
non-CopyDeviceinstance by value transfers ownership. As we saw,rustcreports an error whenmain()tries to print thedevicedetails 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
Deviceby value copies the instance. Passing it byconstreference avoids that copy. - Moving a
unique_ptrinto 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::coutstatement in our C++ example still compiles, even thoughdeviceis 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