(Logo)

Mofafen Blog

Ownership in Rust: Comparative analysis withC++

Published on March 17, 2025

This article aims to provide an analysis of Rust ownership and related concepts for programmers with experience in C++.

Owning vs Borrowing a Value

Borrowing a value means having a reference to the value without owning it.
Taking ownership of a value means becoming the new owner of the value.

let m_str = "Hello, World".to_string(); // m_str owns the value
let m_str_ref = &m_str; // m_str_ref borrows the value
let m_str2 = m_str; // m_str2 becomes the new owner, m_str is invalidated.
println!("{}", m_str_ref); // Ok: It is a reference
println!("{}", m_str2); // Ok: It is the new owner
println!("{}", m_str); // Error: Ownership has moved to m_str2.
// The binding of m_str has been dropped (moved to m_str2)
  • Once a new owner takes ownership of a value, the previous binding is dropped.
  • If we want to retain the original binding, we could use the Copy or Clone traits.
    • Clone trait:
      • Explicitly called by using the clone() method.
      • Creates a duplicate of the value, with a new owner.
    • Copy trait:
      • Works by making a bitwise copy of a value.
      • Automatically happens with simple types like integers or characters, without requiring explicit copying.

Smart Pointers in Rust

1. Multiple Ownership: std::rc::Rc<T>

Rc<T> is a smart pointer that allows multiple ownerships. If you’re familiar with C++, std::rc::Rc<T> is similar to std::shared_ptr<T>.

Both: - Use a reference-counting mechanism to manage heap-allocated data. - Automatically deallocate the object when the last reference is dropped. - Cloning or copying an Rc<T> creates a new reference to the same object and increases the reference count. Similarly, copying a shared_ptr<T> does the same.

Let's look at an example in Rust to demonstrate multiple ownership.
See this example on Compiler Explorer here.

use std::rc::Rc; // Import reference counter

fn main() {
    let owner = Rc::new(8); // Create a new reference counter
    println!("Owners: {}", Rc::strong_count(&owner));

    { // Closure
        println!("New closure (1)");
        let _owner2 = owner.clone();
        println!("Owners: {}", Rc::strong_count(&owner));

        { // Another closure
            println!("New closure (2)");
            let _owner3 = owner.clone();
            println!("Owners: {}", Rc::strong_count(&owner));
            println!("Leave closure (1)");
        }

        println!("Owners: {}", Rc::strong_count(&owner));
        println!("Leave closure (2)");
    }

    println!("Owners: {}", Rc::strong_count(&owner));
}

Running this program gives the following result:

Owners: 1
New closure (1)
Owners: 2
New closure (2)
Owners: 3
Leave closure (1)
Owners: 2
Leave closure (2)
Owners: 1

This illustrates two things: - The reference counter increments when adding a new owner. - The reference counter decrements when the program leaves the scope of an owner.

Differences Between std::rc::Rc<T> and std::shared_ptr<T>

Feature Rc<T> (Rust) std::shared_ptr<T> (C++)
Thread Safety Not thread-safe (modifying the reference count at the same time can cause data races and undefined behavior). Arc<T> is used for multi-threading. Thread-safe for reference counting (atomic operations). Not thread-safe for managed T object (needs to use mutex).
Performance Faster (no atomic operations) Slightly slower (due to atomic reference counting)

2. Unique Ownership: Box<T>

Box<T> provides ownership of heap-allocated data and ensures that the data is deallocated when the Box goes out of scope.

Box<T> is comparable to C++ std::unique_ptr<T>: - Both enforce unique ownership. - C++ is less strict, as it doesn't detect moved ownership at compile time:

Here’s an example showing a runtime error in C++ when trying to access a moved value:

#include <memory>
#include <iostream>
using namespace std;

int main() {
    auto ptr1 = make_unique<int>(10);
    cout << "Ptr1 value: " << *ptr1 << endl;
    auto ptr2 = move(ptr1);
    cout << "Ptr2 value: " << *ptr2 << endl;
    // Trying to access ptr1 after the move
    // Compiles, but causes a SIGSEGV error (runtime error)
    cout << "Ptr1 (2) value: " << *ptr1 << endl;
}

In comparison, the following Rust code won’t compile at all:

fn main() {
    // Create a `Box` to hold an integer on the heap
    let ptr1 = Box::new(10);
    println!("Ptr1: {}", *ptr1);
    let ptr2 = ptr1;
    println!("Ptr2: {}", *ptr2);
    println!("Ptr1 (2): {}", *ptr1); // Compile error
}

This results in the following error message:

error[E0382]: borrow of moved value: `ptr1`
  --> <source>:16:29
   |
12 |     let ptr1 = Box::new(10);
   |         ---- move occurs because `ptr1` has type `Box<i32>`, which does not implement the `Copy` trait
13 |     println!("Ptr1: {}", *ptr1);
14 |     let ptr2 = ptr1;
   |                ---- value moved here
15 |     println!("Ptr2: {}", *ptr2);
16 |     println!("Ptr1 (2): {}", *ptr1);
   |                             ^^^^^ value borrowed here after move

3. Atomic Reference Counting: Arc<T>

Arc<T> is used to enable shared ownership of data between multiple threads. The reference count is atomically updated, making it safe for concurrent access. - Thread-safe (atomic reference counting), unlike Rc<T>. - It is immutable by default.

Here’s an example using Arc in a multi-threaded context:

use std::sync::Arc;
use std::thread;

fn main() {
    let value_ptr = Arc::new(5);
    let value_ptr_clone = Arc::clone(&value_ptr);
    let handle = thread::spawn(move || {
        println!("Inside thread, value: {}", value_ptr_clone);
    });
    handle.join().unwrap();
    println!("Main thread, value: {}", value_ptr);
}

Immutability by Default

Arc<T> doesn’t allow direct mutation of the value because it is immutable by default. This is a major difference compared to C++, where mutable data can be shared across threads. Rust’s emphasis on immutability helps eliminate the risk of modifying the same value from different threads.

Using Mutex<T> for Mutability

To modify a value within a thread, we need to combine Arc<T> with a Mutex<T> (or RwLock<T>, though we won’t cover RwLock here). With Mutex<T>, we must lock the value inside the thread before making any modifications. Below is an example using Arc<Mutex<T>>:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let value_mutex_ptr = Arc::new(Mutex::new(5)); // Using Mutex inside Arc
    let value_mutex_ptr_clone = Arc::clone(&value_mutex_ptr);
    let handle = thread::spawn(move || {
        let mut data_mutex = value_mutex_ptr_clone.lock().unwrap(); // Lock the mutex
        *data_mutex = 42; // Modify the value
        println!("Inside thread: {}", *data_mutex);
    });
    handle.join().unwrap();

    let data_mutex = value_mutex_ptr.lock().unwrap();
    println!("Inside main thread: {}", *data_mutex);
}

Summary

  • We’ve explored what it means in Rust to take ownership of a value and borrow a value.
  • We’ve looked at std::rc::Rc<T>, a smart pointer that allows multiple ownership through reference counting. It is efficient but not designed for multi-threading.
  • In a multi-threaded context, we use std::sync::Arc<T> to share ownership across threads. Arc<T> is immutable by default.
  • To allow mutability with Arc<T>, we combine it with Mutex<T> (e.g., Arc<Mutex<T>>).

Rust C++

Comments

No comments yet. Be the first to comment!

Leave a Comment