Skip to Content
C++作用域(Scoping)

作用域(Scoping)

命名空间(Namespaces)

除少数例外,将代码放在命名空间中。命名空间应该有基于项目名称及其路径的唯一名称。不要使用 using 指令(例如,using namespace foo)。不要使用内联命名空间。对于匿名命名空间,请参阅内部链接

命名空间将全局作用域细分为不同的、命名的作用域,因此有助于防止全局作用域中的名称冲突。

命名空间提供了一种在大型程序中防止名称冲突的方法,同时允许大多数代码使用合理短的名称。

例如,如果两个不同的项目在全局作用域中都有一个类 Foo,这些符号可能在编译时或运行时发生冲突。如果每个项目将其代码放在命名空间中,project1::Fooproject2::Foo 现在是不同的符号,不会冲突,每个项目命名空间内的代码可以继续不带前缀地引用 Foo

内联命名空间自动将其名称放入外围作用域中。例如,考虑以下代码片段:

namespace outer { inline namespace inner { void foo(); } // namespace inner } // namespace outer

表达式 outer::inner::foo()outer::foo() 是可互换的。内联命名空间主要用于跨版本的 ABI 兼容性。

命名空间可能令人困惑,因为它们使确定名称引用哪个定义的机制变得复杂。

特别是内联命名空间可能令人困惑,因为名称实际上并不限于它们被声明的命名空间。它们仅作为更大版本策略的一部分才有用。

在某些上下文中,有必要反复通过完全限定名称引用符号。对于深层嵌套的命名空间,这可能会增加很多混乱。

命名空间应按如下方式使用:

  • 遵循命名空间名称的规则。

  • 用注释终止多行命名空间,如给定示例所示。

  • 命名空间包裹 include 之后的整个源文件、gflags  的定义/声明以及来自其他命名空间的类的前置声明。

    // In the .h file namespace mynamespace { // All declarations are within the namespace scope. // Notice the lack of indentation. class MyClass { public: ... void Foo(); }; } // namespace mynamespace
    // In the .cc file namespace mynamespace { // Definition of functions is within scope of the namespace. void MyClass::Foo() { ... } } // namespace mynamespace

    更复杂的 .cc 文件可能有额外的细节,如标志或 using 声明。

    #include "a.h" ABSL_FLAG(bool, someflag, false, "a flag"); namespace mynamespace { using ::foo::Bar; ...code for mynamespace... // Code goes against the left margin. } // namespace mynamespace
  • 要将生成的 Protocol Message 代码放在命名空间中,请使用 .proto 文件中的 package 说明符。详见 Protocol Buffer Packages 

  • 不要在命名空间 std 中声明任何东西,包括标准库类的前置声明。在命名空间 std 中声明实体是未定义行为,即不可移植。要声明标准库中的实体,请包含适当的头文件。

  • 你不能使用 using 指令来使命名空间中的所有名称可用。

    // Forbidden -- This pollutes the namespace. using namespace foo;
  • 不要在头文件的命名空间作用域使用命名空间别名,除非在显式标记为仅内部使用的命名空间中,因为在头文件中导入到命名空间的任何东西都会成为该文件导出的公共 API 的一部分。当不满足上述条件时可以使用命名空间别名,但它们必须有适当的名称

    // In a .h file, an alias must not be a separate API, or must be hidden in an // implementation detail. namespace librarian { namespace internal { // Internal, not part of the API. namespace sidetable = ::pipeline_diagnostics::sidetable; } // namespace internal inline void my_inline_function() { // Local to a function. namespace baz = ::foo::bar::baz; ... } } // namespace librarian
    // Remove uninteresting parts of some commonly used names in .cc files. namespace sidetable = ::pipeline_diagnostics::sidetable;
  • 不要使用内联命名空间。

  • 使用名称中带有 “internal” 的命名空间来记录 API 中不应被 API 用户提及的部分。

    // We shouldn't use this internal name in non-absl code. using ::absl::container_internal::ImplementationDetail;

    请注意,在嵌套的 internal 命名空间中,库之间仍然存在冲突的风险,因此请通过添加库的文件名来为命名空间中的每个库提供唯一的内部命名空间。例如,gshoe/widget.h 应使用 gshoe::internal_widget 而不是仅仅 gshoe::internal

  • 单行嵌套命名空间声明在新代码中是首选的,但不是必需的。

