Ref: https://www.tangramvision.com/blog/c-rust-interior-mutability-moving-and-ownership

C++和 Rust 中的不变性(constness)

Rust 和 C++有两个非常相似的概念,即 Rust 中的 mutability/immutability 和 C++中的 constness/non-constness. 在 Rust 中,一个给定的值要么是可变的(mutable),要么是不可变的(immutable),正如这些限定符名称所代表的含义,可变的值可以被修改,不可变的值不能被修改。 然而与 C++不同的是,Rust 中不可变的值可以被移动(move),就像下面的代码实例那样:

fn foo() {
    let x = 1;
    println!("{}", x)
    let mut x = x;
    x *= 10;
    println!("{}", x);
}

在 C++中,给定的值要么是常量(const),要么是非常量(non-const)。但是 C++中的常量值不能被移动(move)。在 C++中,对一个 const 限定符修饰的值 进行std::move操作,实际上会触发拷贝构造,这一点在"Effective Modern C++“这本书中作者也有提到:

class Annotation {
public:
    explicit Annotation(const std::string text)
     : value(std::move(text)) //here we want to call string(string&&),
                              //but because text is const,
                              //the return type of std::move(text) is const std::string&&
                              //so we actually called string(const string&)
                              //it is a bug which is very hard to find out
    {}
private:
    std::string value;
};

我们也可以自己写一段程序来验证一下:

#include <iostream>
#include <string>

struct Foo {
  Foo() { std::cout << "default constructor" << std::endl; }
  Foo(const Foo&) { std::cout << "copy constructor" << std::endl; }
  Foo(Foo&&) { std::cout << "move constructor" << std::endl; }
};

int main(int argc, char* argv[]) {
  const Foo foo;
  Foo other = std::move(foo);
  return 0;
}

// 执行结果:
// default constructor
// copy constructor

引用

引用在 C++和 Rust 中有一些相似。对于这两种语言,引用是一段数据的句柄,它允许程序员在不传递副本的情况下引用该数据。 在这两个语言中,引用都是指针的语法糖。而与指针不同的是,引用或多或少能保证它指向的数据的有效性(至少在创建的时候)。

错误的使用引用是 C++中错误的主要来源。而在 Rust 中提供了更多的安全保证,可以消除大多数这些错误。例如 C++并不保证 只要引用存在就一定有效,就像下面这段代码这样:

int& bad_foo() {
    int x = 0;
    return x;
}

上面这个函数返回了对堆栈上分配的值的引用,当函数返回时,栈帧被释放,那么改引用就失效了。 虽然现代 C++编译器和 linter 会针对类似于上面的简单的 case 做出一些诊断,但是在更复杂的情况下它们也无能为力。 Rust 使用生命周期来保证引用对象的有效性,除非你使用了显示不安全的代码。

借用

在 Rust 中创建引用的过程被称为借用(borrowing)。在 Rust 中,你只能可变的借用一个可变的值,就像在 C++中一样,通常只能创建 一个非常量值的非常量引用。不同的是,在 Rust 中虽然允许对一个值有多个不可变的借用,但是在同一时刻只能有一个独占的可变借用, 更进一步说,就是当存在一个可变引用的时候,不允许有其他任何引用,无论是可变的还是不可变的。C++中则没有这个限制。

你可能想知道加上这样的限制到底有啥好处。有的人可能会认为这样做更安全,因为当存在可变引用的时候,不允许其他引用存在。这只能算是部分正确: 这种借用行为确实消除了多线程场景中的竟态条件。然而它也使得跨线程的只读访问之外的任何数据共享都变得不可能,当然 Rust 为我们提供了其他工具 来实现跨线程的 non-trivial 共享和确保其安全性。

Rust 中的借用限制真正做的事情实际上是避免出现任何内存别名(memory aliasing)。当两个指针(或者这里说成引用)指向相同的或者重叠的内存区域时,就会发生内存别名。

内存别名(Memory Aliasing)

别名是编译器优化的障碍。对内存的读取和写入操作通常是一个给定函数中最慢的部分,可能存在的别名会迫使编译器比代码作者的预期更加频繁的发出加载指令。

由于 Borrow-Checker 的强制规则,Rust 编译器可以自由的假设不会有内存别名的情况发生。而 C++编译器却不能这么做。一些 C++编译器有一些 flag,用来控制是否开启假设不会出现内存别名, 也有的 C++编译器使用关键字(例如__restrict__)来注释指针,从而将程序员的假设传达给编译器。但是这些终究只是假设而不是承诺,因此可能会在 无意中被违反,从而产生一些未知的 bug 或者未定义行为。

