欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

C++拷贝控制

程序员文章站 2022-12-10 17:10:29
C++拷贝控制 前言 c++作为高级语言,面向对象编程是其重要的语言特性。设计好的架构,其基础也是类的设计。我们之前已经将类本身的知识梳理了一遍。这一章着重介绍类控制,包括拷贝...

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函数时,必须绝对确认移后源对象没有其他的用户。