Skip to content
no new, no delete, compare modern memory management in cpp with that in rust programming language.
C++ Rust Python
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
cpp
rust
.gitignore
README.md
README.zh.md

README.md

The Rust programming language has been famous for its special, modern memory management strategy since it was born. While, as the target competitor of Rust, Cpp also has paid much attention to memory management modernization since C++11 was released.

Recently I start a project using Cpp, so I would like to summarize my concept about memory management strategy of Cpp, and compare it with that in Rust.

You can also regard this blog as an introduction of Rust to Cpp developer.

This blog consists of four sections: Reference, Copy, Move and Smart Pointer.

Reference

Cpp

At first, let's talk about the reference in Cpp. As we all know, in Cpp, reference can be divide into lvalue reference and rvalue reference. In this section, we will only talk about lvalue reference (rvalue reference will never be introduced before the section 'Move').

Lvalue Reference

The essence of lvalue reference in Cpp is alias of an existed variable, in the other word, multiple variable referring the same object.

So, you can see some code like this:

// [cpp] bazel run //reference:lvalue_ref

#include <iostream>

class A {
    int _data;
  public:
    explicit A(int data) : _data(data) {}
    
    auto data() -> int & {
        return _data;
    }
};


int main() {
    A a(0);
    a.data() = 1; // equal to `a._data = 1;`
    std::cout << "a._data = " << a.data() << std::endl;
    return 0;
}

You can also use a variable to receive this reference:

// [cpp] bazel run //reference:lvalue_ref_bind

int main() {
    A a(0);
    auto &data_ref = a.data();
    data_ref = 1;
    std::cout << "a._data = " << a.data() << std::endl;
    return 0;
}

Or use decltype(auto) in C++14.

// [cpp] bazel run //reference:lvalue_ref_bind_cpp14

int main() {
    A a(0);
    decltype(auto) data_ref = a.data();
    data_ref = 1;
    std::cout << "a._data = " << a.data() << std::endl;
    return 0;
}

Those programs will absolutely print out:

a._data = 1

Immutable Reference

The lvalue reference can be also divide into mutable reference (T&) and immutable reference (const T&). The immutability of reference mean the object referred by it is immutable.

// [cpp] bazel run //reference:const_ref

class B {
    A _a;
  public:
    explicit B(int data) : _a(data) {}
    
    auto a() -> const A & {
        return _a;
    }
};

int main() {
    B b(0);
    auto & ref_a = b.a();
    ref_a = A(1); // need `auto A::operator=(const A&) const -> const A&;`
    auto & ref_data = ref_a.data(); // need `auto data() const -> const int&;`
    return 0;
}

Try to compile this program, the compiler will complain against two errors:

The first is no viable overloaded '=', because the type of ref_a is const A& but there is no copy assignment operator defined as auto A::operator=(const A&) const -> const A&;.

The second error is almost because of the same reason: there is no method defined as auto A::data() const -> const int&;.

We can define a method named const_data for A:

auto const_data() const -> const int & {
    return _data;
}

The two const qualifiers are respectively immutable constraints for this and return type.

Now we can only get an immutable reference of _data.

// [cpp] bazel run //reference:const_ref_and_data

int main() {
    B b(0);
    auto & ref_data = b.a().const_data();
    ref_data = 1; // need `auto int::operator=(const int&) const -> const int&;`
    return 0;
}

compiling error at ref_data = 1;

Unresolved Problems

Lvalue references of Cpp does a good job at reducing copies and controlling immutability, however, there are still two obvious unresolved problems:

  • References may be still in using after destruction of referred object.
  • There may be multiple mutable references referred the same object, potential data race cannot be inspected by compiler.

Example of the first problem:

// [cpp] bazel run //reference:ref_dangling 
class C {
    int _id;
  public:
    explicit C(int id) : _id(id) {}
    
    ~C() {
        std::cout << "C(id=" << _id << ") destructed" << std::endl;
    }
    