下面让我们快速看一个顺序读写的示例,该示例向我们展示了由于内存别名而造成了性能问题。下面两个代码片段是分别用 Rust 和 C++实现了功能相同的函数。 C++版本中使用#defien来帮助我们开启编译器的 aliasing assumptions 开关(-DDMAYBE_RESTRICT=__restrict__)。需要特别强调的是,在安全的 Rust 中调用 src 和 dst 重叠的 foo 函数是不可能的:

fn foo(src: &[u32], dst: &mut [u32]) {
    assert_eq!(src.len(), dst.len());
    let mut i = 0;
    while i < src.len() {
        dst[i] = src[i];
        if src[i] % 2 == 0 {
            i += 1;
        } else {
            i += 2;
        }
    }
}
#ifndef DMAYBE_RESTRICT
#define DMAYBE_RESTRICT
#endif

void foo(const uint32_t* DMAYBE_RESTRICT src, uint32_t* DMAYBE_RESTRICT dst, std::size_t len) {
    std::size_t i = 0;
    while (i < len) {
        dst[i] = src[i];
        if (src[i] % 2 == 0) {
            i += 1;
        } else {
            i += 2;
        }
    }
}

在 main 函数中,我们分配了一个大小为 100000000 的缓冲区,并填充了测试数据,然后测量调用foo所消耗的时间。 当使用clang++在开启最高优化等级的时候,使用了__restrict__关键字的 C++版本和 rust 版本消耗的时间大致相当。 而不使用__restrict__关键字的 C++版本则需要多消耗一倍的时间。

那么我们为什么要在一篇讨论 constness 的文章中来探讨性能呢?与 Rust 中 immutability 相对应的 constness 和关于 mutable borrow 的 Borrow-Checker 规则是保证 不会出现内存别名的关键。优点是性能和正确性,但是代价是必须遵守 Borrow-Checker 的规则。

在 C++中实现相同的事情也是可能的,但是你必须将你的假设告诉编译器。如果你想避免出现 bug,那就必须由你来确保这些假设的正确性。当然你也可以选择不必和 Borrow-Checker 对抗。

C++和 Rust 中的移动

C++和 Rust 中的移动语义在概念上类似。不同的是他们如何集成到各自的语言当中的。C++在 C++11 才引入移动语义,而 Rust 在诞生之初就集成了 move 语义。

C++中的移动

在 C++中,移动跟类的特殊成员函数密切相关:构造函数和赋值运算符。构造函数和赋值运算符被重载以接收不同类型的引用。C++根据值的类型来区分不同的引用。 左值引用通常是指对于具名值的引用,并且用一个’&‘符号连接;右值引用通常是对表达式计算的临时结果的引用,并且用’&&‘符号连接。

当我们调用这些特殊的成员函数的时候,重载解析过程会根据引用类型匹配合适的重载函数。我们来看下面这个示例:

class S {
public:
    S() {}         // Default Constructor
    S(const S&) {} // Copy Constructor
    S(S&&) {}      // Move Constructor
    S& operator=(const S&) {} // Copy Assignment Operator
    S& operator=(S&&) {}      // Move Assignment Operator
};

上面这个类同时具有拷贝构造函数和移动构造函数。当我们尝试使用一个已经存在的引用来构造一个新的S的时候,编译器会通过判断 值的类别来决定使用移动构造还是拷贝构造。

如果引用是函数的返回值或者是通过表达式计算出来的,那么该引用是右值引用,此时会使用移动构造。如果我们仅仅是简单的传递了一个具名变量 那么通常这里是左值引用,会使用拷贝构造:

void test() {
    S s;
    S s_copied(s); // Copy Constructor
    S s_moved(make_me_an_S_please()); // Move Constructor, passing a result
}

那么当我们有一个左值的时候,我们要如何移动呢?答案是使用std::move函数。但是这个函数是怎么实现的呢?莫不是使用了什么技巧来移动对象?当然不是! 打开std::move的源码我们可以很容易发现这个函数仅仅是做了一个类型转换,其他的什么都没做。它将一个左值引用转换成了一个右值引用:

template <class _Tp>
_LIBCPP_NODISCARD_EXT inline _LIBCPP_INLINE_VISIBILITY _LIBCPP_CONSTEXPR typename remove_reference<_Tp>::type&&
move(_Tp&& __t) _NOEXCEPT {
    typedef _LIBCPP_NODEBUG typename remove_reference<_Tp>::type _Up;
    return static_cast<_Up&&>(__t);
}

