5.2. 尊重顺序

在 BigGameDev 公司任职期间,Joe 又接到了一个与角色开发相关的任务(这里指的是游戏角色的开发,而非他个人的性格成长)。这个任务看起来非常简单:只需返回一个格式化字符串,用于显示角色当前的生命值(HP)。

为此,Joe 创建了如下类:

#include <string>
#include <format>
#include <iostream>
#include <string_view>
struct life_point_tracker {
  life_point_tracker(std::string_view player, int points) {
    m_player = player;
    m_points = points;
    m_result = std::format("{} has {} LPs",
    m_player, m_points);
  }
  std::string get_data() const {
    return m_result;
  }
private:
  std::string m_result {""};
  std::string m_player {""};
  int m_points {0};
};
int main() {
  life_point_tracker lpt("Joe", 120);
  std::cout << lpt.get_data();
}

这个实现非常直观:接收输入数据并将其存储为成员变量,以便后续访问。Joe 现在心情不错 —— 虽然经历了上一次因内存对齐问题引发的教训后,他已不再执着于严格的字母排序,但他这次按照类型大小合理地组织了类成员顺序。此外,他还使用了一些现代 C++ 特性,例如 std::format(或类似格式化库)以及成员变量的就地初始化。

虽然对于 std::string 类型来说,默认构造函数已经会将其初始化为空字符串,因此就地初始化并不是必须的,但对于像 int 这样的基本类型,这一特性确实带来了更清晰、安全的初始化逻辑。

Joe 几乎就要直接提交这段代码到主仓库了,但好在他理智占了上风。他先做了一个快速测试,确认功能正常,并请主管 Jimmy(就是前一节提到的那位“一眼看出问题”的技术高手)进行了代码审查。

代码看起来没有问题:能够顺利编译,功能也完全符合预期。但 Jimmy 提出了两个小建议,旨在进一步提升代码的质量与健壮性:

  • 应在构造函数的初始化列表中进行赋值,而非在构造函数体内赋值。
  • 既然使用了初始化列表,不妨将成员变量声明为 const,还可能为编译器提供额外的优化机会。

因此,修改后的代码应如下所示:

const std::string m_result {""};
const std::string m_player {""};
const int m_points {0};

在 C++ 中,使用构造函数的初始化列表相比在函数体内部通过赋值操作,具有以下几个关键优势:

  • 更高的效率:初始化列表直接在对象构造时完成成员变量的初始化,避免了先调用默认构造函数再执行赋值操作所带来的开销,尤其对复杂类型来说性能提升明显。
  • 语法必要性:对于 const 成员变量和引用类型成员,它们必须在初始化列表中完成初始化,因为它们进入构造函数体后就无法再被赋值。
  • 初始化顺序的明确性:成员变量总是按照其在类中声明的顺序进行初始化,无论初始化列表中的书写顺序如何。这有助于避免因依赖初始化顺序而引发的潜在错误。

Joe 欣然接受了这些改进,并愉快地修改了代码。由于改动看起来非常小,他甚至“不小心”跳过了测试环节,直接提交了如下修改内容:

life_point_tracker(std::string_view player, int points)
: m_player(player), m_points(points),
  m_result(std::format("{} has {} LPs", m_player, m_points)) {}

很快,他就收到了回复 —— 但出乎意料,并不是他原本期待的表扬。

Jimmy 在代码审查系统中留下了一句简短却意味深长的问题: “Joe,你测试过这段代码吗?”

Joe 不得不承认,他觉得没必要测试 —— 毕竟改动看起来微不足道:

  • 把几行赋值操作从构造函数体“上移”到了初始化列表;
  • 把等号 = 换成了括号 ();
  • 其他部分几乎没有任何变化。

“哦,我明白了……” Jimmy 若有所思地说道,随后从后裤袋掏出一份崭新的、还带着油墨味的 C++ 最新标准文档。

他在 [class.base.init] 章节中翻到一页关键内容,念道:

在非委托构造函数中,初始化按以下顺序执行:

首先(仅适用于最派生类的构造函数):

