当前位置:首页 > 博客 > 笔记 > 正文内容

Scoping 作用域

RWYQ阿伟2025-12-23笔记900

命名空间

        除非极少数例外情况,请将代码放入命名空间中。命名空间的名称应基于项目名称(并可能包含其路径)以保证唯一性。禁止使用 using 指令(例如 using namespace foo)。禁止使用内联命名空间(inline namespaces)。关于未命名命名空间,请参阅“内部链接”(Internal Linkage)。

定义:

        命名空间将全局作用域划分为不同的、具名的作用域,因此对于防止全局作用域中的命名冲突非常有用。

优点:

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

例如,如果有两个不同的项目都在全局作用域中定义了一个名为 Foo 的类,这些符号可能会在编译时或运行时发生冲突。如果每个项目都将他们的代码放在命名空间中,project1::Foo 和 project2::Foo 现在就变成了两个互不冲突的不同符号。而且,每个项目命名空间内的代码仍然可以继续直接使用 Foo 这个名称,而无需添加前缀。

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

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

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

ABI: 应用程序二进制接口。它定义了编译后的代码如何交互(不仅仅是函数名,还包括符号修饰规则等)。

用途:内联命名空间通常用于库的版本管理。

假设你有一个库版本 1,在 inline namespace v1 里。

当你升级到版本 2 时,你可以在 inline namespace v2 里添加新功能。

旧的二进制程序在链接时,仍然可以通过 library::foo() 找到符号,因为内联机制保证了符号在最外层可见,从而避免了因改名导致的链接错误。

缺点:

  • 命名空间可能会让人感到困惑,因为它们使确定一个名称具体指代哪个定义的机制变得更加复杂。

  • 特别是内联命名空间,由于名称实际上并不限制在它们声明的那个命名空间内,这可能会造成混淆。它们只有作为某种更广泛的版本控制策略的一部分时才有用。

  • 在某些情况下,必须通过符号的完全限定名(全名)来反复引用它们。对于深度嵌套的命名空间,这会增加大量的代码杂乱感。

