Google C++每周贴士 #140: 常量:安全用法

本文探讨了C++中正确表达常量的最佳实践,包括如何定义安全的编译期常量,避免链接性问题,以及如何防止常量在不同编译单元中产生不同的对象。同时介绍了C++17新特性带来的便利。

(原文链接:https://abseil.io/tips/140 译者:clangpp@gmail.com)

每周贴士 #140: 常量:安全用法

C++里表达常量最好的方式是什么?你也许知道这个词儿在英语里是什么意思,但在代码里却很容易被错误地表达。这里我们先定义几个核心概念,然后探讨一系列安全技巧。如果你还好奇的话,我们会接着聊聊容易弄错的情况细节,然后介绍一个更容易表达常量的C++17的特性。

“C++常量”没有正式定义,所以咱给他来个非正式定义。

  1. 值: 数值永远不变;五永远是五。当我们需要一个常量的时候,我们需要一个值,但也只要一个。
  2. 对象: 任一时刻下,一个对象都有一个值。C++特别强调了可修改对象,但不允许修改常量。
  3. 命名: 命名常量比纯粹的字面常量更有用。变量和函数都可以得出常量对象。

归了包堆,咱可以把常量定义为总得出同一值的变量或函数。下面还有些其他关键概念:

  1. 安全初始化: 很多时候常量被表示为静态存储空间里的值,其必须被安全地初始化。更多内容,请参考the C++ Style Guide
  2. 链接性: 链接性关注程序中一个命名对象有多少份实例(或“拷贝”)。在程序中,一个常量通常最好只对应一个对象。关于全局或命名空间变量,其需要被称作外部链接(external linkage)的东西(关于链接你可以在这里了解更多)。
  3. 编译期求值: 如果常量的值在编译期已知,有时候编译器就可以更好地优化代码。这个好处有时候可以作为头文件中定义常量的理由,即使这回带来额外的复杂性。

当我们说到“添加一个常量”的时候,我们实际上是在“声明”一个API,然后以满足上述大部分条件的方式“实现”它。语言没有规定我们要怎么做这件事,而且有些方式比其他方式更好。通常最简单的方式是声明一个const或者constexpr变量,如果在头文件里还要标记上inline。另一种方式是在一个函数里返回一个值,这样更灵活。我们会对两种方式都举例说明。

关于const的一个注解:不够。一个const对象是只读的,但这不代表它是不可修改的,也不意味着它的值保持不变。语言提供了方法来改变我们以为是const的值,例如mutable关键字和const_cast。但就算是直白的代码也可以证明这点:

void f(const std::string& s) {
  const int size = s.size();
  std::cout << size << '\n';
}

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

在上述代码中size是一个const变量,然而在程序运行过程中它有多个值。它可不是常量。

头文件中的常量

这一节中的用法都是鲁棒的和推荐的。

inline constexpr变量

从C++17开始变量可以被标记为inline了(译者注:以前只有函数可以),确保这个变量只有一份拷贝。和constexpr一起用以保证安全地初始化和析构,这样就提供了另一种方式来定义编译期常量。

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

extern const变量

// foo.h中声明
ABSL_CONST_INIT extern const int kMyNumber;
ABSL_CONST_INIT extern const char kMyString[];
ABSL_CONST_INIT extern const absl::string_view kMyStringView;

上面的例子中为每一个对象声明一个实例。extern关键字保证了外部链接性。const关键字帮助避免了对该值的意外改变。这方法也不错,虽然它意味着编译器“看”不到常量的值。这某种程度上限制了他们的适用范围,但不影响通常的使用场景。它还要求在相关的.cc文件中定义这些变量。

// foo.cc中定义
const int kMyNumber = 42;
const char kMyString[] = "Hello";
const absl::string_view kMyStringView = "Hello";

ABSL_CONST_INIT宏保证了每个常量都是编译期初始化的,但也仅限于此。它 不会 把变量变成const,也 不会 禁止声明拥有非朴素(non-trivial)析构函数的变量,即使它们违反代码规范。关于该宏请参考风格指南

你也许会想要在.cc文件中以constexpr定义这些变量,但这在目前不是一个可移植的方式(见下文“不可移植的代码”)。

注解:absl::string_view是声明字符串常量的好方式。该类型有个constexpr的构造函数和朴素析构函数,所以将其声明为全局变量是安全的。字符串视图(string view)知道自己的长度,所以使用他们不需要运行时的strlen()调用。

constexpr函数

一个没有参数的constexpr函数总是返回相同的值,因此它在功能上是一个常量,而且常常可以被用来在编译期初始化其他常量。因为所有的constexpr函数都被隐式标记为inline,所以没有链接性的后顾之忧。这种方式最大的劣势是对constexpr函数体代码的限制。其次,constexpr是API协议中不可忽略的方面,会带来真实的后果。

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

普通函数

如果constexpr函数不必要或不可行,普通函数也许可以作为一个选项。下面例子中的函数不能是constexpr,因为它有一个静态变量:

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

注解:在返回数组数据的时候,请确保使用static constexpr标识符,例如char[]字符串,absl::string_viewabsl::Span等等,以避免坑爹的问题

static类成员

如果你正在使用一个类,类的静态成员是个好的选项。其总是拥有外部链接性。

// foo.h中声明
class Foo {
 public:
  static constexpr int kMyNumber = 42;
  static constexpr char kMyHello[] = "Hello";
};

在C++17以前,在.cc文件中提供这些static数据成员的定义是必要的。但现在(译者注:C++17及以后)对于既是static又是constexpr的数据成员,这变得不必要(且被淘汰)了。

// foo.cc中定义,C++17以前
constexpr int Foo::kMyNumber;
constexpr char Foo::kMyHello[];

如果引入一个类仅仅是为了给一坨常量提供作用域,那完全不值得。请考虑使用其他的技巧。

不建议的备选方案

#define WHATEVER_VALUE 42

使用预编译基本都不对劲儿,请参考风格指南

enum : int { kMyNumber = 42 };

以上使用的枚举技巧在有些情况下是适当的。它产生了一个常量kMyNumber,且不会产生此贴士中讨论到的问题。但是早前列出来的备选方案对大多数人来说更熟悉,因此也更推荐。请在使用枚举本身更合理的地方使用enum(在 Tip #86 "Enumerating with Class"中可以找到例子)。

源文件中可用的方式

以上描述的所有方式也都可以在单个.cc文件中使用,但可能引入不必要的复杂性。因为源文件中声明的变量默认只在该文件中可见(参考内部链接规则),那些更简单的方式,例如定义constexpr变量,通常就够了:

// 在同一个.cc文件之内!
constexpr int kBufferSize = 42;
constexpr char kBufferName[] = "example";
constexpr absl::string_view kOtherBufferName = "other example";

以上在.cc文件中可以,但是在头文件中不行(参考)。再念一遍,记在脑子里。我很快会解释原因。长话短说:在.cc文件中以constexpr定义变量,或在头文件中以extern const声明变量。

头文件里,多加小心!

除非你认真地运用上述的用法,否则constconstexpr对象在每个编译单元内很可能是不同的对象。

这意味着:

  1. 漏洞(bugs):任何使用了常量的地址的代码,都可能有漏洞,甚至是可怕的“未定义行为”。
  2. 爆炸(bloat):每个引用了你头文件的编译单元都有一份自己的拷贝。对原始数字类型之类的简单玩意儿问题不大。对字符串和更大的数据结构问题很大。

在命名空间作用域下(也就是说,不在函数里,也不在类里),constconstexpr对象都隐式地拥有内部链接性(跟那些既不在函数里又不在类里的匿名命名空间变量和static变量一样)。C++标准保证,在每个使用和引用了这些对象的编译单元中,都有一份该对象不同的“拷贝”或“实例”,分别有不同的地址

在类里,你必须额外地将这些对象声明为static,否则他们就会是不可修改的实例变量,而不是不可修改的类变量,后者为该类的所有实例共享。(译者注:实例变量,N个对象有N个该变量(数据成员);类变量:N个对象有1个该变量(静态数据成员))

类似地,在函数里,你必须将这些对象声明为static,否则他们就会占用栈空间,且每次函数被调用时都被构造一次。(译者注:静态变量储存在静态数据区,不占用栈空间)

漏洞举例

那么,这是真实的风险吗?考虑:

// do_something.h中声明
constexpr char kSpecial[] = "special";

// 干点嘛。传入kSpecial的时候,它就干点特别的事儿。
void DoSomething(const char* value);
// do_something.cc中定义
void DoSomething(const char* value) {
  // 地址等于kSpecial作为触发条件。
  if (value == kSpecial) {
    // 做特别的事
  } else {
    // 做没劲的事
  }
}

请注意这段代码比较了kSpecial的首字符地址和value,作为该函数中的一种魔法值(magic value)。有时候你会看到类似的代码,以节约全字符串比较的代价。

这导致了一个讨厌的漏洞。kSpecial数组是constexpr,意味着它是(具有“内部”链接性的)static。虽然我们以为kSpecial是“一个常量”——但它实际上不是——它是一整族常量,每个编译单元都有一个!对DoSomething(kSpecial)的调用看起来都在做相同的事情,但函数根据调用点的不同会选择不同的分支。

任何使用了定义在头文件中的常量数组的代码,或者获取了定义在头文件中的常量的地址的代码,都面临这类漏洞。这种漏洞通常见于字符串常量,因为它们是在头文件中定义数组的最常见的原因。

未定义行为举例

稍微改改上面的例子,把DoSomething挪到头文件中作为inline函数。哦吼:现在我们得到了一个未定义行为(undefined behavior),简称UB。语言要求所有的inline函数在每个编译单元(源文件)中都以同样的方式被定义——这是语言“单定义规则(One Definition Rule)”的一部分。这个特定的DoSomething实现引用了一个静态变量,所以每个编译单元实际上是以不同的方式定义DoSomething,也就导致了未定义行为。

程序代码或编译器中与此无关的修改有可能改变内联决定(译者注:内联是程序员给编译器的建议,编译器可以自行决定要不要真的内联),也就导致这里的未定义行为既可能是良好的行为又可能是漏洞。

实践中会导致问题吗?

会。在一个我们真的遇到的漏洞中,编译器能够判定在一个编译单元(源文件)里,一个定义在头文件中的很大的静态常量数组只是部分地被用到了。与其产生出整个数组,编译器优化掉了它知道没有用到的那部分。这个数组被部分使用的一条路径是,在一个头文件中定义的一个内联函数。

麻烦来了,在另一些编译单元里,这个静态常量数组被完整地使用了。对于这些编译单元,编译器生成了另一版本的该内联函数,它们使用了整个数组。

然后链接器出场了。链接器假定该内联函数的所有实例都相同,因为单定义原则说它们得这样。然后它就丢弃了所有其他实例,只留下一份拷贝——那个被部分优化没了的数组。

当代码需要一个变量的地址可知的时候,这类漏洞就可能出现。这事儿的专业说法叫“ODR used”(译者注:单定义原则使用)。现代C++程序中很难避免变量的ODR使用,尤其是这些值被传递给模板函数的时候(就像上述例子中一样)。

这些漏洞确实会发生,而且不容易通过测试或代码审查发现。这算是交学费了,教会我们定义常量时要坚持安全用法。

其他常见错误

错误#1:非常量的常量

最常见于指针:

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

上面的kFoo是一个指向常量的指针(译者注:习惯称为常指针),但是指针本身不是常量。你可以为其赋值,将其设置为空,等等。

// 改正后。
const Thing* const kFoo = ...;
// 这也行。
constexpr const Thing* kFoo = ...;

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

考虑如下代码:

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

字符串字面值的地址,被允许在每次求值时都改变1,所以上面的代码有微妙的错误,因为在每次调用中,其返回的string_view都有可能有不同的.data()值。虽然在大多数情况下这也没什么问题,但它会导致上面所述的漏洞(见“漏洞举例”)。

MyString()标识为constexpr也没用,因为语言没说它有用2。一种理解方式是,constexpr函数只是在被用来初始化常量的时候,允许在编译期执行的inline函数。在运行期它和inline函数没什么两样。

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

为了避免这个漏洞,请在函数中使用static constexpr变量:

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

经验法则:如果你的“常量”是个数组类型,那请在返回之前将其存储为函数本地的静态变量。这样就锁死了它的地址。

错误#3:不可移植的代码

一些现代C++特性尚未被一些主流编译器支持。

  1. 在Clang和GCC中,上面例子中MyString函数里的static constexpr char kHello[]数组,可以写作static constexpr absl::string_view。但这在微软的Visual Studio中编译不过。如果需要考虑可移植性,那么在C++17的std::string_view可用以前,避免使用constexpr absl::string_view

    inline absl::string_view MyString() {
      // Visual Studio拒绝编译之。
      static constexpr absl::string_view kHello = "Hello";
      return kHello;
    }
    
  2. 对于头文件中声明的extern const(见“extern const变量”),根据C++标准,以如下的方式定义它们的值是合法的,且比ABSL_CONST_INIT更推荐使用,但是尚未被一些编译器支持。

    // foo.cc中定义——合法的C++,但不被MSVC 19支持。
    constexpr absl::string_view kOtherBufferName = "other example";
    

    作为.cc文件中constexpr变量的变通方法,你可以以函数将该值提供给其他文件。

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

为了避免常见的对静态或全局变量在运行时初始化的问题,编程风格指南里有详尽的规则。如果全局变量X初始化时引用了另一个全局变量Y,根本问题就出现了。我们怎么知道Y本身不会以某种方式依赖于X的值?循环初始化依赖在全局变量中间很容易发生,尤其是那些我们以为是常量的全局变量。

这货在语言中也是个棘手的领域。风格指南是一个权威的参考。

请把上面的链接当做必读项。专注于常量初始化,初始化的部分可以被解释为:

  1. 零值初始化。也就是把未初始化的变量初始化为对应类型的“零”值(例如00.0'\0',null,等等)。

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

    需要注意的是,C语言中很流行依赖零值初始化,但C++中却很小众。通常情况下为变量显式赋值会更清楚,即使该值是0也一样,这也就引入了……

  2. 常量初始化

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

    “常量初始化”和“零值初始化”在C++标准中都被称作“静态初始化”。它俩都总是安全的。

  3. 动态初始化

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

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

    注意一些如谷歌C++风格指南的文档,因为历史原因,将动态初始化包含在了宽泛的“静态初始化”分类里。“静态”一词对应了C++中一些不同的概念,有时候容易懵圈。“静态初始化”可以表示“静态变量初始化”,这就可以包括运行时的计算(动态初始化)。语言标准将术语“静态初始化”用于一个不同的,更窄的场景:静态地或编译期的初始化。

初始化速查表

这是一个超级快的常量初始化速查表(不在头文件中):

  1. constexpr既确保了安全的常量初始化,又确保了安全(朴素)的析构。任何定义在.cc文件中的constexpr变量都完全没问题,但在头文件中会有问题(原因前面解释了)。
  2. ABSL_CONST_INIT确保了安全的常量初始化。与constexpr不同,它不是真的让变量成为const,也不保证析构函数是朴素的,所以用其声明静态变量的时候要多加小心。也请参考https://google.github.io/styleguide/cppguide.html#Static_and_Global_Variables。
  3. 其他情况下,最好使用函数内的静态变量并返回之。参考http://go/cppprimer#static_initialization,以及稍早前的例子“普通函数”。

延伸阅读和链接集合

  • https://google.github.io/styleguide/cppguide.html#Static_and_Global_Variables
  • http://en.cppreference.com/w/cpp/language/constexpr
  • http://en.cppreference.com/w/cpp/language/inline
  • http://en.cppreference.com/w/cpp/language/storage_duration (linkage rules)
  • http://en.cppreference.com/w/cpp/language/ub (Undefined Behavior)

结论

C++17中的inline变量不会很快到来。在那之前我们能做的一切就是使用安全用法,以免撞上马路牙子。


  1. 从C++17语言标准的[lex.string]中,我们总结出字符串字面值没有被要求总是求值为同一个对象。在C++11和C++14中也有等价的表述。 ↩︎

  2. [lex.string]没有对constexpr上下文有其他行为的表述。 ↩︎

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值