C++类的特殊成员-默认/拷贝/移动构造函数

Posted tupelo-shen

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++类的特殊成员-默认/拷贝/移动构造函数相关的知识,希望对你有一定的参考价值。

首先学习这章,需要对动态内存分配有一定的理解。
类的特殊成员函数有六个,如下:

接下来让我们逐一分析:

1 默认构造函数

默认构造函数相信大家都不陌生了,只有当没有声明构造函数或者对象在声明的时候没有任何初始化参数就会调用默认构造函数。

class Example 
public:
    int total;
    void accumulate (int x)  total += x; 
;

编译器假定Example有一个默认构造函数。因此,类的对象可以不使用任何参数简单地声明。

Example ex;

但是,只要类的构造函数被使用任何参数显式地声明,编译器就不会隐式地调用默认构造函数,也就是不再允许类对象的声明不使用参数。例如,下面的类:

class Example2 
public:
    int total;
    Example2(int value):total(value)  ;
    void accumulate (int x)  total += x; ;
;

这儿,我们声明了一个带有int形的构造函数。因此,下面的对象声明是正确的:

Example2 ex (100);   // ok: calls constructor 

但是,下面的声明就是不正确的:

Example2 ex; // 不正确: 没有默认构造函数 

因此,类能够使用带有一个参数的显式构造函数取代默认构造函数。因此,如果类对象需要使用无参声明,类中必须有正确的默认构造函数。例如:

// 类和默认构造函数
#include <iostream>
#include <string>
using namespace std;

class Example3 
    string data;
  public:
    Example3 (const string& str) : data(str) 
    Example3() 
    const string& content() const return data;
;

int main () 
  Example3 foo;
  Example3 bar ("Example");

  cout << "bar's content: " << bar.content() << '\\n';
  return 0;

这里,Example3有一个默认构造函数,它具有空的函数体。

Example3() 

上面的Example3就是默认构造函数,当class声明中没有其它构造函数时就会调用这个默认构造函数。但是,在上面的例子中还有其它构造函数:

Example3 (const string& str);

如果在类中显式声明了任何构造函数,默认构造函数就不会自动提供。

2 析构函数

什么是析构函数?
析构函数一般与构造函数成对出现,主要用于类对象在声明周期结束时释放对象所占内存。为了做到这点,析构函数旧出现了。
看下面的例子:

// destructors
#include <iostream>
#include <string>
using namespace std;

class Example4 
    string* ptr;
  public:
    // constructors:
    Example4() : ptr(new string) 
    Example4 (const string& str) : ptr(new string(str)) 
    // destructor:
    ~Example4 () delete ptr;
    // access content:
    const string& content() const return *ptr;
;

int main () 
  Example4 foo;
  Example4 bar ("Example");

  cout << "bar's content: " << bar.content() << '\\n';
  return 0;

在例4中,为一个字符串分配了存储空间,这个存储空间就会被析构函数在后面释放。

3 复制构造函数

当用一个已初始化过了的自定义类类型对象去初始化另一个新构造的对象的时候,拷贝构造函数就会被自动调用。也就是说,当类的对象需要拷贝时,拷贝构造函数将会被调用。以下情况都会调用拷贝构造函数:

(1)一个对象以值传递的方式传入函数体
(2)一个对象以值传递的方式从函数返回
(3)一个对象需要通过另外一个对象进行初始化。

如果在类中没有显式地声明一个拷贝构造函数,那么,编译器将会自动生成一个默认的拷贝构造函数,该构造函数完成对象之间的位拷贝。位拷贝又称浅拷贝,后面将进行说明。

自定义拷贝构造函数是一种良好的编程风格,它可以阻止编译器形成默认的拷贝构造函数,提高源码效率。

浅拷贝和深拷贝

在某些状况下,类内成员变量需要动态开辟堆内存,如果实行位拷贝,也就是把对象里的值完全复制给另一个对象,如A=B。这时,如果B中有一个成员变量指针已经申请了内存,那A中的那个成员变量也指向同一块内存。这就出现了问题:当B把内存释放了(如:析构),这时A内的指针就是野指针了,出现运行错误。

深拷贝和浅拷贝可以简单理解为:如果一个类拥有资源,当这个类的对象发生复制过程的时候,资源重新分配,这个过程就是深拷贝,反之,没有重新分配资源,就是浅拷贝。下面举个深拷贝的例子。

// 拷贝构造函数: 深复制
#include <iostream>
#include <string>
using namespace std;

class Example5 
	string* ptr;
public:
	Example5(const string& str):ptr(new string(str))
    ~Example5() delete ptr;
    // 拷贝构造函数:
    Example5(const Example5& x):ptr(new string(x.content())) 
    // 访问类的字符串:
    const string& content() const return *ptr;
;

int main () 
  Example5 foo ("Example");
  Example5 bar = foo;

  cout << "bar's content: " << bar.content() << '\\n';
  return 0;