但是当我们将std::move与构造函数一起使用的时候,就会触发移动构造的调用。

void test() {
    S s;
    std::move(s); // This has no effect at all
    S s2;
    s2 = std::move(s); // move s to s2 by Move Assignment Operator
}

C++中移动语义经常会给程序员带来麻烦。std::move仅仅是将左值引用类型转换成右值引用,然后触发特殊成员函数的右值引用重载版本的调用。 实际上,在 C++中移动只是将一个值传递给这些特殊的重载函数之一,这对移动对象的生命周期并没有影响。在上面的例子中,对象s并不会因为 被移动了而变得不可用或者超出使用范围,也就意味着我们在移动之后仍然可以自由的使用s对象。虽然一些编译器会给出use-after-move的 警告,但是这并不能涵盖到所有的场景。

C++移动语义的另一个问题就是,它需要程序员自己来编写移动函数。这就意味着可能会写出有 bug 的构造函数,此外我们对移动构造和移动赋值的行为也没有达成共识。 人们普遍认为,这些函数应该以一种高效的形式将资源从一个对象转移到另一个对象,而不应该是拷贝。

另一个问题是,当我们在处理被移动的vector的时候,如果我们知道这个vector再也不会被使用,那么我们可能会简单的什么都不做。没有人会对这个vector再做任何改变,但是 我们忽略了一点,这个被移动的vector仍然存在,当它超过它的作用域的时候,就会调用析构函数,也就是说,如果我们不清除掉被移动的vector中的元素,那么这些元素可能会触发 double free。这种情况有一个很容易想到的答案,就是将已经被 move 的vector的状态设置为同默认构造(没有任何空间分配)的vector一样的状态,这样就能避免 double free 的问题, 同时也很容易析构。

然而,并不是所有的情况都那么容易,因为不是所有的对象都是默认可构造的,将被移动的对象在移动完它的资源后设置为一个有效的状态有时是很昂贵的操作。 通常会给一个对象添加一些额外的状态以避免昂贵的重复初始化操作。对于更复杂的对象,通常会在数据成员中添加形如bool _isInitialized或者bool _shouldDestroyX这样的字段 来处理析构函数的条件行为。尽管使用值比使用指针更加自然,但是人们常常将对象用std::unique_ptr包装起来,以此来避免移动的过程中对象被析构。

最终要的是,没有明确的操作指南。各种来源的建议表明,移动函数应该将被移动的对象设置为“有效,但是不确定的状态”,但是这也太模糊了。

Rust 中的移动

从 Rust 诞生之初开始,移动便是它的组成部分。因此人们可能会觉得 Rust 中的移动更加完整,因为通常情况下程序员不需要定义移动的行为,所以移动在 Rust 中更容易解释。

  • 移动总是一个字节一个字节的内存拷贝
  • 移动会消耗被移动的对象,一个对象一旦被移动,那么再去访问或引用它就会触发编译器报错
  • 如果一个类型实现了 Copy trait,那么对该类型的对象进行 move 操作仍然是按字节做内存拷贝,但是不再消耗被移动的对象。这种规则应该只适用于简单的类型,它们没有复杂的所有权语义, 例如,例如像整型这样的简单的内置 primitive 类型,但是string就不行,因为它拥有一个在堆上分配的字符串。即使我们想为 string 实现Copy(虽然这是不可能的,以为它内部的Vec不允许Copy), 那也只是浅的拷贝,这将会违背一个String只有一个所有者的理念。
  • 移动不会造成被移动对象的销毁。在 Rust 中,实现 Drop trait 能够允许我们自定义类型的销毁行为。因此 Rust 中的Drop有点类似于 C++中的非平凡析构函数。

对比

简而言之,主要区别就是 Rust 中有破坏性的 move,而 C++中没有;C++中有特殊的成员函数,但是 Rust 中没有。 就像许多 C++和 Rust 特性的对比那样,你也可以用 Rust 更加刻意和呆板,而 C++具有更高的灵活度来总结。通常情况下,Rust 已经帮我们做了很多, 我们不必再考虑那些繁琐的问题,但是当面对一些边缘 case(定制移动行为可能会有所帮助)的时候就会变得非常困难。而在 C++中,我们有更大 的出现 bug 的可能。另一方面,如果你想构建功能完备,且具备值语义的类型,同时将它们与其他的类型进行组合成一个新的类型,那么你可能没有必要 自己实现移动构造函数,在这种情况下整个过程是很顺利的,但是这个仅仅是因为遵循了规范产生的结果,而不是编译器告诉我们该如何去做。

C++和 Rust 中的共享所有权

