C++拷贝控制

前言

c++作为高级语言,面向对象编程是其重要的语言特性。设计好的架构,其基础也是类的设计。我们之前已经将类本身的知识梳理了一遍。这一章着重介绍类控制,包括拷贝控制、重载、面向对象设计以及模板和泛型编程。这些非常非常重要,是实现工程必须要掌握的基础知识。要打起十分的精神来学习。

按照c++ primer的顺序,我们从拷贝控制说起。

这章看的时间有点久,有些东西很陌生。可能自己接触的实际工程太少,有些经验的东西还体会不到。不过也不影响学习。至少有了前车之鉴,以后走过的坑会少点。

c++是一门很细致的语言。对象的生命周期可能会经过初始化、拷贝、赋值、释放一系列的过程。c++统统可以对其进行控制。在类的基础知识那一章着重分析了类的初始化,也就是类的构造函数。这一章主要讨论的是如何控制对象拷贝、赋值、移动和销毁。

类通过特殊的成员函数来控制这些操作。

  • 拷贝 —- 拷贝构造函数(初始化)
  • 赋值 —- 拷贝赋值运算符
  • 移动 —- 移动构造函数(初始化)、移动赋值运算符
  • 销毁 —- 析构函数

注意:

拷贝构造函数和赋值是不一样的,前面提到过初始化和赋值的区别。拷贝构造函数是在用另一个对象创建本对象时调用,而赋值是在用右值替换左值过程调用。

这些统称为拷贝控制操作。需要指出的是,如果类没有定义这些操作,编译器会自动生成默认的操作。但是一些特殊的场景下,使用默认的操作会出现问题。因此,问题的关键在于认识在何时需要定义这些操作


拷贝、赋值、销毁

这三者操作是最基本的操作。移动操作是新的标准提供的性质,一会深入分析。

1.拷贝构造函数

拷贝构造函数使用场景有三个:

  • 拷贝初始化
  • 类类型按值传递
  • 函数返回对象
  • 使用花括号列表初始化一个数组的元素或聚合类成员

拷贝构造函数的第一个参数是自身类类型的引用,且任何其他参数都有默认值。

class Foo {
public: 
    Foo();
    Foo(const Foo &); // 拷贝构造函数
};

Foo a;
Foo B = a; // 拷贝初始化
Foo B(a); // 直接初始化

拷贝构造函数不应该是explicit,因为拷贝构造函数在几种情况下会被隐式的使用。

拷贝构造函数参数必须是引用,因为如果不是引用,按值传递就必须调用拷贝构造函数,如此无限循环。

2.拷贝赋值运算符

初始化和赋值是两个不同的操作,我们反复在强调这一点。这里是重载赋值运算符,以控制同类型之间的对象赋值。

场景

Sales_data trans, accum;
trans = accum; // 使用Sales_data的拷贝赋值运算符

重载运算符本质上是函数。

class Foo {
public:
    Foo& operator= (const Foo &); // 赋值运算符重载    
};

Foo& Foo::operator= (const Foo &rhs) {
    data = rhs.data;
    return *this;
}

3.析构函数

析构函数是在对象销毁之前调用的函数。和构造函数执行相反的操作,这个相反在全方位。比如构造函数先执行初始化列表(按照数据成员定义顺序),然后执行函数体。析构过程先执行函数体,然后销毁数据成员(按照数据成员定义的逆序)。

析构函数不接受参数,不返回值。

class Foo {
public:
    ~Foo(); // 析构函数    
};

场景

  • 变量离开作用域
  • 对象被销毁时
  • 容器(数组、vector等)被销毁时
  • delete显示销毁时
  • 临时对象,创建的表达式结束时

注意:

当指向一个对象的引用或者指针离开作用域时,析构函数不会执行

4.三/五法则

何时定义拷贝控制操作,有几条原则可寻。

  • 需要析构函数的类也需要拷贝和赋值操作

这是因为,需要析构函数,常常伴随动态内存管理。而使用编译器合成的操作,往往是浅拷贝。可能会析构多次,导致未定义的错误。因此,几乎肯定需要拷贝和赋值操作

  • 需要拷贝操作的类也需要赋值操作,反之亦然

5.使用合成版本

使用=default 显示地要求编译器生成合成的版本。

6.阻止拷贝

在某些场景下,需要禁止拷贝操作。比如iostream类,阻止拷贝,以避免多个对象读写同一个IO缓冲,导致数据不一致。

实现拷贝阻止有两种方式:

  • 定义删除的函数(新标准)
struct NoCopy {
    NoCopy() = default;
    NoCopy(const NoCopy &) = delete; // 阻止拷贝
    NoCopy &operator=(const NoCopy &) = delete; // 阻止赋值
    ~NoCopy() = default;
};