    namespace my_project::my_component { ... } // namespace my_project::my_component

内部链接(Internal Linkage)

.cc 文件中的定义不需要在该文件外部引用时,通过将它们放在匿名命名空间中或声明为 static 来给予它们内部链接。不要在 .h 文件中使用这两种构造。

所有声明都可以通过将它们放在匿名命名空间中来获得内部链接。函数和变量也可以通过声明为 static 来获得内部链接。这意味着你声明的任何东西都不能从另一个文件访问。如果不同的文件声明了同名的东西,那么这两个实体是完全独立的。

对于不需要在其他地方引用的所有代码,鼓励在 .cc 文件中使用内部链接。不要在 .h 文件中使用内部链接。

匿名命名空间的格式与命名命名空间相同。在结束注释中,将命名空间名留空:

namespace { ... } // namespace

非成员函数、静态成员函数和全局函数

优先将非成员函数放在命名空间中;很少使用完全全局的函数。不要仅仅为了将静态成员分组而使用类。类的静态方法通常应该与类的实例或类的静态数据密切相关。

非成员和静态成员函数在某些情况下很有用。将非成员函数放在命名空间中可以避免污染全局命名空间。

非成员和静态成员函数作为新类的成员可能更有意义,特别是如果它们访问外部资源或有重大依赖。

有时定义一个不绑定到类实例的函数是有用的。 这样的函数可以是静态成员函数或非成员函数。非成员函数不应依赖外部变量,并且几乎总是应该存在于命名空间中。不要仅为了分组静态成员而创建类;这与给名称一个公共前缀没有区别,而且这种分组通常也是不必要的。

如果你定义了一个非成员函数,且它只在其 .cc 文件中需要,请使用内部链接来限制其作用域。

局部变量(Local Variables)

将函数的变量放在尽可能窄的作用域中,并在声明时初始化变量。

C++ 允许你在函数中的任何位置声明变量。我们鼓励你在尽可能局部的作用域中声明它们,并尽可能靠近首次使用的位置。这使读者更容易找到声明并查看变量的类型和初始化值。特别是,应该使用初始化而不是先声明后赋值,例如:

int i; i = f(); // Bad -- initialization separate from declaration.
int i = f(); // Good -- declaration has initialization.
int jobs = NumJobs(); // More code... f(jobs); // Bad -- declaration separate from use.
int jobs = NumJobs(); f(jobs); // Good -- declaration immediately (or closely) followed by use.
std::vector<int> v; v.push_back(1); // Prefer initializing using brace initialization. v.push_back(2);
std::vector<int> v = {1, 2}; // Good -- v starts initialized.

ifwhilefor 语句所需的变量通常应在这些语句中声明,以便将这些变量限制在这些作用域中。例如:

while (const char* p = strchr(str, '/')) str = p + 1;

有一个注意事项:如果变量是一个对象,其构造函数会在每次进入作用域并创建时被调用,其析构函数会在每次离开作用域时被调用。

// Inefficient implementation: for (int i = 0; i < 1000000; ++i) { Foo f; // My ctor and dtor get called 1000000 times each. f.DoSomething(i); }

在循环外部声明此类在循环中使用的变量可能更高效:

Foo f; // My ctor and dtor get called once each. for (int i = 0; i < 1000000; ++i) { f.DoSomething(i); }

静态和全局变量

具有静态存储期 的对象是被禁止的,除非它们是可平凡析构的 。非正式地说,这意味着析构函数什么都不做,即使考虑成员和基类的析构函数也是如此。更正式地说,这意味着该类型没有用户定义的或虚析构函数,并且所有基类和非静态成员都是可平凡析构的。静态函数局部变量可以使用动态初始化。不鼓励对静态类成员变量或命名空间作用域的变量使用动态初始化,但在有限的情况下是允许的;详见下文。

经验法则:如果一个全局变量的声明单独考虑时可以是 constexpr,那么它就满足这些要求。

每个对象都有一个存储期,与其生命周期相关。具有静态存储期的对象从初始化时起一直存活到程序结束。这样的对象以命名空间作用域的变量(“全局变量”)、类的静态数据成员或使用 static 说明符声明的函数局部变量的形式出现。函数局部静态变量在控制流首次通过其声明时初始化;所有其他具有静态存储期的对象作为程序启动的一部分被初始化。所有具有静态存储期的对象在程序退出时被销毁(这发生在未加入的线程被终止之前)。

初始化可能是动态的,这意味着在初始化过程中发生了非平凡的事情。(例如,考虑一个分配内存的构造函数,或一个用当前进程 ID 初始化的变量。)另一种初始化是静态初始化。不过这两者并不完全相反:静态初始化总是会发生在具有静态存储期的对象上(将对象初始化为给定的常量或由全零字节组成的表示),而动态初始化在此之后发生(如果需要的话)。

全局和静态变量对大量应用非常有用:命名常量、某个翻译单元内部的辅助数据结构、命令行标志、日志记录、注册机制、后台基础设施等。

使用动态初始化或具有非平凡析构函数的全局和静态变量会创造复杂性,容易导致难以发现的错误。 动态初始化在不同翻译单元之间没有顺序,析构也是如此(除了析构按初始化的相反顺序发生)。 当一个初始化引用另一个具有静态存储期的变量时,可能会导致在对象的生命周期开始之前(或结束之后)访问该对象。此外,当程序启动的线程在退出时未被加入(join),如果这些线程的析构函数已经运行,它们可能会尝试在其生命周期结束后访问对象。

关于析构的决定

当析构函数是平凡的时,它们的执行完全不受顺序约束(它们实际上不会”运行”);否则我们面临在对象生命周期结束后访问它们的风险。 因此,我们只允许具有静态存储期的对象是可平凡析构的。基本类型(如指针和 int)是可平凡析构的,可平凡析构类型的数组也是如此。请注意,标记为 constexpr 的变量是可平凡析构的。

const int kNum = 10; // Allowed struct X { int n; }; const X kX[] = {{1}, {2}, {3}}; // Allowed void foo() { static const char* const kMessages[] = {"hello", "world"}; // Allowed } // Allowed: constexpr guarantees trivial destructor. constexpr std::array<int, 3> kArray = {1, 2, 3};
// bad: non-trivial destructor const std::string kFoo = "foo"; // Bad for the same reason, even though kBar is a reference (the // rule also applies to lifetime-extended temporary objects). const std::string& kBar = StrCat("a", "b", "c"); void bar() { // Bad: non-trivial destructor. static std::map<int, int> kData = {{1, 0}, {2, 0}, {3, 0}}; }

请注意,引用不是对象,因此它们不受可析构性约束的限制。但动态初始化的约束仍然适用。特别是,形如 static T& t = *new T; 的函数局部静态引用是允许的。

关于初始化的决定

初始化是一个更复杂的话题。这是因为我们不仅必须考虑类构造函数是否执行,还必须考虑初始化器的求值:

int n = 5; // Fine int m = f(); // ? (Depends on f) Foo x; // ? (Depends on Foo::Foo) Bar y = g(); // ? (Depends on g and on Bar::Bar)

除第一条语句外,所有语句都使我们面临不确定的初始化顺序问题。

在 C++ 标准的正式语言中,我们寻找的概念叫做常量初始化(Constant Initialization)。它意味着初始化表达式是常量表达式,如果对象是通过构造函数调用初始化的,那么构造函数也必须声明为 constexpr

struct Foo { constexpr Foo(int) {} }; int n = 5; // Fine, 5 is a constant expression. Foo x(2); // Fine, 2 is a constant expression and the chosen constructor is constexpr. Foo a[] = { Foo(1), Foo(2), Foo(3) }; // Fine

常量初始化始终允许。静态存储期变量的常量初始化应使用 constexprconstinit 标记。任何未如此标记的非局部静态存储期变量都应被推定为具有动态初始化,并需非常仔细地审查。

相比之下,以下初始化是有问题的:

// Some declarations used below. time_t time(time_t*); // Not constexpr! int f(); // Not constexpr! struct Bar { Bar() {} }; // Problematic initializations. time_t m = time(nullptr); // Initializing expression not a constant expression. Foo y(f()); // Ditto Bar b; // Chosen constructor Bar::Bar() not constexpr.

非局部变量的动态初始化是不鼓励的,通常是禁止的。 然而,如果程序的任何方面都不依赖于此初始化与所有其他初始化之间的顺序,我们确实允许它。在这些限制下,初始化的顺序不会产生可观察的差异。例如:

int p = getpid(); // Allowed, as long as no other static variable // uses p in its own initialization.

静态局部变量的动态初始化是允许的(也很常见)。

常见模式