    auto id() -> int & {
        return _id;
    }
};

decltype(auto) get_data() {
    return C(0).id();
}

int main() {
    decltype(auto) ref_data = get_data();
    std::cout << "id=" << ref_data << std::endl;
}

The print result:

C(id=0) destructed
id=-1840470160

Example of the second problem:

// [cpp] bazel run //reference:data_race 

#include <thread>
#include <iostream>
#include <random>
#include <chrono>

int main() {
    auto str = "hello, world";
    std::uniform_int_distribution<> dist{10, 100};
    auto handler = std::thread([&str, &dist]() {
        std::mt19937_64 eng{std::random_device{}()};
        std::this_thread::sleep_for(std::chrono::milliseconds{dist(eng)});
        str = nullptr;
    });
    std::mt19937_64 eng{std::random_device{}()};
    std::this_thread::sleep_for(std::chrono::milliseconds{dist(eng)});
    std::cout << str << std::endl;
    handler.join();
    return 0;
}

segmentation fault at random。

Rust

Lifetime of reference

A example of rust reference(&T):

// [rust] cargo run --example mutable_ref 

struct A {
    _data: i32,
}

impl A {
    fn new(data: i32) -> Self {
        A { _data: data }
    }

    fn data(&self) -> &i32{
        &self._data
    }

    fn mut_data(&mut self) -> &mut i32{
        &mut self._data
    }
}

fn main() {
    let mut a = A::new(0);
    let data = a.mut_data();
    *data = 1;
    println!("a._data={}", data);
}

In Rust, objects and references are both immutable by default, the mut qualifier is needed to get a mutable object or reference. This strategy is just opposite against that in Cpp.

All of the constraint rules mentioned in Cpp can be accordingly applied in Rust. As we can also notice, differing from Cpp, references in Rust are more similar to pointer instead of variable alias, ref(&) or deref(*) is needed at most cases.

Let's continue the previous topic, the two problems.

The first problem, can references live longer than referred object? The answer is 'NO', never. Once your program passes compiling, validity of references is forever guaranteed by compiler.

The compiler will refuse to compile program like this:

// [rust] cargo run --example ref_dangling

fn get_data() -> &i32 {
    let a = A::new(0);
    a.data()
}

fn main() {
    println!("a._data={}", get_data());
}

Compiling fails missing lifetime specifier, because each reference in Rust has its lifetime. Compiler can infer the lifetime in most cases, for instance, if a function inputs a reference and outputs a reference, compiler will infer the output reference relies on the input reference, so they have the same lifetime:

// [rust] cargo run --example lifetime_infer

fn get_data(a: &A) -> &i32 {
    a.data()
}

fn main() {
    println!("a._data={}", get_data(&A::new(0)));
}

Compiler infers the returned &i32 in get_data relies on parameter a;the data method is the same:

struct A {
    _data: i32,
}

impl A {
    fn data(&self) -> &i32{
        &self._data
    }
}

The returned &i32 should rely on parameter &self,and it does, so compiling successfully.

However, if the output reference does not rely on the input one, compiling will fail:

// [rust] cargo run --example failed_infer

fn get_data(a: &A) -> &i32 {
    let data = 1;
    &data
}

fn main() {
    println!("a._data={}", get_data(&A::new(0)));
}

The returned &i32 does not rely on the parameter a, compiling fails.

For more complex conditions, compiler has other inference rules, sometimes lifetime marks are also needed. Interested readers can go to official website for specific documentation.

Constraints to mutable references

Lifetime system of Rust can resolve the first problem I mentioned, how about the second one? Can multiple mutable references referred the same object exist? Can data race escape from compiler inspecting?

The answer is still 'NO',

我们前文说过 “Cpp 中对可变引用的约束规则 Rust 也全部涵盖了”,言外之意就是 Rust 对可变引用还有更多的约束:一个可变引用不能与其他引用同时存在

