Skip to content

基础、存储期与内存 ​

局部变量、全局变量与静态局部变量 ​

应从 作用域(名字可见范围)、存储期(对象生存期) 和 链接属性(跨编译单元可见性) 三个维度理解。

类型典型声明位置作用域存储期常见存放区域
自动局部变量函数/代码块内当前块进入块时创建,离开时销毁通常在栈帧,优化后可能在寄存器
全局变量命名空间作用域声明后的相应作用域静态存储期,程序开始/结束附近构造析构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 的意义随上下文改变:

  1. 局部变量:延长到静态存储期,同时保持块作用域;
  2. 命名空间作用域变量/函数:内部链接,只在当前翻译单元可见(现代 C++ 常优先用匿名命名空间);
  3. 类静态数据成员:属于类而非某个对象,所有实例共享;
  4. 类静态成员函数:没有 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)。

递归应有可达的终止条件;深度过大或无限递归会耗尽线程栈并导致栈溢出。编译器有时会内联函数,或对满足条件的尾递归进行尾调用优化,减少额外栈帧;但不能依赖所有编译器和所有构建配置都会进行此优化。

使用 Markdown 与 VitePress 构建