注意:

析构函数不能是删除的成员,可以是删除,但是一旦定义为删除的,就不能定义这种类型的变量或成员,但是可以动态分配对象,却又无法释放对象。

  • private 拷贝控制

通过将拷贝构造函数和拷贝赋值函数声明成private 来阻止拷贝

class PrivateCopy {
    PrivateCopy(const PrivateCopy &); // class 默认为private ,阻止拷贝
    PrivateCopy &operator=(const PrivateCopy &); // 阻止赋值
public:
    PrivateCopy() = default;
    ~PrivateCopy() = default;
};

注意:

虽然声明成private,但是友元类和友元函数还是可以拷贝,因此,为了阻止友元类和友元函数拷贝,我们将这些拷贝成员声明为private,但是不定义它们。这样友元类和友元函数如果拷贝,会发生链接错误。


对象移动

拷贝的过程,是先分配新的空间,然后向新的空间里赋值。大部分情况下,对象拷贝之后会立即销毁。如果使用移动而非拷贝对象,性能将会大幅度的提升。新标准就提供了移动的新特性。

1.右值引用

为了支持移动操作,新标准引入了右值引用,非常triky。

我们之前提到的引用都是左值引用,即绑定到左值的引用。右值引用顾名思义,绑定到右值的引用。使用的方法是&&而非&

右值引用一个非常重要的特性是—–只能绑定到一个将要销毁的对象(临时对象),因此,我们可以自由的将右值引用的资源”移动”到另一个对象中。好好体会一下。

int i = 42;
int &r = i; // 左值引用
int &&rr = i; // 错误,不能将右值引用绑定到一个左值
int &r2 = i * 42; // 错误,不能将一个左值引用绑定到一个右值
const int &r3 = i * 42; // 正确,const引用绑定右值
int &&rr2 = i * 42; // 正确,右值引用绑定右值 

左值持久,右值短暂(字面值、临时对象)

但是我们说了,右值引用这个性质,可以让我们自由的接管所引用的对象的资源。

int &&rr1 = 42; // 正确,绑定字面值
int &&rr2 = rr1; // 错误,rr1表达式是一个左值

虽然我们不可以将一个右值直接绑定到左值上,但是我们可以显示的将一个左值转换为右值引用

#include 

int &&rr3 = std::move(rr1); // ok

调用了move意味着承诺:除了对rr1赋值或者销毁它以外,我们将不再使用它(不再使用它的值)。

2.移动构造函数、移动赋值运算符

移动操作,表示从给定对象“窃取”资源,而不是拷贝资源。

StrVec::StrVec(StrVec &&s) noexcept  // 移动操作不应该抛出任何异常
    // 成员初始化接管 s 中的资源
    : element(s.element), first_free(s.first_free), cap(s.cap)
{
    // 必须让 s 进入这样的状态-----对其运行析构函数是安全的。
    // 因为,其后s 源对象会被销毁,也就是执行析构函数,如果s.first_free 还指向原来的资源
    // 那么,移动的内存就会被销毁,这不是我们所想要的。
    s.element = s.first_free = s.cap = nullptr; // 源对象必须不再指向被移动的资源
}

移动赋值运算符必须要正确处理自赋值(赋值运算符都需要考虑这一点)

StrVec &StrVec::operator=(StrVec &&rhs) noexcept {
    // 检测自赋值
    if (this != &rhs) {
        free(); // 释放已有的元素
        elements = rhs.elements; // 从rhs接管资源
        first_free = rhs.frist_free;
        cap = rhs.cap;

        // 将rhs置于可析构状态
        rhs.elements = rhs.first_free = rhs.cap = nullptr;
    }
}

注意:

移源后对象必须可析构,另外,移动构造函数默认不能由编译器合成,但是如果类中的每个非static数据成员都是可移动的,编译器就可以为它合成移动构造函数或移动赋值运算符。其中,内置类型可以移动,string定义了自己的移动操作。

如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来”移动”的。

class HasPtr {
public:
    // 移动构造函数 
    HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) {
        p.ps = 0;
    }

    // 赋值运算符既是移动赋值运算符,也是拷贝赋值运算符
    HasPtr &operator=(HasPtr rhs) {
        swap(*this, rhs);
        return *this;
    }
};

hp = hp2; // hp2 是一个左值,使用拷贝构造函数来拷贝
hp = std::move(hp2); // 移动构造函数移动hp2

最后建议:不要随意使用移动操作,因为移后源对象具有不确定的状态,对其调用std::move是危险的。当我们调用move函数时,必须绝对确认移后源对象没有其他的用户。

你可能感兴趣的:(c++)