不能同时存在是什么意思呢?就是生命期不能重叠,比如

// [rust] cargo run --example mut_ref_conflict 

fn main() {
    let mut data = 0;
    let ref_data = &data;
    let mut mut_ref = &mut data;
    println!("data={}", ref_data);
}

编译失败。

由于 NLL 的存在,Rust 引用的生命期从它定义的地方持续到它最后一次被使用的地方而非作用域结尾(注意主语是生命期,存在依赖关系的引用拥有同一个生命期)。

所以这样是 OK 的:

// [rust] cargo run --example mut_ref

fn main() {
    let mut data = 0;
    let ref_data = &data;
    let mut mut_ref = &mut data;
}

这个编译不过:

// [rust] cargo run --example dep_ref_conflict

fn main() {
    let mut a = A::new(0);
    let data = a.data();
    let mut mut_a = &mut a;
    println!("data={}", data);
}

那么,这个约束对线程安全有什么帮助呢?一个可变引用不能与其他引用同时存在,再加上后面会提到的对象在被引用时不能移动,这就意味着在理想情况下是绝对不会出现数据竞争的。

当然这只是在理想情况下,事实上,由于这个约束过强,很多时候必须使用一些基于 �Unsafe Rust 的组件(在更强的约束上开洞而非在更弱的约束上修补也算 Rust 的设计哲学吧,首要考虑安全性)。

对 Rust 线程安全有兴趣的读者可以自行参阅官方文档,本文也无法讨论太多了;有一定 Rust 基础的读者还可以看看这篇文章作为拓展阅读《如何理解 rust 中的 Sync、Send?》

拷贝和移动

Cpp

拷贝

Cpp 的拷贝本质上是调用了拷贝构造函数T(const T& other)(构造时)或拷贝赋值运算符T& operator=(const T& other)(赋值时)。

other 也可以是 T&

一般来说开发者无需自己定义拷贝构造函数或者拷贝赋值运算符,编译器会隐式实现(默认实现是调用所有成员的拷贝构造函数或拷贝赋值运算符),但在一些特殊情况下(比如存在没有拷贝构造函数或拷贝赋值运算符的成员)必须自己实现。具体的隐式实现条件请参考 cppreference,本文不作赘述。

拷贝场景:

// [cpp] bazel run //move-or-copy:copy  

class A {
  public:
    A() = default;
    
    A(const A &) {
        std::cout << "call copy constructor" << std::endl;
    }
        
    auto operator=(const A &) -> A & {
        std::cout << "call copy operator=" << std::endl;
        return *this;
    }
    
};

auto set_a(A a) {}

auto get_a() {
    A a;
    return static_cast<A&>(a); // 防止返回值优化
}

int main() {
    A a;
    set_a(a);
    get_a();
    auto copy_a = a;
    a = copy_a;
    return 0;
}

打印出

call copy constructor
call copy constructor
call copy constructor
call copy operator=

移动

Cpp 的移动本质上是调用了移动构造函数T(T&& other)(构造时)或移动赋值运算符T& operator=(T&& other)(赋值时)。

其中 T&& 就是 T 类型的右值引用。

Cpp 左值引用本质上是变量别名(alias),即与同一对象绑定的多个变量;而右值引用(语义上)则表示某对象没有与任何变量绑定,故可能在两种情况下出现:

  • 直接取自右值
  • 取自左值(使用 std::movestd::forward 等)

这里重点说一下第二种情况,

std::move 仅仅是语义上的 move,用于从左值取出右值引用,表示该对象与原来的所有的左值引用解除绑定,move 过后原来所有的左值引用全部失效,不允许再被使用。

正是因为右值引用有着这样的语义,所以移动构造函数和移动赋值运算符可以放心使用右值引用(无变量绑定,移动不影响其它变量)。