上面的代码中,拷贝构造函数的参数列表中,new一个新的字符串存储位置用来存储复制过来的旧类对象的字符串内容。通过这样的操作,两个对象就具有内容相同,但是存储位置不同的字符串。

来总结一下关于 深拷贝与浅拷贝需要知道的基本概念和知识:

(1)什么时候用到拷贝函数?
a.一个对象以值传递的方式传入函数体;
b.一个对象以值传递的方式从函数返回;
c.一个对象需要通过另外一个对象进行初始化。
如果在类中没有显式地声明一个拷贝构造函数,那么,编译器将会自动生成一个默认的拷贝构造函数,该构造函数完成对象之间的位拷贝。位拷贝又称浅拷贝。
(2)是否应该自定义拷贝函数?
(3)什么叫深拷贝?什么是浅拷贝?两者异同?
自定义拷贝构造函数是一种良好的编程风格,它可以阻止编译器形成默认的拷贝构造函数,提高源码效率。
深拷贝:如果一个类拥有资源,当这个类的对象发生复制过程的时候,资源重新分配,这个过程就是深拷贝,反之,没有重新分配资源,就是浅拷贝。
(4)深拷贝好还是浅拷贝好?
如果实行位拷贝,也就是把对象里的值完全复制给另一个对象,如A=B。这时,如果B中有一个成员变量指针已经申请了内存,那A中的那个成员变量也指向同一块内存。这就出现了问题:当B把内存释放了(如:析构),这时A内的指针就是野指针了,出现运行错误。

4 拷贝赋值操作

对象不仅仅在构造阶段的初始化时被拷贝,它们也可以被任何赋值操作拷贝。看看它们的不同:

MyClass foo;
MyClass bar(foo);       // 对象初始化: 调用拷贝构造函数
MyClass baz = foo;      // 对象初始化: 调用拷贝构造函数
foo = bar;              // 对象已经被初始化,调用拷贝赋值

在上面的例子中,MyClass baz = foo;虽然使用了等号“=“但是这并不是赋值操作(尽管它看起来像是):对象的声明不是赋值操作,它仅仅是调用单参数构造函数的另一种语法。
但是对foo这个对象的操作就是赋值操作了,这里没有对象的声明,仅仅是对一个已经存在的对象进行赋值操作。
对象的拷贝复制操作是操作符“=“的一种重载形式。返回值是*this指针的引用(尽管这里没有要求)。语法如下:

MyClass& operator= (const MyClass&);

拷贝赋值操作符是一种特殊的函数,如果一个类没有用户定义的拷贝或者移动赋值(或者移动构造函数)就会隐含的声明拷贝赋值操作符。
同拷贝赋值操作一样,执行的也是浅拷贝。这种操作,不仅仅会有删除对象两次,还会造成内存泄漏的风险。这些问题可以通过拷贝赋值先前的对象且执行深拷贝来避免,看下面的例子:

Example5& operator= (const Example5& x) 
	delete ptr;    // 删除现在指向的字符串
	// 为新字符串分配存储空间,并拷贝
	ptr = new string (x.content());  
	return *this;

更好的方式就是,基于字符串成员不是constant,它可以重新利用相同的字符串对象:

Example5& operator= (const Example5& x) 
	*ptr = x.content();// 指向同一个字符串对象
	return *this;

5 转移构造函数

与拷贝类似,移动也使用一个对象的值设置另一个对象的值。但是,又与拷贝不同的是,移动实现的是对象值真实的转移(源对象到目的对象):源对象将丢失其内容,其内容将被目的对象占有。移动操作的发生的时候,是当移动值的对象是未命名的对象的时候。
这里未命名的对象就是那些临时变量,甚至都不会有名称。典型的未命名对象就是函数的返回值或者类型转换的对象。
使用临时对象的值初始化另一个对象值,不会要求对对象的复制:因为临时对象不会有其它使用,因而,它的值可以被移动到目的对象。做到这些,就要使用移动构造函数和移动赋值:
当使用一个临时变量对对象进行构造初始化的时候,调用移动构造函数。类似的,使用未命名的变量的值赋给一个对象时,调用移动赋值操作。

MyClass fn();            // 函数返回一个 MyClass 对象
MyClass foo;             // 默认构造函数
MyClass bar = foo;       // 拷贝构造函数
MyClass baz = fn();      // 移动构造函数
foo = bar;               // 拷贝赋值
baz = MyClass();         // 移动赋值 

fn的返回值和MyClass构造的值都是临时变量。在这些例子里,没有需要做拷贝,因为临时变量的生命周期很短,能够被其它对象获取,这种操作是更有效的。
下面是移动构造函数和移动赋值的语法,它们的返回类型都是class类自身。

MyClass (MyClass&&);             // move-constructor
MyClass& operator= (MyClass&&);  // move-assignment

