C++之旅——类(续)

C++之旅——类(续)

上一篇中,我们聊了 C++ 中几种常见的类设计,以及虚函数、继承和资源管理等相关特性。这一篇我们继续看看类对象在初始化、赋值、拷贝和移动时的一些细节。

这些操作看起来基础,却直接决定了一个类是否好用,以及它能否正确管理自己的资源。

构造函数、析构函数、拷贝和移动操作并不是彼此独立的。如果一个类直接管理内存、锁等资源,就需要把这些操作放在一起考虑,让它们遵循一致的资源管理规则。一个类 X 可能涉及下面这些函数:

class X {
public:
    X(Sometype);            // “普通构造函数”:创建对象
    X();                    // 默认构造函数
    X(const X&);            // 拷贝构造函数
    X(X&&);                 // 移动构造函数
    X& operator=(const X&);  // 拷贝赋值:用副本替换目标原有的值
    X& operator=(X&&);       // 移动赋值:接管资源,处理目标原有的资源
    ~X();                   // 析构函数:清理
    // ...
};

析构、拷贝构造、拷贝赋值、移动构造和移动赋值需要一起设计,这就是常说的五法则(Rule of Five)。这些函数不必全部手写:可以自行实现、使用 = default,或用 = delete 禁止。如果资源已经交给 vector、unique_ptr 等成员管理,通常可以直接依赖编译器生成的操作,也就是零法则(Rule of Zero)。

1. 初始化对象

1.1 构造函数

  • 构造函数不仅用于初始化具名对象和动态分配的对象,也用于构造临时对象,以及实现到类类型的转换。

  • 满足相应条件时,编译器会隐式声明默认构造、拷贝和移动等特殊成员函数。如果希望明确要求使用默认实现,可以写上 = default:

    class Y {
    public:
        Y(Sometype);
        Y(const Y&) = default;   // 我确实想要默认拷贝构造函数
        Y(Y&&) = default;        // 也想要默认移动构造函数
        // ...
    };
    

    这些函数的生成规则会相互影响。例如,声明了构造函数后,编译器就不会再隐式声明默认构造函数;声明了析构函数或拷贝操作后,不会再隐式声明移动操作;声明了移动操作后,隐式声明的拷贝操作会被定义为删除。

  • 当类中有指针成员时,首先要弄清楚它是否拥有所指对象。如果拥有,默认拷贝只会复制地址,很可能导致重复释放,需要自行定义拷贝、移动语义,或者禁止相应操作;如果只是借用其他对象,复制指针也可能正是我们需要的行为。关键是把所有权表达清楚。

  • 与 = default 对应,= delete 用来明确禁止某个操作。上一篇的自定义 Vector 就先禁用了拷贝,避免两个对象错误地拥有同一块数组。

    使用被删除的函数会导致编译错误。= delete 也能用于普通函数,并不限于特殊成员函数。

1.2 类型转换

能用单个实参调用、且没有声明为 explicit 的构造函数,可以用于从实参类型到类类型的隐式转换。这里也包括其他参数都有默认值的情况。例如,上一篇的 complex 提供了从 double 构造复数的方式。假定已经定义了接受两个 complex 的乘法运算符,就可以这样使用:

complex z1 = 3.14;   // z1 变为 {3.14, 0.0}
complex z2 = z1 * 2; // z2 变为 z1 * {2.0,0} == {6.28,0.0}

这种隐式转换用在复数运算中很自然,但放到容器上就未必合适了:一个整数到底代表元素数量,还是某个元素的值?标准库 vector 的按数量构造函数使用了 explicit,避免把整数隐式转换成容器。我们也可以这样定义自己的 Vector:

class Vector {
public:
    explicit Vector(int s);   // 不允许从 int 到 Vector 的隐式转换
    // ...
};

除非隐式转换本身就是接口的一部分,否则给这类构造函数加上 explicit,通常会让代码的意图更清楚。例如,Vector v(10); 可以正常构造对象,而 Vector v = 10; 则会被拒绝。

1.3 成员初始化器

有些成员在大多数情况下都有相同的初始值,可以直接在声明时写出来,这就是默认成员初始化器:

class complex {
    double re = 0;
    double im = 0;   // 表示:两个 double,默认值为 0.0
public:
    complex(double r, double i) : re{r}, im{i} {}   // 从两个标量构造复数
    complex(double r) : re{r} {}                    // 从一个标量构造复数
    complex() {}                                    // 默认复数:{0,0}
    // ...
};

构造函数的成员初始化列表没有指定某个成员时,就会使用它的默认成员初始化器。比如,complex(double r) 只初始化 re,im 仍取默认值 0。这样既少写了重复代码,也不容易漏掉初始化。

2. 拷贝和移动对象

对象的拷贝和移动,常见于下面几种场景:

  • 作为赋值操作的源
  • 作为对象初始化器
  • 作为按值传递的函数参数
  • 作为函数返回值
  • 构造异常对象或按值捕获异常

给已有对象赋值,使用的是拷贝或移动赋值运算符;创建新对象,则可能使用拷贝或移动构造函数。通过引用传参不会因此拷贝对象,而返回值等场景中的构造操作也可能被消除,后面会继续讨论。

2.1 拷贝

类对象的拷贝通常由两个成员负责:拷贝构造函数和拷贝赋值运算符。前者创建新对象,后者给已有对象赋值。