结论:

        命名空间的使用方式如下:

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

  • 如示例所示,使用注释来结束多行命名空间。

  • 命名空间应包裹整个源文件的内容(位于 #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 文件可能包含其他细节,例如标志(flags)或 using 声明(using-declarations)。

#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
  • 若要将生成的协议消息代码放入命名空间,请在 .proto 文件中使用 package 说明符。详情请参阅“协议缓冲区包”。

  • 不得在 std 命名空间中声明任何内容,包括标准库类的前置声明。在 std 命名空间中声明实体属于未定义行为,即不具备可移植性。如需使用标准库中的实体,请包含相应的头文件。

  • 禁止使用 using 指令(using-directive)来引入整个命名空间的所有名称。

// 禁止 —— 这会污染命名空间。
using namespace foo;
  • 除了明确标记为仅限内部使用的命名空间外,禁止在头文件的命名空间作用域内使用命名空间别名,因为将任何内容导入头文件中的命名空间,都会成为该文件导出的公共 API 的一部分。在不适用上述条件的情况下(例如源文件或局部作用域),可以使用命名空间别名,但必须为其赋予合适的名字。

// 在一个.h文件中, 别名不得作为独立的 API 存在,或者必须隐藏在实现细节中。
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
// 在 .cc 文件中,移除一些常用名称中不重要的部分。
namespace sidetable = ::pipeline_diagnostics::sidetable;
  • 禁止使用内联命名空间(inline namespaces)。

  • 使用名称中包含 "internal" 的命名空间,来标注 API 中那些不应被 API 使用者提及的部分。

// 我们不应在非 absl 代码中使用此内部名称。
using ::absl::container_internal::ImplementationDetail;

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

// gshoe/widget.h
namespace gshoe {
namespace internal_widget { // 只有 widget 的代码在这里
    void Helper();
}
}

// gshoe/gadget.h
namespace gshoe {
namespace internal_gadget { // gadget 的代码在另一个空间
    void Helper(); // 不会冲突,因为全名是 gshoe::internal_gadget::Helper
}
}
  • 在新代码中,推荐使用单行嵌套命名空间声明,但并非强制要求。

namespace my_project::my_component {

  ...

}  // namespace my_project::my_component

内部链接

        当源文件(.cc 文件)中的定义不需要被外部引用时,请通过将它们放入匿名命名空间或使用 static 修饰来赋予其内部链接属性。切勿在头文件(.h 文件)中使用上述任何一种结构。

定义:

        所有声明都可以通过放入匿名命名空间来赋予内部链接属性。对于函数和变量,也可以通过声明为 static 来赋予内部链接属性。这意味着你声明的任何内容都无法从另一个文件中访问。如果另一个文件声明了同名的实体,这两个实体将是完全独立的。

结论:

        我们鼓励在 .cc 文件中,对所有不需要被外部引用的代码使用内部链接。请勿在 .h 文件中使用内部链接。

        匿名命名空间的格式应与具名命名空间保持一致。在结束的注释中,保持命名空间名称为空:

//匿名命名空间,推荐,C++ 风格
namespace {    
        int helper_var = 0; // 仅本文件可见
    void HelperFunc() { /* ... */ } // 仅本文件可见
}
//static 关键字,C 风格,C++ 中仍支持
static int helper_var = 0;
static void HelperFunc() { /* ... */ }

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

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

优点:

  • 在某些情况下,非成员函数和静态成员函数非常有用。将非成员函数置于命名空间内可以避免污染全局命名空间。

缺点:

  • 非成员函数和静态成员函数在某些场景下,设计为一个新类的成员可能更为合理,特别是当它们需要访问外部资源或具有重大依赖关系时。

结论:

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

        如果你定义了一个非成员函数,且该函数仅在其实现文件(.cc 文件)中使用,请使用内部链接来限制其作用域。

场景    ——    推荐方案

纯粹的工具函数 (如 int Max(int a, int b))    ——    放入相关的命名空间 (如 ::math::Max)    

需要管理状态/资源 (如日志写入、文件操作)    ——    封装成类 (包含成员变量和方法)    

仅在单个文件中使用的辅助函数    ——    放入该 .cc 文件的匿名命名空间    

与某个类强相关但不依赖实例 (如工厂方法)    ——    定义为该类的静态成员函数    

局部变量

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

        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.

        用于 if、while 和 for 语句的变量,通常应在这些语句内部进行声明,以便将这些变量限制在对应的作用域内。例如:

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

        有一个需要注意的例外情况:如果该变量是一个对象,那么每次进入作用域并创建该变量时,都会调用其构造函数;每次变量离开作用域时,都会调用其析构函数。

// 低效写法,每次循环都会调用构造函数和析构函数
for (int i = 0; i < 1000000; ++i) {
  Foo f;  // My ctor and dtor get called 1000000 times each.
  f.DoSomething(i);
}

        对于在循环中使用的此类变量,将其声明在循环外部可能会更高效。

// My ctor and dtor get called once each.
// 构造函数和析构函数只会调用一次
Foo f;
for (int i = 0; i < 1000000; ++i) {
  f.DoSomething(i);
}

静态变量和全局变量

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

        粗略的经验法则:如果一个全局变量的声明在孤立地看来可以被声明为 constexpr,那么它就满足这些要求。

1. 为什么禁止非平凡的静态对象?

        C++ 标准不保证不同编译单元(.cc 文件)之间,全局变量的构造顺序和析构顺序。如果一个全局对象 A 的析构函数依赖于另一个全局对象 B,而 B 已经被销毁了,程序就会崩溃。因此,最安全的做法是禁止使用那些需要在程序结束时执行复杂清理逻辑(非平凡析构)的全局对象。

2. 什么是“平凡可析构”?

        一个对象是“平凡可析构”的,意味着它的销毁过程不需要执行任何特殊的代码(比如释放内存、关闭文件句柄等)。

✅ 允许的:int, float, 指针,std::array<int, 10>,简单的 struct(只包含上述基本类型的成员)。

❌ 禁止的:std::string, std::vector, std::unique_ptr(除非是裸指针),任何带有自定义析构函数 ~MyClass() 的类。

定义:

        每个对象都有一个存储期,这与其生命周期相关联。具有静态存储期的对象从其初始化那一刻起开始存在,直到程序结束。

        此类对象表现为命名空间作用域的变量(“全局变量”)、类的静态数据成员,或者是用 static 说明符声明的函数局部变量。函数局部静态变量在控制流首次经过其声明处时进行初始化;而所有其他具有静态存储期的对象则作为程序启动的一部分进行初始化。所有具有静态存储期的对象都在程序退出时被销毁(这发生在未等待结束的线程被终止之前)。

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

优点:

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

缺点:

  • 使用动态初始化或具有非平凡析构函数的全局和静态变量会引入复杂性,这很容易导致难以发现的 bug。动态初始化在不同的编译单元之间没有固定的顺序,销毁顺序也是如此(除了销毁发生在初始化的逆序这一规则之外)。当一个初始化操作引用了另一个具有静态存储期的变量时,就有可能导致在该对象生命周期开始之前(或在生命周期结束之后)对其进行访问。此外,当程序启动了在退出时未被等待结束(未 join)的线程时,如果对象的析构函数已经运行,这些线程可能会尝试在对象生命周期结束后访问它们。

结论:

(针对全局/静态对象)析构问题的决策

        当析构函数是平凡的(trivial)时,其执行根本不涉及顺序问题(它们实际上不会被“执行”);否则,我们将面临在对象生命周期结束后仍访问该对象的风险。因此,我们仅允许使用那些具有平凡可析构性的、具有静态存储期的对象。基本类型(如指针和 int)是平凡可析构的,平凡可析构类型的数组也是如此。请注意,用 constexpr 修饰的变量也是平凡可析构的。

// Allowed
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: 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++ 标准的正式术语中,我们所寻求的概念被称为常量初始化。这意味着初始化表达式必须是一个常量表达式;并且,如果对象是通过构造函数调用来初始化的,那么该构造函数也必须被定义为 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

        允许使用常量初始化。具有静态存储期的变量如果进行常量初始化,应当使用 constexpr 或 constinit 进行标记。任何未使用这些关键字标记的非局部静态存储期变量,都应被视为具有动态初始化,并需进行非常仔细的审查。

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

// 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.

        允许(且常见)对静态局部变量进行动态初始化。

我们原则上不让你用动态初始化,因为太危险。除非你能证明这个变量是“独行侠”,它的初始化早一点晚一点对程序结果没有任何影响,否则绝对禁止。

常用范式

  • 全局字符串:如果你需要一个命名的全局或静态字符串常量,建议使用 constexpr 变量,其类型可以是 string_view、字符数组或字符指针,并让它指向一个字符串字面量。字符串字面量本身就具有静态存储期,通常情况下已经足够满足需求。参见 TotW #140

  • 映射表、集合及其他动态容器:如果你需要一个静态的、固定不变的集合(例如用于比对的集合或查找表),则不能将标准库中的动态容器用作静态变量,因为它们具有非平凡的析构函数。相反,建议使用由平凡类型组成的简单数组,例如 int 数组的数组(用于“int 到 int 的映射”),或者 pair 数组(例如 int 和 const char* 的配对)。对于小型集合,线性搜索完全足够(且高效,得益于内存局部性);建议使用 absl/algorithm/container.h 中的工具来执行标准操作。如果有必要,可以保持集合有序并使用二分查找算法。如果你确实更倾向于使用标准库中的动态容器,请考虑使用下文所述的函数局部静态指针。

  • 智能指针(std::unique_ptr, std::shared_ptr):智能指针在销毁时会执行清理操作,因此被禁止使用。请考虑你的使用场景是否符合本节描述的其他模式。一个简单的解决方案是使用指向动态分配对象的普通指针,并且永不删除它(见最后一项)。

  • 自定义类型的静态变量:如果你需要定义自己的类型作为静态常量数据,请确保该类型拥有一个平凡的析构函数和一个 constexpr 构造函数。

  • 如果以上所有方案均不可行,你可以通过使用函数局部静态指针或引用的方式,动态创建一个对象且永不删除它(例如:static const auto& impl = *new T(args...);)。

最优:让自定义类型满足 constexpr 构造 + 平凡析构。

最差(最后手段):使用 static T& t = *new T;,通过制造一个“内存泄漏”来换取程序的稳定性和初始化的安全性。

thread_local 变量

        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。

  • 如果 thread_local 变量具有复杂的析构函数,我们可能会遭受“释放后使用(use-after-free)”的 bug。特别是,任何此类变量的析构函数不得调用(递归地)任何引用了可能已被销毁的 thread_local 变量的代码。这一性质很难强制执行。

  • 在全局/静态上下文中避免“释放后使用”的方法对 thread_local 变量无效。具体来说,对于全局和静态变量,允许跳过析构函数(不执行清理),因为它们的生命周期在程序关闭时结束;此时,操作系统会立即清理内存和其他资源,从而管理了这种“泄漏”。相比之下,如果跳过 thread_local 变量的析构函数(即不释放资源),会导致资源泄漏量与程序生命周期内终止的线程总数成正比。

结论:

        类或命名空间作用域的 thread_local 变量必须使用真正的编译期常量进行初始化(即,它们不得进行动态初始化)。为了强制执行这一要求,类或命名空间作用域的 thread_local 变量必须使用 constinit(或者 constexpr,但这种情况应很少见)进行标注。

constinit thread_local Foo foo = ...;

        函数内部的 thread_local 变量没有初始化方面的顾虑,但在线程退出期间仍然面临“释放后使用(use-after-free)”的风险。

        注意,你可以通过定义一个暴露该变量的函数或静态方法,来使用函数作用域的 thread_local 变量模拟类或命名空间作用域的 thread_local 变量:

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

        或者:

// 定义一个函数来“模拟”命名空间级别的 thread_local 变量
const std::vector<int>& GetMyThreadLocalData() {
  // 定义在函数内部,没有初始化顺序问题
  // 每个线程第一次调用时初始化
  static thread_local std::vector<int> data = InitializeData();
  return data;
}

// 使用时就像在访问一个全局变量
void SomeFunction() {
  auto& local_data = GetMyThreadLocalData();
  // ...
}

        注意:thread_local 变量会在线程退出时被销毁。如果任何一个此类变量的析构函数引用了任何其他(可能已被销毁的)thread_local 变量,我们就会遭遇难以诊断的“释放后使用(use-after-free)”错误。

        应优先选择平凡类型,或者那些能证明在销毁时不会执行任何用户自定义代码的类型,以尽量减少访问其他 thread_local 变量的可能性。

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


扫描二维码推送至手机访问。

版权声明:本文由阿伟的笔记本发布,如需转载请注明出处。

本文链接:http://awnotebook.com/post/924.html

标签: 笔记编程C++
分享给朋友:

发表评论

访客

看不清,换一张

◎欢迎参与讨论,请在这里发表您的看法和观点。