Chapter 5 Essential Operation – A Tour of C++

5.1.1 Essential Operations

一个类型的构造,析构,copy(拷贝),move(移动)操作在逻辑上都不是独立的。我们必须对其进行定义,否则就会造成逻辑上的问题或者性能上的问题。如果class X的析构函数需要进行许多工作,那么该class需要上述四个操作完整的定义。

class X
{
public:
    X(Sometype);            // 普通构造函数,创建一个对象
    X();                    // 默认构造函数
    X(const X&);            // 拷贝构造函数
    X(X&&);                 // 移动构造函数
    X& operator=(const X&); // 拷贝赋值
    X& operator=(X&&);      // 移动赋值
    ~X();                   // 析构函数
};

以下的五种情况可能会导致对象被拷贝或者移动:

  • 作为赋值运算的来源
  • 作为初始化对象
  • 作为函数的参数
  • 作为函数的返回值
  • 作为exception

构造函数除了能够用来初始化命名对象以及对heap上的对象进行初始化,还能够用来初始化临时对象并且进行显示的类型转换。

如果你想要显示地让编译器生成一些默认构造函数,你可以使用修饰符default。

class Y
{
public:
    Y(SomeType);
    Y(const Y&) = default; // 默认拷贝构造函数
    Y(Y&&) = default;      // 默认移动构造函数
};

当class中含有指针成员,我们最好显示地声明拷贝与移动运算。这是因为成员指针可能指向某些需要被class进行delete的对象,这样的话默认的拷贝函数可能会发生错误。或者,我们可以让指针只会指向那些class并不会进行delete的对象。

对我们来说,最佳的选择就是定义所有运算,或者不定义任何运算。

struct Z
{
    Vector v;
    string s;
};

Z z1;        // 默认对z1.v与z1.s进行初始化
Z z2 = z1;   // 默认拷贝z1.v与z1.s

在上述代码中,编译器将会合成默认的成员构造函数,拷贝函数,移动函数以及析构函数。

之前,我们说到可以使用=default来生成编译器默认的运算,除此之外,我们还能够使用=delete使得编译器不会生成某一运算。在实践中,我们可能会将基类的成员拷贝函数进行delete。这样的话,如果我们调用了基类的拷贝函数,在代码编译时就会报错。此外=delete能够废除任何函数,不单单是拷贝或者移动运算。

5.1.2 Conversions

如果class的构造函数需要一个参数,那么这意味着我们定义了一个该参数类型的转换。例如,我们之前定义的complex,其可以传入一个double变量来进行构造。

complex z1 = 3.14;
complex z2 = z1 * 2;

但是有时我们并不需要这种隐示转换,这会混淆代码的意义。此时,我们可以使用修饰符explicit来禁用隐示转换。

class Vector
{
public:
    explicit Vector(int s); // 不能隐示地将int转换为Vector
};

Vector v1(7);  // 运行ok,v1拥有7个元素
Vector v2 = 7; // error!!! 不能隐示地将int转换为Vector

5.2 Copy and Move

默认情况下,对象是能够被拷贝的。默认的拷贝就是拷贝每一个成员。当我们设计一个class时,必须考虑其对象是否需要被拷贝,同时该如何进行拷贝。对于简单的concrete类型,对成员变量进行逐一拷贝通常是正确的选择。但是对于那些稍许复杂的concrete类型,例如Vector,成员的逐一拷贝可能就不一定是正确的选择了。对于abstract类型,我们应该尽可能避免拷贝。

5.2.1 Copying Containers

当一个class是一个resource handle,也就是说,该class负责通过指针来读取一个对象(class中的一个成员为指针)。那么默认的逐一拷贝将是灾难性的,其会破坏resource handle的不可变性。

void bad_copy(Vector v1)
{
    Vector v2 = v1; // 将v1的representation拷贝至v2
    v1[0] = 2;      // v2[0]也将变为2
    v2[1] = 3;      // v1[1]也将变为3
}

class的对象的拷贝运算通常由两个成员函数所定义:拷贝构造函数以及拷贝赋值函数。

class Vector
{
private:
    double* elem;
    int sz;

public:
    Vector(int s);
    ~Vector() { delete[] elem; }

