27 March 2023

C++ 核心指南目录

“Always initialize an object”

理由

避免设置前使用的错误。以及可能产生的未定义行为。避免难理解的复杂的初始化。使重构变得简单。

例子

void use(int arg)
{
    int i;   // bad: uninitialized variable
    // ...
    i = 7;   // initialize i
}

i = 7 并不是初始化 i ,而是赋值给 i 。在省略号部分,可能就会读取 i 的未初始化的值。

更好的代码:

void use(int arg)   // OK
{
    int i = 7;   // OK: initialized
    string s;    // OK: default initialized
    // ...
}

注意

“总是要初始化”的规则比“对象必须设值”的规则更强。后者相对宽松,但是会导致:

  • 难以阅读的代码
  • 让人们在过大的范围声明变量
  • 鼓励复杂的代码,导致逻辑bug
  • 妨碍重构

总是要初始化规则是一个代码风格规则,可提升可维护性。

例子

这个例子经常用于演示对稍微宽松一些的初始化规则的需求:

widget i;    // "widget" a type that's expensive to initialize,
             // possibly a large POD
widget j;

if (cond) {  // bad: i and j are initialized "late"
    i = f1();
    j = f2();
}
else {
    i = f3();
    j = f4();
}

以上代码不能很轻松地就重构,从而可以通过初始化操作来初始化 ij 。注意,如果一个类型有默认构造函数,那么,你尝试推出初始化,只会简单的导致先进行默认初始化,再进行一次赋值操作。以上这种写法的主流原因是“效率”。但是编译器其实能够检测到我们是否犯了“设置前使用”错误,也能避免多余的重复初始化。

假设 ij 之间有一些逻辑关联,那么这种关联可以通过代码表示:

pair<widget, widget> make_related_widgets(bool x)
{
    return (x) ? {f1(), f2()} : {f3(), f4()};
}

auto [i, j] = make_related_widgets(cond);    // C++17

如果说,你觉得 make_related_widgets 函数有些重复多余,我们也可以通过 lambda 省略之。

auto [i, j] = [x] { return (x) ? pair{f1(), f2()} : pair{f3(), f4()} }();
// C++17

利用某个特殊的值表示“未初始化”通常是一种问题的症状,而非解决方法:

widget i = uninit;  // bad
widget j = uninit;

// ...
use(i);         // possibly used before set
// ...

if (cond) {     // bad: i and j are initialized "late"
    i = f1();
    j = f2();
}
else {
    i = f3();
    j = f4();
}

这样写之后,编译器都无法简单的检测到“设置之前使用”这类错误。进一步来说,我们还在 widget 的状态空间引入了新的复杂性:哪些操作对未初始化的 widget 是合法的,哪些是不合法的?

多年以来,聪明的程序员流行使用复杂的初始化方法。然而,这往往是大部分代码错误和复杂的来源。很多这类错误会在代码实现之后若干年的维护过程中引入。

例如:

class X {
public:
    X(int i, int ci) : m2{i}, cm2{ci} {}
    // ...

private:
    int m1 = 7;
    int m2;
    int m3;

    const int cm1 = 7;
    const int cm2;
    const int cm3;
};

编译器会标记出未初始化的 cm3 ,因为这是个 const 成员变量。但是编译器不会发现 m3 缺少初始化。通常,偶尔的伪造的成员变量初始化是值得的。因为它可以避免缺少初始化而引起的错误。而且,编译器经常能够优化掉多余的初始化(比如,赋值操作前的一次初始化)。

例外

如果你声明一个以输入数据进行初始化的对象,那么在此之前进行初始化,会导致重复初始化。然而,必须注意,不要在输入数据之后,还存在未初始化的对象。那样会导致错误,以及安全性问题。

constexpr int max = 8 * 1024;
int buf[max];         // OK, but suspicious: uninitialized
f.read(buf, max);

初始化这个大数组可能在某些情况下比较消耗计算资源。但是,这个例子中,未初始化的变量可能被访问。所以千万要小心处理。

constexpr int max = 8 * 1024;
int buf[max] = {};   // zero all elements; better in some situations
f.read(buf, max);

因为对于数组和 std::array 的约束性初始化规则,所以有很多针对这类数据的例外情况。

但是,如果可以,尽量使用不会溢出的库函数。比如:

string s;   // s is default initialized to ""
cin >> s;   // s expands to hold the string

对于简单的变量,不要考虑套用这里提到的例外情况(通过输入数据初始化)。

int i;   // bad
// ...
cin >> i;

通常,输入操作和对象的操作可能会分开,就可能会出现“设置前使用”的情况。

int i2 = 0;   // better, assuming that zero is an acceptable value for i2
// ...
cin >> i2;

一个好的编译优化器应该能识别输入操作,避免多余的初始化操作。

注意

有时候,我们可以用 lambda 作为初始化工具,从而避免未初始化的变量:

error_code ec;
Value v = [&] {
    auto p = get_value();   // get_value() returns a pair<error_code, Value>
    ec = p.first;
    return p.second;
}();

或者,可能这样:

Value v = [] {
    auto p = get_value();   // get_value() returns a pair<error_code, Value>
    if (p.first) throw Bad_value{p.first};
    return p.second;
}();

强化

标记每一个未初始化的变量。不要标记有默认构造函数的用户类型。

确保未初始化的缓冲区在声明之后马上写入数据。以非 const 参数类型传递未初始化的变量,可以认为是对变量的一次写入操作。