C++中的那些拷贝控制函数

  构造、析构、拷贝、移动是C++中的那与众不同的几大件,他们有些时候会自动被编译器生成、有些时候又不会,有些时候会被自动调用、有些时候又不会自动被调用,有时候编译器自动调用还是错误的版本,所以这些东西总体是比较繁琐的。依稀记得当初被面试的时候被问道:派生类拷贝构造的时候基类的拷贝构造函数会不会被调用,现在想想当时自己的回答就倍感羞愧,所以决定痛定思痛想要把这些东西再行梳理一下。
  其实,想要深入了解这个东西,还真的要去啃《深度探索C++对象模型》这本古籍才行,不然也只是人云亦云不能真正深入知其所以然。现在没时间,但是对于每一个学习C++的人来说,以后肯定是要钻研的。

一、构造函数

  当类没有声明任何构造函数的时候,编译器会自动生成默认构造函数,默认构造函数的行为是:如果存在类内初始化值,则使用这个初始值来初始化类成员;否则,执行默认初始化操作。
  C++11新标准规定,可以在定义类的时候给数据成员提供一个类内初始值,创建对象的时候该类内初始值将用于初始化成员,对于没有初始值的成员将会被默认初始化。默认初始化行为跟成员类型和对象定义位置相关:如果成员是内置数据类型且没有被显式初始化,那么当定义于任何函数之外的位置都会被初始化为0,而定义在函数体内部的内置类型将不会被初始化,其内容是未被定义的;对于类类型的对象如果没有进行显式初始化,其值由类本身所决定,直白来说就是如果没有提供类内初始化值,且该对象定义在函数中,则其内置成员变量的初始值是未定义的,其他情况下都有良好的初始值。

1
2
3
class Date {
int year {1970}; int month {1}; int day{1}; ...
}

  因为类内初始化值只有新标准编译器才支持,所以依赖编译器自动生成构造函数必须特别的小心,尤其当类含有内置类型成员变量的时候,就需要手动定义默认构造函数为这些内置类型变量进行列表初始化,而且递归看待该类的其他非内置类型成员变量,他们也需要有良好的默认初始化行为。
  如果类含有对引用、const成员以及没有默认构造行为的成员的时候,就必须在初始化列表中对这些成员进行初始化操作。同时新标准C++11还扩充了构造函数初始化列表的功能,可以执行委托构造函数的行为:委托构造函数就是使用该类的其他构造函数执行它自己的初始化过程,受委托的构造函数初始化列表、函数体被依次执行,然后委托者构造函数的函数体才会接着被执行。
  static成员是静态分配的而不属于任何一个对象,需要在类外进行定义初始化。例外是当成员是const整形、枚举类型、constexpr字面常量类型的情况,且初始化器是一个常量表达式的时候,就不需要额外在类外部进行定义和初始化,这主要是把这类成员方便当做常量符号使用,所以使用的时候不能对这类变量取地址操作。

二、拷贝操作

2.1 拷贝构造函数

1
Foo(const Foo&);

  如果没有为一个类定义拷贝构造函数,编译器总是会自动生成一个,其默认行为是:将指定对象中每个非static成员依次拷贝到正在创建的对象中去。根据类非static成员变量类型有着默认的拷贝行为:对于类类型的成员,会自动调用其拷贝构造函数来拷贝;对于内置类型的变量会直接进行拷贝;对于数组类型,按照数组元素类型会对数组的逐元素调用其构造函数或者直接拷贝。
  拷贝构造函数是在创建对象的时候被调用的,其和直接初始化的区别是:直接初始化是根据函数匹配的规则匹配调用普通构造函数,而拷贝构造函数是要求编译器将一个已经存在的对象拷贝到正在创建的对象中。
  拷贝构造函数被调用的情形有:
  (1). 将一个对象作为实参传递给一个非引用类型的形参,即函数参数的传值调用;
  (2). 从一个返回类型为非引用类型的函数返回一个对象;
  (3). 用花括号列表初始化一个数组中的元素或一个聚合类中的成员时候;
  (4). 比如标准化容器使用insert、push等成员增加元素的时候也会调用拷贝构造函数,而emplace族成员函数会进行直接初始化,因此后者更高效。
  某些情况下编译器可能会绕过拷贝/移动构造函数直接创建对象,这是编译器可能执行的优化但是不能依赖他们,在执行这个优化的时候也必须要求拷贝/移动构造函数是存在且可访问的,不然noncopable这种禁止拷贝的限制就无法工作了。