  • 全局字符串:如果你需要一个命名的全局或静态字符串常量,考虑使用 string_view、字符数组或字符指针的 constexpr 变量,指向字符串字面量。字符串字面量本身已经具有静态存储期,通常已经足够。参见 TotW #140 
  • 映射、集合和其他动态容器:如果你需要一个静态的、固定的集合,例如用于搜索的集合或查找表,你不能使用标准库中的动态容器作为静态变量,因为它们具有非平凡析构函数。相反,考虑使用简单的平凡类型数组,例如整数数组的数组(用于”从 int 到 int 的映射”),或键值对数组(例如,intconst char* 的键值对)。对于小集合,线性搜索完全足够(且由于内存局部性而高效);考虑使用 absl/algorithm/container.h  中的工具进行标准操作。如有必要,保持集合排序并使用二分搜索算法。如果你确实更倾向于使用标准库的动态容器,考虑使用函数局部静态指针,如下所述。
  • 智能指针(std::unique_ptrstd::shared_ptr):智能指针在析构时执行清理操作,因此是被禁止的。考虑你的用例是否适合本节中描述的其他模式之一。一个简单的解决方案是使用指向动态分配对象的普通指针且永远不删除它(参见最后一项)。
  • 自定义类型的静态变量:如果你需要你自己定义的类型的静态常量数据,请给该类型一个平凡析构函数和一个 constexpr 构造函数。
  • 如果上述方法都不行,你可以动态创建一个对象且永远不删除它,使用函数局部静态指针或引用(例如,static const auto& impl = *new T(args...);)。

thread_local 变量

未在函数内部声明的 thread_local 变量必须用真正的编译时常量初始化,并且必须通过使用 constinit 属性来确保这一点。优先使用 thread_local 而不是其他定义线程局部数据的方式。

变量可以使用 thread_local 说明符声明:

thread_local Foo foo = ...;

这样的变量实际上是对象的集合,因此当不同的线程访问它时,它们实际上访问的是不同的对象。 thread_local 变量在许多方面很像静态存储期变量。例如,它们可以在命名空间作用域、函数内部或作为静态类成员声明,但不能作为普通类成员。

thread_local 变量实例的初始化与静态变量非常相似,只是它们必须为每个线程分别初始化,而不是在程序启动时只初始化一次。这意味着在函数内声明的 thread_local 变量是安全的,但其他 thread_local 变量受制于与静态变量相同的初始化顺序问题(甚至更多)。

thread_local 变量有一个微妙的析构顺序问题:在线程关闭期间,thread_local 变量将按其初始化的相反顺序被销毁(这在 C++ 中通常如此)。如果任何 thread_local 变量的析构函数触发的代码引用了该线程上任何已销毁的 thread_local,我们将遇到特别难以诊断的释放后使用(use-after-free)错误。

