首页 > 代码库 > C++的那些事:类的拷贝控制

C++的那些事:类的拷贝控制

1。什么是类的拷贝控制

当我们定义一个类的时候。为了让我们定义的类类型像内置类型(char,int,double等)一样好用,我们通常须要考以下几件事:

Q1:用这个类的对象去初始化还有一个同类型的对象。

Q2:将这个类的对象赋值给还有一个同类型的对象。

Q3:让这个类的对象有生命周期。比方局部对象在代码部结束的时候,须要销毁这个对象。

因此C++就定义了5种拷贝控制操作,当中2个移动操作是C++11标准新增加的特性:

拷贝构造函数(copy constructor)

移动构造函数(move constructor)

拷贝赋值运算符(copy-assignment operator)

移动赋值运算符(move-assignment operator)

析构函数 (destructor)

前两个构造函数发生在Q1时。中间两个赋值运算符发生在Q2时,而析构函数则负责类对象的销毁。

可是对刚開始学习的人来说,既是福音也是灾难的是,假设我们没有在定义的类里面定义这些控制操作符,编译器会自己主动的为我们合成一个版本号。这有时候看起来是好事。可是编译器不是万能的,它的行为在非常多时候并非我们想要的。

所以,在实现拷贝控制操作中,最困难的地方是认识到什么时候须要定义这些操作

2。拷贝构造函数

拷贝构造函数是构造函数之中的一个,它的參数是自身类类型的引用,且假设有其它參数。则不论什么额外的參数都有默认值。

class Foo{ 
public: 
    Foo(); 
    Foo(const Foo&); 
};

我们从上面代码中能够注意到几个问题:

1,我们把形參定义为const类型,尽管我们也能够定义非const的形參。可是这样做基本上没有意义的。由于函数的功能仅仅涉及到成员的复制操作。

2,形參是本身类类型的引用,并且必须是引用类型。为什么呢?

我们知道函数实參与形參之间的值传递,是通过拷贝完毕的。

那么当我们将该类的对象传递给一个函数的形參时。会调用该类的拷贝构造函数。而拷贝构造函数本身也是一个函数。由于是值传递而不是引用,在调用它的时候也须要调用类的拷贝构造函数(它自身),这样无限循环下去,无法完毕。

3,拷贝构造函数通过不是explict的。

假设我们未定义拷贝构造函数,编译器会为我们定义一个。这个函数会从给定的对象中依次将每一个非static成员复制到正在创建的对象中。成员自身的类型决定了它是怎样被拷贝的:类类型的成员,会使用其拷贝构造函数来拷贝;内置类型则直接拷贝;数组成员会逐元素地拷贝

区分直接初始化与拷贝初始化:

string name("name_str");        //直接初始化 
string name = string("name_str");    // 拷贝初始化 
string name = "name_str";        // 拷贝初始化

直接初始化是要求编译器使用普通的函数匹配来选择与我们提供的參数最匹配的构造函数。当我们使用拷贝初始化时,我们要求编译器将右側运算对象复制到正在创建的对象中。假设须要的话还要进行类型转换(第三行代码隐藏了一个C风格字符串转换为string类型)。

3,拷贝赋值运算符

拷贝赋值运算符是一个对赋值运算符的重载函数,它返回左側运算对象的引用。

class Foo 
{ 
public: 
    Foo& operator=(const Foo&); 
};

与拷贝构造函数一样,假设没有给类定义拷贝赋值运算符。编译器将为它合成一个。

4,析构函数

析构函数是由波浪线接类名构成,它没有返回值,也不接受參数。

由于没有參数,所以它不存在重载函数。也就是说一个类仅仅有一个析构函数。

析构函数做的事情与构造函数相反,那么我们先回顾一个构造函数都做了哪些事:

1,按成员定义的顺序创建每一个成员。

2。依据成员初始化列表初始化每一个成员。

3,运行构造函数函数体。

而析构函数中不存在类似构造函数中初始化列表的东西来控制成员怎样销毁,析构部分是隐式的。

成员怎样销毁依赖于成员自身的类型。假设是类类型则调用本身的析构函数,假设是内置类型则会自己主动销毁。

而假设是一个指针,则须要手动的释放指针指向的空间。与普通指针不同的是,智能指针是一个类。它有自己的析构函数。

那么什么时候会调用析构函数呢?在对象销毁的时候:

  • 变量在离开其作用域时被销毁;
  • 当一个对象被销毁时,其成员被销毁。
  • 容器被销毁时。成员被销毁。
  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。

  • 对于暂时对象。当创建它的赛事表达式结束时被销毁。

值得注意的析构函数是自己主动执行的。析构函数的函数体并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的。

在整个对象销毁过程中。析构函数体是作为成员销毁步骤之外的还有一部分而进行的。

5,定义拷贝控制操作的原则

在第1点里有提过,在定义类的时候处理拷贝控制最困难的在于什么时候须要自定义。什么时候让编译器自己合成。

那么我们能够有以下2点原则:

假设一个类须要定义构造函数,那么差点儿能够肯定它也须要一个拷贝构造函数和一个拷贝赋值函数。反过来不一定成立

假设一个类须要一个拷贝构造函数。差点儿能够肯定它也须要一个拷贝赋值函数,反之亦然。

为什么析构函数与拷贝构造函数与赋值函数关系这么紧密呢。或者说为什么我们在讨论拷贝控制(5种)的时候要把析构函数一起放进来呢?

