拷贝控制在一个类里定义了五种成员函数来控制这些操作,除了本科教材里提到的拷贝构造函数、析构函数,还有拷贝赋值运算符,移动构造函数,移动赋值运算符;如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作,但是对于一些类来说,默认定义会导致灾难,因此要明白什么时候需要定义这些操作;
13.1 拷贝、赋值与销毁
- 拷贝构造函数的第一个参数必须是引用类型,它在某些情况下会被隐式地使用,因此拷贝构造函数通常不应该是explicit的;
- 拷贝初始化不仅在使用=定义变量时会发生,还有三种情况,(本科课本里讲到的)a将一个对象作为实参传递给一个非引用类型的形参,b从一个返回类型为非引用类型的函数返回一个对象,c用花括号列表初始化一个数组中的元素或一个聚合类中的成员
//对象为实参传递给一个非引用形参 void function(T obj){} //从一个返回类型为非引用类型的函数返回一个对象 obj function() {return obj;} //用花括号列表初始化数组元素或者聚合类中的成员 int array[10] = {1,2,3,4,5,6,7,8,9,0};
-
运算符本质上是函数,由operator关键字后接表示要定义的运算符的符号组成,因此赋值运算符就是一个名为operator=的函数;如果一个运算符是成员函数,则左侧运算对象就绑定到隐式的this参数,对于一个二元运算符(如赋值运算),右侧运算对象作为显式参数传递,赋值运算符通常应该返回一个指向其左侧运算对象的引用;
-
析构函数没有参数,因此不能重载,对于一个给定类,只有唯一一个析构函数;
-
内置类型没有析构函数,因此销毁内置类型成员什么也不需要做,隐式销毁一个内置指针类型成员不会delete它所指向的对象;
-
当指向一个对象的指针或者引用离开作用域时,析构函数不会执行;
-
与普通指针不同,智能指针是类类型,所以具有析构函数,在析构阶段会被自动销毁;
-
析构函数体自身是不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的;在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的;
-
如果一个类需要自定义析构函数,那几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数,反之未必;
-
13.1.4节练习
class numbered { private: static int num; //这里不能初始化,类内静态成员初始化必须为常量 public: int myseq; numbered() { myseq = num++; } numbered(numbered& s) { myseq = num++; } }; int numbered::num = 0; void f1(numbered s) { cout << s.myseq << endl; } void f2(numbered& s) { cout << s.myseq << endl; } int main() { numbered a, b, c; cout << a.myseq << " " << b.myseq << " " << c.myseq << endl; f1(a); f1(b); f1(c); return 0; }
-
在类内使用 = default 修饰成员的声明时,合成的函数将隐式地声明为内联函数,如果不希望是内联函数,那就在类外定义;
-
= delete 通知编译器,不希望定义这些成员,如拷贝构造函数、拷贝赋值运算符,与=default不同,=delete必须出现在函数第一次声明的时候;
-
=delete与=default还有一个不同,=delete可以对任何函数指定,但是=default只能对编译器可以合成的默认构造函数或拷贝控制成员如拷贝构造拷贝赋值运算符; 析构函数不能是=delete, 析构函数已经删除的类,不能定义该类型的变量或者delete指向该类型对象的指针;
-
如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的,详细见450页;
-
在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private来组织拷贝,但是成员函数和友元函数还是可以调用拷贝构造、拷贝赋值,解决办法是声明但不定义private的拷贝构造函数,详见451页;
-
13.1.6节练习
class Employee { private: string name; int myid; public: static int idno; Employee() { myid = idno++; } Employee(string s) :name(s) { myid = idno++; } Employee(Employee& emp) : name(emp.name), myid(idno++){} }; int Employee::idno = 0;
13.2 拷贝控制和资源管理
- 赋值运算符通常组合了析构函数和构造函数的操作,对于左侧运算对象调用析构函数来销毁,同时从右侧运算对象调用拷贝构造函数来拷贝数据;一个好的模式顺序是先将右侧运算对象拷贝到一个局部临时对象,拷贝完成后销毁左侧运算对象的现有成员,最后把临时对象拷贝到左侧运算对象的成员中;
- 可以使用shared_ptr来管理指向同一个对象的指针,或者自定义一个引用计数的类,详细见456页;
13.3 交换操作
-
定义swap是一种很重要的优化手段,对于类类型数据,可以避免多次拷贝和赋值,只需要对指针进行相应的交换;
-
如果一个类的成员有自己的特定的swap函数,调用swap时会优先调用类型特定的swap,除非显式调用std::swap;
-
定义swap的类通常使用swap来定义他们的赋值运算符,这种方法天然是异常安全的,且能处理自赋值
HasPtr& HasPtr::operator=(HasPtr rhs) { swap(*this, rhs); return *this; }
13.4 拷贝控制示例
- 看460页代码
- 练习13.36
class Folder { friend class Message; private: set<Message*> msgbox; public: Folder() = default; ~Folder() = default; void addMsg(Message* msgp) { msgbox.insert(msgp); } void remMsg(Message* msgp) { msgbox.erase(msgp); } };
13.5 动态内存管理类
- 学会使用allocator对象进行内存分配,详见466页;
- construct(pos, args)第一个参数必须是指针,指向调用allocator所分配的为构造的内存空间,剩余参数用于构造函数来构造对象
- destroy(ptr)会运行ptr指向的对象的析构函数,释放内存空间;
- std::move(str) string的移动构造函数使用方法,469页;
13.6 对象移动
- C++旧标准中没有直接方法移动对象;新标准中,可以用容器保存不可拷贝的类型,只要他们能被移动即可;标准库容器,string和shared_ptr类既支持移动也支持拷贝,IO类和unique_ptr类可以移动但是不能拷贝;
- 通过&&而不是&来获得右值引用,右值引用有一个重要性质——只能绑定到一个将要销毁的对象,因此可以右值引用的资源“移动”到另一个对象中去;
- 返回左值引用的函数,赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子,可以将一个左值引用(&)绑定到这类表达式的结果上;
- 返回非引用类型的函数,算术、关系、位以及后置递增/递减运算符,都生成右值,不能将左值引用绑定这类表达式上,但可以将一个const 的左值或者一个右值引用绑定到这类表达式上;
- 左值持久、右值短暂,左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象;右值引用只能绑定到临时对象,该对象将要被销毁,该对象没有其他用户;
- 可以通过一个名为move的标准库函数获得绑定到左值上的右值引用,此函数定义在头文件<utility>中
int &&r1 = 42; //正确,字面常量是右值,&&是右值引用 int &&r2 = r1; //错误,表达式r1是左值,r2是右值引用 int &&r2 = std::move(r1) //OK
move调用告诉编译器,我们有一个左值,但我们希望像一个右值一样处理它;除了使用r1进行赋值或销毁它意外,我们将不再使用它,对于移后源对象(move from obj),可以赋予它新值,但是不能使用它的值;
-
使用move的代码应该使用std::move,避免与潜在的名字冲突;
-
在函数参数列表后面加上noexcept表示通知标准库函数我们的构造函数不抛出任何异常;如果类的定义在类外的话,在声明和定义中都要指定noexcept
strVec::StrVec(StrVec &&s) noexcept: elements(s.elements), first_free(s.first_free), cap(s.cap) { s.elements=s.first_free=s.cap=nullptr; //不加这句的话,当语句块结束时s的析构函数将释放掉刚刚移动的内存 }
-
移动操作之后,移后源对象(move from obj)必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设(因为不知道它会是什么值);
-
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构成或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符;
-
移动操作永远不会隐式定义为删除的函数,但是如果现实要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数
-
定义了移动构造函数和移动赋值运算符的类,如果不定义自己的拷贝构造函数和拷贝赋值运算符的话会默认为是删除的;
-
如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来‘移动’的。拷贝赋值运算符和移动赋值运算符类似;当一个类同时有移动和拷贝构造函数时,如果参数为右值,优先匹配移动构造函数,因为拷贝构造函数需要进行一个到const&的转换,而移动构造函数是精准匹配&&;
-
三/五法则,三(拷贝构造函数,拷贝赋值运算符,析构函数),五(多了移动构造函数,移动赋值运算符),这五种函数可以看作一个整体,一般来说如果一个类定义了任何一个拷贝操作,就应该定义所有五个操作;
-
在移动构造函数和移动赋值运算符这些实现类代码之外的地方,只有当确信需要进行移动操作且移动操作是安全的才可以使用std::move;
-
区分移动和拷贝的重载函数通常有一个版本接受const T&, 而另一个版本接受 T&&;
-
引用限定符将指定函数返回的对象为左值或者右值,引用限定符跟const限定符同时使用的情况下要在const之后;
-
如果一个成员函数有引用限定符,则具有相同参数列表的重载函数都必须有引用限定符;
这章看的头有点晕,有些东西还是得实际写点代码才能理解。