赋值与初始化

我们在这里停一下。或许读者已经注意到了,复制构造和复制赋值长得非常像:

class T {
public:
    // 典型的复制构造函数
    T(const T&);
    // 典型的复制赋值重载
    T& operator=(const T&);
};

而且它们做的事情也非常相似。在我们的 String 类中,它们的主要部分都是:

  • 新分配一片空间,大小取自参数;
  • 将参数的内容复制到自己。

而复制赋值重载只需要多做一件事情:检测自赋值以及释放旧空间。

最后,它们两者都有可能会被预置地生成。当我们没有定义复制构造函数时,编译器生成预置复制构造;当我们没有定义复制赋值重载,编译器生成一个赋值运算符重载。所以为了辨析赋值和初始化这两个概念,我在这里特意单独开出一节来做稍微详细的讲解。

本质上的区别

初始化是一个从零到一的过程,而赋值是从一到一的过程。

形象地讲:如果我手里没有苹果,初始化之后我将有一个苹果;如果我手里有一个梨,赋值之后得到了一个苹果。

也就是说,当执行初始化的时候,是凭空地产生一个对象,这个过程不会发生类似“删除”的事情;而赋值则是将一个对象的内容进行整体的替换,而这个变量原来的内容就丢失了。

int main() {
    int a{42}; // 初始化,我就直接生成一个值为 42 的变量

    int b;
    b = 42;    // 赋值,原来 b 有一个自己的值,我不要了,给它换成 42
}

尽管这两个行为它最后的效果都是得到了新的值,但赋值需要有一个“抛弃旧值”的过程。这也就是为什么 Stringoperator= 需要一个 delete 的操作;而这个“抛弃旧值”的操作如果处理不当也会带来自赋值这个潜在的问题。

什么时候干什么事?

当声明并定义对象的时候,发生初始化;当执行赋值表达式时,发生赋值。

初始化的时候,调用构造函数(自己定义的或预置的);执行赋值表达式时,调用赋值运算符重载(自己定义的或预置的)。

唯一需要注意的就是,接受函数的参数以及函数的返回值时,这个对象也会被初始化,而不是赋值——因为它们属于“凭空诞生”而非“替换”得到的。

类的构造细节

当我们想要自己定义一个构造函数的时候,我们可能会去做一些成员的“初始化”工作,比如:

struct A {
    int a, b;
    int sum;
    A(int x, int y) {
        a = x;
        b = y;
        sum = x + y;
    }
};

如果这种情形下你想要一个和成员名相同的参数名,可以通过运用 this 指针来解决:

struct A {
    int a, b;
    int sum;
    A(int a, int b) {
        this->a = a;
        this->b = b;
        this->sum = a + b;
    }
};

那么你会发现,这里我们对成员 a b sum 做的并不是初始化而是赋值。所以这里我们就要介绍一下一个类类型对象时如何初始化的。在一般的情况下:

  • 如果某个成员在 初始化列表 中提及,则按 初始化列表 中的初始化值初始化这个成员;
  • 如果某个成员提供了 默认初始化器,则按 默认初始化器 的初始化值初始化这个成员;
  • 如果某个成员不满足上述两条件,则默认初始化它;
  • 当成员全部初始化完毕后,调用构造函数。

暂且先不管那些术语是什么意思,但你总可以看到,成员的初始化是在调用构造函数之前。所以在调用构造函数的那一刻起,每一个成员都已经有了自己的值,而我们的赋值则是“替换旧值”。很显然,这是比较浪费的:有许多成员的初始化是一个徒劳的过程(不久后就在构造函数里面被替换掉了)。所以我们需要详细展开前三步的内容。

成员初始化列表

构造函数提供了一种特殊的语法:

struct A {
    int a, b;
    int sum;
    A(int x, int y): a{x}, b{y}, sum{x + y} {
        // 语句...
    }
};

注意构造函数参数列表和函数体之间的内容,长成这个样子(不要忘记最开头的冒号):

: 成员名1 初始化器1, 成员名2 初始化器2 ...

这个就是成员初始化列表了。它的意思是,如果在构造整个 A 类时匹配上了目前这个构造函数,则执行成员初始化列表中的初始化:用 初始化器1 去初始化 成员名1,用 初始化器2 去初始化 成员名2 …… 这样一直做下去。这里,初始化器可以从参数中获取,所以我们可以利用这种方式来初始化成员而非之后在构造函数函数体中赋值成员。

当然,我这里只演示了初始化列表里的大括号初始化器;如果这个成员是一个类类型的话,你也可以用小括号初始化器(来调用这个成员对应的构造函数)。

默认成员初始化器

如果成员初始化列表中没有提供某个成员的初始化方法,则编译器会看看有没有默认成员初始化器。说来复杂其实很简单,就是这个:

#include <iostream>
struct A {
    int a{42}; // {42} 是默认成员初始化器
    int b{56}; // {56} 也是
    int arr[3]{1, 2, 3}; // 数组也可以
    A() {} // 默认构造函数什么都不做
};
int main() {
    A sth; // 调用默认构造,但成员按照默认初始化器初始化
    std::cout << sth.a << std::endl; // 42
}

所以它看上去非常直观,不用过多讲解。但是需要注意的是,默认成员初始化器只能用大括号(这是为了防止歧义而规定的)。

最后……

然后如果还找不到某一个成员的初始化方法,则默认初始化:简单类型什么都不做,类类型调用默认构造函数。

了解这些之后,就能知道预置复制构造的真正写法实际上是拥有一个长长的初始化列表,每一个成员都是复制初始化的:

struct B { /* 定义略 */ };
struct A {
    int a;
    B b;
    // 编译器生成的预置复制构造
    A(const& A rhs): a{rhs.a}, b(rhs.b) /* ... 复制初始化其它成员 */ { }
    // 编译器生成的预置默认构造
    A() {}
};

而预置默认构造不包含初始化列表,所以它直接就去尝试默认初始化器;如果没有就执行默认初始化。

初始化每个成员的顺序是按照成员列表的顺序执行的(即定义在最“上面”的最先被构造),与初始化列表的书写顺序无关。

析构顺序

类的析构顺序一般和其构造顺序是相反的。这里,当一个类的生存期结束时,会顺序执行这些操作:

  1. 调用析构函数;
  2. 逆序析构每个成员。

与初始化过程恰好相反,析构函数会在最初被构造,然后逐一析构每个成员。这里,析构的顺序和成员列表的顺序相反(写在最“下面”的最先被析构)。

最近更新:
代码未运行