虚基类的初始化按照基类有向无环图中的深度优先、从左到右顺序进行。

这里的“从左到右”是指派生类的 base-specifier-list 中基类的实际声明顺序。

接着,直接基类的初始化:按照 base-specifier-list 中的声明顺序进行初始化,与初始化列表中书写顺序无关。

然后,是非静态数据成员的初始化:按照类定义中成员变量的声明顺序进行初始化,同样与初始化列表中它们的书写顺序无关。

最后,执行构造函数体中的复合语句。

实际运行时,无论你在初始化列表中如何调整成员变量的顺序,都会严格按照类定义中声明的顺序进行初始化。

如果某个成员变量在其初始化表达式中依赖了后续声明的成员变量,就会导致使用未初始化的数据 —— 这将引发未定义行为(Undefined Behavior)。轻则返回默认值,重则在某些环境下造成灾难性崩溃,尤其是在生产环境中才暴露问题。

现在,掌握了这些知识的 Joe 终于意识到自己之前的疏忽,并迅速修正了代码。他重新组织了成员变量的声明顺序,确保每个成员在其初始化时所依赖的变量已经完成初始化。

最终,他按时交付了一段既安全又符合最高编码标准的代码 —— 这次,不仅逻辑正确,也完全经得起编译器和时间的考验。

life_point_tracker(std::string_view player, int points)
try :
  m_result(std::format("{} has {} LPs", player, points)),
  m_player(player),
  m_points(points)
{
}
catch(...) {throw;}

他终于领悟到:虽然初始化列表在许多情况下是 C++ 中构造对象的“天赐良物”,能够带来更高的效率与更清晰的语义,但如果忽视了 C++ 标准中规定的基本规则,它也可能将代码引入编译器无法理解的“地狱深渊”。

C++ 标准明确要求,类成员变量必须按照它们在类中声明的顺序进行初始化,而不是依据初始化列表中的书写顺序。这一设计看似限制了灵活性,实则确保了对象构造过程的一致性与可预测性 —— 尤其是在没有使用初始化列表、或只初始化部分成员的情况下,这种固定的初始化顺序能够避免歧义和潜在错误。

更重要的是,这种初始化顺序直接影响了对象的析构顺序。C++ 规定,析构函数中成员变量的析构顺序与初始化顺序完全相反。这样的机制确保了一个重要的安全性保障:当某个成员在其析构过程中依赖另一个成员时,后者此时仍未被销毁,仍处于有效状态。这种一致的生命周期管理机制,有助于预防资源释放顺序不当导致的各类隐患,从而维护整个对象生命周期的完整性。

基于这一语言特性,结合现代 C++ 的发展,我们还可以利用 C++20 引入的“指定初始化器”(designated initializers) 特性,为这类问题提供更加优雅且简洁的解决方案。简化后的结构如下:

struct life_point_tracker {
  std::string get_data() const {
  return m_result;
  }
  std::string m_player {"Nameless"};
  int m_points {0};
  const std::string m_result
    {std::format("{} has {} LPs", m_player, m_points)};
};

这些简单结构满足作为聚合体(aggregate)的要求 —— 这是使用指定初始化器特性的前提条件。如你所见,m_result成员在自身构造时就能正确使用已初始化的m_player和m_points成员。在使用该类的场景中,我们只需这样做:

int main(int argc, char **argv) {
  life_point_tracker lpt {
    .m_player = "Joe",
    .m_points = 120
  };
  std::cout << lpt.get_data();
}

通过这一便捷的语言特性,我们可以显式指定每个成员的初始化值,这在需要初始化多个整型成员时尤其有用。该特性强制要求成员必须按照其声明顺序进行初始化,从而提升了代码的可读性与可维护性。

当然,这项特性也带来了一定限制:它要求类必须是一个聚合体(aggregate),所以不能有虚函数、自定义构造函数或任何封装机制,本质上是牺牲了 C++ 类的一些强大功能。

不过,既然 Joe 认为这种方式已经足够满足需求,我们也可以接受这种权衡。毕竟,在某些场景下,简洁和直观比复杂而强大更为重要。