而你一旦使用任何变量接收右值引用,这个变量就变成了左值,因为右值引用不与任何变量绑定。如果要保证引用在函数之间传递时能“完美转发”(右值引用不会转成左值),可使用 std::forward

// [cpp] bazel run //move-or-copy:move 

class A {
  public:
    A() = default;
    
    A(const A &) {
        std::cout << "call copy constructor" << std::endl;
    }
    
    A(A &&) noexcept {
        std::cout << "call move constructor" << std::endl;
    }
    
    auto operator=(const A &) -> A & {
        std::cout << "call copy operator=" << std::endl;
        return *this;
    }
    
    auto operator=(A &&) noexcept -> A & {
        std::cout << "call move operator=" << std::endl;
        return *this;
    }
};

auto set_a(A a) {}
auto copy_ref(A&& a) {
    A _copy(a); // a 转成了左值
}
auto move_ref(A&& a) {
    A _move(std::forward<A>(a)); // 转发
}
int main() {
    A a;
    set_a(std::move(a));
    set_a(static_cast<A&&>(A())); // 防止构造优化
    copy_ref(A());
    move_ref(A());
    return 0;
}

打印出

call move constructor
call move constructor
call copy constructor
call move constructor

为什么需要拷贝和移动

拷贝和移动本质上都是为了保证变量与其绑定的对象生命期一致,这是它们与引用本质上的目的区别,用额外的内存开销换取内存安全和编码便利。

有时这点额外内存开销是可以忽略不计的,但不是所有时候都这样。减少内存开销的常见做法是堆分配,但堆分配带来的新问题是可能会内存泄漏。

如果使用堆内存分配和拷贝,就需要想一套方案来决定什么时候回收内存。常见的思路是引用计数或者 GC

但我们可以发现,移动是天然符合 RAII 的:堆内存分配,堆内存生命期与栈对象一致(在栈对象析构函数中释放堆内存)。

比如我们来造一个 Vector

// [cpp] bazel run //move-or-copy:vector 

#include <cstdlib>
#include <iostream>

template<typename T>
class Vector {
    T *header;
    size_t capacity;
    size_t length;
  public:
    explicit Vector(size_t cap = 0) : header(new T[cap]), capacity(cap), length(0) {};
    Vector(const Vector &) = delete;
    
    Vector(Vector &&other) noexcept : header(other.header), capacity(other.capacity), length(other.length) {
        other.header = nullptr;
        other.capacity = 0;
        other.length = 0;
    }
    
    ~Vector() {
        std::cout << "destruct Vector(header=" << header << ")" << std::endl;
        delete[] header;
    }
    
    auto first() -> const T& {
        return *header;
    }
};


auto product_vector() {
    auto v = Vector<int>(10);
    return std::move(v);
}

auto consume_vector(Vector<int> _v) {

}

int main() {
    auto v = product_vector();
    consume_vector(std::move(v));
    return 0;
}

我们在这里显式删除了拷贝构造函数,导致它只能移动而无法拷贝,你也可以实现它。

打印出

destruct Vector(header=0x0)
destruct Vector(header=0x7fd361c02a10)
destruct Vector(header=0x0)

从析构顺序来看只有 consume_vector 中的 v delete 了真正的 headerproduct_vectormain 中的 v 再析构之前被 move 从而被“掏空”,header 变成了 nullptr

不足之处

虽然 Cpp 的拷贝和移动机制已经很完善了,但依然存在一些缺陷,最主要的问题就是语义上的 move 并没有静态检查。

  • 虽然 move 了,但后面可能还会不小心用到。当然这种情况现代编辑器和编译器一般都会给个 warning。
  • 虽然 move 了,但之前的引用还在被使用,这种情况编辑器和编译器很难发觉。
// [cpp] bazel run //move-or-copy:object_moved

int main() {
    auto v = Vector<int>(10);
    auto& ref_v = v;
    std::cout << ref_v.first() << std::endl;
    auto moved = std::move(v);
    std::cout << ref_v.first() << std::endl;
}

