How to Use C++ unique_ptr - Inspired by Rust Ownership
2022-05-15
Rust Ownership
Rust's ownership mechanism is a unique approach to managing the lifecycle of variables. Its basic rules are:
- Each value in Rust has a variable that's called its owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the value will be dropped.
In Rust, = assignment and function argument passing both implicitly cause ownership transfer of the variable. Therefore:
let s1 = String::from("hello");
do_something_with(s1);
println!("{}, world!", s1); // s1 no longer points to "hello", so this fails to compile
To avoid function calls causing ownership transfer, Rust introduces the concept of "references". References are similar to pointers, but the compiler guarantees they never point to a null address.
let s1 = String::from("hello");
do_something_with(&s1); // pass variable by reference
println!("{}, world!", s1); // s1 still points to "hello" and works normally
Rust references are immutable by default. To modify the "value" a reference points to, you must explicitly declare mutability:
do_something_with(&mut s1);
To prevent a value from being modified by multiple functions simultaneously, Rust restricts that there can only be one mutable reference at a time, while immutable references have no such limit.
Summary
In Rust, there are two ways to interact with a value:
- Become its owner, controlling its lifecycle.
- Use a reference.
From a function signature perspective, there are three forms:
- Take ownership, controlling the value's lifecycle.
- Read the value's content.
- Modify the value's content.
struct Object {
content: String,
}
fn control_lifecycle(o: Object) -> Object {};
fn only_check_value(o: &Object) {};
fn modify_it_content(o: &mut Object) {};
C++ Smart Pointers
To manage object lifecycles properly, modern C++ introduces a set of smart pointers:
shared_ptr— use when an object pointer needs to be shared.unique_ptr— use when an object pointer does not need to be shared.weak_ptr— used alongsideshared_ptr, does not control the object's lifecycle.
shared_ptr atomically updates the reference count during function passing, copying, and when going out of scope. Additionally, besides the managed pointer itself, shared_ptr allocates a control block (holding the reference count, deleter, etc.). That means a single shared_ptr requires the space of two pointers. Since C++ is typically used in performance-sensitive scenarios, one should carefully weigh the trade-offs before deciding to use shared_ptr.
unique_ptr has no such runtime overhead and performs better. However, when passing unique_ptr in functions, there is no way to ensure the pointed object won't be modified. And const std::unique_ptr<X> x only ensures that x itself cannot transfer ownership.
Inspiration from Rust
As discussed earlier, abusing unique_ptr loses compile-time checks for variable mutations. To retain these capabilities, const and references must participate. Similar to the function categorization for Rust above, C++ can adopt a similar approach:
class Object {
public:
explicit Object(std::string s): content(std::move(s)) {};
std::string content;
};
std::unique_ptr<Object> control_lifecycle(std::unique_ptr<Object> o) {
o->content = "owner changed it";
return o;
};
void only_check_value(const Object& o) {
std::cout << "It contains: " << o.content << std::endl;
};
void modify_it_content(Object& o) {
o.content = "3rd-party changed it";
};
int main() {
auto o = std::make_unique<Object>("Hello");
only_check_value(*o); // It contains: Hello
o = control_lifecycle(std::move(o)); // ownership moved out and then in
only_check_value(*o); // It contains: owner changed it
modify_it_content(*o);
only_check_value(*o); // It contains: 3rd-party changed it
return 0;
}
The advantage of this style is that the function's purpose is clear from its signature. The downside is that, compared to shared_ptr, the programmer must manually ensure the const reference is not used after the unique_ptr object is destroyed.