默认拷贝会依次拷贝基类部分和各个成员。对于 complex 这样的简单类型,它通常已经足够;如果成员本身是 string、vector 等类型,也会调用它们各自的拷贝操作。真正需要特别处理的,是像自定义 Vector 这样直接拥有裸指针所指资源的类。面向多态接口的类则通常不开放按值拷贝,以免发生对象切片。

假如 Vector 仍使用默认拷贝,执行 Vector v2 = v1; 时,只会复制 elem 的地址和 sz 的值。两个对象因此指向同一块数组,这就是这里所说的浅拷贝:

image-20261027101554010

这显然不是我们想要的结果:不仅修改元素会相互影响,两个对象析构时还会重复释放数组。

上一篇先用 = delete 禁用了 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];
}

完成深拷贝后,两个对象就互不干扰了。如下图所示,把 v2[0] 改为 3,v1[0] 仍然是 2:

image-20261027101923177

给已经存在的 Vector 赋值,还需要拷贝赋值运算符:

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;
}

this 是成员函数中预定义的名字,指向调用该成员函数的对象。

这里先分配、拷贝新数组,再释放旧数组,因此也能正确处理 v = v 这样的自赋值;如果分配失败,原对象的数据也不会受影响。

2.2 移动

拷贝语义解决了正确性问题,但对大型容器来说,逐个复制元素还是有些昂贵。传参时可以通过引用避免复制,可返回结果时,不能返回指向普通局部对象的引用:函数结束后,对象就已经销毁了,留下的只会是悬空引用。

比如,我们为 Vector 定义一个加法运算符,返回逐元素相加的结果:

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;
}

然后这样使用它:

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

x + y + z 会先计算 x + y,再把结果与 z 相加。如果结果传递都依赖深拷贝,大量元素就可能被反复复制。不过,这里编译器也可能对 return res; 进行具名返回值优化(NRVO),直接在结果位置构造对象,省掉一次拷贝或移动。

当构造无法被消除,或者需要像 r = ... 这样给已有对象赋值时,移动语义就派上用场了。对于即将结束使命的临时结果,我们更希望接管它的数组,而不是重新复制所有元素。

在 C++ 中,可以通过移动构造函数和移动赋值运算符表达这个意图。把前面的操作放在一起,Vector 的接口可以写成:

class Vector {
public:
    Vector() : elem{nullptr}, sz{0} {}   // 默认构造一个空 Vector
    explicit Vector(int s) : elem{new double[s]{}}, sz{s} {}
    ~Vector() { delete[] elem; }

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

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

    double& operator[](int i) { return elem[i]; }
    const double& operator[](int i) const { return elem[i]; }
    int size() const { return sz; }

private:
    double* elem;
    int sz;
};

默认构造的 Vector 是空容器;const 版本的下标运算符则让加法函数能够读取只读参数中的元素。

移动构造函数定义起来也很简单:

Vector::Vector(Vector&& a) noexcept
    : elem{a.elem},   // “抓取” a 的元素
      sz{a.sz}
{
    a.elem = nullptr; // 现在 a 没有元素
    a.sz = 0;
}

移动赋值还要先处理目标对象原有的资源:

Vector& Vector::operator=(Vector&& a) noexcept
{
    if (this != &a) {
        delete[] elem;
        elem = a.elem;
        sz = a.sz;
        a.elem = nullptr;
        a.sz = 0;
    }
    return *this;
}
  • Vector&& 中的 && 表示右值引用。右值常见于临时对象、按值返回对象的函数调用,以及 std::move 的结果。局部变量 res 的名字通常是左值表达式,但在 return res; 这样的返回场景中,可以触发隐式移动。
  • 这里的移动操作使用非 const 引用,因为接管数组后还需要修改源对象,把它的指针清空。noexcept 表示这些操作不抛出异常,也有利于标准库容器在迁移元素时选择移动。
  • 移动后的对象仍需能正常析构,也应支持重新赋值。这个 Vector 把源对象恢复为空容器状态,因此还可以正常调用 size() 等操作。
  • std::move() 本身不搬运资源,它把表达式转换成右值形式,让后续操作有机会调用移动构造或移动赋值。真正接管资源的,是我们上面定义的函数。

3. 资源管理

把构造、析构、拷贝和移动配合好,就能控制对象所拥有资源的生命周期。对于 Vector 这样的资源句柄,移动只需要转交指针和大小,不必逐个复制元素,也就能低成本地把资源交给另一个对象。

内存并不是唯一的资源。凡是需要获取,并在用完后释放的东西,都可以看作资源,例如锁、套接字、文件句柄和线程句柄。资源管理不仅要避免泄漏,也要及时释放:让不再需要的文件、内存或锁一直占着位置,同样会给程序带来负担。

C++ 的常见做法,是把资源交给对象管理,让资源的释放跟随所有者的析构,这就是 RAII(资源获取即初始化)。局部对象离开作用域时会自动析构,因异常进行栈展开时也一样。需要转交资源时,可以使用移动语义或 unique_ptr;确实需要共享所有权时,则可以使用 shared_ptr。

标准库中到处都能看到 RAII:string、vector、map 等管理内存,ifstream、ofstream 管理文件,lock_guard、unique_lock 管理锁,unique_ptr 和 shared_ptr 则管理动态对象的生命周期。

线程这里要留意一个区别:std::thread 如果在 joinable() 为真时析构,会调用 std::terminate(),需要在此前调用 join() 或 detach();C++20 的 std::jthread 则会在析构时自动请求停止并等待线程结束。

参考资料

  • Bjarne Stroustrup:《A Tour of C++》。
posted @ 2026-10-27 13:56  JaaaLmg  阅读(8)  评论(0)    收藏  举报