本周小贴士#140:常量:安全习语

Posted -飞鹤-

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了本周小贴士#140:常量:安全习语相关的知识,希望对你有一定的参考价值。

作为TotW#140最初发表于2017年11月8日

由Matt Armstrong创作

在 C++ 中表达常量的最佳方法是什么? 您可能知道这个词在英语中的含义,但是在代码中很容易错误地表达这个概念。 在这里,我们将首先定义一些关键概念,然后列出安全技术。 对于好奇的人,我们随后会更详细地了解可能出现的问题,并描述 C++17 语言功能,该功能使表达常量更容易。

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

  1. 值:值永远不改变;五还是五。当我们想表达一个常量时,我们需要一个值,且只有一个。
  2. 对象:在每个时间点,对象有一个值。C++非常强调对象的可变,但是常量的变化是不允许的。
  3. 名称:命名为常量比纯字面常量更有用。变量和函数都可以评定为常量对象。

综上所述,让我们将常量称为始终计算为相同值的变量或函数。 这里有几个关键概念。

  1. 安全初始化:很多时候常量表示为静态存储中的值,必须安全初始化。 有关更多信息,请参阅 C++风格指南。
  2. 链接:链接与程序中有多少命名对象的实例(或“副本”)有关。 通常最好是程序中单个对象最好只有一个常量名。对于全局或命名空间作用域的变量,这涉及到外部链接的东西(您可以在此处阅读有关链接的更多信息)。https://en.cppreference.com/w/cpp/language/storage_duration
  3. 编译时评估:如果在编译时知道常量的值,有时编译器可以更好地优化代码。 这种好处有时可以证明在头文件中定义常量的值是合理的,尽管会增加复杂性。

当我们说我们“添加一个常量”时,我们实际上是在声明一个 API 并以满足上述大部分或全部标准的方式定义它的实现。 语言并没有规定我们如何做到这一点,有些方法比其他方法更好。 通常最简单的方法是声明一个 const 或 constexpr 变量,如果它在头文件中,则标记为内联。 另一种方法是从函数返回一个值,这种方法更灵活。 我们将介绍这两种方法的示例。

关于 const 的说明:这还不够。 一个 const 对象是只读的,但这并不意味着它是不可变的,也不意味着它的值总是相同的。 该语言提供了改变我们认为是 const 的值的方法,例如 mutable 关键字和 const_cast。 但即使是简单的代码也可以证明这一点:

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


f("");  // Prints 0
f("foo");  // Prints 3

在上面的代码中,size是一个 const 变量,但它在程序运行时保存多个值。 它不是一个常数.

头文件中的常量

本节中的所有习语都是稳健且值得推荐的。

一个内联的constexpr变量

从 C++17 开始,变量可以标记为内联,确保变量只有一个副本。 当与 constexpr 一起使用以确保安全初始化和销毁时,这提供了另一种定义常量的方法,该常量的值可在编译时访问。

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

一个extern const变量

// Declared in 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 文件中定义变量。

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

ABSL_CONST_INIT 宏确保每个常量在编译时初始化,但仅此而已。 它不会使变量为 const,也不会阻止使用违反风格指南规则的非平凡析构函数声明变量。 请参阅风格指南中对宏的提及。

您可能很想用 constexpr 在 .cc 文件中定义变量,但目前这不是一种可移植的方法。

注意: absl::string_view 是声明字符串常量的好方法。 该类型有一个 constexpr 构造函数和一个普通的析构函数,因此将它们声明为全局变量是安全的。 因为字符串视图知道它的长度,所以使用它们不需要运行时调用 strlen()。

一个constexpr函数

不带参数的 constexpr 函数将始终返回相同的值,因此它用作常量,并且通常可用于在编译时初始化其他常量。 因为所有 constexpr 函数都是隐式内联的,所以没有链接问题。 这种方法的主要缺点是对 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;

注意:确保在返回数组数据时使用静态 constexpr 说明符,例如 char[] 字符串、absl::string_view、absl::Span 等,以避免细微的错误。

一个静态类成员

假设您已经在使用一个类,则类的静态成员是一个不错的选择。 这些总是有外部链接。

// Declared in foo.h
class Foo 
 public:
  static constexpr int kMyNumber = 42;
  static constexpr char kMyHello[] = "Hello";
;

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

// Defined in foo.cc, prior to C++17.
constexpr int Foo::kMyNumber;
constexpr char Foo::kMyHello[];

仅仅为了充当一堆常量的作用域而引入一个类是不值得的。 请改用其他技术替代。