  • 线程局部数据天生不受竞争条件影响(因为通常只有一个线程可以访问它),这使得 thread_local 对并发编程很有用。
  • thread_local 是创建线程局部数据的唯一标准支持方式。
  • 访问 thread_local 变量可能会在线程启动时或给定线程首次使用时触发执行不可预测和不可控制数量的其他代码。
  • thread_local 变量实际上是全局变量,具有全局变量的所有缺点(线程安全性除外)。
  • thread_local 变量消耗的内存与运行线程的数量成比例(在最坏情况下),在程序中可能相当大。
  • 数据成员不能是 thread_local,除非它们同时也是 static
  • 如果 thread_local 变量具有复杂的析构函数,我们可能会遭受释放后使用的错误。特别是,任何此类变量的析构函数都不能(传递地)调用引用任何可能已销毁的 thread_local 的代码。这个属性很难强制执行。
  • 在全局/静态上下文中避免释放后使用的方法不适用于 thread_local。具体来说,跳过全局和静态变量的析构函数是允许的,因为它们的生命周期在程序关闭时结束。因此,任何”泄漏”都会被操作系统清理内存和其他资源时立即处理。相比之下,跳过 thread_local 变量的析构函数会导致与程序生命周期内终止的线程总数成比例的资源泄漏。

类或命名空间作用域的 thread_local 变量必须用真正的编译时常量初始化(即它们不能有动态初始化)。为了强制执行这一点,类或命名空间作用域的 thread_local 变量必须使用 constinit(或 constexpr,但这应该很少见)来标注:

constinit thread_local Foo foo = ...;

函数内的 thread_local 变量没有初始化方面的顾虑,但在线程退出时仍有释放后使用的风险。请注意,你可以通过定义一个暴露它的函数或静态方法,使用函数作用域的 thread_local 来模拟类或命名空间作用域的 thread_local

Foo& MyThreadLocalFoo() { thread_local Foo result = ComplicatedInitialization(); return result; }

请注意,thread_local 变量将在线程退出时被销毁。如果任何此类变量的析构函数引用了任何其他(可能已销毁的)thread_local,我们将遭受难以诊断的释放后使用错误。优先使用平凡类型或可证明在析构时不运行用户提供代码的类型,以最大限度地减少访问任何其他 thread_local 的可能性。

thread_local 应优先于其他定义线程局部数据的机制。

Last updated on