2.2 拷贝赋值运算符

1
Foo& operator=(const Foo&);

  如果没有定义,编译器也总是会自动合成一个拷贝赋值运算符,为了满足其函数签名,这个函数的最后总是需要返回一个*this值,拷贝赋值运算符默认会将右侧运算对象的每一个非static成员赋值给左侧对象的对应成员,对于数组类型会依次赋值数组的每个元素,虽然我们无法手动这么做但是编译器可以执行数组赋值。
  在编写拷贝赋值运算符的时候,一定要注意自赋值安全,尤其目的对象在接收资源前需要释放资源的时候。保证实现这个要求最简单方法就是先创建一个临时对象,将源对象拷贝到这个临时对象中,再删除目的对象的资源,接着再将临时对象的资源赋值给目的对象就可以了,这本质上将资源管理的工作委托了拷贝构造函数去实施。另外一点,如果直接赋值的话赋值过程中可能会抛出异常,这样目标对象将处于中间被破坏状态,为了提供强保证也建议我们先创建一个副本,成功之后进行交换。

1
2
3
Foo& Foo::operator=(const Foo& rhs) {
Foo tmp{rhs}; swap(rhs, *this); return *this;
}

三、移动操作

3.1 移动构造函数

1
Foo(Foo&&) noexcept;

  移动构造函数的参数是该类型的一个右值引用,注意移动构造函数和移动赋值运算符一般都声明为noexcept的,noexcept关键字是新标准引入的,在函数的声明和定义处都要加上该关键字。因为移动操作不会涉及到新资源的申请操作,所以通常也不会抛出异常,通过声明noexcept可以同标准库更好的合作,包括真正的零拷贝提高性能。因为标准库如果不确认该对象不抛出异常的话,他就会调用其拷贝操作而不是移动操作,因为只有这样才能保证标准库操作是异常安全。
  移动构造函数的操作通常是接管传入的右值对象的资源,同时将源对象置于一个析构安全的状态,不能够对移动后的对象做任何的假设,最好也不要操作被移动后的对象,但是可以考虑对其重新赋值。比如最常见的容器类型,经过移动操作后容器的内容就是空的了,而且可以对这个容器重新赋值操作。

3.2 移动赋值运算符

1
Foo& operator=(Foo&&) noexcept;

  移动赋值运算符也要声明为noexcetpt,同时和拷贝赋值运算符一样,移动赋值运算符在处理的开始需要考虑自移动的问题,通过检查只有传入对象的地址不是this才进行后续的操作,否则直接返回*this。如果将一个std::move调用后的结果赋值给源对象,就会出现上述移动自赋值的情况。

3.3 移动操作小结

  当一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数的时候,编译器就不会再为其合成移动操作了,即使没有移动操作,按照参数匹配的规则也会自动调用其拷贝操作来代替移动操作;只有当一个类没有定义任何自己版本的拷贝控制成员(拷贝构造函数、拷贝赋值运算符或者析构函数),而且类的每个非static成员都支持移动,编译器才会合成默认移动操作运算符。
  按照函数调用匹配规则,拷贝操作接收非const左值、const左值,移动操作接收一个右值,将一个右值传递给拷贝操作需要一个const转换,所以在允许的情况下右值总是优先匹配移动操作,同时不存在移动操作的时候拷贝函数也能退而求其次满足调用需求。
  总体来说,还是感觉自定义类的移动操作比较的负责也比较的危险,而用拷贝操作代替移动操作总是安全的。现在系统都十分庞大,获取这么一点便捷性能提升不太值的,善用标准库的拷贝支持和成熟第三方库的拷贝支持就足够了,那些不支持拷贝的库资源也有良好的移动语义直接安全调用,自作聪明的移动优化往往过犹不及,常常栽跟头。