在开始讨论所有权之前,我们先来定义它。所有权有几个方面,这取决于你所使用的技术以及你是否与潜在的外部所有者(例如,给你提供资源的操作系统)对接。 在这个特殊的上下文环境中,所有者仅仅是指对对象生命周期的控制以及该控制何时结束。这里所有权很大程度上取决于谁何时应该删除一个对象。明确的所有权规则 能够避免很多 bug,例如对同一个对象的多次释放,或者对一个已经释放的对象进行引用等。

在 C++和 Rust 中,一般值对象(非指针和应用)存在于堆栈中,或者作为其他对象的成员,可以认为归属于包含它们的作用域。当然,对象可能会在不同的作用域之间移动, 或者作为函数的返回值返回,但是最终,对象没有移动,而且作用域结束,那么对象的生命周期也就结束了。

独享所有权(Single-Owner): Box 和 std::unique_ptr

将所有权和作用域分开的主要方式就是将对象放在堆上。在 C++和 Rust 中,都推荐使用智能指针的方式与堆进行对接。它们在这两种语言上的工作方式大致相同,智能指针拥有 分配对象的所有权,并将该对象的生命周期与自身绑定,也就是说谁拥有了智能指针的所有权,也就相当于拥有了其内部对象的所有权。

C++

由于 C++和 C 兼容性关系,在 C++中存在很多手动分配堆内存的方式(例如: new, malloc等)。这些方式只为程序员返回了一个指针的所有权,但是指针可能会被复制或者泄露,而编译器 却不会有任何提示。在现代 C++中,手动管理内存不是通用的方式。C++中更推荐使用std::unique_ptr来管理独享所有权的堆内存,尽管它也允许包含一个空指针。

Rust

在 Rust 中,你可以使用Box来存放一个堆上分配的对象。一个 Box 拥有其持有的堆对象的所有权,跟 C++中std::unique_ptr不同的是,Box必须被安全的初始化。Box 还需要一个完全初始化的对象, 首先创建创建对象,然后再将其移动到堆内存中。它不能像 C++中那样,使用std::make_unique直接在堆构造对象。但是编译器通常会通过一些优化来避免不必要的拷贝和栈分配。

这些智能指针用于独享所有权的情况。std::unique_ptr 禁用了拷贝构造和拷贝赋值。Box没有实现Copy只实现了Clone。这些特殊处理都是为了保证单一的所有者,而拷贝会导致所有者的增加, 从而打破了单一所有者的规则。如果你需要在堆上分配内存,使用Boxstd::unique_ptr是非常不错的选择,但是最终它们也有同其他简单值类型一样的所有权语义。

共享所有权(Shared-Owner): Rc,Arc,和 std::shared_ptr

共享所有权必须发生在堆上。共享所有权通过进行引用计数的智能指针作为媒介。这种智能指针在持有堆内存的同时,也会保存一个对该堆内存引用的计数器。对智能指针执行拷贝(通过克隆,或者拷贝构造) 会为其持有的堆内存增加一个新的所有者,这会触发引用计数器的自增。当一个智能指针超出其所在的作用域的时候,其内部的引用计数器就会自减。如果一个智能指针超出其所在的作用域,并且它是其持有 的堆内存的最后一个所有者,那么就会销毁该堆内存。

C++

在 C++中,使用std::shared_ptr来共享堆内存的所有权。它并没有被设计为在避免内存别名和线程安全方面提供太多的功能,而仅仅是保证正确的处理其持有的对象的生命周期,确保对象不会泄露也不会 被多次释放。std::shared_ptr仅仅在处理引用计数的时候提供了线程安全的保证,多个线程能够自由的新增和减少资源的所有者,它内部的引用计数器是原子的,因此能够保证一个对象恰好被释放一次。 但是其持有的资源是非线程安全的,如果要保证数据的线程安全,就需要通过其他方式,例如mutex

Rust

Rust 有两种共享型智能指针,一个是Rc(称为 Reference Count),用于单线程的应用,另一个是Arc(Atomic Reference Count),用于多线程环境。在安全的 Rust 中,编译器将会强制要求不会存在可变 内存别名和竟态资源的存在。智能指针也不会给这个规则添加例外。即使在单线程环境下,数据可能有多个所有者,也依然不允许内存别名。这也就意味着不借用其他工具,任何通过共享所有权的超出简单 只读访问的操作都是不可能实现的。

内部可变性,Cells 的工作原理

