前言

曾经在关于noncopyable和STL容器的思考中讨论了一些关于noncopyable的话题。
在那篇文章关于什么样的类才应当是不可拷贝的讲太粗糙了。
这里我想进一步讨论语义以及符合什么条件的类适合是copyablemoveable

值语义

值语义(value semantic)关注的是对象的值本身,至于是不是同一个对象,它并不关注,因此往往这类对象需要支持拷贝(duplicate),它拥有与原来对象相同的值,却是一个新的对象(从memory角度来看,拥有一个区别于原来对象的独特地址)。
典型的拥有值语义的对象就是各种数据结构,比如STL中的容器。
C++是如何体现这种语义的,即拷贝是如何提供的?
C++有一类特殊成员函数是与拷贝相关的,即拷贝构造函数(copy constructor)和拷贝赋值运算符重载(copy assignment overloading operator),它们提供隐式的拷贝操作,即不需要显式调用这些函数,编译器通过特定的语法可以转调用这些函数。
某种意义上,既省了功夫也提高了可读性。但是代价是隐式本身是个双刃剑,有的时候因为自己的不注意,导致出现了没必要的拷贝,甚至破坏了程序逻辑。
因此我推荐采用显式的拷贝函数,即类作者实现显式的函数Clone()/CopyAssign(),这样由用户明确拷贝的场景,避免莫名的bug。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 由于该例子十分简单
// 因此特殊成员函数均声明为default简化
// 在实际情况下要考虑default带来的内联展开是否影响性能
struct A {
public:
A() = default;

// 代替拷贝构造函数
// 之所以这样写,是为了利用编译器生成的拷贝构造函数进行逐成员的拷贝构造
// 在成员均是支持拷贝构造(user-defined or compiler-generated)的条件下
// 生成的拷贝构造函数也是bug-free和符合要求的
// 即目的是偷懒而不失合理
// \see https://en.cppreference.com/w/cpp/language/copy_constructor
// Implicitly-defined copy constructor

// 我并没有声明为void Clone(A const &)
// 因为这样有两个好处:
// 1)
// A aa = a.Clone();
// // instead of
// A aa;
// aa.Clone(a);
// 2)
// void f(A a);
// f(a.Clone());
// // instead of
// A aa;
// aa.Clone(a);
// f(std::move(aa));
A Clone() {
A a = *this;
// 有RVO(Return value optimization),
// 不需要担心返回类类型会造成性能下降
return a;
}

// 代替拷贝赋值运算符重载
// \see https://en.cppreference.com/w/cpp/language/copy_assignment
// "Implicitly-defined copy assignment operator"
void CopyAssign(A const &a) {
*this = a;
}

// 之所以需要提供移动构造函数是因为
// Clone()返回a需要。
// 这是因为a是move-eligible对象,所以重载决议会选择一个合适的构造函数
// 构造它并返回,由于此时移动构造不会被隐式定义,因此必须显式定义它。
// 上面虽说有RVO作用,但是由于优化只是可能的,因此必须要有合适的构造函数
// 以防RVO无法作用。
// \see https://en.cppreference.com/w/cpp/language/return

// 基本上不存在copyable & nonmoveable的类(因为没有应用场景)
// 因此不太可能将移动构造定义在private中
// 如果有的话,```A a2(a.Clone())```将不能正常作用,理由同Clone()中的a。
A(A &&) noexcept = default;

// 往往移动赋值和移动构造是成对出现的
A &operator(A &&) noexcept = default;
private:
A(A const &) = default;
A &operator=(A const &) = default;
};

int main() {
A a;
A a2 = a.Clone(); // copy construct
A a3;
a3.CopyAssign(a2); // copy assignment
}

引用语义

引用语义(reference semantic)关注的是对象本身,其值是共享的,具体表现为指针或引用。
在继承体系中,往往采用的是引用语义,因为可以重解释其对象的位模式。

支持引用语义的对象往往不支持拷贝,一方面它们只通过指针或引用定义或作为参数传递(注意,反之不然),另一方面拷贝这类对象没有任何意义,相反还会导致bug。
经典的比如资源管理类(或句柄类)就不支持拷贝,否则析构时很可能会触发double free的bug。
比如将文件指针作为数据成员的类若支持拷贝,那么析构函数会对同一个指针调用两次close(),这是非法的(ill-formed)。
所谓资源管理类不支持拷贝,其背后的理念是资源具有独特性(unique),即该值只有一份,所以管理这种资源的类不应支持拷贝。还有一种就是类本身具有独特性,这种类也应当不支持拷贝。

1
2
3
4
5
6
7
8
9
10
11
std::vector<int> a;
void f(std::vector<int> &vec) {
// ...
}

void g(std::vector<int> const &vec2) {
// ...
}

