拷贝控制操作
当定义一个类时,我们显式地或隐式地指定在此类望的对象拷贝,移动、赋值和销毁时做什么。
一个类通定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数(copy consinuctor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(movecomstructor)、移动赋值运算符(move-assignment operator)和析构函数(destructor)。
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。我们称这些操作为拷贝控制操作(copy control)。
如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作。
因此,很多类会忽略这些拷贝控制操作。
但是,对一些类来说,依赖这些操作的默认定义会导致灾难。
通常,实现拷贝控制操作最困难的地方是首先认识到什么时候需要定义这些操作。
在定义任何C++类时,拷贝控制操作都是必要部分。对初学C++的程序员来说,必须定义对象拷贝、移动、赋值或销毁时做什么,这常常令他们感到困惑。
这种困扰很复杂,因为如果我们不显式定义这些操作,编译器也会为我们定义,但编译器定义的版本的行为可能并非我们所想。
拷贝构造函数
我们将以最基本的操作——拷贝构造函数、拷贝赋值运算符和析构函数作为开始。
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo {
public:
Foo(); // 默认构造函数
Foo(const Foo&); //拷贝构造函数
Foo(const Foo&t,int b=0)://拷贝构造函数
};
拷贝构造函数的第一个参数必须是一个引用类型,原因我们稍后解释。
虽然我们可以定一个接受非const引用的拷贝构造函数,但此参数几乎总是一个const的引用。
拷贝检造函数在几种情况下都会被隐式地使用。因此,拷贝构造函数通常不应该是 explicit的
合成拷贝构造函数
如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。
与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
对某些类来说,合成拷贝构造函数用来阻止我们拷贝该类类型的对象。
而一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。
编译器从给定对象中依次将每个非与at3c成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何接贝;对类类型的成员,会使用其拷贝构造函数来拷贝:
内置类型的成员则直接拷贝。
虽然我们不能直接拷贝一个数组,但合成拷贝构造函数正会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。
作为一个例子,我们的Sales_data类的合成拷贝构造函数等价于;
class Sales_data {
pubiic:
//其他成员和构造函数的定义,如前
// 与合成的拷贝构造函数等价的拷贝构造函数的声明
Sales_data(const Sales_data&);
private:
std::string bookNo;
int units_sold =0;
double revenue = 0.0;
};
//与Sales_data的合成的拷贝构造函数等价
Sales data::Sales_data(const Sales_data &orig):
bookNo (orig.bookNo). //使用string的拷贝构造函数
units_sold(orig.units_sold),//拷贝 orig.units_sold
revenue(orig.revenue) //拷贝 orig.revenue
{}//空函数体
拷贝初始化
现在,我们可以完全理解直接初始化和拷贝初始化之间的差异了(参见3.2.1节,第76页):
string dots(10,'.'); // 直接初始化
string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
string null book="9-999-99999-9"; //.拷贝初始化
string nines = string(100, '9'); //拷贝初始化
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。
当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
拷贝初始化通常使用拷贝构造函数来完成。但是,如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。
发生时机
但现在,我们只需了解拷贝初始化何时发生,以及拷贝初始化是依靠拷贝构造函数或移动构造函数来完成的就可以了。
拷贝初始化不仅在我们用=定义变量时会发生,在下列情况下也会发生
将一个对象作为实参传递给一个非引用类型的形参
#include<iostream>
using namespace std;
class A
{
public:
int a_;
A(const A& t)
{
a_ = t.a_;
cout << "拷贝函数被调用" << endl;
}
A() = default;
};
void A1(A r){}
int main()
{
A a;
A1(a);
}
从一个返回类型为非引用类型的函数返回一个对象
#include<iostream>
using namespace std;
class A
{
public:
int a_;
A(const A& t)
{
a_ = t.a_;
cout << "拷贝函数被调用" << endl;
}
A() = default;
};
A A2(A& r) { return r; }
int main()
{
A a;
A b = A2(a);
}
用花括号列表初始化一个数组中的元素或一个聚合类中的成员
int a[2]={1,2};//数组里存的是副本
某些类类型还会对它们所分配的对象使用拷贝初始化。
例如,当我们初始化标准库容器或是调用其insert或push成员时,容器会对其元素进行将拷贝初始化。与之相对,用emplace成员创建的元素都进行直接初始化
参数和返回值
在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。
类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。
拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函新自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。
拷贝初始化的限制
如前所述,如果我们使用的初始化值要求通过一 explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了:
vector<int> v1(10); // 正确:直接初始化
vector<int> v2 = 10;// 错误:接受大小参数的构造函数是explicit的
void f(vector<int>);// f的参数进行拷贝初始化
f(10);//错误:不能用一个explicit的构造函数拷贝一个实参
f(vector<int>(10));// 正确:从一个int直接构造一个临时vector
直接初始化v1是合法的,但看起来与之等价的拷贝初始化v2则是错误的,因为vector的接受单一大小参数的构造函数是 explicit 的。
出于同样的原因,当传递一个实参或从函数返回一个值时,我们不能隐式使用一个 explicit 构造函数。
如果我们希望使用个explicit构造函数,就必须显式地使用,像此代码中最后一行那样。
编译器可以绕过拷贝构造函数
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。
即,编译器被允许将下面的代码
string null_book ="9-999-99999-9"; // 拷贝初始化
改写为
string null_book("9-999-99999-9");//编译器略过了拷贝构造函数
但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的(例如,不能是private的)。
拷贝赋值运算符
与类控制其对象如何初始化一样,类也可以控制其对象如何赋值:
Sales_data trans, accum;
trans = accum; //使用Sales_data的拷贝赋值运算符
与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。
重载赋值运算符
在介绍合成赋值运算符之前,我们需要了解一点儿有关重载运算符的知识,
重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数。类似于任何其他函数,运算符函数也有一个返回类型和一个参数列表。
重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数(参见7.1.2节,第231页)。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。
拷贝赋值运算符接受一个与其所在类相同类型的参数;
class Foo
{
public:
Foo& operator=(conat Foo&);// 赋值运算符
//...
};
为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。
另外值得注意的是,标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
合成拷贝赋值运算符
与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。
类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。
如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。
对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。
作为一个例子,下面的代码等价于Sales_data的合成拷贝赋值运算符:
// 等价于合成拷贝赋值运算符
Sales_data& Sales_data::operator=(const Sales_data &rhs)
{
bookNo = rhs.bookNo; // 调用 string::operator=
units_sold = rhs.units_sold; // 使用内置的int赋值
revenue = rhs.revenue; // 使用内置的double赋值
return *this; // 返回一个此对象的引用
}