Appearance
基础、存储期与内存
局部变量、全局变量与静态局部变量
应从 作用域(名字可见范围)、存储期(对象生存期) 和 链接属性(跨编译单元可见性) 三个维度理解。
| 类型 | 典型声明位置 | 作用域 | 存储期 | 常见存放区域 |
|---|---|---|---|---|
| 自动局部变量 | 函数/代码块内 | 当前块 | 进入块时创建,离开时销毁 | 通常在栈帧,优化后可能在寄存器 |
| 全局变量 | 命名空间作用域 | 声明后的相应作用域 | 静态存储期,程序开始/结束附近构造析构 | data/BSS 等静态区域 |
| 静态局部变量 | 函数/代码块内加 static | 当前块 | 静态存储期,只初始化一次 | 静态区域 |
图中的“程序开始/结束附近”刻意不表示精确构造析构顺序:跨翻译单元的非局部静态对象仍可能有初始化顺序问题。
cpp
int global_count = 1; // 全局对象:静态存储期
int next_id() {
static int id = 0; // 仅该函数可见,但整个程序期间存在
return ++id;
}- 未显式初始化的静态存储期对象会先零初始化;自动局部基础类型若未初始化,其值通常是不确定的。
- C++11 起,函数内静态变量的初始化由语言保证线程安全;但之后对该变量的并发读写仍需自行同步。
- 非局部静态对象跨编译单元的初始化顺序可能有问题(static initialization order fiasco);按需构造的函数内静态对象常用于规避。
static 的常见含义
static 的意义随上下文改变:
- 局部变量:延长到静态存储期,同时保持块作用域;
- 命名空间作用域变量/函数:内部链接,只在当前翻译单元可见(现代 C++ 常优先用匿名命名空间);
- 类静态数据成员:属于类而非某个对象,所有实例共享;
- 类静态成员函数:没有
this指针,只能直接访问静态成员,可通过Class::func()调用。
类静态数据成员与静态成员函数
类静态数据成员只有一份对象实体,不嵌入每个类对象;它具有静态存储期,所有该类对象都访问同一份状态。派生类可以按访问控制规则使用基类静态成员,但这仍是基类的同一份成员;若派生类声明同名静态成员,发生的是名称隐藏,不会自动“覆盖”基类成员。
cpp
class Limits {
public:
static int max_connections; // 类内声明,不分配定义实体
static void reset() {
max_connections = 1'024; // 合法:静态函数没有 this,但可访问静态成员
}
void set_limit(int value) {
max_connections = value; // 非静态函数也可访问静态成员
}
};
// C++17 之前或未使用 inline 时:在一个 .cpp 中恰好定义一次。
// 这里不再写 static,也不写 public/private。
int Limits::max_connections = 1'024;C++17 起,适合放在头文件中的共享状态可写成 inline static,声明同时就是定义,不需要额外的类外定义:
cpp
class Defaults {
public:
inline static int timeout_ms = 1'000;
};静态成员与对象本身的布局分离,因此可声明为所属类自身的类型;普通非静态成员在类定义结束前需要已知完整大小,不能按值包含自己:
cpp
class Node {
public:
static Node sentinel; // 合法:定义可放在类完整定义之后
Node* next = nullptr; // 合法:指针大小已知
// Node child; // 不合法:Node 尚未完整,且会无限递归嵌套
};默认实参在调用时没有 this 对象可用,因此可以使用此前已声明的静态成员,不能使用普通非静态数据成员:
cpp
class Buffer {
public:
static int default_capacity;
void reserve(int capacity = default_capacity); // 合法
// void reserve_bad(int capacity = capacity_); // 不合法:需要具体对象的成员
private:
int capacity_ = 0;
};静态成员函数同样没有 this,不能直接读写 capacity_ 等非静态成员,也不能声明为 virtual、成员函数 const 或访问某个对象特有的状态;需要对象状态时应使用非静态成员函数并明确由哪个对象调用。
常见易错点:
- 不要在类外定义中再次写
static,也不要重复public/private; - 不要把“所有对象共享”理解为自动线程安全;对静态可变状态的并发读写仍需锁或原子同步;
- C++17 前,类内初始化的整型
const/constexpr静态成员在某些 odr-use 场景仍可能需要类外定义;新代码优先使用inline static消除这类定义分离问题; - 静态成员函数可通过
Class::func()调用,也可用对象调用,但后者不会隐式提供this,通常不如类名调用清晰。
const 的作用
const 表示通过该名字/访问路径不可修改:
const int value = 42;:对象不可经value修改;void print(const Widget& w);:承诺不经该引用修改对象;int size() const;:成员函数不能修改对象的非mutable成员;- 配合指针限定“指向的数据”和“指针自身”是否可变,详见下一篇。
const 不等于绝对线程安全:不可变对象通常更易安全共享,但若对象包含 mutable 状态、指向可变外部数据或发生未同步发布,仍可能有并发问题。
常见程序内存区域
教材常将进程映像概括为代码段、只读数据段、已初始化数据段、BSS、堆和栈。它有助于理解,但标准 C++ 不规定具体地址布局;实际布局会受操作系统、ABI、ASLR、优化和分配器影响。
这不是所有平台都使用的精确地址图;尤其是映射区、堆栈方向和段的合并方式均属于系统与 ABI 实现细节。
.bss 为什么能节省可执行文件体积
零初始化的全局/静态对象通常位于 .bss,例如:
cpp
int huge_buffer[10'000'000]; // 通常位于 .bss链接器不必把一千万个 0 真正写入可执行文件,只需记录 .bss 所需的大小;程序加载时,操作系统再为它提供零初始化的内存页。因此 .bss 可以让运行时占用很大内存的零初始化数据不显著增大可执行文件。相对地,含有非零初始值的数据通常需要把初值写入文件,常位于 .data。
核心原则是对象的存储期和所有权:自动对象由作用域销毁,动态对象由 delete 或 RAII 所有者销毁,静态对象由运行时在程序结束附近清理。
对象大小与内存对齐
对象成员通常按其对齐要求布局,编译器可能插入 padding;最终 sizeof(T) 也通常补齐为 alignof(T) 的整数倍。下面以常见 ABI 中的 char + int + char 为例:
成员顺序改为 int value; char c; char tag; 在常见 ABI 下常可减小填充,但不能为了节省几个字节破坏语义或可读性。对齐规则应通过 sizeof、alignof 在目标平台验证。
空类、空基类优化与对象大小
真正的空类没有非静态数据成员、虚函数或虚基类。单独创建的完整对象仍需具有可区分的地址,因此 sizeof(Empty) 至少为 1;常见实现中为 1 字节。静态数据成员和成员函数不属于单个对象的存储,通常不会改变这个结论:
cpp
struct Empty {
static inline int count = 0; // C++17:不嵌入每个 Empty 对象
void run() {} // 成员函数代码也不嵌入对象
};
static_assert(sizeof(Empty) >= 1);空类作为普通成员时也需要自己的地址,可能再引入 padding;作为基类时,编译器通常可以应用空基类优化(EBO),让空基类子对象不额外占用存储:
cpp
struct Tag {};
struct MemberLayout {
Tag tag; // 普通成员通常需要占位
int value;
};
struct BaseLayout : Tag { // 常见实现可应用 EBO
int value;
};
// 常见 ABI:sizeof(MemberLayout) 为 8,sizeof(BaseLayout) 为 4;
// 具体结果仍应在目标平台用 sizeof 验证。C++20 的 [[no_unique_address]] 可让空的普通成员也与其他成员复用存储,适合零状态策略/分配器成员;是否以及如何重叠仍由实现决定:
cpp
struct CompactLayout {
[[no_unique_address]] Tag tag;
int value;
};这是需要编译器实际支持的 C++20 属性;可用 __has_cpp_attribute(no_unique_address) 检查能力。未支持时不能假定空成员会消失,也不应为了固定 sizeof 依赖厂商扩展。
不要把“没有自己声明的数据成员”误认为一定是空类。含虚函数的类并非标准意义的空类;主流 ABI 通常会加入虚调用相关指针,大小常接近一个指针,但 vptr/vtable 的存在和位置都不是 C++ 标准保证,详见对象模型、RTTI 与多态设计。
函数调用栈与递归
每次函数调用通常会创建一个栈帧(stack frame),保存该调用恢复执行所需的信息,例如返回地址、参数、自动局部变量以及部分被保存的寄存器状态。具体布局由 CPU 架构、ABI、编译器和优化选项决定,并非 C++ 标准固定规定。
以阶乘递归为例:
cpp
int factorial(int n) {
if (n == 1) return 1;
int sub = factorial(n - 1);
return n * sub;
}调用 factorial(3) 时,调用栈的变化可简化为:
每一层递归都必须保留自己的 n 与“递归调用返回后还要继续执行什么”。例如 factorial(3) 必须等待 factorial(2) 的结果,才能完成 3 * sub;因此递归额外空间通常与最大递归深度成正比,即 O(depth)。
递归应有可达的终止条件;深度过大或无限递归会耗尽线程栈并导致栈溢出。编译器有时会内联函数,或对满足条件的尾递归进行尾调用优化,减少额外栈帧;但不能依赖所有编译器和所有构建配置都会进行此优化。