每周技巧 #140:常量:安全惯用法

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #140: Constants: Safe Idioms

原文最初作为 TotW #140 发布于 2017 年 12 月 8 日。

作者:Matt Armstrong

更新于 2020 年 5 月 6 日。

快捷链接:abseil.io/tips/140

C++ 中表达常量的最佳方式是什么?你大概知道这个词在英语里的含义,但在代码里很容易把这个概念表达错。这里我们先定义几个关键概念,然后给出一组安全技术。对于好奇的读者,后面会更详细地讨论可能出错的地方,并描述一个让表达常量更容易的 C++17 语言特性。

“C++ 常量”没有正式定义,所以我们先提出一个非正式定义。

  1. 值: 值永远不会改变;五永远是五。想表达常量时,我们需要一个值,而且只需要一个。
  2. 对象: 在每个时间点,对象都有一个值。C++ 非常强调可变对象,但常量不允许被修改。
  3. 名称: 有名称的常量比裸字面量常量更有用。变量和函数都可以求值得到常量对象。

把这些合在一起,我们把常量称为总是求值为同一个值的变量或函数。还有几个关键概念。

  1. 安全初始化: 许多时候,常量会表达为静态存储中的值,这些值必须安全初始化。更多内容见 C++ 风格指南
  2. 链接性: 链接性关心程序中一个具名对象有多少个实例(或“副本”)。通常,一个名称对应的常量最好在程序中引用单个对象。对于全局或命名空间作用域变量,这需要所谓外部链接(这里可以阅读更多关于链接性的内容)。
  3. 编译期求值: 有时,如果常量值在编译期已知,编译器可以更好地优化代码。这个好处有时足以证明在头文件中定义常量值是合理的,尽管这会带来额外复杂性。

当我们说“添加一个常量”时,实际上是在以满足上述大多数或全部标准的方式声明一个 API,并定义它的实现。语言没有规定必须怎么做,而有些方式比其他方式更好。通常最简单的方法是声明一个 constconstexpr 变量;如果它在头文件中,则标记为 inline。另一种方法是从函数返回一个值,这更灵活。下面会覆盖这两类方法的例子。

关于 const 的一点说明:它不够。const 对象是只读的,但这并不意味着它不可变,也不意味着它的值总是相同。语言提供了一些修改我们认为是 const 的值的方式,例如 mutable 关键字和 const_cast。但即使是直接的代码也能说明这一点:

1
2
3
4
5
6
7
void f(const std::string& s) {
  const int size = s.size();
  std::cout << size << '\n';
}

f("");  // 打印 0
f("foo");  // 打印 3

在上面的代码中,sizeconst 变量,但随着程序运行,它持有多个值。它不是常量。

头文件中的常量

本节中的所有惯用法都健壮且值得推荐。

inline constexpr 变量

从 C++17 开始,变量可以标记为 inline,以确保变量只有一个副本。与 constexpr 一起使用以确保安全初始化和析构时,这提供了另一种定义常量的方式,并且其值可在编译期访问。更多信息见 技巧 #168

1
2
3
// in foo.h
inline constexpr int kMyNumber = 42;
inline constexpr absl::string_view kMyString = "Hello";

extern const 变量

1
2
3
4
// Declared in foo.h
extern const int kMyNumber;
extern const char kMyString[];
extern const absl::string_view kMyStringView;

上面的例子为每个对象声明一个实例。extern 关键字确保外部链接,而 const 关键字有助于防止意外修改值。这是一种不错的做法,不过它确实意味着编译器无法“看到”常量值。这会稍微限制它们的用途,但对典型用例来说通常无关紧要。它还要求在对应的 .cc 文件中定义这些变量。

1
2
3
4
// Defined in foo.cc
constexpr int kMyNumber = 42;
constexpr char kMyString[] = "Hello";
constexpr absl::string_view kMyStringView = "Hello";

constexpr 关键字确保每个变量都是常量、进行编译期初始化,并且有平凡析构函数。这是一种方便方式,可确保它满足风格指南中关于全局变量的规则

除非你需要支持旧工具链,否则应该在 .cc 文件中用 constexpr 定义变量。

注意:absl::string_view 是声明字符串常量的好方式。该类型有 constexpr 构造函数和平凡析构函数,因此可以安全地把其实例声明为全局变量。由于 string_view 知道自身长度,使用它不需要运行时调用 strlen()