到目前为止,我们在 Rust 中遇到了一些自我强加的限制。我们不能对同一个对象进行多个未完成的可变借用,同样的,如果一个对象有多个所有者,我们不能对这个对象进行修改操作,不管是在多线程环境 还是单线程环境中。

所有的这些限制都是源于 Rust 中关于内存别名的规则。我们需要一种方法突破这些限制,否则将无法实现任何更有趣的事情。人们可以简单的了解一下不安全的代码,当然幸运的是,Rust 也提供了一种安全 的方式绕过这些限制,只需要损失一点性能。

在 Rust 中,可以使用Cells来绕过这些限制。Cells有一个“内部可变”(Interior Mutability)的属性,当一个对象内部包含这个属性的时候,即使对象本身是不可变的,它也可以可变的被借用。

不同类型的Cells可以适用于不同的场景。但是它们都是基于UnsafeCell的。UnsafeCell持有一个对象,并做了简单的包装。它提供了一个非常有趣的方法:

pub fn get(&self) -> *mut T

get方法传入一个不可被借用的self,返回一个可变的指针。从功能上来说,这就是我们获取内部可变性的地方。有趣的是,尽管它的名字带了“不安全”,但是这里并没有使用任何不安全的代码。 在 Rust 中,你可以自由的安全的创建指针,但是解引用是不安全的,这也就意味着,UnsafeCell可以安全的创建,但是不能以任何非平凡(non-trivial)的方式使用。

所以,事实上你确实需要使用不安全的代码来绕过 Borrow-Checker,但幸运的是标准库已经在其他更高类别的Cell的实现中帮我们处理了不安全的部分。在实际的开发中,我们很少会直接用到UnsafeCell,下面来 看看有哪些可用的Cell类型。

Cell

Cell 通过使用拷贝替代借用的方式来避免内存别名。访问Cell内部的值将会返回一个副本,修改这个值需要将修改后的值传递给Cell。大多数Cells的方法都需要其内部的类型是可Copy的。 这种类型的Cell会有额外的拷贝开销,但是由于没有借用,也因此避免了由于避免借用造成的内存别名而造成的开销。这种类型的Cell适用于较小的基本类型。实际上,你可以在RefCell中看到Cell 被用于存放引用计数器。

RefCell

RefCell允许可变和不可变的借用一个内部值。它提供了两个方法来实现这个功能:

pub fn borrow(&self) -> Ref<'_, T>
pub fn borrow_mut(&self) -> RefMut<'_, T>

这些函数返回一个借用内部值的包装,需要注意的是,即使返回的是可变的借用,函数的参数传递的依然是自身的不可变借用。 使用RefCell并没有给别名规则增加一些例外,而是仅仅绕过了 Borrow-Checker 的检查,最终这些内存别名的规则依然会得到 保证。

RefCell通过引用计数的方式来遵循这些规则。从RefCell借用,将会触发引用计数的增加,反之会递减。如果程序在运行时 尝试以违反别名规则的方式借用,程序将会抛出异常。

Mutex

Mutex虽然没有被列在cell模块中,但是它们的功能却是相似的,其内部也使用到了UnsafeCellMutex没有自己维护引用计数,而是是用了底层的同步机制(例如:POSIX Mutex)来确定是否可以借用该值或者阻塞。 我们还可以增加其他类似的同步原语,例如RwLock,它允许跨线程的可变借用。

使用

在 Rust 中,你必须为你的程序使用正确的工具组合,否则代码都编译不过。如果你想使用共享所有权,在单线程中,可以使用Rc, 编译器会阻止你将Rcs拷贝到别的线程。如果是多线程,就必须使用ArcRcArc都实现了Borrow,它允许不可变的借用, 但在一般情况下,都不允许可变的借用。

如果你需要修改共享对象,那么就要用到内部可变性。RefCell允许可变的借用,但是它内部的引用计数不是线程安全的。 它只会在验证借用规则不通过的时候通过抛出 panic,而不是通过同步线程来强制执行同步规则。例如使用Mutex或者RwLock 来保证同一个时刻只有一个线程可以拥有写访问权限。因此RefCell不能与Arc一起使用。

下面的表格提供了一些常见的场景以及推荐选择使用哪种工具的指南。这些可能不一定适用于所有的场景,但是能够提供一定的参考:

Mutable AccessMulti-ThreadC++Rust
NoNostd::shared_ptr<const T>std::rc::Rc<T>
YesNostd::shared_ptr<T>std::rc::Rc<std::cell::RefCell<T>>
NoYesstd::shared_ptr<const T>std::sync:Arc<T>
YesYesstd::shared_ptr<T> + syncstd::sync::Arc<std::sync::Mutex<T>>