首先。我们思考什么时候我们一定要自己来定义析构函数。比方:类里面有动态分配内存。

class HasPtr 
{ 
public: 
    HasPtr(const string&s = string()) :ps(new string(s), i(0)){} 
    ~HasPtr(){ delete ps; } 
private: 
    int i; 
    string* ps; 
};

我们知道假设是编译器自己主动合成的析构函数,则不会去delete指针变量的,所以ps指向的内存将无法释放,所以一个主动定义的析构函数是须要的。那么假设没有给这个类定义拷贝构造函数和拷贝赋值函数。将会怎么样?

编译器自己主动合成的版本号。将简单的拷贝指针成员,这意味着多个HasPtr对象可能指向同样的内存。

HasPtr p("some values"); 
f(p);        // 当f结束时,p.ps指向的内存被释放 
HasPtr q(p);// 如今p和q都指向无效内存

6,使用=default和=delete

我们能够使用=default来显式地要求编译器生成合成的版本号。合成的函数将隐式地声明为内联的。假设我们不希望合成的成员是内联的,应该仅仅对成员的类外定义使用=default。

有的时候我们定义的某些类不须要拷贝构造函数和拷贝赋值运算符,比方iostream类就阻止拷贝,以避免多个对象写入或读取同样的IO缓冲。

新的标准里。我们能够在拷贝构造函数和拷贝赋值运算符函数的參数列表后面加上=delete用来指出我们希望将它定义为删除的,这种函数称为删除函数。

class NoCopy 
{ 
    NoCopy() = default;    // 使用合成的默认构造函数 
    NoCopy(const NoCopy&) = delete;        // 删除拷贝 
    NoCopy& operator=(const NoCopy&) = delete;    // 删除赋值 
    ~NoCopy() = default;    // 使用合成的析构函数 
};

注意:析构函数不能是删除的成员,由于这种类是无法销毁的。

假设一个类有const成员或者有引用成员。则这个类合成拷贝赋值运算符是被定义为删除的。

在新的标准出来之前,类是通过将其拷贝构造函数的拷贝赋值运算符声明为private来阻止拷贝,并且为了防止成员被友元或其它成员訪问。会对这些成员函数仅仅声明,但不定义。

7。右值引用

所谓的右值引用就是必须绑定在右值上的引用。我们能够通过&&来获得右值引用,右值引用一个非常重要的性质是仅仅能绑定到一个将要销毁的对象,所以我们能够自由地将一个右值引用的资源“移动”到还有一个对象中。

我们能够将一个右值引用绑定到表达式上。但不能将右值引用绑定到一个左值上:

int i = 42; 
int &r = i;        // 正确:r引用i 
int &&rr = i;    // 错误:不能将一个右值引用绑定到一个左值上 
int &r2 = i * 42;    // i*42是一具右值 
const int& r3 = i * 42;    // 能够将一个const的引用绑定到一个右值上 
int && rr2 = i * 42;    // 正确:将rr2绑定到乘法结果上

整体来说:左值有持久的状态,而右值要么是字面常量,要么是表达式求值过程中创建的暂时对象。

从而我们得知,关于右值引用:1)所引用的对象将要销毁;2)该对象没有其它用户。

标准库提供了一个std::move函数,让我们能够获得左值上的右值引用:

int  &&r3 = std::move(rr1); // rr1是一个变量

move调用告诉编译器:我们有一个左值。可是我们希望像一个右值一个处理它。在上面的代码后,要么销毁rr1,要么对rr1进行赋值,否则我们不能使用rr1。

另外一点值得注意的是,我们使用std::move而不是move。即使我们提供了using声明。

8,移动构造函数和移动赋值运算符

与拷贝一样,移动操作相同发生在我们一个类的对象去初始化或赋值同一个类类型的对象时。可是与拷贝不同的是。对象的内容实际上从源对象移动到了目标对象,而源对象丢失了内容。

移动操作一般仅仅发生在当这个源对象是一个uname的对象的时候。

一个uname object意思是一个暂时对象,还没有被赋予一个名字,比如一个返回该类型的函数返回值或者一个类型转换操作返回的对象。

MyClass fn();            // function returning a MyClass object
MyClass foo;             // default constructor
MyClass bar = foo;       // copy constructor
MyClass baz = fn();      // move constructor
foo = bar;               // copy assignment
baz = MyClass();         // move assignment 

上面的代码中由fn()返回的对象和由MyClass构造出来的对象都是unnamed,用这种对象给MyClass赋值或初始化时,并不须要拷贝。由于源对象仅仅有非常短的生命周期。

移动构造函数与移动赋值函数的定义形式上与拷贝操作一样,仅仅是将拷贝函数的形參的引用换成右值引用。

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

移动操作对那些须要管理存储空间的类是很实用的,比方我们以下定义的这个类:

// move constructor/assignment
#include <iostream>
#include <string>
using namespace std;

class Example6 {
    string* ptr;
  public:
    Example6 (const string& str) : ptr(new string(str)) {}
    ~Example6 () {delete ptr;}
    // move constructor
    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");   // move-construction
  
  foo = foo + bar;                  // move-assignment

  cout << "foo‘s content: " << foo.content() << ‘\n‘;
  return 0;
}


C++的那些事:类的拷贝控制