constexpr 函数

不接收实参的 constexpr 函数总是返回同一个值,因此它可以充当常量,并且常常可用于在编译期初始化其他常量。由于所有 constexpr 函数都隐式为 inline,不存在链接性问题。这种方式的主要缺点是 constexpr 函数中的代码受到限制。其次,constexpr 是 API 契约中一个非平凡方面,会产生真实后果。

1
2
// in foo.h
constexpr int MyNumber() { return 42; }

普通函数

constexpr 函数不理想或不可行时,普通函数可能是一种选择。下面示例中的函数不能是 constexpr,因为它有一个静态变量:

1
2
3
4
inline absl::string_view MyString() {
  static constexpr char kHello[] = "Hello";
  return kHello;
}

注意:返回数组数据时,例如 char[] 字符串、absl::string_viewabsl::Span 等,请确保使用 static constexpr 说明符,以避免微妙 bug

static 类成员

如果你本来就在处理一个类,类的静态成员是不错的选择。它们总是具有外部链接。

1
2
3
4
5
6
7
// Declared in foo.h
class Foo {
 public:
  static constexpr int kMyNumber = 42;
  static constexpr absl::string_view kMyHello1 = "Hello";
  static constexpr char kMyHello2[] = "Hello";  // 不再推荐
};

C++17 之前,还必须在 .cc 文件中为这些 static 数据成员提供定义;但对于同时是 staticconstexpr 的数据成员,现在这些定义已经不必要(并且已废弃)。

1
2
3
4
// Defined in foo.cc, prior to C++17.
constexpr int Foo::kMyNumber;
constexpr absl::string_view Foo::kMyHello1;
constexpr char Foo::kMyHello2[];

仅仅为了给一组常量充当作用域而引入一个类并不值得。请改用其他技术之一。

不鼓励的替代方案

1
#define WHATEVER_VALUE 42

使用预处理器很少有正当理由,见风格指南

1
enum : int { kMyNumber = 42 };