没有任何 warning,运行时 segmentation fault。

Rust

移动

与 Cpp 不同的是,Rust 所有类型默认都是移动的,除非它实现了 trait Copy,所以我们先来讲移动。

Rust 无法像 Cpp 那样自定义移动操作,目前在实现上移动只是一次 memory copy。但是,变量的所有权(ownership)已经移交出去了,你永远不能再使用这个变量,除非你再给它所有权。

  • 移交所有权可以看作对象与当前变量解除绑定后与新的变量绑定。
  • 所有权的概念同样存在于 Cpp 智能指针中。
// [rust] cargo run --example ownership_moved 

fn main() {
    let a = String::from("hello, world");
    println!("{}", &a);
    let b = a;
    println!("{}", &a);
}

编译失败:borrow of moved value: 'a'

如果它正在被引用,就不能被移动

// [rust] cargo run --example ownership_borrowed 
fn main() {
    let a = String::from("hello, world");
    let ref_a = &a;
    println!("{}", ref_a);
    let b = a;
    println!("{}", ref_a);
}

编译失败: cannot move out of 'a' because it is borrowed

仔细想想可以发现,Rust 的移动跟 Cpp 的移动在语义上是完全一致的。但是,Rust 可以在编译期保证:

  • 不能对已移交所有权的变量取引用(已移交所有权的变量无绑定对象)。
  • 在其任意引用的生命期内对象不能被移动。

说到这,还剩下的一个问题就是,Rust 怎样在不自定义移动操作的情况下控制资源的回收(堆内存的释放)呢?

如果 Cpp 的移动也只能是 memory copy,那么:

// [cpp] bazel run //move-or-copy:move_by_memcopy 

class B {
    int *_data;
  public:
    explicit B(int data) : _data(new int(data)) {}
    
    B(const B &) = delete;
    
    B(B &&other) noexcept : _data(other._data) {}
    
    ~B() {
        std::cout << "delete " << _data << std::endl;
        delete _data;
    }
};

auto get_b() {
    auto b = B(1);
    return std::move(b); // 防止返回值优化,强制移动
}

int main() {
    auto b = get_b();
    return 0;
}

运行

delete 0x7ffa04402a10
delete 0x7ffa04402a10
move_by_memcopy(58846,0x10af085c0) malloc: *** error for object 0x7ffa04402a10: pointer being freed was not allocated
move_by_memcopy(58846,0x10af085c0) malloc: *** set a breakpoint in malloc_error_break to debug
[1]    58846 abort      bazel run //move-or-copy:move_by_memcopy

get_bmain 两个函数执行完后 delete 了同一个指针。

但在 Rust 中

// [rust] cargo run --example drop

#![feature(ptr_internals)]

use core::ptr::{self, Unique};
use core::mem;
use std::alloc::{dealloc, Layout, alloc};

struct B {
    data: Unique<i32>
}

static I32_LAYOUT: Layout = unsafe { Layout::from_size_align_unchecked(mem::size_of::<i32>(), mem::align_of::<i32>()) };

impl B {
    fn new(value: i32) -> Self {
        unsafe {
            let raw_ptr = alloc(I32_LAYOUT) as *mut i32;
            ptr::write(raw_ptr, value);
            Self { data: Unique::new_unchecked(raw_ptr) }
        }
    }

    fn as_ref(&self) -> &i32 {
        unsafe {
            self.data.as_ref()
        }
    }
}

impl Drop for B {
    fn drop(&mut self) {
        println!("drop raw ptr: {:?}", self.data);
        unsafe {
            dealloc(self.data.as_ptr() as *mut u8, I32_LAYOUT);
        }
    }
}

fn boxed(value: i32) -> B {
    let b = B::new(value);
    return b;
}

fn main() {
    let b = boxed(1);
    println!("b = {}", b.as_ref());
}