    Vector(const Vector& a); // 拷贝构造函数
    Vector& operator=(const Vector& a); // 拷贝赋值函数

    double& operator[](int i);
    const double& operator[](int i) const;

    int size() const;
};

对于上述Vector的拷贝构造函数,我们应该先在内存中分配合适的空间,之后再将每一个成员拷贝至该对象。这样,两个Vector对象都会拥有自己的拷贝元素。

Vector::Vector(const Vector& a) // 拷贝构造函数
    : elem{new double[a.sz]}    // 为每一个元素分配空间
    , sz{a.sz}
{
    for(int i = 0; i!=sz; ++i)  // 拷贝每一个元素 
        elem[i] = a.elem[i];
}

Vector& Vector::operator=(const Vector& a) // 拷贝赋值函数
{
    double* p = new double[a.sz];
    for(int i = 0; i!=a.sz; ++i)
        p[i] = a.elem[i];

    delete[] elem; // 删除旧成员
    elem = p;
    sz = a.sz;
    return *this;
}

5.2.2 Moving Containers

对于那些大型的container,调用拷贝函数无疑非常费时。当我们将某些对象作为参数传入函数时,可以通过使用引用来避免拷贝的开销。但是我们不能将引用返回至一个局部对象,因为局部对象可能会被销毁。

Vector operator+(const Vector& a, const Vector& b)
{
    if(a.size() != b.size())
        throw Vector_size_mismatch{};

    Vector res(a.size());
    for(int i = 0; i != a.size(); ++i)
        res[i] = a[i] + b[i];

    return res;
}

那么运算符+的返回将会包含局部变量res的拷贝运算。

void f(const Vector& x, const Vector& y, const Vector& z)
{
    Vector r;
    r = x + y + z;
}

在上述代码中,我们至少会拷贝一个Vector两次。如果该Vector包含10000个double变量,那么情况将会非常糟糕。不过最为糟糕的部分,是运算符+中的res,在我们完成拷贝之后,res就不会再被使用了。实际上,我们并不想要拷贝运算;只是想要得到函数运算的结果:我们想要move移动一个Vector而不是copy拷贝。

class Vector
{
    Vector(const Vector& a); // 拷贝构造函数
    Vector& operator=(const Vector& a); // 拷贝赋值函数

    Vector(Vector&& a); // 移动构造函数
    Vector& operator=(Vector&& a); // 移动赋值函数
}

Vector::Vector(Vector&& a)
    : elem{a.elem} // 从a
    , sz{a.sz}
{
    a.elem = nullptr;
    a.sz = 0;
}

当我们定义了上述函数之后,返回值被函数返回时,编译器将会选择移动构造函数。这意味着r=x+y+z并不会包含任何Vector的拷贝,它们只是被移动了。上述代码中的&&表示“右值引用”。所谓右值,其与左值相对应。粗略地说,左值就是在赋值运算符左侧的对象。所以,右值粗略地说,就是一个你无法赋值的对象,例如函数返回的值。因此,右值引用的对象是其他东西都无法赋值的对象,这样我们才能安全地“窃取”它的值。

移动构造函数的参数不能是const值,因此移动构造函数将会把参数的值移除。移动赋值函数的定义也相类似。当右值引用作为初始化或者用作赋值运算右侧的对象时,移动运算会被调用。在move之后,被move的对象应该处于能够运行析构函数的状态。

当程序员知道某一个值不会被再次使用,但是编译器无法识别这一情况,那么我们可以这么做。标准库中的函数move()实际上并不会移动任何数据。相反,其只是返回传入参数的一个右值引用;本质上这一函数是一种cast。

Vector f()
{
    Vector x(1000);
    Vector y(2000);
    Vector z(3000);
    z = x; // copy,因为x是左值
    y = std::move(x); // move
    // 之后我们最好不再使用x
    return z; // move
}

5.5 Advice

[03] 定义所有的关键运算或者一个也不定义.

[04] 如果一个class拥有一个指针成员,那么我们最好自己定义析构函数,拷贝函数以及移动函数。

[07] 默认情况下,如果构造函数只有一个参数,那我们应该使用修饰符explicit。

[15] 根据标准库的模式来设计container。

留下评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据