不鼓励的替代方案

#define WHATEVER_VALUE 42

使用预处理器没有什么合理性,请参阅风格指南。

enum : int  kMyNumber = 42 ;

上面使用的枚举技术在某些情况下是合理的。 它产生一个常数 kMyNumber,不会导致本技巧中讨论的问题。 但是已经列出的替代方案对大多数人来说会更熟悉,因此通常是首选。 当枚举本身有意义时使用枚举(例如,请参阅技巧 #86 “使用类枚举”)。

在源文件中生效的方法

上述所有方法也适用于单个 .cc 文件,但可能过于复杂。 因为在源文件中声明的常量默认情况下仅在该文件中可见(参见内部链接规则),所以更简单的方法,例如定义 constexpr 变量,通常生效:

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

以上在 .cc 文件中很好,但在头文件中没有(请参阅警告)。 再读一遍并记住它。 我会尽快解释原因。 长话短说:在 .cc 文件中定义变量 constexpr 或在头文件中声明它们为 extern const 。

在头文件中,请注意

除非您注意使用上面解释的习语,否则 const 和 constexpr 对象可能是每个翻译单元中的不同对象。

这意味着:

  1. 错误:任何使用常量地址的代码都会出现错误,甚至是可怕的“未定义行为”。
  2. 膨胀:包括标题在内的每个翻译单元都有自己的副本。 对于像原始数字类型这样的简单事物来说没什么大不了的。 对于字符串和更大的数据结构来说不是很好。

在命名空间范围内(即不在函数或类中)时,const 和 constexpr 对象都隐式具有内部链接(用于未命名命名空间变量和不在函数或类中的静态变量的相同链接)。 C++ 标准保证使用或引用对象的每个翻译单元都获得对象的不同“副本”或“实例化”,每个都位于不同的地址。

在一个类中,您必须另外将这些对象声明为静态,否则它们将是不可更改的实例变量,而不是在类的每个实例之间共享的不可更改的类变量。

同样,在函数中,您必须将这些对象声明为静态对象,否则它们将占用堆栈空间并在每次调用函数时构造。

一个示例错误

那么,这是一个真的风险吗?请考虑:

// Declared in do_something.h
constexpr char kSpecial[] = "special";
// Does something.  Pass kSpecial and it will do something special.
void DoSomething(const char* value);
// 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 中第一个 char 的地址与 value 作为函数的一种魔术值进行比较。 您有时会看到代码这样做是为了缩短完整的字符串比较。

这会导致一个微妙的错误。 kSpecial 数组是 constexpr,这意味着它是静态的(具有“内部”链接)。 尽管我们认为 kSpecial 是“一个常数”——实际上不是——它是一个常数族,每个翻译单元一个! 对 DoSomething(kSpecial) 的调用看起来应该做同样的事情,但是该函数根据调用发生的位置采用不同的代码路径。

任何使用头文件中定义的常量数组的代码,或采用头文件中定义的常量地址的代码,都足以解决这种错误。 此类错误通常出现在字符串常量中,因为它们是在头文件中定义数组的最常见原因。

一个未定义行为的示例

只需调整上面的示例,并将 DoSomething 作为内联函数移动到头文件中。 Bingo:现在我们有了未定义的行为,或 UB。 该语言要求在每个翻译单元(源文件)中以完全相同的方式定义所有内联函数——这是该语言“一个定义规则”的一部分。 这个特定的 DoSomething 实现引用了一个静态变量,因此每个翻译单元实际上对 DoSomething 的定义不同,因此是未定义的行为。

对程序代码和编译器的不相关更改可能会更改内联决策,这可能会导致此类未定义行为从良性行为变为错误。

这在实践中会产生问题吗?

是的。在我们遇到的一个实际错误中,编译器能够确定在特定的翻译单元(源文件)中,仅部分使用了头文件中定义的大型静态 const 数组。它没有发出整个数组,而是优化掉了它知道未使用的部分。部分使用数组的一种方法是通过在标头中声明的内联函数。

问题是,该数组被其他翻译单元以这样一种方式使用,即静态 const 数组被完全使用。对于那些翻译单元,编译器生成了一个使用完整数组的内联函数版本。

然后链接器出现了。链接器假定内联函数的所有实例都是相同的,因为单一定义规则规定它们必须相同。它丢弃了该函数的所有副本,但只有一份副本 - 那是带有部分优化数组的副本。

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

这些错误确实会发生,并且不容易在测试或代码审查中发现。在定义常量时坚持使用安全的习惯用法是值得的。

其他常见错误

错误#1:非constat常量

常见于指针:

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

上面的kFoo是一个指向常量的指针,但是指针本身不是常量。你能够赋值给他,设置为NULL,等。

// 修正的
const Thing* const kFoo = ...;
// 这也行
constexpr const Thing* kFoo = ...;

错误#2:非常量MyString()

考虑这个代码:

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

字符串字面量常量的地址在每次计算时都允许更改,所以上面的方法有点错误,因为它返回的 string_view 每次调用可能有不同的 .data() 值。 虽然在许多情况下这不会成为问题,但它可能会导致上述错误。

制作 MyString() constexpr 并不能解决问题,因为语言标准没有说它确实。 看待这一点的一种方法是,constexpr 函数只是一个内联函数,在初始化常量值时允许在编译时执行。 在运行时,它与内联函数没有什么不同。

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 函数中的静态 constexpr char kHello[] 数组都可以是静态 constexpr absl::string_view。 但这不会在 Microsoft Visual Studio 中编译。 如果要考虑可移植性,请避免使用 constexpr absl::string_view,直到我们从 C++17 获得 std::string_view 类型。
inline absl::string_view MyString() 
  // Visual Studio refuses to compile this.
  static constexpr absl::string_view kHello = "Hello";
  return kHello;

  1. 对于头文件中声明的extern const变量,根据标准C++,以下定义其值的方法是有效的,并且事实上比ABSL_CONST_INIT更可取,但某些编译器尚不支持这种方法。
// 定义在foo.cc -- 但是MSVC 19不支持.
constexpr absl::string_view kOtherBufferName = "other example";

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

错误#4:未正确初始化的常量

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

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

考虑上述链接需要阅读。 以常量初始化为重点,初始化阶段可以解释为:

  1. 零初始化。 这就是将其他未初始化的静态变量初始化为类型的“零”值(例如 0、0.0、‘\\0’、null 等)的原因。
const int kZero;  // this will be zero-initialized to 0
const int kLotsOfZeroes[5000];  // so will all of these

请注意,依赖零初始化在 C 代码中相当流行,但在 C++ 中相当少见和小众。通常更清楚地为变量分配显式值,即使值为零,这使我们…
2. 常量初始化

const int kZero = 0;  // this will be constant-initialized to 0
const int kOne = 1;   // this will be constant-initialized to 1

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

// This will be dynamically initialized at run-time to
// whatever ArbitraryFunction returns.
const int kArbitrary = ArbitraryFunction();

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

请注意,Google C++ 风格指南等文档历来将动态初始化包含在“静态初始化”这一广泛类别中。 “静态”一词适用于 C++ 中的几个不同概念,这可能会造成混淆。 “静态初始化”可以表示“静态变量的初始化”,其中可以包括运行时计算(动态初始化)。 语言标准在不同的、更狭义的意义上使用术语“静态初始化”:静态或在编译时完成的初始化。

初始化备忘单

这是一个超快速的常量初始化备忘单(不在头文件中):

constexpr 保证安全的常量初始化以及安全的(微不足道的)破坏。 任何 constexpr 变量在 .cc 文件中定义时都是完全可以的,但由于前面解释的原因,在头文件中是有问题的。
ABSL_CONST_INIT 保证安全的常量初始化。 与 constexpr 不同,它实际上并不使变量为 const,也不确保析构函数是微不足道的,因此在用它声明静态变量时仍然必须小心。 再次查看 https://google.github.io/styleguide/cppguide.html#Static_and_Global_Variables。
否则,您最好在函数中使用静态变量并返回它。 请参阅 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(链接规则)
http://en.cppreference.com/w/cpp/language/ub(未定义行为)

结论

C++17 中的内联变量来得还不够快。 在那之前,我们所能做的就是使用安全的习语,让我们远离粗糙的边缘。

  1. 我们得出结论,在 C++17 语言标准的 [lex.string] 中,字符串文字不需要从以下语言评估为相同的对象。 C++11 和 C++14 中也存在等效语言。

  2. [lex.string] 中没有语言描述 constexpr 上下文中的不同行为。

以上是关于本周小贴士#140:常量:安全习语的主要内容,如果未能解决你的问题,请参考以下文章

本周小贴士#146:默认vs值初始化

本周小贴士#146:默认vs值初始化

本周小贴士#90:退役标志

本周小贴士#103:Flag是全局变量

本周小贴士#143:C++11 删除的函数(= delete)

本周小贴士#76:使用absl::status