四、析构函数

  析构函数的作用主要是释放对象使用的资源,并销毁对象非static的数据成员。构造函数是先按照派生列表声明的顺序执行基类的构造函数,然后按照数据成员在类中的声明顺序进行初始化,最后再执行构造函数的函数体;析构函数按照相反的顺序执行,即先执行析构函数的函数体,然后按照和构造初始化顺序相反的顺序销毁数据成员,然后再依次调用基类的析构函数,所以推断执行析构函数体的时候类成员都还是有效状态,析构函数体可以访问这些存在成员。
  至于虚基类,需要在任何可能用它的基类之前构造,并在他们之后销毁,这种特殊的东西就先不考虑了。内置类型没有析构函数,或者说析构函数不需要销毁操作,隐式销毁一个指针类型不会delete其所指向的成员。
  析构函数的调用情形:
  (1). 变量离开其作用域的时候,会自动调用其析构函数;
  (2). 当一个对象被销毁的时候,其成员变量也会被销毁;
  (3). 当容器类和数组被销毁的时候,其元素也将被销毁;
  (4). 对于动态申请分配的对象,当指向它的指针被delete的时候会被销毁;
  (5). 对于临时对象,当创建它的完整表达式结束的时候,临时对象会被自动销毁。
  通常我们需要严格观察析构函数,如果在析构函数中做了重要的事情,比如释放自由空间存储、释放锁等操作,那么我们通常在其他拷贝控制成员中也需要添加类似的操作。

五,拷贝控制的常用法则

5.1 三五法则

  虽然析构函数和拷贝、移动操作是独立的,但是一旦我们需要重新定义析构函数的时候,那我们就必须仔细审核拷贝、移动操作是否也需要重新定义了,而且一旦重新定义拷贝构造函数,那么通常也需要重新定义拷贝赋值运算符,反之亦然;移动操作也是类似的。这些拷贝操作一般组成三五法则,即重新定义了析构函数,则拷贝构造函数和拷贝赋值运算符也需要重新定义,如有必要还需奥增加移动操作控制的重新定义。