移动操作的概念对对象管理它们使用的存储空间很有用的,诸如对象使用new和delete分配内存的时候。在这类对象中,拷贝和移动是不同的操作:从A拷贝到B意味着,B分配了新内存,A的整个内容被拷贝到为B分配的新内存上。
而从A移动到B意味着分配给A的内存转移给了B,没有分配新的内存,它仅仅包含简单地拷贝指针。
看下面的例子:

// 移动构造函数和赋值
#include <iostream>
#include <string>
using namespace std;

class Example6 
    string* ptr;
public:
    Example6 (const string& str) : ptr(new string(str)) 
    ~Example6 () delete ptr;
    // 移动构造函数,参数x不能是const Pointer&& x,
    // 因为要改变x的成员数据的值;
    // C++98不支持,C++0x(C++11)支持
    Example6 (Example6&& x) : ptr(x.ptr) 
    
        x.ptr = nullptr;
    
    // move assignment
    Example6& operator= (Example6&& x) 
    
        delete ptr; 
        ptr = x.ptr;
        x.ptr=nullptr;
        return *this;
    
    // access content:
    const string& content() const return *ptr;
    // addition:
    Example6 operator+(const Example6& rhs) 
    
        return Example6(content()+rhs.content());
    
;
int main () 
    Example6 foo("Exam");           // 构造函数
    // Example6 bar = Example6("ple"); // 拷贝构造函数
    Example6 bar(move(foo)); 	// 移动构造函数
    							// 调用move之后,foo变为一个右值引用变量,
    							// 此时,foo所指向的字符串已经被"掏空",
    							// 所以此时不能再调用foo
    bar = bar+ bar;             // 移动赋值,在这儿"="号右边的加法操作,
    							// 产生一个临时值,即一个右值
 								// 所以此时调用移动赋值语句
    cout << "foo's content: " << foo.content() << '\\n';
    return 0;

执行结果:

foo's content: Example

编译器早已能用返回值优化的方式优化大多数上形式上调用移动构造的情况。最显著的是,当一个函数返回值被用来初始化一个对象时。在这些情况下,移动构造函数事实上不会被调用。
注意,即使返回值引用能够用作任何一个函数参数的类型,对于实际使用也没有什么用,除了移动构造函数。返回值引用是十分危险的,不必要的使用可能会成为错误的源头,而且十分难追踪。
总结:使用场景:
如果第二个对象是在复制或赋值结束后被销毁的临时对象,则调用移动构造函数和移动赋值运算符,这样的好处是避免深度复制,提高效率。

6 隐含成员的总结

下面是6种隐含成员出现的时机以及默认定义的总结

注意,在同一个类中并不是没有的特殊成员都会被隐含定义。这主要是为兼容C结构体和更早版本的C++,以及一些废弃的类,而作的妥协。幸运的是,每个类可以显式地选择哪个成员使用它们默认的定义存在,或使用关键字default和delete进行选择。语法如下:

function_declaration = default;
function_declaration = delete;
// 默认和删除隐含成员
#include <iostream>
using namespace std;

class Rectangle 
    int width, height;
  public:
    Rectangle (int x, int y) : width(x), height(y) 
    Rectangle() = default;
    Rectangle (const Rectangle& other) = delete;
    int area() return width*height;
;

int main () 
  Rectangle foo;
  Rectangle bar (10,20);

  cout << "bar's area: " << bar.area() << '\\n';
  return 0;

上面的代码,Rectangle类既能够使用两个int型的参数,也能够默认构造函数构建对象。但是没有拷贝构造函数,因为它已经被delete。因此,对于上面例子中的对象,下面的声明将是不正确的。

Rectangle baz (foo);

但是,可以通过定义它的拷贝构造函数显式地使上面的语句合法:

Rectangle::Rectangle(const Rectangle& other)=default;

它在本质上等于下面的语句:

Rectangle::Rectangle (const Rectangle& other) : width(other.width), height(other.height) 

注意,关键字default定义的成员函数不等于默认构造函数(例如,在这儿,默认构造函数意味着没有参数),但是等于如果没有被删除的隐含定义的构造函数。
通常,为了未来的兼容性,类将会显式地定义一个拷贝/移动构造函数,或者拷贝/移动赋值操作,而不是两个都定义。对于其它特殊成员函数,不想显式定义的使用delete或default指定,这种做法就会被鼓励。

以上是关于C++类的特殊成员-默认/拷贝/移动构造函数的主要内容,如果未能解决你的问题,请参考以下文章

[ C++ ] C++类与对象之 类中6个默认成员函数

详解c++中类的六个默认的成员函数

详解c++中类的六个默认的成员函数

详解c++中类的六个默认的成员函数

C++类和对象—— 类的6个默认成员函数及日期类的实现

C++:= default & = delete