或许在这里你会觉得 Rust 很繁琐,这是因为手动 alloc、dealloc 和操作裸指针是不安全的行为,大多数场景下应使用已封装好的组件(如 Box)来替换裸指针。

运行,打印结果是

b = 1
drop raw ptr: 0x7ff42ec02c40

你会发现这个叫作 Drop,看起来像是析构函数的 trait 跟 Cpp 析构函数还是有明显差别的:Drop::drop 只会在拥有对象所有权的变量被 drop 的时候被调用。

函数 boxed 中的 b 返回之后已经把对象的所有权移交给了 main 里的 b,故 boxedb 在函数调用结束被回收时仅仅回收了它在栈上占用的内存,而不会调用 Drop

所以,一个对象在被移动之后就没有任何被访问的可能性(甚至没有析构函数之类的东西可调用)。Rust 就是以如此简洁的方式完美地实现了移动!

拷贝

Rust 有两个拷贝相关的 trait,一个是 Clone,一个是 Copy

Clone

Clone 是显式拷贝,跟 Cpp 中的拷贝行为是类似的:

// [rust] cargo run --example clone
impl Clone for B {
    fn clone(&self) -> Self {
        B::new(*self.as_ref())
    }
}

fn boxed(value: i32) -> B {
    let b = B::new(value);
    return b.clone();
}

fn main() {
    let b = boxed(1);
    println!("b = {}", b.as_ref());
}

运行,打印出

drop raw ptr: 0x7fb7a3c02c40
b = 1
drop raw ptr: 0x7fb7a3c02c50

这样的话 B 还是默认移动,但你需要拷贝时可以显式调用 clone 方法。

Copy

Copy 是隐式拷贝,语义上就是 memory copy。Rust 的布尔值(bool)、字符(Char)、数值类型(各种整型和浮点型)、不可变引用以及各种指针都实现了 Copy,但复合类型 T 要实现它需要符合三个条件:

  • T 实现了 Clone
  • T 所有的成员都实现了 Copy
  • T 不能实现 Drop

T 需要实现 Clone 的原因是很多时候 Clone 的实现依赖 Copy 会比较方便:

struct MyStruct;

impl Copy for MyStruct { }

impl Clone for MyStruct {
    fn clone(&self) -> MyStruct {
        *self
    }
}

T 所有成员都必须实现 Copy 是很显然的,当然要所有成员都允许 memory copy。

同时实现 DropCopy 在语义上没什么毛病,但是在当前实现上有问题所以禁止了,详情见 E0184

而且 Copy 一般不需要手动实现,当所有成员都实现了 Copy,你可以给 T 自动实现 CloneCopy

// [rust] cargo run --example copy

#[derive(Clone, Copy)]
pub struct C {
    data: i32
}

impl C {
    pub fn new(value: i32) -> Self {
        Self { data: value }
    }

    pub fn data(&self) -> &i32 {
        &self.data
    }
}


fn consume_c(_c: C) {}

fn main() {
    let c = C::new(1);
    consume_c(c); // copy
    println!("c = {}", c.data()); // unmoved
}

总之,隐式拷贝仅仅能用于纯栈对象的拷贝(momery copy 的同时复制所有权),没有任何可操作性。

智能指针

智能指针(smart pointer)是 Cpp 造出来的概念,Rust 也沿用了。智能指针就是能自动释放所管理内存的指针。

这一部分主要讨论两种智能指针,一种是独占的,一种是共享的。

Cpp

unique_ptr

就如同我们在为什么需要拷贝和移动中讨论的,移动行为是天然遵守 RAII 的。Cpp 中的 std::unique_ptr 就是如此设计的,它的结构类似这样:

template<typename T>
class unique_ptr {
    T *raw_ptr;
  public:
    explicit unique_ptr(T *raw_ptr) : raw_ptr(raw_ptr) {}
    
    unique_ptr(const unique_ptr &) = delete;
    