5.2 阻止某些拷贝操作

  有些时候我们需要组织拷贝或者移动操作,我们不定义这些函数是达不到这个要求的,因为我们不定义编译器就会自动合成它们,在新标准中我们可以将他们定义为=delete的方式达到这个效果,表明我们虽然声明了它,但是不能以任何形式使用它们,编译器会禁止任何试图使用它们的操作,而在新标准之前,都是将对应的成员声明为privte的来阻止拷贝操作,只声明不定义是因为成员函数和友元仍然可以访问private成员函数,只声明而不定义函数是合法的,如果使用了该函数会在链接的时候发生错误。
  我们可以在某些情况下主动将某些成员函数定义为=delete,而某些情况下编译器也会将那些自动合成函数自动定义为=delete的,总体的规则就是如果一个类有数据成员是不能默认构造、拷贝、赋值或者销毁,则这个类对应的成员函数也会被定义为=delete,比如:
  (1). 如果类某个成员的析构函数是=delete或者不可访问的(比如private),则类的合成析构函数就会被定义为=delete,这就意味着无法定义该类的变量或者临时对象,我们可以new出这个对象,但是无法delete析构这个对象;同时类的合成拷贝构造函数也会被自动定义为=delete的,因为没有析构函数我们就可能创建出无法被销毁的对象;
  (2). 如果类某个成员的拷贝构造函数是=delete或者不可访问的(比如private),则类的合成构造函数会被定义为=delete;
  (3). 如果类某个成员的拷贝赋值运算符是=delete或者不可访问的(比如private),或者类含有一个const成员或者引用成员,则该类的合成拷贝赋值运算符被定义为=delete的;因为给引用成员赋值其实是改变该引用所指对象的实际值,引用还是会指向赋值前的那个实体,这种行为比较怪异,所以这种情况下赋值操作是被定义为=delete的;
  (4). 如果类的某个成员的析构函数被定义为=delete或者不可访问的,或者某个成员是引用类型但是没有指定类内初始化器,或者某个成员是const类型但是没有指定类内初始化器且不支持默认构造,则该类的默认构造函数被定义为=delete;
  如果将移动操作符也考虑进来,则情况就稍微的复杂一点点。移动操作永远不会自动定义为=delete的,除非将移动操作定义为=default强制要求移动操作符,同时某些成员不支持移动操作,则编译器会将移动操作定义为=delete,情况和上面的规则类似:
  (5). 如果有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者类成员没有定义自己的拷贝构造函数但是编译器却不能为其合成移动构造函数,即类成员没有可访问的移动构造函数,则该类的移动构造函数被定义为=delete;
  (6). 如果有类成员的移动构造函数或者移动赋值运算符被定义为=delete或者不可访问的(比如private),则该类的移动构造函数或者移动赋值运算符会被定义为=delete;
  (7). 如果类的析构函数被定义为=delete或者不可访问的(比如private),则类的移动构造函数会被定义为=delete;
  (8). 如果类有const成员或者引用类型的成员,则类的移动赋值运算符被定义为=delete;
  (9). 如果一个类定义了移动构造函数和/或移动赋值运算符,则该类的拷贝构造函数和拷贝赋值运算符会被定义为=delete;
  (10). 类的拷贝控制和移动控制有某种制约的关系:如果定义了类的拷贝操作,则编译器不会为其定义移动操作;如果定义了类的移动操作,则该类的合成拷贝操作会被定义为=delete的。

六、继承下的拷贝控制

  继承情况下派生类的虚函数只负责释放派生类自己的资源,所以大多情况下派生体系需要注意虚析构函数的问题。而且这还伴随着一个引申的问题:如果一个类定义了析构函数(即使是default),编译器就不会为其合成移动操作了。
  (1). 如果基类的默认构造函数、拷贝构造函数、拷贝赋值运算符、析构函数是=delete或者不可访问的,则派生类中对应的拷贝控制成员也是=delete的,因为编译器无法使用基类中对应的成员完成对象的构造、赋值和销毁工作;
  (2). 如果基类中有不可访问或者删除掉的析构函数,则派生类中合成的默认构造函数和拷贝构造函数将是=delete的,因为派生类无法销毁派生类对象中的基类部分;
  (3). 派生类中如果使用=default请求一个移动操作时,而基类中对应操作是删除的或者不可访问的,则派生类中该移动操作是=delete的。这时候如果需要派生类支持移动操作,则必须派生类定义移动操作的实体,同时还需要考虑如何处理基类的移动操作,不过这种需求很少见啦。
  注意:默认情况下派生类的拷贝操作、移动操作会调用基类的默认构造函数初始化派生类对象的基类部分,所以如果想要拷贝或者移动基类部分,必须在派生类构造函数的初始化列表中显式调用基类的拷贝或者移动构造函数,类似的赋值操作也必须手动显式调用基类的赋值操作语句,形如:

1
2
3
4
5
D(const D& d): Base(d) { ... }
D(D&& d):: Base(std::move(d)) { ... }
D& D::operator=(const D& rhs) { Base::operator=(rhs); ... }
D& D::operator=(D&& rhs) { Base::operator=(std::move(rhs)); ... }

嗯,其实本文主要就是耍嘴皮子用的~
本文完!

参考