f(a);
g(a);

在这里avec应当视为两个对象,前者是值语义,后者是引用语义,它关注的不是a的值,而是a本身。
vec2更为复杂:

  • 关注的是值,但披着引用语义的皮避免了拷贝
  • 关注的是对象本身,但保护该对象不受改动
  • 如果接受的是左值,可以是上述两种
  • 如果接受的是右值,只可能是第一种

动态分配的误解

有一种常见的误解就是认为继承体系的类必须要动态分配,这是大特特错的。
需要引用语义的对象没有必要进行动态分配,动态分配和它是否是派生类没有一点关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct B {

};

struct D : B {

}

void f(B &b);

D d;
// This is ok, and can call the virtual method from B
f(d);

而至于作为数据成员的指针往往是动态分配的,是因为它本身是owner,而不是引用别的对象。

如果读者有Qt编程经验,会发现数据成员都是指针,并且往往是动态分配的。一方面这是因为Qt是通过对象树来实现Qt对象的GC,所以如果成员不是指针通过动态分配的,那么对象树是不能删除这样的对象的,因为此时它可能在栈上也可能是堆上包含对象的一部分,即分配释放的时机均交给了用户,如果程序员对cpp的理解太浅,那么很可能写出不成熟的代码导致各种泄露,因此Qt对象往往是这样定义的。某种意义上,这是一种牺牲。

真正需要动态分配的往往是因为runtime动态变化的需求,比如运行时大小变化的动态数组,以及跨栈对象(见下):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
B *pb = nullptr;
if (...) {
pb = new D();
} else {
pb = new D2();
}

// 错误做法
B *pb = nullptr;
if (...) {
D d;
pb = &d;
} else {
D2 d;
pb = &d;
}
// pb is a dangling pointer

讨论

所以通过上述对于值语义和引用语义的区别,你肯定会觉得很困惑,值语义的对象也可以披着引用语义的皮,通过引用到处作为参数传递,还有必要提供拷贝吗?
也就是说语义是完全理论上的问题,在实际设计和编写类时我们需要考虑的更多:

  • 必要性:在项目中该类尽管是值语义,但是并没有用到了拷贝的场景,而实现拷贝往往十分麻烦,这种情况有必要实现吗?

在实际的项目中,大部分类都将是不需要支持拷贝的,它们通常使用引用语义即可满足各种场景。相对,我们更需要的是资源的转移而不是拷贝,即移动语义

移动语义

移动语义于C++11引入标准,它关注的也是对象的值,但是和值语义区别于它不是拷贝一份原件相同的值,而是“偷”(steal)对象的值挪为己用。
在很多场景下,移动语义是更常用的,比如一个对象已经不再使用,可以通过移动到容器中从而在常数时间复杂度下构造一个对象。

1
2
3
4
5
6
7
void f(std::vector<A> &vec, A &&a) {
vec.emplace_back(a);
}

std::vector<A> vec;
A a;
f(vec, std::move(a))

另外还有一些移动语义的trick,这里不展开。
C++11的所有容器都添加了支持移动语义的新接口,具体参考API文档。

另外移动语义往往实现简单,一般是通过swap()实现的。换言之,在C++11要实现等效的移动语义基本都是通过swap()代替。
对于移动特殊成员函数,它们的隐式定义我表示肯定,因为

  • 对于左值,如果不是通过std::move()是无法触发移动操作的
  • 对于右值,移动是合理的(一般有RVO和Copy elision作用,更可能是原地构造)

因此没有必要提供任何显式函数,比如Steal()
在这里我们引入了新的类划分:可移动类(moveable)。

除了一些极为特殊的类(std::mutexstd::thread,…)外,大多数类都可以是可移动的。

结论

我们在设计类时,明确它是否为可拷贝类可以考虑以下因素:

  • 管理不支持值语义的资源且其没有禁止拷贝(比如(裸)指针成员)
  • 无拷贝应用场景

满足上述任一条件的都不应当允许拷贝并显式禁止避免被错误地使用和导致性能下降。

而一个类是否是可移动类,基本上在大多数情况下都是默认选项,即支持移动操作。
当然,如果使用场景并没有用到移动,或许可以保留移动操作不实现,比如只需要引用或指针参数传递等场景。

因此我个人看法如下:

  • 支持值语义
    • 实现移动相关函数
    • 库实现显式拷贝相关函数,应用根据应用场景考虑是否实现
  • 支持引用语义
    • 库实现移动相关函数,引用根据应用场景考虑是否实现
    • 禁止任何拷贝操作,包括隐式和显式

在实际项目中,大部分类都将会是不可拷贝的且可移动的。其他的根据上述要点考虑是否支持相关操作即可。
(尽管我这个可能讲的有点复杂了。。。)