    unique_ptr(unique_ptr &&other) noexcept : raw_ptr(other.raw_ptr) {
        other.raw_ptr = nullptr;
    };
    
    T *operator->() {
        return raw_ptr;
    }
    
    ~unique_ptr() {
        delete raw_ptr;
    }
};

它是独占式的智能指针,用例:

// [cpp] bazel run //smart-pointer:unique_ptr 

class A {
    int data;
  public:
    explicit A(int data) : data(data) {};
    auto say() {
        std::cout << "data=" << data << std::endl;
    }
    ~A() {
        std::cout << "A destruct, data=" << data << std::endl;
    }
};

auto get_a(int data) {
    auto a = unique_ptr(new A(data));
    return std::move(a);
}

int main() {
    auto a = get_a(1);
    auto a2 = std::move(a);
    a2->say();
    return 0;
}

运行,打印出:

data=1
A destruct, data=1

std::unique_ptr 具体用法请移步 cppreference

shared_ptr

如同我们在为什么需要拷贝和移动中讨论的,如果使用堆内存分配和拷贝,就需要想一套方案来决定什么时候回收内存。常见的思路是引用计数或者 GC

std::shared_ptr 是使用引用计数方案的,共享的智能指针。它的大致结构如下:

template<typename T>
class shared_ptr {
    T *raw_ptr;
    uint64_t *counter;
  public:
    explicit shared_ptr(T *raw_ptr) : raw_ptr(raw_ptr), counter(new uint64_t(1)) {}
    
    shared_ptr(const shared_ptr &other) : raw_ptr(other.raw_ptr), counter(other.counter) {
        ++(*counter);
    };
    
    shared_ptr(shared_ptr &&other) noexcept : raw_ptr(other.raw_ptr), counter(other.counter) {
        other.raw_ptr = nullptr;
        other.counter = nullptr;
    };
    
    T *operator->() {
        return raw_ptr;
    }
    
    ~shared_ptr() {
        std::cout << "counter=" << *counter - 1 << std::endl;
        if (--(*counter) == 0) {
            delete raw_ptr;
            delete counter;
        }
    }
};

用例:

auto product(int data) {
    auto a = shared_ptr(new A(data));
    return static_cast<shared_ptr<A>&>(a); // 防止返回值优化,强制拷贝
}

auto consume(shared_ptr<A> a) {

}

int main() {
    auto a = product(1);
    auto a2 = a;
    consume(a2);
    a->say();
    return 0;
}

打印出

counter=1
counter=2
data=1
counter=1
counter=0
A destruct, data=1
weak_ptr

std::weak_ptr 代表“弱引用”(std::shared_ptr 代表“强引用”),是引用计数里的概念,用于解决循环引用的问题。它的使用依赖 std::shared_ptr

循环引用:

// [cpp] bazel run //smart-pointer:circular_ref 

#include <iostream>

class Boy;

class Girl;

class Boy {
    std::shared_ptr<Girl> girl_friend;
  public:
    explicit Boy() : girl_friend(nullptr) {};
    auto set(std::shared_ptr<Girl> &girl) {
        girl_friend = girl;
    };
    ~Boy() {
        std::cout << "boy destruct" << std::endl;
    }
};

class Girl {
    std::shared_ptr<Boy> boy_friend;
  public:
    explicit Girl() : boy_friend(nullptr) {};
    
    auto set(std::shared_ptr<Boy> &boy) {
        boy_friend = boy;
    };
    
    ~Girl() {
        std::cout << "girl destruct" << std::endl;
    }
};


int main() {
    auto boy = std::make_shared<Boy>();
    auto girl = std::make_shared<Girl>();
    boy->set(girl);
    girl->set(boy);
    return 0;
}

运行,没有任何打印结果,说明这两个对象都没有被析构,内存泄漏了。

std::weak_ptr 用例:

std::shared_ptr<int> p1(new int(5));
std::weak_ptr<int> wp1 = p1; // 还是只有p1有所有权。

{
  std::shared_ptr<int> p2 = wp1.lock(); // p1和p2都有所有权
  if (p2) // 使用前需要检查
  { 
    // 使用p2
  }
} // p2析构了,现在只有p1有所有权。

p1.reset(); // 内存被释放。

std::shared_ptr<int> p3 = wp1.lock(); // 因为内存已经被释放了,所以得到的是空指针。
if(p3)
{
  // 不会执行到这。
}

std::weak_ptr 本身的构造并不会使引用计数增加(不会复制所有权),它仅仅在需要使用时(试图)临时获取所有权。

使用 std::weak_ptr 改造我们的程序:

// [cpp] bazel run //smart-pointer:weak_ptr    

#include <iostream>

class Boy;

class Girl;

class Boy {
    std::shared_ptr<Girl> girl_friend;
  public:
    explicit Boy() : girl_friend(nullptr) {};
    auto set(std::shared_ptr<Girl> &girl) {
        girl_friend = girl;
    };
    ~Boy() {
        std::cout << "boy destruct" << std::endl;
    }
};

class Girl {
    std::weak_ptr<Boy> boy_friend;
  public:
    explicit Girl() : boy_friend(std::shared_ptr<Boy>(nullptr)) {};
    
    auto set(std::shared_ptr<Boy> &boy) {
        boy_friend = boy;
    };
    
    ~Girl() {
        std::cout << "girl destruct" << std::endl;
    }
};


int main() {
    auto boy = std::make_shared<Boy>();
    auto girl = std::make_shared<Girl>();
    boy->set(girl);
    girl->set(boy);
    return 0;
}

运行,打印出:

boy destruct
girl destruct

两个对象顺利地被析构。

Rust

Box

Rust 中的 Box 等同于 Cpp 中的 std::unique_ptr

pub struct A {
    _data: i32,
}

impl A {
    pub fn new(data: i32) -> Self {
        A { _data: data }
    }

    pub fn data(&self) -> &i32 {
        &self._data
    }
}

fn main() {
    let a = Box::new(A::new(1)); // 堆分配
    println!("a._data={}", a.data());
}

Arc(Rc)

std::sync::Arc 等同于 Cpp 中的 std::shared_ptr

// [rust] cargo run --example arc 

use std::sync::Arc;

fn main() {
    let a = Arc::new(1);
    let b = a.clone();
    println!("a={}", a);
    println!("b={}", b);
}

std::sync::Weak 相当于 Cpp 中的 std::weak_ptr

// [rust] cargo run --example weak 

use std::sync::{Arc, Weak};

fn get_dead_data() -> Weak<&'static str> {
    Arc::downgrade(&Arc::new("dead"))
}

fn main() {
    if let Some(alive) = Arc::downgrade(&Arc::new("alive")).upgrade() {
        println!("{}", alive);
    }

    if let Some(dead) = get_dead_data().upgrade() {
        println!("{}", dead);
    }
}

&'static str 表示字符串字面量类型

打印结果:

alive

std::rc::RcArc 的单线程版本。

对比

Cpp 和 Rust 智能指针的对比就是它们移动和拷贝机制的对比。

Rust 的 Box 相对 Cpp 的std::unique_ptr更优,因为 Rust 可以在编译期保证:

  • 不能对已移交所有权的变量取引用(已移交所有权的变量无绑定对象)。
  • 在其任意引用的生命期内对象不能被移动。

Rust 的 std::sync::Arcstd::shared_ptr 差距不大,但 Arc 必须显式 clone

总结

Cpp 和 Rust 在现代化内存管理的思路上是十分一致的,但 Rust 在静态检查上更胜一筹。学习 Rust 也让笔者对 Cpp 有了更深的理解,有兴趣的读者快打开 Rust 官网进行学习吧!

You can’t perform that action at this time.