From 3d56179022356a05e839b1c622ee94e2e2accaab Mon Sep 17 00:00:00 2001 From: Shine wOng <1551885@tongji.edu.cn> Date: Thu, 2 Jan 2020 10:58:35 +0800 Subject: [PATCH] update the conclusion on constructor and copy control, move operation not added yet. --- c++ note/chp13/chp13.md | 154 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 1 deletion(-) diff --git a/c++ note/chp13/chp13.md b/c++ note/chp13/chp13.md index 65e0c8c..4eba10b 100644 --- a/c++ note/chp13/chp13.md +++ b/c++ note/chp13/chp13.md @@ -36,7 +36,7 @@ Sales_data::Sales_data(const string &s, unsigned int cnt, double price){ 在功能上没有任何区别。我之前也是认为两者完全等价,甚至还更加偏向于第二种写法,因为感觉格式要好看一点。实际上,两者在性能上是不相同的。 -构造函数的执行顺序,是首先执行初始化列表,继而执行构造函数体内的赋值语句。需要注意的是,即使初始化列表为空,编译器仍然会首先对各个成员变量进行默认的初始化操作,因此相对于第一种版本,第二种版本不仅没有剩下执行初始化列表的时间,还增加了函数体内的变量赋值的开销。如果成员变量是一个较大的类类型的话,两者的时间开销差别还是蛮大的。 +构造函数的执行顺序,是首先执行初始化列表,继而执行构造函数体内的赋值语句。需要注意的是,即使初始化列表为空,编译器仍然会首先对各个成员变量进行默认的初始化操作,因此相对于第一种版本,第二种版本不仅没有省下执行初始化列表的时间,还增加了函数体内的变量赋值的开销。如果成员变量是一个较大的类类型的话,两者的时间开销差别还是蛮大的。 因此,应该尽量使用初始化列表来对成员变量进行初始化操作,这样可以获得更优的性能。实际上,在一些情况下,必须使用初始化列表。 @@ -163,3 +163,155 @@ explicit Mystring::Mystring(int num){ ... } ``` + +## 拷贝控制 + +拷贝控制(copy control)是指一个类控制它的对象在拷贝,移动,赋值,析构操作下所进行的活动。其中包含五个函数(操作),即拷贝构造函数(copy constructor),拷贝赋值运算符(copy assignment operator),移动构造函数(move constructor),移动赋值运算符(move assignment operator)以及析构函数(destructor)。下面将对它们分别进行讨论。 + +### 拷贝构造函数 + +拷贝构造函数是一类特殊的构造函数,它的参数为同类对象的一个引用,该引用通常是一个常量(const)引用,尽管非常量也是可以的。 + +```cpp +class Mystring{ +public: + string str; + + //default constructor + Mystring() = default; + //copy constructor + Mystring(const Mystring& right); + //converting constructor + Mystring(int num); +} +``` + +顾名思义,拷贝构造函数的作用,就是在对象被拷贝初始化(copy initialization)的时候被调用,它规定了新的对象应该如何从被拷贝对象进行复制: + +```cpp +Mystring str1; //default initialization +Mystring str2 = str1; //copy initialization, call copy constructor +``` + +如果不自己定义拷贝构造函数,那么编译器会自动添加一个默认拷贝构造函数(synthesized copy constructor),它的函数体是,将类的所有可拷贝成员,逐个复制到新的对象当中,即浅拷贝(shallow copy)。 + +因此,如果一个类的成员变量包含指针类型,默认拷贝构造函数就可以导致隐患的错误——将一个对象的指针简单拷贝到另一个对象,两个指针指向同一个变量,如果一个对象释放了该指针,则另一个对象也将不再能访问该变量。在这种情况下,就有必要手动定义拷贝构造函数,为目标指针分配一个新的存储空间: + +```cpp +class HasPtr{ +private: + std::string *ps; + int i; +public: + HasPtr() = default; + //copy constructor + HasPtr(const HasPtr& hp): ps(new string(*hp.ps)), i(hp.i){} +} +``` + +在上面的例子中,就为目标对象的指针重新分配了存储空间,即深拷贝(deep copy),从而避免了默认拷贝构造函数可能存在的问题。需要注意的是,这里直接访问了另一个对象`hp`的私有成员变量,这是因为同一类的对象互为友元(friend),因此可以相互访问对象的私有变量。 + +下面以一个例子,来说明拷贝初始化过程中发生的操作: + +```cpp +std::string str = "Study hard tomorrow."; +``` + +这个例子在前面转换构造函数的部分已经提过了,它会首先调用转换构造函数,将`const char*`类型的`"Study hard tomorrow"`转换为`std::string`类型,继而再调用拷贝构造函数,拷贝初始化`str`。 + +这里的两次构造函数调用看起来多少有些冗余,实际上,在上面这种情况下,一些编译器可能会省略掉(omit)拷贝构造函数的调用,将上面的拷贝初始化优化为直接初始化: + +```cpp +std::string str("Study hard tomorrow."); +``` + +此时就只会调用一次转换构造函数了。 + +实际上,除了拷贝初始化以外,拷贝构造函数还在多个场合会被隐式调用: + ++ 函数调用的参数为对象的非引用类型(non-reference type)。此时为了将实参转化为形参传入函数内部,编译器会自动调用拷贝构造函数构造一个新的对象(形参)。 ++ 函数调用的返回值为对象的非引用类型。此时并不能简单返回函数体内的局部变量,因为函数执行完毕后所有局部变量都将被回收,因此编译器会自动调用拷贝构造函数复制出一个新的对象作为返回结果。 ++ 初始化数组等复合类型时,将被拷贝的数组的每个对象逐个复制到新的数组当中,此时对拷贝的每个对象编译器都会自动调用拷贝构造函数。 ++ std的容器进行拷贝初始化的时候,对复制的每一个对象都自动调用拷贝构造函数。 + +由于拷贝构造函数有这么多隐式调用的场合,所以绝对不可以用`explicit`关键字去修饰拷贝构造函数。 + +现在也可以说明为什么拷贝构造函数的参数必须是同类对象的一个`引用`了。如果拷贝构造函数的参数是非引用类型,在进行拷贝构造时,对于传入的非引用类型的实参(即被拷贝的对象),需要调用拷贝构造函数构造出一个新的形参,这个过程就将不断持续下去,无法终止。 + +### 拷贝赋值运算符 + +顾名思义,拷贝赋值运算符在对象被赋值时被调用。和拷贝构造函数一样,如果用户没有定义拷贝赋值运算符,编译器将添加默认的拷贝赋值运算符(synthesized copy assignment operator),并且该默认的运算符也是进行浅拷贝。 + +因此,如果类含有类型为指针的成员变量,则往往需要自己定义拷贝复制运算符。这里需要注意的是,拷贝赋值运算符的参数通常也是同类对象的常量引用`const reference type`,尽管不是引用类型也是可以的。在这种情况下,进行赋值操作时,编译器会首先调用拷贝构造函数构造出新的形参,随后再执行拷贝赋值运算符内的操作。 + +由于c++内置类型的赋值操作,其返回值都是左侧操作数的引用。为了保持与内置类型的一致性,自己定义的拷贝赋值运算符也应该返回左侧操作数的引用。 + +```cpp +HasPtr& HasPtr::operator=(const HasPtr& hp){ + delete ps; //free ps first + ps = new string(*hp.ps); + i = hp.i; + return *this; //return a reference to left-hand operand +} +``` + +可以看到,在拷贝赋值运算符中,应该首先释放掉原有指针的空间,防止内存泄露。 + +### 析构函数以及三者之间的关系 + +关于析构函数我觉得我还是挺熟悉的,这里就不做详细的说明了。 + +和上面两个函数一样,如果用户没有自己定义析构函数,编译器将自动为类添加一个默认析构函数(synthesized destructor),它的函数体是空的,如下: + +```cpp +HasPtr{ +private: + ... +public: + //other member functions + + ~HasPtr(){} //default destructor +} +``` + +但这并不是说默认析构函数什么工作都没有进行。实际上,析构函数体并不直接释放对象的成员变量,对象的成员变量是在执行完析构函数体之后隐式被释放的。因此析构函数体主要进行的应该是指针成员变量的释放,释放动态分配的内存空间。 + +下面重点讲解拷贝构造函数,拷贝赋值运算符以及析构函数三者的关系(the rule of three)。 + +通常说来,我们并不需要手动定义全部的三个函数,而是视情况定义部分函数就可以了。这里存在一些经验规则: + +> 定义了析构函数的类往往需要同时定义拷贝构造函数和拷贝赋值运算符。 + +仍然以上面的`HasPtr`类为例,假设它只定义了析构函数: + +```cpp +HasPtr{ +private: + string *ps; + int i; +public: + ~HasPtr(){ + delete ps; + } + //other member functions +} +``` + +析构函数体内释放`ps`指向的内存空间,随后成员变量`ps`和`i`被隐式回收。考虑一种情况,存在一个外部函数`f`,它的函数体如下所示: + +```cpp +HasPtr f(HasPtr hp){ + HasPtr ret = hp; + return ret; +} +``` + +如果传入一个`HasPtr`类型的对象,在实参形参转换时会第一次调用拷贝构造函数,浅拷贝出形参`hp`;在`ret = hp`处调用拷贝赋值运算符,浅拷贝出`ret`;函数返回时,会第二次调用拷贝构造函数,随后两个局部变量`hp`和`ret`都会被销毁。需要注意的是,这里所有对象,他们的成员变量`ps`都指向同一个内存区域,因此在销毁过程中,该内存区域会被释放两次,这是一个内存错误。 + +此外,这样一次调用后,原来的实参指向的`string`将失效,因为该区域已经被释放了。从这个例子可以看出,如果定义了析构函数,往往还需要手动定义拷贝构造函数和拷贝赋值运算符。 + +> 拷贝构造函数和拷贝赋值运算符往往需要同时被定义,或者同时不被定义。 + +换言之,如果定义了一个,往往还需要定义另一个。但是定义了拷贝构造函数和拷贝赋值运算符的类并不一定需要定义析构函数。 + +作为总结,理解这里三个函数的关键,在于把握它们各自的调用时机,即在什么语句中,参数的形式是什么样的时候会被调用,这样就可以避免不少潜在的错误。