上面使用的 enum 技术在某些场景下可以成立。它会生成一个不会导致本技巧所讨论问题的常量 kMyNumber。但前面列出的替代方案对大多数人来说更熟悉,因此通常更推荐。只有当 enum 本身有意义时才使用它(示例见 技巧 #86“用类枚举”)。

源文件中可用的方法

上面描述的所有方法在单个 .cc 文件中也能工作,但可能不必要地复杂。由于源文件内声明的常量默认只在该文件内可见(见内部链接规则),更简单的方法通常也能工作,例如定义 constexpr 变量:

1
2
3
4
// within a .cc file!
constexpr int kBufferSize = 42;
constexpr char kBufferName[] = "example";
constexpr absl::string_view kOtherBufferName = "other example";

上面这种写法在 .cc 文件中没问题,但在头文件中不行(见注意事项)。请再读一遍并记住它。稍后我会解释原因。长话短说:在 .cc 文件中把变量定义为 constexpr,或在头文件中把它们声明为 extern const

在头文件中要小心!

除非你谨慎使用前面解释的惯用法,否则 constconstexpr 对象很可能在每个翻译单元中都是不同对象。

这意味着:

  1. Bug:任何使用常量地址的代码都可能出现 bug,甚至出现可怕的未定义行为
  2. 膨胀:每个包含你的头文件的翻译单元都会得到自己的那份东西。对基本数值类型这类简单东西来说问题不大。对字符串和更大的数据结构就没那么好。

在命名空间作用域(也就是不在函数或类中)时,constconstexpr 对象都隐式具有内部链接(和匿名命名空间变量、非函数/非类中的 static 变量使用相同链接性)。C++ 标准保证,使用或引用该对象的每个翻译单元都会得到该对象的不同“副本”或“实例”,并且每个副本位于不同地址

在类中,还必须把这些对象声明为 static;否则它们会成为不可变的实例变量,而不是所有类实例共享的不可变类变量。

同样,在函数中,必须把这些对象声明为 static;否则它们会占用栈空间,并且每次调用函数都会构造。

示例 bug

那么,这真有风险吗?考虑:

1
2
3
4
5
// Declared in do_something.h
constexpr char kSpecial[] = "special";

// Does something.  Pass kSpecial and it will do something special.
void DoSomething(const char* value);
1
2
3
4
5
6
7
8
9
// Defined in do_something.cc
void DoSomething(const char* value) {
  // Treat pointer equality to kSpecial as a sentinel.
  if (value == kSpecial) {
    // do something special
  } else {
    // do something boring
  }
}

注意,这段代码把 kSpecial 中第一个字符的地址与 value 比较,把它当作这个函数的一种魔法值。有时你会看到代码这么做,试图短路完整字符串比较。

这会导致一个微妙 bug。kSpecial 数组是 constexpr,这意味着它是 static(具有“内部”链接)。即使我们把 kSpecial 想成“一个常量”,它其实并不是;它是一整个常量家族,每个翻译单元一个!对 DoSomething(kSpecial) 的调用看起来应该做同样的事情,但这个函数会根据调用发生的位置走不同代码路径。

任何使用头文件中定义的常量数组的代码,或者任何取得头文件中定义常量地址的代码,都足以导致这类 bug。这类 bug 通常出现在字符串常量上,因为字符串常量是最常见的在头文件中定义数组的原因。

未定义行为示例

稍微调整上面的例子,把 DoSomething 移到头文件中作为 inline 函数。好了:现在我们有了未定义行为,也就是 UB。语言要求所有 inline 函数在每个翻译单元(源文件)中的定义完全相同,这是语言“一处定义规则”的一部分。这个特定的 DoSomething 实现引用了一个静态变量,所以每个翻译单元实际上都以不同方式定义了 DoSomething,因此产生未定义行为。

程序代码和编译器中的无关改动可能改变内联决策,这会让这类未定义行为从良性行为变成 bug。

实践中会造成问题吗?

会。在我们遇到的一个真实 bug 中,编译器能够确定:在某个特定翻译单元(源文件)中,头文件里定义的一个大型 static const 数组只被部分使用。因此它没有发出整个数组,而是优化掉了它知道未被使用的部分。该数组被部分使用的一种方式,是通过头文件中声明的一个内联函数。

麻烦在于,其他翻译单元以完整使用这个 static const 数组的方式使用了它。对这些翻译单元,编译器生成了使用完整数组的内联函数版本。

然后链接器来了。链接器假设内联函数的所有实例都是相同的,因为一处定义规则说它们必须相同。于是它丢弃了除一个副本之外的所有函数副本,而留下来的正是使用部分优化数组的那个副本。

当代码以需要知道变量地址的方式使用变量时,就可能出现这类 bug。其技术术语是“ODR 使用”。在现代 C++ 程序中,很难阻止变量被 ODR 使用,特别是当这些值传给模板函数时(上面的例子就是这种情况)。

这些 bug 确实会发生,而且不容易在测试或代码评审中捕捉到。定义常量时坚持使用安全惯用法是值得的。

其他常见错误

错误 #1:非常量的常量

最常见于指针:

1
2
const char* kStr = ...;
const Thing* kFoo = ...;

上面的 kFoo 是指向 const 的指针,但指针本身不是常量。你可以给它赋值、把它设为 null 等等。

1
2
3
4
// Corrected.
const Thing* const kFoo = ...;
// This works too.
constexpr const Thing* kFoo = ...;

错误 #2:非常量的 MyString()

考虑这段代码:

1
2
3
inline absl::string_view MyString() {
  return "Hello";  // 每次调用都可能返回不同值
}

字符串字面量常量的地址允许在每次求值时改变1,所以上面写法有微妙错误,因为它返回的 string_view 在每次调用时可能有不同的 .data() 值。虽然在许多情况下这不是问题,但它可能导致前面描述的 bug

MyString() 设为 constexpr 并不能修复这个问题,因为语言标准并没有说它会修复2。一种看法是,constexpr 函数只是一个在初始化常量值时允许在编译期执行的 inline 函数。在运行时,它和 inline 函数没有区别。

1
2
3
constexpr absl::string_view MyString() {
  return "Hello";  // 每次调用都可能返回不同值
}

为避免这个 bug,请改用函数局部 static constexpr 变量:

1
2
3
4
inline absl::string_view MyString() {
  static constexpr char kHello[] = "Hello";
  return kHello;
}

经验法则:如果你的“常量”是数组类型,在返回它之前,把它存储在函数局部 static 中。这能固定它的地址。

错误 #3:不可移植代码

对于头文件中声明的 extern const 变量,下面这种定义其值的方法根据 C++ 标准是有效的,并且通常比 C++20 的 constinit(或更早的 ABSL_CONST_INIT)更可取,但它会撞上至少一个常见编译器的 bug:

1
2
// Defined in foo.cc -- valid C++, but not supported by MSVC 19 by default.
constexpr absl::string_view kOtherBufferName = "other example";

遗憾的是,除非使用 /Zc:externConstexpr 选项,否则 MSVC++19 会错误地对这段代码给出 C2370 错误。如果代码需要用 MSVC++19 编译,并且不能依赖 /Zc:externConstexpr,一种变通方法是通过函数而不是全局变量把它的值提供给其他文件。

错误 #4:初始化不当的常量

风格指南中有一些详细规则,旨在让我们避开与静态和全局变量运行时初始化相关的常见问题。根本问题出现在全局变量 X 的初始化引用另一个全局变量 Y 时。我们怎么能确定 Y 本身不会以某种方式依赖 X 的值?循环初始化依赖很容易在全局变量中发生,尤其是那些我们认为是常量的全局变量。

这本身就是语言中相当棘手的领域。风格指南是权威参考。

请把上面的链接视为必读材料。聚焦于常量初始化时,可以把初始化阶段解释为:

  1. 零初始化。这会把原本未初始化的静态变量初始化为该类型的“零”值(例如 00.0'\0'、null 等)。

    1
    2
    
    const int kZero;  // 这会被零初始化为 0
    const int kLotsOfZeroes[5000];  // 这些也一样
    

    注意,依赖零初始化在 C 代码中相当常见,但在 C++ 中比较少见也比较小众。通常显式给变量赋值更清楚,即使这个值是零。这就引出了……

  2. 常量初始化

    1
    2
    
    const int kZero = 0;  // 这会被常量初始化为 0
    const int kOne = 1;   // 这会被常量初始化为 1
    

    “常量初始化”和“零初始化”在 C++ 语言标准中都称为“静态初始化”。两者总是安全的。

  3. 动态初始化

    1
    2
    
    // 这会在运行时动态初始化为 ArbitraryFunction 返回的任何值。
    const int kArbitrary = ArbitraryFunction();
    

    动态初始化是大多数问题发生的地方。风格指南解释了原因:https://google.github.io/styleguide/cppguide.html#Static_and_Global_Variables

    注意,像 Google C++ 风格指南这样的文档历史上曾把动态初始化纳入广义的“静态初始化”类别。static 这个词在 C++ 中适用于几个不同概念,可能令人困惑。“静态初始化”可以表示“对静态变量的初始化”,这可能包括运行时计算(动态初始化)。语言标准使用的“静态初始化”则是另一个更窄的含义:静态完成或在编译期完成的初始化。

初始化速查表

下面是一份超简短的常量初始化速查表(不适用于头文件):

  1. constexpr 保证安全的常量初始化,以及安全的(平凡)析构。任何 constexpr 变量在 .cc 文件中定义都完全没问题,但在头文件中会因为前面解释的原因而有问题。
  2. constinit(C++20 之前是 ABSL_CONST_INIT)保证安全的常量初始化。与 constexpr 不同,它实际上不会让变量成为 const,也不确保析构函数平凡,因此用它声明静态变量时仍需小心。再次参见 https://google.github.io/styleguide/cppguide.html#Static_and_Global_Variables
  3. 否则,最可能的最佳做法是在函数中使用静态变量并返回它。见前面展示的“普通函数”示例。

延伸阅读和链接汇总

结论

C++17 的 inline 变量真是来得越早越好。在此之前,我们能做的就是使用安全惯用法,让自己避开那些粗糙边缘。

每周技巧 #136:无序容器

上一节

每周技巧 #141:小心到 `bool` 的隐式转换

下一节

  1. 根据 C++17 语言标准 [lex.string] 中的如下文字,我们得出结论:字符串字面量不要求求值得到同一个对象。C++11 和 C++14 中也有等价文字。 ↩︎

  2. [lex.string] 中没有描述 constexpr 上下文里有不同行为的文字。 ↩︎