C++专题:如何写好一个类

Source

数据模型

在计算机编程和系统架构中,LP32、ILP32、LLP64、LP64 是描述不同数据类型(尤其是整数和指针)在特定平台上所占位数的模型,它们直接影响程序的内存占用、兼容性和跨平台移植性。以下是详细解释:

核心概念:数据类型位数

这些模型的命名基于三种关键数据类型的位数:

  • L(Long):长整数类型(long)的位数;
  • P(Pointer):指针类型(如int*)的位数;
  • I(Int):普通整数类型(int)的位数。

四种模型的具体定义

模型 int(I) long(L) 指针(P) 典型应用场景
LP32 16位 32位 32位 早期16位系统(如MS-DOS)
ILP32 32位 32位 32位 32位操作系统(如x86 Linux/Windows)
LLP64 32位 32位 64位 64位Windows系统
LP64 32位 64位 64位 64位类Unix系统(如Linux、macOS)

各模型的详细说明

1. LP32(16位系统主导)
  • 特点int为16位,long和指针为32位。
  • 背景:流行于16位处理器时代(如80286),典型代表是MS-DOS系统。
  • 局限int的16位限制了表示范围(-32768~32767),需频繁使用long处理大整数。
2. ILP32(32位系统主流)
  • 特点intlong、指针均为32位(三者位数相同)。
  • 背景:32位处理器(如x86)普及后成为标准,被32位Linux、Windows、macOS等广泛采用。
  • 优势:简化了编程,无需区分intlong的范围差异,指针能直接寻址4GB内存(2³²字节)。
3. LLP64(64位Windows的独特选择)
  • 特点intlong仍为32位,仅指针和long long为64位(LL代表long long为64位)。
  • 背景:微软为兼容大量32位Windows程序,在64位系统中保留了intlong的32位特性,仅扩展指针至64位以支持大内存。
  • 影响:在64位Windows中,longint位数相同(32位),需用long longint64_t表示64位整数。
4. LP64(64位类Unix系统标准)
  • 特点int为32位,long和指针为64位。
  • 背景:由Unix系统演化而来,是64位Linux、macOS、FreeBSD等的标准模型。
  • 设计逻辑
    • 保持int为32位,避免不必要的内存开销(多数场景无需64位整数);
    • long与指针位数一致,符合Unix传统中“long足够容纳指针”的设计哲学;
    • 支持超过4GB的内存寻址(2⁶⁴字节)。

数据类型

字节

字节是最小的可独立寻址的内存块(由 sizeof(char) == 1 保证)。在C++中,字节必须能表示以下三种值:

  • UTF-8 代码单元:每个 UTF-8 单元范围为 0x00~0xFF(256 种值)。
  • 基本执行字符集:包含 ASCII 字符(如字母、数字、标点)和控制字符。
  • 普通字面编码:如 ‘A’、‘\n’ 等字符字面量的编码值。

在 C++ 标准中,sizeof 返回的是对象占用的字节数,而不是位数。标准规定 sizeof(char) 始终为 1,无论字节的实际位数是多少。

整型

下图是上面四种数据模型所有可用的标准整型及其属性:
在这里插入图片描述
signed代表有符号,unsigned对应的代表无符号,像short、int、long都是默认为有符号的,所以signed可以省略,因此C++能表示的整数类型全部包含在了等价类型中。这其中char特殊,作为字符类型也可以保存整型,或者说在 ASCII 编码中,字符和整数是一一对应的,所以,char类型可以看作是一种特殊的整数类型,但其默认是否有符号依赖于编译器和目标平台(ARM 和 PowerPC 的默认值通常是无符号的,x86 和 x64 的默认值通常是有符号的。),所以建议加上signed。对于相同类型有多种表达方式主要是因为C语言的早期版本允许使用不完整的类型说明符,这一传统保留了下来。

布尔值

bool能够容纳两个值之一:true 或 false。sizeof(bool) 的值是实现定义的,可能与 1 不同。
布尔值转换为整数:false转换为0,true转换为1。
整数转换为布尔值:0转换为false,任何非零值(不管是正数还是负数)都会转换为true。

字符类型

字符类型大致分为char、signed char、unsigned char以及wchar_t,其中值得一说的是wchar_t,C++98/03 要求 wchar_t 必须足够大以表示所有支持的字符(如 UCS-4/UTF-32),但 Windows 系统的 16 位实现(UTF-16)无法满足这一要求。C++11 移除了这一限制,仅规定 wchar_t 是一个与整数类型大小兼容的独立类型,具体大小由编译器决定。
由于 wchar_t 的不一致性,现代 C++ 除了使用std::string外通常使用以下类型:

  • char16_t — 用于 UTF-16 字符表示的类型,要求足够大以表示任何 UTF-16 代码单元(16 位)。它具有与 std::uint_least16_t 相同的大小、符号性和对齐方式,但它是一个不同的类型。
  • char32_t — 用于 UTF-32 字符表示的类型,要求足够大以表示任何 UTF-32 代码单元(32 位)。它具有与 std::uint_least32_t 相同的大小、符号性和对齐方式,但它是一个不同的类型。
    (自 C++11 起)
  • char8_t — 用于 UTF-8 字符表示的类型,要求足够大以表示任何 UTF-8 代码单元(8 位)。它具有与 unsigned char 相同的大小、符号性和对齐方式(因此,与 char 和 signed char 相同的大小和对齐方式),但它是一个不同的类型。

C++保证1 == sizeof(char) ≤ sizeof(short) ≤ sizeof(int) ≤ sizeof(long) ≤ sizeof(long long)。除了整型外,C++还有布尔类型、字符类型以及浮点类型。注意:这允许极端情况,其中 字节大小为 64 位,所有类型(包括 char)都为 64 位宽,并且对于每种类型,sizeof 都返回 1。即当字节大小为 64 位时,所有整数类型(包括 char)都可能是 64 位宽,此时 sizeof 对所有类型都返回 1。
为了防止这个问题,为了避免依赖sizeof的假设,可以使用固定宽度类型或者标准库的类型特性。
固定宽度类型

/* Convenience types.  */
typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
typedef unsigned long int __u_long;

/* Fixed-size types, underlying types depend on word size and compiler.  */
typedef signed char __int8_t;
typedef unsigned char __uint8_t;
typedef signed short int __int16_t;
typedef unsigned short int __uint16_t;
typedef signed int __int32_t;
typedef unsigned int __uint32_t;
#if __WORDSIZE == 64
typedef signed long int __int64_t;
typedef unsigned long int __uint64_t;
#else
__extension__ typedef signed long long int __int64_t;
__extension__ typedef unsigned long long int __uint64_t;
#endif

/* Smallest types with at least a given width.  */
typedef __int8_t __int_least8_t;
typedef __uint8_t __uint_least8_t;
typedef __int16_t __int_least16_t;
typedef __uint16_t __uint_least16_t;
typedef __int32_t __int_least32_t;
typedef __uint32_t __uint_least32_t;
typedef __int64_t __int_least64_t;
typedef __uint64_t __uint_least64_t;

/* quad_t is also 64 bits.  */
#if __WORDSIZE == 64
typedef long int __quad_t;
typedef unsigned long int __u_quad_t;
#else
__extension__ typedef long long int __quad_t;
__extension__ typedef unsigned long long int __u_quad_t;
#endif

/* Largest integral types.  */
#if __WORDSIZE == 64
typedef long int __intmax_t;
typedef unsigned long int __uintmax_t;
#else
__extension__ typedef long long int __intmax_t;
__extension__ typedef unsigned long long int __uintmax_t;
#endif

通过源码我们可以看出固定宽度类型并非编译器内置类型,而是通过加上前面提到的各种类型限定词依靠typedef而成的类型别名。
类型特性:
C++ 标准库中 <type_traits> 头文件提供类型属性查询(property queries)、类型关系判断(type relations)、类型变换(type transformations)、条件类型(conditional traits)、以及辅助变量模板。
标准库类型 std::byte(C++17 起)明确规定为 8 位,可用于替代 char 进行位操作:
std::byte 是 C++17 引入的标准类型,用于表示原始内存字节。它提供了类型安全的位操作,避免了 char 和整数类型在内存操作中的隐患:

  • char 的歧义性:char 既可以表示字符(如 ‘A’),也可以作为原始字节(如 0xFF),导致语义混淆。
  • 整数提升问题:对 char 进行位运算时,会隐式提升为 int,可能导致意外结果。
  • 类型安全:std::byte 只能进行位运算,禁止隐式转换为整数或字符,强制显式类型转换。

特别的类

一、枚举(Enum)

枚举用于定义一个由离散常量组成的集合,本质是“命名的整数常量”,目的是提高代码的可读性和可维护性。

1. 基本语法
// 传统枚举(C和C++均支持)
enum Color {
    
       RED, GREEN, BLUE };  // 默认值:RED=0,GREEN=1,BLUE=2

// 带初始值的枚举
enum Month {
    
       JAN = 1, FEB, MAR, APR };  // FEB=2,MAR=3,以此类推

// C++11枚举类(强类型枚举)
enum class TrafficLight {
    
       RED, YELLOW, GREEN };  // 作用域限定,避免冲突
2. 核心特性
  • 本质是整数:枚举值默认从0开始递增,也可手动指定整数初始值(必须是整数)。
  • 类型特性
    • 传统枚举:枚举值暴露在全局作用域,可隐式转换为整数,可能导致命名冲突。
    • 枚举类(enum class):枚举值作用域限定在类内,禁止隐式转换为整数,更安全(推荐使用)。
  • 内存占用:与底层整数类型(通常是int)相同,枚举类可显式指定底层类型(如enum class E : uint8_t { A, B };)。
3. 适用场景
  • 表示固定的离散选项(如颜色、状态、月份等)。
  • 替代魔法数字(如用Status::SUCCESS代替1Status::ERROR代替-1)。
  • 限制变量的取值范围(如只能取预定义的几个值)。

示例

enum class LogLevel {
    
       DEBUG, INFO, WARN, ERROR };

void log(LogLevel level, const char* msg) {
    
      
    switch(level) {
    
      
        case LogLevel::DEBUG:  // 必须通过作用域访问
            printf("[DEBUG] %s\n", msg);
            break;
        // ... 其他case
    }
}

二、联合(Union)

联合是一种特殊的复合类型,允许在同一块内存空间中存储不同类型的数据(但同一时间只能有效存储一种类型),目的是节省内存。

1. 基本语法
// C风格联合
union Data {
    
      
    int i;         // 4字节(假设)
    float f;       // 4字节
    char str[10];  // 10字节
};  // 内存大小为最大成员的大小(10字节)

// C++中可包含成员函数和访问控制
union MyUnion {
    
      
private:
    double d;
public:
    int i;
    void setDouble(double val) {
    
       d = val; }
    double getDouble() {
    
       return d; }
};
2. 核心特性
  • 共享内存:所有成员共用同一块内存空间,修改一个成员会覆盖其他成员的值。
  • 大小:等于其最大成员的大小(需考虑内存对齐)。
  • 类型安全:C/C++不检查当前有效成员的类型,需手动管理(容易出错)。
  • C++扩展:C++中的联合可包含构造函数、析构函数和成员函数,但不能有虚函数。
3. 适用场景
  • 节省内存:当变量在不同时刻需要表示不同类型(但不会同时使用)时,如解析二进制数据(协议、文件格式)。
    // 示例:解析一个可能是int或float的二进制字段
    union BinaryData {
          
            
        int as_int;
        float as_float;
    };
    
  • 类型转换:通过联合的内存共享特性进行底层类型转换(需谨慎使用)。
    union Converter {
          
            
        float f;
        uint32_t u;
    };
    // 将float的二进制表示转换为uint32_t
    Converter c;
    c.f = 3.14f;
    uint32_t bits = c.u;  // 获取3.14的IEEE 754二进制表示
    

三、仿函数

在C++中,仿函数(Functor) 也称为函数对象(Function Object),是一种特殊的对象,其行为类似于函数。仿函数本质是一个类或结构体,通过重载函数调用运算符(),使得该类的实例可以像函数一样被调用。

核心特点

  1. 可调用性:通过()运算符重载实现。
  2. 状态存储:可以包含成员变量,存储调用状态或参数。
  3. 类型安全:作为模板参数传递时比普通函数更灵活。
  4. 性能优化:内联展开可能比普通函数调用更高效。

基本语法

class Adder {
    
      
public:
    int operator()(int a, int b) const {
    
        // 重载()运算符
        return a + b;
    }
};

// 使用示例
Adder adder;
int result = adder(3, 4);  // 等价于 adder.operator()(3, 4)

常见应用场景

1. STL算法中的谓词

在标准库算法(如std::sortstd::find_if)中作为自定义比较或筛选条件:

#include <algorithm>
#include <vector>

// 自定义比较仿函数
class GreaterThan {
    
      
private:
    int threshold;
public:
    GreaterThan(int val) : threshold(val) {
    
      }
    bool operator()(int x) const {
    
      
        return x > threshold;
    }
};

// 使用示例
std::vector<int> nums = {
    
      1, 5, 3, 7, 2};
auto it = std::find_if(nums.begin(), nums.end(), GreaterThan(4));
// it指向5(第一个大于4的元素)
2. 自定义排序规则
// 降序排序仿函数
struct Descending {
    
      
    bool operator()(int a, int b) const {
    
      
        return a > b;
    }
};

// 使用示例
std::sort(nums.begin(), nums.end(), Descending());
// 结果:{7, 5, 3, 2, 1}
3. 带状态的计算

仿函数可以保存状态,适用于需要累积结果的场景:

class SumCalculator {
    
      
private:
    int sum = 0;
public:
    void operator()(int x) {
    
      
        sum += x;
    }
    int getSum() const {
    
       return sum; }
};

// 使用示例
SumCalculator calc = std::for_each(nums.begin(), nums.end(), SumCalculator());
int total = calc.getSum();  // 计算总和
4. 回调函数封装

将不同行为封装到仿函数中,作为回调传递:

class FileProcessor {
    
      
public:
    void process(const std::string& filename, const std::function<void(const std::string&)>& callback) {
    
      
        // 读取文件内容
        callback("File content");
    }
};

// 使用lambda表达式(本质是匿名仿函数)作为回调
FileProcessor().process("data.txt", [](const std::string& content) {
    
      
    std::cout << "Processed: " << content << std::endl;
});

仿函数 vs 普通函数

特性 仿函数 普通函数
状态存储 可包含成员变量保存状态 依赖静态变量或全局变量
类型安全性 作为模板参数时类型明确 可能需要函数指针,类型较松散
性能 内联优化可能性更高 可能存在函数调用开销
灵活性 可通过继承实现多态行为 需依赖函数指针或虚函数

C++11后的改进

C++11引入的lambda表达式本质是编译器自动生成的匿名仿函数,简化了临时仿函数的使用:

// 使用lambda替代GreaterThan仿函数
auto it = std::find_if(nums.begin(), nums.end(), [](int x) {
    
      
    return x > 4;
});

类的内存布局

要想写好一个类,我们首先要了解类的内存布局,我们先来一个最简单的类:

class SimpleClass {
    
      
public:
    int a;      // 4字节
    char b;     // 1字节
    double c;   // 8字节
};
  • 成员变量按照声明顺序存放
  • 类要满足内存对齐的要求
  • 对象总大小为8的倍数
  • 此时这个类总大小为16字节
  • 如果有虚函数的情况下就会生成一个虚函数表,并且每个对象都会有一个指向该虚函数表的指针,vptr一般位于对象的起始位置(4/8字节,前者为32位系统,后者为64位系统),成员变量紧跟着vptr之后。例如:
class SimpleClass {
    
      
private:
    int a;      // 4字节
public:
    char b;     // 1字节
    double c;   // 8字节
	virtual void func() {
    
      };
};
  • 此时这个类的总大小为24字节,内存布局如下:
偏移量 | 内容
----------------
0-7    | vptr (8字节)
8-11   | int a (4字节)
12     | char b (1字节)
13-15  | 填充字节 (3字节)
16-23  | double c (8字节)

如果此时我们添加一个单继承:

class SimpleClass {
    
      
private:
    int a;      // 4字节
public:
    char b;     // 1字节
    double c;   // 8字节
	virtual void func() {
    
      };
};

class Derivedpublic SimpleClass{
    
      
public:
	int d;
    virtual void func() override {
    
      } 
}
  • 派生类的内存布局是基类部分在前,派生类部分在后
  • 派生类会继承基类的vptr,并且可能会扩展虚函数表
  • 虚函数表中,重写的函数会指向派生类的实现,未重写的函数则指向基类的实现
  • 此时这个类的总大小为32字节,内存布局如下:
偏移量 | 内容
----------------
0-7    | vptr (8字节)
8-11   | int a (4字节)
12     | char b (1字节)
13-15  | 填充 (3字节)
16-23  | double c (8字节)
24-27  | int d (4字节)
28-31  | 填充 (4字节,使总大小为8的倍数)

如果我们添加一个多重继承:

class SimpleClass1 {
    
      
private:
    int a;      // 4字节
public:
    char b;     // 1字节
    double c;   // 8字节
	virtual void func() {
    
      };
};

class SimpleClass2 {
    
      
private:
    int a;      // 4字节
public:
    char b;     // 1字节
    double c;   // 8字节
	virtual void func() {
    
      };
};

class Derivedpublic SimpleClass1, public SimpleClass2{
    
      
public:
	int d;
}
  • 按照继承的顺序,基类部分依次排序
  • 每个有虚函数的基类都有自己的vptr
  • 派生类成员位于所有基类部分之后
  • 此时类的大小为56字节
偏移量 | 内容
----------------
0-7    | SimpleClass1::vptr1 (指向 Derived 的虚表)
8-11   | SimpleClass1::a
12     | SimpleClass1::b
13-15  | 填充
16-23  | SimpleClass1::c
24-31  | SimpleClass2::vptr2 (指向 SimpleClass2 的虚表,可能被 Derived 重写)
32-35  | SimpleClass2::a
36     | SimpleClass2::b
37-39  | 填充
40-47  | SimpleClass2::c
48-51  | Derived::d
52-55  | 填充 (使总大小为 8 的倍数)

虚继承是为了解决菱形继承中的数据冗余问题(最后继承的那个派生类会有多份基类的对象),虚继承的核心目的是在多重继承中共享同一个基类实例(如 SimpleClass 在 Final 中仅存在一份)。但这带来一个问题:如何通过不同的派生类子对象(如 Derived1 或 Derived2)找到共享的基类实例?:

class SimpleClass {
private:
    int a;      // 4字节
public:
    char b;     // 1字节
    double c;   // 8字节
	virtual void func() {};
};

class Derived1:virtual public SimpleClass{
public:
	int d;
    virtual void func() override {} 
}

class Derived2:virtual public SimpleClass{
public:
	int e;
    virtual void func() override {} 
}

class Final: public Derived1 public Derived2{
public:
	int f;
    virtual void func() override {} 
}
  • 虚基类指针通过虚基类表中的偏移量,在运行时计算共享基类的位置,解决了菱形继承的二义性问题,每个包含虚函数的类都有自己的虚函数表(vptable),每个派生类都有自己的虚基类指针(vbptr),用于指向虚基类表(vbtable),其中记录了到自身对象起始地址的偏移以及到共享基类实例的偏移,即:
vbtable1:
[0] -> -8    (vbptr1 到 Derived1 起始地址的偏移)
[1] -> +56   (vbptr1 到 SimpleClass 起始地址的偏移)

vbtable2:
[0] -> -8    (vbptr2 到 Derived2 起始地址的偏移)
[1] -> +32   (vbptr2 到 SimpleClass 起始地址的偏移)
  • 在 C++ 的虚继承机制中,虚基类指针(vbptr)和虚基类表(vbtable)的存在与否,取决于类在继承层次中的角色。对于 Final 类而言,它作为最终派生类(最底层的派生类),不需要自己的 vbptr 和 vbtable
  • Derived1 和 Derived2 是 SimpleClass 的虚派生类(中间派生类),它们需要 vbptr:因为它们可能被进一步继承(如被 Final 继承),而后续的派生类(如 Final)需要通过它们的 vbptr 找到共享的 SimpleClass 实例。而Final 是整个继承链的最终派生类(没有子类),它的内存布局是固定的,不需要再为 “被其他类继承” 做准备。
  • 在虚继承中,最终派生类负责分配和固定虚基类的内存位置。Final 作为最终派生类,会直接将 SimpleClass 实例放在自己内存布局的某个固定位置(通常是末尾),它明确知道虚基类的地址,不需要通过 vbptr 间接计算偏移量
  • Derived1 和 Derived2 作为中间派生类,它们的 vbptr 已经存储了到 SimpleClass 的偏移量信息(在 vbtable 中)。当 Final 继承它们时,会使用这些中间类的 vbptr 来定位 SimpleClass,无需自己再新增 vbptr
  • vbtable 存储的是 “当前类到虚基类的偏移量”,仅中间类需要它(因为它们的位置可能在被继承后发生相对变化)。Final 作为最终类,位置固定,无需 vbtable
  • 多重虚继承中,所有层级的 vptr 指向最终派生类的虚表,确保动态绑定的一致性
  • 虚基类的部分会被放在对象的末尾,由所有派生类共享
  • 当 Final 对象转换为 Derived1* 或 Derived2* 时,需通过 vbptr 计算 SimpleClass 的位置,即:
Final obj;
Derived1* d1 = &obj;  // 无需调整,d1 直接指向 obj 的起始地址(0)

Derived2* d2 = &obj;  // 需要调整,d2 指向 obj 的 Derived2 子对象(偏移量 24)

SimpleClass* s1 = d1;  // 通过 Derived1::vbptr1 计算:0(d1) + 0(vbptr1 偏移) + 56(vbtable1[1]) = 56
SimpleClass* s2 = d2;  // 通过 Derived2::vbptr2 计算:24(d2) + 0(vbptr2 偏移) + 32(vbtable2[1]) = 56
  • 此时类的总大小为80字节(一般经过编译器优化后为64字节)
偏移量 | 内容
----------------
0-7    | Derived1::vbptr1
8-15   | Derived1::vptr1 (指向 Final 的虚表)
16-19  | Derived1::d
20-23  | 填充
24-31  | Derived2::vbptr2
32-39  | Derived2::vptr2 (指向 Final 的虚表)
40-43  | Derived2::e
44-47  | 填充
48-51  | Final::f
52-55  | 填充 (使 SimpleClass 对齐到 8 字节)
56-63  | SimpleClass::vptr3 (指向 Final 的虚表)
64-67  | SimpleClass::a
68     | SimpleClass::b
69-71  | 填充
72-79  | SimpleClass::c
  • 对于多重虚继承:
    每个有虚函数的类(无论是否虚继承)都有 vptr,指向自身类的虚表,但虚表中的函数入口可能被最终派生类重写。
    每个派生类有 vbptr,指向虚基类表,用于计算共享基类的位置。
    共享基类的虚表与其他类的虚表独立:vbptr 不指向基类的虚表,而是通过偏移量定位基类实例。

但是目前为止我们对于多重虚继承的了解还不够,我们需要更加深入的剖析一下,第一个问题是虚继承相比于普通继承多继承了什么:

  • 对于普通继承来说,每个继承路径都会保留独立的基类副本,存在菱形继承时会导致数据冗余和访问二义性
  • 对于虚继承来说所有继承路径共享同一个基类实例,并通过虚基类指针和虚基类表来实现动态定位,这两个组件也就是虚继承相较于普通继承多出的内容
  • 每个派生类对象包含一个vbptr,通常位于对象起始位置,vbptr指向虚基类表,虚基类表存储到共享基类的偏移量
  • 虚基类表记录两个偏移量,分别是vbptr到自身对象起始地址的偏移(通常为负数)和vbptr到共享基类实例的偏移(正数)
  • 在普通继承中基类子对象的位置在编译时固定(位于对象起始位置),访问基类成员只需直接偏移
  • 在虚继承中,基类位置由vbptr动态计算,访问基类成员需经历两次间接访问:通过vbptr找到vbtable,从vbtable中读取基类偏移量,计算基类地址

第二个问题是,为什么derived不复用simpleclass的vptr:

  • 虚继承会导致基类子对象的位置在运行时动态确定,这使得基类的 vptr 的复用变得复杂。在普通继承中,基类子对象的位置是固定的(位于派生类对象的起始地址),因此派生类可以直接复用基类的 vptr。但在虚继承中,共享基类的位置在运行时动态计算(通过 vbptr),导致:派生类对象的起始地址与基类子对象的起始地址可能不连续。如果派生类复用基类的 vptr,当对象转换为基类指针时,vptr 的地址需要动态调整,这会增加额外开销。
  • 如果 Derived 复用 SimpleClass 的 vptr,每次通过 Derived 对象调用虚函数时,需要先通过 vbptr 定位到 SimpleClass 的 vptr,再通过 vptr 调用虚函数,这会导致两次间接访问(效率较低)。为了优化性能,编译器通常会为虚继承的派生类单独分配 vptr,使其直接指向派生类的虚表,减少间接访问次数。

第三个问题是,为什么 Final 可以复用 Derived 的 vptr,而 Derived 不能复用 SimpleClass 的 vptr:

  • Final 是最终派生类,它重写的虚函数会覆盖所有基类虚表中的对应条目。Final 继承自 Derived1 和 Derived2,这两个基类已有 vptr,Final 直接复用这些 vptr,并更新虚表内容。复用的前提:Derived1 和 Derived2 的 vptr 位置在编译时是固定的(分别位于各自子对象的起始地址)。
  • SimpleClass 是虚基类,其位置在运行时动态确定(通过 vbptr)。如果 Derived 复用 SimpleClass 的 vptr,当 Derived 对象转换为 SimpleClass* 时,vptr 的地址需要动态调整,这会导致额外开销。为了避免这种开销,编译器为 Derived 单独分配 vptr,使其直接指向派生类的虚表。

第四个问题是,假设 Final 不仅重写 func(),还新增了一个基类没有的虚函数会怎样,此时,编译器是否会为 Final 新增一个 vptr?:

  • 编译器通常会为 Final 新增一个独立的虚函数指针(vptr)。这是因为新增的虚函数无法被现有基类的虚函数表(vtable)容纳,需要一个新的 vtable 来存储这些新增虚函数的地址,而这个新的 vtable 需要通过 Final 自己的 vptr 来指向。

我们可以通过下面的代码来验证这一点:

#include <iostream>
#include <cstddef>  // 包含offsetof宏(注意:非POD类型使用可能是未定义行为,但多数编译器支持)

class SimpleClass {
    
      
private:
    int a;
public:
    char b;
    double c;
    virtual void func() {
    
      };
};

class Derived1 : virtual public SimpleClass {
    
      
public:
    int d;
    virtual void func() override {
    
      }
};

class Derived2 : virtual public SimpleClass {
    
      
public:
    int e;
    virtual void func() override {
    
      }
};

class Final : public Derived1, public Derived2 {
    
      
public:
    int f;
    virtual void func() override {
    
      };
    virtual void newVirtualFunc() {
    
      
        std::cout << "Final::newVirtualFunc()" << std::endl;
    }
};

int main() {
    
      
    Final obj;
    Final* p = &obj;

    // 打印对象起始地址
    std::cout << "Final对象起始地址: " << static_cast<void*>(p) << std::endl;
    std::cout << "对象总大小: " << sizeof(obj) << " 字节" << std::endl;
    std::cout << "-------------------------" << std::endl;

    // 打印Derived1成员d的地址与偏移
    std::cout << "Derived1::d 地址: " << &(p->d) << ",偏移: "
        << reinterpret_cast<char*>(&(p->d)) - reinterpret_cast<char*>(p) << " 字节" << std::endl;

    // 打印Derived2成员e的地址与偏移
    std::cout << "Derived2::e 地址: " << &(p->e) << ",偏移: "
        << reinterpret_cast<char*>(&(p->e)) - reinterpret_cast<char*>(p) << " 字节" << std::endl;

    // 打印Final成员f的地址与偏移
    std::cout << "Final::f    地址: " << &(p->f) << ",偏移: "
        << reinterpret_cast<char*>(&(p->f)) - reinterpret_cast<char*>(p) << " 字节" << std::endl;

    // 打印SimpleClass成员的地址与偏移(需通过虚基类访问)
    std::cout << "SimpleClass::b 地址: " << &(p->b) << ",偏移: "
        << reinterpret_cast<char*>(&(p->b)) - reinterpret_cast<char*>(p) << " 字节" << std::endl;
    std::cout << "SimpleClass::c 地址: " << &(p->c) << ",偏移: "
        << reinterpret_cast<char*>(&(p->c)) - reinterpret_cast<char*>(p) << " 字节" << std::endl;

    // 尝试打印虚函数表指针(编译器相关,非标准行为)
    // 注意:64位系统指针为8字节,以下代码仅作演示,可能在部分编译器失效
    void** vptr = reinterpret_cast<void**>(p);  // 假设对象首地址是vptr
    std::cout << "-------------------------" << std::endl;
    std::cout << "虚函数表指针(vptr)地址: " << &vptr[0] << ",指向: " << vptr[0] << std::endl;

    return 0;
}

将newVirtualFunc()去掉前后总大小分别输出72和64,可以知道确实新增了一个vptr,由于没有标准的方式去寻找vptr的地址,所以只能变相的去推测。

类的内存管理

构造函数

C++ 中函数参数传递有两种基本方式:

  • 值传递:函数接收实参的副本(会调用对应类型的拷贝构造函数创建副本)。
  • 引用传递:函数接收实参的别名(不创建副本,直接操作原对象)。

因此如果拷贝构造函数的参数采用值传递(不加 &),语法上会写成:

class MyClass {
    
      
public:
    MyClass(const MyClass other) {
    
       // 错误:参数为值传递
        // 用 other 初始化当前对象
    }
};

此时,当我们执行 MyClass a; MyClass b(a);(用 a 初始化 b)时,会触发以下连锁反应:

  • 调用 b 的拷贝构造函数 MyClass(const MyClass other),实参是 a。
  • 由于参数是值传递,编译器需要先创建 a 的副本 other(即 other = a)。
  • 创建 other 的过程本质上也是 “用已有对象初始化新对象”,因此会再次调用 MyClass 的拷贝构造函数。
  • 新的拷贝构造函数调用又需要创建参数副本,导致无限递归,最终引发栈溢出(Stack Overflow)。

如果参数采用引用传递(加 &),定义如下:

class MyClass {
    
      
public:
    MyClass(const MyClass& other) {
    
       // 正确:参数为引用
        // 用 other 初始化当前对象
    }
};

此时,执行 MyClass a; MyClass b(a); 时:

  • 调用 b 的拷贝构造函数,参数 other 是 a 的引用(别名),不创建副本。
  • 构造函数内部直接使用 other(即 a)的数据初始化 b,整个过程只调用一次拷贝构造函数,无递归。

引用传递的核心是避免了参数的额外拷贝,打破了 “拷贝构造→参数拷贝→再拷贝构造” 的死循环。
C++ 标准明确规定:拷贝构造函数的第一个参数必须是自身类型的引用(可以是 const 或非 const),否则编译器会将其视为普通构造函数,而非拷贝构造函数。
虽然 C++ 标准不强制要求拷贝构造函数的参数必须是 const,但几乎所有场景下都会使用 const 类名&,原因是:

  • 防止意外修改原对象:拷贝构造的目的是复制原对象,而非修改它,const 可以保证这一点。
  • 支持常量对象的拷贝:如果原对象是 const 类型(如 const MyClass a;),非 const 引用参数无法接收它,会导致编译错误。

析构函数

析构函数没有返回值也不接受参数(因此无法重载,一个类只能有一个析构函数),并且析构函数并不直接释放内存,而是释放对象所管理的资源(包括动态内存),对象自身的内存释放由编译器(栈)或delete(堆)操作完成。
C++的对象内存管理遵循“谁创建,谁销毁”的原则。栈对象是由编译器自动分配的,离开作用域时编译器会自动调用析构函数清理资源并释放内存;堆对象由程序员通过new手动分配,必须通过delete手动触发析构函数清理资源并释放内存,否则会导致内存泄漏,销毁顺序遵循与初始化顺序相反
栈上创建对象:

class MyClass {
    
      };

void func() {
    
      
    MyClass obj; // 栈上创建,离开函数作用域时自动销毁
}

堆上创建对象:

MyClass* ptr = new MyClass(); // 堆上创建
delete ptr; // 手动释放,否则内存泄漏

静态存储区创建对象:

MyClass globalObj; // 全局对象,程序启动时创建,结束时销毁

void func() {
    
      
    static MyClass staticLocalObj; // 静态局部对象,首次调用时创建,程序结束时销毁
}

class MyClass {
    
      
    static MyClass staticMemberObj; // 静态成员对象,属于类而非实例
};

类的实例被销毁时,其静态成员对象并不会随之销毁。因为静态成员对象属于类本身,类的静态成员对象本质是类级别的全局资源,其生命周期不受实例影响。
对于类的静态成员对象(包括静态成员变量、静态成员实例),整个程序中只会存在一份副本,被所有类的实例共享。这是因为静态成员属于类本身,而非某个实例,存储在静态存储区,与类绑定而非与实例绑定。例如:

class Counter {
    
      
public:
    static int count; // 静态成员变量(本质是类级别的全局变量)
};

int Counter::count = 0; // 类外初始化

int main() {
    
      
    Counter a, b, c;
    a.count++;  // 所有实例共享同一个count
    b.count++;
    c.count++;
    cout << Counter::count; // 输出3(而非1)
    return 0;
}

无论创建多少个Counter实例,count始终只有一个,所有操作都作用于同一份数据。
静态成员必须通过类名或实例访问,不能直接使用成员名(与非静态成员不同)。具体有两种合法方式:

  • 类名::静态成员(推荐,更清晰地表明是静态成员)
  • 实例名。静态成员(语法允许,但本质仍是访问类的静态成员)
class MyClass {
    
      
public:
    static int static_val;
};
int MyClass::static_val = 0;

int main() {
    
      
    MyClass obj;
    
    // 合法访问方式
    MyClass::static_val = 10; // 推荐:通过类名访问
    obj.static_val = 20;      // 允许:通过实例访问(实际操作的仍是类的静态成员)
    
    // 不合法:直接访问会编译错误
    // static_val = 30;  ❌ 必须指定类或实例
    return 0;
}

new、delete、malloc和free

特性 malloc/free new/delete
所属语言 C 语言标准库函数 C++ 关键字(运算符)
操作对象 仅分配/释放内存(字节流) 不仅分配内存,还会调用对象的构造/析构函数
参数形式 malloc(size_t size):需手动指定字节数 new 类型(初始化值):自动计算类型大小
返回值类型 返回 void*,需强制类型转换 返回对应类型的指针,无需转换
内存不足处理 返回 NULL(需手动检查) 抛出 std::bad_alloc 异常(默认行为)
数组支持 需手动计算总字节数(如 malloc(n*sizeof(int)) 直接支持数组:new 类型[n]delete[] 释放
重载能力 无法重载 可通过重载 operator new/operator delete 自定义内存管理

以new为例,调用new时,new会调用operator new函数分配内存(底层可通过malloc实现),自动调用对象的构造函数初始化内存(与malloc的关键区别,malloc/new仅管理内存,而new/delete不仅管理内存,还会处理对象的生命周期)。其中operator new并不是运算符重载,而是语言内置的特殊的全局函数,负责动态内存的分配,因此我们重载new的实现本质上是重载operator new函数(包括全局重载和类级重载),从而自定义动态内存的分配逻辑(如使用内存池、添加内存跟踪、实现特殊对齐等)。例如:

#include <iostream>
#include <cstdlib> // 用于 malloc/free

class MyClass {
    
      
private:
    int data;
public:
    // 构造函数
    MyClass(int d) : data(d) {
    
      
        std::cout << "MyClass 构造函数,data = " << data << "\n";
    }

    // 析构函数
    ~MyClass() {
    
      
        std::cout << "MyClass 析构函数\n";
    }

    // 1. 重载单个对象的 operator new
    // 参数:size_t size(必须是这个类型,代表需要分配的字节数)
    // 返回值:void*(指向分配的内存)
    void* operator new(size_t size) {
    
      
        std::cout << "类级 operator new 分配 " << size << " 字节\n";
        // 自定义内存分配(这里用 malloc 示例,实际可替换为内存池等)
        void* ptr = std::malloc(size);
        if (!ptr) {
    
       // 处理内存分配失败
            throw std::bad_alloc(); // 符合标准行为:抛出异常
        }
        return ptr;
    }

    // 2. 必须配套重载 operator delete(释放内存)
    // 当 new 分配内存后构造函数抛出异常时,会调用此函数释放内存
    void operator delete(void* ptr) noexcept {
    
      
        std::cout << "类级 operator delete 释放内存\n";
        std::free(ptr); // 与分配逻辑对应(malloc 分配则 free 释放)
    }

    // 3. 重载数组的 operator new[](用于 new MyClass[n])
    void* operator new[](size_t size) {
    
      
        std::cout << "类级 operator new[] 分配 " << size << " 字节(数组)\n";
        void* ptr = std::malloc(size);
        if (!ptr) throw std::bad_alloc();
        return ptr;
    }

    // 4. 配套重载数组的 operator delete[]
    void operator delete[](void* ptr) noexcept {
    
      
        std::cout << "类级 operator delete[] 释放数组内存\n";
        std::free(ptr);
    }
};

// 使用示例
int main() {
    
      
    // 测试单个对象
    MyClass* obj = new MyClass(10); // 调用类级 operator new
    delete obj; // 调用类级 operator delete

    // 测试数组
    MyClass* arr = new MyClass[2]{
    
      20, 30}; // 调用类级 operator new[]
    delete[] arr; // 调用类级 operator delete[]

    return 0;
}

智能指针

在C++中,智能指针是一种封装了原始指针的模板类,它通过RAII(资源获取即初始化) 思想自动管理动态内存,避免手动调用delete导致的内存泄漏、重复释放、悬空指针等问题。C++标准库提供了四种主要的智能指针(其中auto_ptr已被弃用),下面详细介绍:

一、为什么需要智能指针?

手动管理动态内存(new/delete)存在以下风险:

  • 内存泄漏:忘记调用delete释放内存;
  • 重复释放:对同一指针多次调用delete,导致未定义行为;
  • 悬空指针:指针指向的内存已被释放,但指针未置空,后续访问导致崩溃;
  • 异常安全问题:若new后、delete前抛出异常,delete可能无法执行,导致内存泄漏。

智能指针通过对象生命周期管理资源:当智能指针对象销毁时(如离开作用域),其析构函数会自动释放所管理的内存,从根本上避免上述问题。

二、主要智能指针类型

1. auto_ptr(C++11弃用,C++17移除)

auto_ptr是C++98引入的首个智能指针,试图解决独占所有权问题,但设计存在缺陷,已被unique_ptr替代。

缺陷
通过“所有权转移”实现复制(赋值时原指针会失效),导致意外的悬空指针。例如:

auto_ptr<int> p1(new int(10));
auto_ptr<int> p2 = p1;  // p1失去所有权,变为空指针
*p1 = 20;  // 未定义行为(访问空指针)

结论:永远不要使用auto_ptr,改用unique_ptr

2. unique_ptr(C++11引入)

unique_ptr独占所有权的智能指针:同一时间只能有一个unique_ptr管理某块内存,所有权不可复制,只能转移。

核心特性:
  • 独占性:禁止复制(copy操作被删除),只能通过std::move转移所有权;
  • 高效:无额外开销(仅封装原始指针),性能接近原始指针;
  • 支持数组:有专门的unique_ptr<T[]>版本,析构时会自动调用delete[]
基本用法:
#include <memory>  // 智能指针头文件

// 1. 创建unique_ptr(推荐用make_unique,C++14引入)
std::unique_ptr<int> up1 = std::make_unique<int>(10);  // 管理int(10)
std::unique_ptr<int[]> up2 = std::make_unique<int[]>(5);  // 管理大小为5的int数组

// 2. 访问资源(重载*和->运算符)
*up1 = 20;
up2[0] = 100;

// 3. 转移所有权(通过std::move)
std::unique_ptr<int> up3 = std::move(up1);  // up1失去所有权,变为nullptr
if (up1 == nullptr) {
    
      
    std::cout << "up1 is null" << std::endl;  // 输出:up1 is null
}

// 4. 主动释放资源(reset())
up3.reset();  // 释放内存,up3变为nullptr
适用场景:
  • 管理独占资源(如局部动态对象、函数返回动态对象);
  • 作为容器元素(避免复制,适合移动语义);
  • 替代auto_ptr,解决所有权管理问题。
3. shared_ptr(C++11引入)

shared_ptr共享所有权的智能指针:多个shared_ptr可同时管理同一块内存,通过引用计数(reference count)跟踪所有者数量,当最后一个shared_ptr销毁时,才释放内存。

核心特性:
  • 共享性:支持复制,复制时引用计数+1;
  • 引用计数:内部维护一个控制块(存储引用计数、弱引用计数、删除器等);
  • 线程安全:引用计数的增减是原子操作(多线程环境下安全),但对管理的资源访问需手动加锁。
基本用法:
#include <memory>

// 1. 创建shared_ptr(推荐用make_shared,更高效)
std::shared_ptr<int> sp1 = std::make_shared<int>(100);  // 引用计数=1

// 2. 复制:引用计数+1
std::shared_ptr<int> sp2 = sp1;  // 引用计数=2
std::shared_ptr<int> sp3(sp2);   // 引用计数=3

// 3. 访问资源
*sp1 = 200;
std::cout << *sp3 << std::endl;  // 输出:200(所有shared_ptr共享同一资源)

// 4. 销毁shared_ptr:引用计数-1
sp1.reset();  // 引用计数=2
sp2 = nullptr; // 引用计数=1
sp3.reset();  // 引用计数=0,内存被释放
引用计数原理:

shared_ptr内部包含两个指针:

  • 指向管理资源的原始指针
  • 指向控制块的指针(控制块存储:引用计数、弱引用计数、自定义删除器等)。

当复制shared_ptr时,仅复制这两个指针,并将控制块中的引用计数+1;当shared_ptr销毁时,引用计数-1,若计数为0,则释放资源和控制块。

注意事项:
  • 避免循环引用:两个shared_ptr互相引用会导致引用计数无法归零,内存泄漏(见weak_ptr解决方法);
  • 不要用同一原始指针初始化多个shared_ptr:会导致重复释放(控制块独立,引用计数各自为1);
    int* raw = new int(10);
    std::shared_ptr<int> sp1(raw);
    std::shared_ptr<int> sp2(raw);  // 错误!sp1和sp2的控制块独立,析构时会重复释放raw
    
  • 优先使用make_shared:它能一次性分配资源和控制块的内存,比直接用new更高效,且避免内存泄漏风险。
4. weak_ptr(C++11引入)

weak_ptr是一种弱引用智能指针,它不拥有资源的所有权,仅作为shared_ptr的“观察者”,用于解决shared_ptr循环引用问题。

核心特性:
  • 不影响引用计数weak_ptr不会增加shared_ptr的引用计数;
  • 无法直接访问资源:需通过lock()方法获取shared_ptr后才能访问;
  • 生命周期独立weak_ptr的存在不影响资源的释放。
循环引用问题及解决:

循环引用示例(内存泄漏):

struct A {
    
      
    std::shared_ptr<B> b_ptr;
};
struct B {
    
      
    std::shared_ptr<A> a_ptr;  // B持有A的shared_ptr
};

int main() {
    
      
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->b_ptr = b;  // a引用b(b的引用计数=2)
    b->a_ptr = a;  // b引用a(a的引用计数=2)
    
    // 离开作用域时,a和b的引用计数各减1(变为1),但互相引用导致计数无法归零
    // 内存泄漏!A和B的内存不会被释放
}

weak_ptr解决:将其中一个shared_ptr改为weak_ptr,打破循环:

struct A {
    
      
    std::shared_ptr<B> b_ptr;
};
struct B {
    
      
    std::weak_ptr<A> a_ptr;  // 改为weak_ptr,不增加a的引用计数
};

int main() {
    
      
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->b_ptr = b;  // b的引用计数=2
    b->a_ptr = a;  // a的引用计数仍为1(weak_ptr不增加计数)
    
    // 离开作用域时:
    // a销毁 → a的引用计数=0 → 释放A的内存
    // b销毁 → b的引用计数=1→0 → 释放B的内存
    // 无内存泄漏!
}
基本用法:
#include <memory>

int main() {
    
      
    auto sp = std::make_shared<int>(10);  // shared_ptr管理资源
    std::weak_ptr<int> wp = sp;  // weak_ptr观察sp(不影响引用计数)
    
    // 通过lock()获取shared_ptr访问资源(若资源已释放,lock()返回nullptr)
    if (auto tmp = wp.lock()) {
    
        // tmp是shared_ptr<int>,引用计数+1
        std::cout << *tmp << std::endl;  // 输出:10
    }  // tmp销毁,引用计数-1
    
    sp.reset();  // 资源释放,引用计数=0
    if (wp.lock() == nullptr) {
    
      
        std::cout << "资源已释放" << std::endl;  // 输出:资源已释放
    }
}

三、智能指针的底层实现核心

所有智能指针本质是模板类,核心实现包括:

  1. 封装原始指针:存储指向资源的原始指针;
  2. 重载运算符*(解引用)、->(成员访问),模拟原始指针的行为;
  3. 析构函数:在智能指针对象销毁时,自动释放资源(unique_ptr直接释放,shared_ptr检查引用计数);
  4. 禁用/控制复制unique_ptr删除copy构造和赋值,shared_ptr实现copy时增减引用计数。

四、总结

智能指针 所有权 核心特性 适用场景
auto_ptr 独占(有缺陷) 已弃用,复制会转移所有权导致悬空指针 禁止使用
unique_ptr 独占 不可复制,可移动,高效无开销 管理独占资源(如局部对象)
shared_ptr 共享 引用计数,支持复制,适合多所有者场景 管理共享资源(如跨作用域对象)
weak_ptr 无(弱引用) 不影响引用计数,解决shared_ptr循环引用 配合shared_ptr观察资源

最佳实践

  • 优先使用unique_ptr(性能最优,独占场景首选);
  • 共享资源用shared_ptr,并优先通过make_shared创建;
  • weak_ptr解决shared_ptr的循环引用;
  • 永远不要混合使用智能指针和原始指针管理同一资源。

我们需要详细讲讲循环引用这个问题:循环引用是指两个或多个shared_ptr互相持有对方的引用,导致引用计数无法归零,最终造成内存泄漏的现象,以下面为例:

#include <memory>
#include <iostream>

// 前向声明
struct B;

struct A {
    
      
    std::shared_ptr<B> b_ptr;  // A对象持有B对象的shared_ptr
    ~A() {
    
       std::cout << "A被销毁" << std::endl; }
};

struct B {
    
      
    std::shared_ptr<A> a_ptr;  // B对象持有A对象的shared_ptr
    ~B() {
    
       std::cout << "B被销毁" << std::endl; }
};

int main() {
    
      
    {
    
      
        // 创建A和B的shared_ptr
        std::shared_ptr<A> a = std::make_shared<A>();  // a的引用计数 = 1
        std::shared_ptr<B> b = std::make_shared<B>();  // b的引用计数 = 1

        // 建立互相引用
        a->b_ptr = b;  // B的引用计数变为2(b本身 + a->b_ptr)
        b->a_ptr = a;  // A的引用计数变为2(a本身 + b->a_ptr)
    }  // 离开作用域,a和b被销毁

    std::cout << "程序结束" << std::endl;
    return 0;
}

我们首先要明白,shared_ptr本质是一个管理对象的工具,它自身(作为一个指针容器)的生命周期,和它所管理的对象(如A或B的实例)的生命周期是完全分离的,也就是说shared_ptr自身的销毁(如离开作用域),只会影响所管理对象的引用计数(减1),所管理的对象(如A实例)的销毁只取决于引用计数是否归0,与某个具体的shared_ptr是否销毁无关。
因此,当a和b被销毁时,由于B对象还持有着A对象的shared_ptr导致A对象的引用计数不为0,导致A对象不会被销毁,而A对象不被销毁导致其持有的B对象的shared_ptr也不会被销毁,因此导致B对象也会活着,这就造成了循环引用。
为了解决shared_ptr的循环引用问题,C++11引入了weak_ptr,但是weak_ptr的设计目的是打破已知的循环引用,其生效的前提是:开发者在编译时就能明确知道 “哪里会形成环”,从而针对性地用weak_ptr替代shared_ptr,避免引用计数累加。这也被称为静态环,但当遇到动态环时,环的形成完全依赖运行时的输入、操作或状态,编译时无法预知其结构。例如下面的例子:

#include <iostream>
#include <memory>
#include <string>
#include <unordered_set>

// 社交网络中的用户
class User {
    
      
private:
    std::string name;
    // 用shared_ptr存储好友列表(强引用)
    std::unordered_set<std::shared_ptr<User>> friends;

public:
    User(std::string n) : name(std::move(n)) {
    
      }
    
    // 添加好友(双向引用)
    void addFriend(std::shared_ptr<User> friend_user) {
    
      
        if (friend_user) {
    
      
            friends.insert(friend_user);
            friend_user->friends.insert(shared_from_this()); // 自己也添加对方为好友
        }
    }
    
    // 打印好友列表(用于观察)
    void printFriends() const {
    
      
        std::cout << name << "的好友:";
        for (const auto& f : friends) {
    
      
            std::cout << f->name << " ";
        }
        std::cout << std::endl;
    }
    
    ~User() {
    
      
        std::cout << name << "被销毁" << std::endl;
    }
};

int main() {
    
      
    // 场景1:正常的非环形关系(无内存泄漏)
    {
    
      
        auto alice = std::make_shared<User>("Alice");
        auto bob = std::make_shared<User>("Bob");
        
        alice->addFriend(bob); // Alice和Bob互相加好友
        alice->printFriends(); // Alice的好友:Bob
        bob->printFriends();   // Bob的好友:Alice
    } // 离开作用域,alice和bob引用计数归0,正常销毁
    std::cout << "场景1结束\n" << std::endl;
    
    // 场景2:运行时动态形成的环(内存泄漏)
    {
    
      
        auto charlie = std::make_shared<User>("Charlie");
        auto dave = std::make_shared<User>("Dave");
        auto eve = std::make_shared<User>("Eve");
        
        // 形成环:Charlie ←→ Dave ←→ Eve ←→ Charlie
        charlie->addFriend(dave);
        dave->addFriend(eve);
        eve->addFriend(charlie); // 这一步在运行时才决定,编译时无法预知
        
        charlie->printFriends(); // Charlie的好友:Dave Eve
        dave->printFriends();   // Dave的好友:Charlie Eve
        eve->printFriends();    // Eve的好友:Dave Charlie
    } // 离开作用域,但环中的对象引用计数无法归0
    std::cout << "场景2结束(但对象未被销毁,发生内存泄漏)" << std::endl;
    
    return 0;
}

环的动态性:

  • 在场景 2 中,Charlie→Dave→Eve→Charlie形成的环完全取决于运行时的addFriend调用顺序。如果用户操作不同(比如eve没有添加charlie为好友),则不会形成环。这种不确定性是动态环的核心特征。
    weak_ptr 为何无效:

开发者在设计User类时,无法预知:

  • 哪些用户会形成环(可能是任意组合);
  • 环的引用路径是什么(比如 3 人环、4 人环,甚至更复杂);
  • 该将哪个addFriend中的引用改为weak_ptr(改任何一个都可能导致正常的好友关系提前失效)。

为什么这是动态环:

  • 结构不可预测:编译时无法知道用户会创建 3 人环、4 人环,还是根本不形成环。
  • 依赖运行时输入:环的形成由用户操作(调用addFriend的顺序和参数)决定。
  • weak_ptr 无法干预:没有固定的 “环断点” 可供开发者用weak_ptr打破循环。

这种场景在现实中很常见但C++却无法解决,比如社交网络、知识图谱、脚本语言中的对象引用等,也是为什么这些场景往往需要垃圾回收(GC)来解决问题。但也不是绝对的,对于这种情况有两种解决方法,第一种依赖于业务逻辑,第二种没有限制,也就是自己实现一个局部GC:

#include <iostream>
#include <memory>
#include <string>
#include <unordered_set>

class User {
    
      
private:
    std::string name;
    // 好友列表:用shared_ptr存储“我添加的好友”,用weak_ptr存储“添加我的好友”
    std::unordered_set<std::shared_ptr<User>> friends_added;  // 强引用(我主动加的)
    std::unordered_set<std::weak_ptr<User>, std::owner_less<std::weak_ptr<User>>> friends_adding;  // 弱引用(加我的)

public:
    User(std::string n) : name(std::move(n)) {
    
      }
    
    // 添加好友:我持有对方的强引用,对方持有我的弱引用
    void addFriend(std::shared_ptr<User> friend_user) {
    
      
        if (!friend_user) return;
        
        friends_added.insert(friend_user);
        friend_user->friends_adding.insert(shared_from_this()); // 对方存我的弱引用
    }
    
    // 打印所有有效好友(需检查weak_ptr是否有效)
    void printAllFriends() const {
    
      
        std::cout << name << "的所有好友:";
        
        // 处理我添加的好友(强引用,一定有效)
        for (const auto& f : friends_added) {
    
      
            std::cout << f->name << " ";
        }
        
        // 处理添加我的好友(弱引用,需.lock()检查有效性)
        for (const auto& wp : friends_adding) {
    
      
            if (auto sp = wp.lock()) {
    
       // 弱引用升级为强引用(若对象仍存在)
                std::cout << sp->name << " ";
            }
        }
        std::cout << std::endl;
    }
    
    ~User() {
    
      
        std::cout << name << "被销毁" << std::endl;
    }
};

int main() {
    
      
    {
    
      
        auto charlie = std::make_shared<User>("Charlie");
        auto dave = std::make_shared<User>("Dave");
        auto eve = std::make_shared<User>("Eve");
        
        // 形成潜在环:但引用方向是“强→弱”
        charlie->addFriend(dave);   // Charlie强引用Dave,Dave弱引用Charlie
        dave->addFriend(eve);       // Dave强引用Eve,Eve弱引用Dave
        eve->addFriend(charlie);    // Eve强引用Charlie,Charlie弱引用Eve
        
        charlie->printAllFriends(); // Charlie的好友:Dave(强) + Eve(弱,有效)
        dave->printAllFriends();   // Dave的好友:Eve(强) + Charlie(弱,有效)
    } // 离开作用域,所有对象引用计数归0,正常销毁
    std::cout << "场景结束,对象均被销毁" << std::endl;
    
    return 0;
}

这是第一种解决方法,规定 “单向弱引用”:A 添加 B 为好友时,A 持有 B 的shared_ptr,但 B 持有 A 的weak_ptr。这样即使形成闭环,弱引用也不会阻止对象释放。

#include <iostream>
#include <memory>
#include <string>
#include <unordered_set>
#include <vector>
#include <algorithm>

// GC管理的用户类(不直接用shared_ptr,由GC统一管理)
class GCUser {
    
      
private:
    std::string name;
    std::unordered_set<GCUser*> friends; // 用原始指针存储引用(避免引用计数)
    bool marked = false; // 标记-清除算法用:是否可达

public:
    GCUser(std::string n) : name(std::move(n)) {
    
      }
    
    void addFriend(GCUser* friend_user) {
    
      
        if (friend_user) {
    
      
            friends.insert(friend_user);
        }
    }
    
    // 标记可达对象(递归遍历所有好友)
    void mark() {
    
      
        if (marked) return;
        marked = true;
        for (auto* f : friends) {
    
      
            f->mark();
        }
    }
    
    // 重置标记(供下一次GC使用)
    void unmark() {
    
      
        marked = false;
    }
    
    bool isMarked() const {
    
       return marked; }
    std::string getName() const {
    
       return name; }
    
    ~GCUser() {
    
      
        std::cout << name << "被GC销毁" << std::endl;
    }
};

// 局部GC管理器(负责所有用户对象的生命周期)
class UserGC {
    
      
private:
    std::vector<std::unique_ptr<GCUser>> all_users; // 管理所有对象的所有权
    std::unordered_set<GCUser*> roots; // 根对象(当前可直接访问的对象)

public:
    // 创建新用户(自动加入GC管理)
    GCUser* createUser(std::string name) {
    
      
        auto user = std::make_unique<GCUser>(std::move(name));
        GCUser* raw_ptr = user.get();
        all_users.push_back(std::move(user));
        return raw_ptr;
    }
    
    // 添加根对象(如当前登录的用户)
    void addRoot(GCUser* user) {
    
      
        if (user) roots.insert(user);
    }
    
    // 移除根对象(如用户退出登录)
    void removeRoot(GCUser* user) {
    
      
        roots.erase(user);
    }
    
    // 执行GC:标记-清除不可达对象
    void collectGarbage() {
    
      
        std::cout << "\n开始GC..." << std::endl;
        
        // 第一步:标记所有可达对象(从根对象出发)
        for (auto* root : roots) {
    
      
            root->mark();
        }
        
        // 第二步:清除未标记的对象(不可达对象)
        all_users.erase(
            std::remove_if(all_users.begin(), all_users.end(),
                [](const std::unique_ptr<GCUser>& user) {
    
      
                    return !user->isMarked(); // 未标记的对象被清除
                }),
            all_users.end()
        );
        
        // 第三步:重置所有对象的标记(供下次GC使用)
        for (const auto& user : all_users) {
    
      
            user->unmark();
        }
        
        std::cout << "GC结束,剩余" << all_users.size() << "个对象" << std::endl;
    }
};

int main() {
    
      
    UserGC gc;
    
    // 创建用户(由GC管理)
    GCUser* charlie = gc.createUser("Charlie");
    GCUser* dave = gc.createUser("Dave");
    GCUser* eve = gc.createUser("Eve");
    
    // 形成环:Charlie ←→ Dave ←→ Eve ←→ Charlie
    charlie->addFriend(dave);
    dave->addFriend(eve);
    eve->addFriend(charlie);
    
    // 设置根对象(假设这三个用户均登录)
    gc.addRoot(charlie);
    gc.addRoot(dave);
    gc.addRoot(eve);
    gc.collectGarbage(); // GC:所有对象可达,均不清除
    
    // 所有用户退出登录(根对象被移除)
    gc.removeRoot(charlie);
    gc.removeRoot(dave);
    gc.removeRoot(eve);
    gc.collectGarbage(); // GC:环内对象不可达,全部清除
    
    return 0;
}

这是第二种解决方法,通过 “可达性分析” 识别并回收不可达对象,基于 “标记 - 清除” 算法的局部 GC 实现。当然也可以选择现有的C++GC库。下面是主流的编程语言GC方案:

语言 核心GC算法 理论基础 特点
Java 分代收集(Generational GC)+ 标记-清除/复制 基于“对象存活时间短”的分代假说,将内存分为年轻代、老年代,对不同代采用不同算法 兼顾吞吐量和延迟,通过分代减少扫描范围
Python 引用计数 + 标记-清除(解决循环引用) 以引用计数为基础(简单高效),配合标记-清除处理动态环 实时性较好,但标记-清除阶段可能有停顿
JavaScript(V8) 分代式标记-整理 + 增量标记 结合分代理论和增量标记(将GC任务拆分为小步骤,穿插在程序运行中) 减少长停顿,适应浏览器交互场景
Go 并发标记-清除(三色标记法)+ 写屏障 基于可达性分析,通过三色标记追踪对象,利用写屏障在并发时保持一致性 低延迟、高并发,适合服务端长时间运行场景
Lua 标记-清除(针对动态环优化) 全量扫描但轻量,通过标记所有可达对象后清除不可达对象 简单适配脚本语言的动态结构

以GO为例,Go的GC以“低延迟”为核心目标,其理论基础是可达性分析并发处理,关键技术包括:

  1. 三色标记法
    • 用“白、灰、黑”三色标记对象状态:白色(未标记)、灰色(待处理)、黑色(已处理)。
    • 从根对象(如全局变量、栈上引用)开始,递归标记所有可达对象,最终白色对象即为可回收对象。
  2. 写屏障
    • 在并发标记时,若程序修改对象引用(如将黑色对象指向白色对象),通过写屏障将被引用的白色对象标记为灰色,避免漏标。
  3. 并发执行
    • 标记阶段与程序运行并发进行,仅在初始标记和最终清理阶段有短暂停顿(毫秒级)。

但是 Go的GC依赖语言 runtime 对内存和指针的完全控制,而C++允许指针算术、手动内存操作(如reinterpret_cast),难以直接照搬其并发标记机制(无法保证指针追踪的准确性),这也是为什么C++不引入GC的原因之一。

STL

STL(Standard Template Library),中文名 标准模板库,是一个高效的C++程序库,包含很多常用的 基本数据结构 和 基本算法,为C++程序员们提供了一个可扩展的应用框架,高度体现了软件的可复用性。

  • 从逻辑层次来看,在STL中体现了 泛型编程思想(generic programming)。
  • 从实现层次看,整个STL是以一种 类型参数化(type parameterized) 的方式实现的。

STL有六大组件(容器(containers)、迭代器(iterators)、空间配置器(allocator)、配接器(adapters)、算法(algorithms)、仿函数(functors))。主要是容器、迭代器、算法。
STL 的基本观念就是将数据和操作分离。

  • 数据由 容器 进行管理;
  • 操作由 算法进行;
  • 而 迭代器 在两者之间充当粘合剂,使任何 算法 都可以和任何 容器 交互运作。

下面是C++不同容器的底层结构表格:

容器类型 底层结构/依赖容器 核心特点 适用场景
序列式容器
vector 动态数组(连续内存) 支持随机访问(O(1));尾部增删高效(O(1)),中间增删需移动元素(O(n));自动扩容(1.5~2倍) 频繁随机访问,少中间增删(如动态列表)
deque 分段连续内存(数组块+指针数组) 首尾增删高效(O(1));支持随机访问(O(1),效率略低于vector);无需整体扩容 频繁首尾操作(如实现队列、栈)
list 双向链表(节点含前驱/后继指针) 任意位置增删高效(O(1),需定位位置);不支持随机访问(O(n));内存开销较大 频繁任意位置增删,无需随机访问(如链表、邻接表)
forward_list 单向链表(节点含后继指针) 比list节省内存;仅单向遍历;增删需通过前驱节点 内存受限,仅需单向遍历的场景
array 固定大小静态数组(连续内存) 大小编译期确定,不支持扩容;性能接近原生数组;支持随机访问(O(1)) 需要固定大小数组,且需STL接口(如迭代器)
关联式容器
set / multiset 红黑树(自平衡二叉搜索树) 元素自动升序排序(set键唯一,multiset允许重复);增删查效率O(log n);无随机访问 需自动排序+频繁查找(如去重、范围查询)
map / multimap 红黑树(键值对按key排序) key升序排列(map唯一,multimap可重复);增删查效率O(log n);支持[key]访问 键值映射+按key排序(如字典、配置表)
无序关联式容器
unordered_set / unordered_multiset 哈希表(数组+链表/红黑树解决冲突) 元素无序(set唯一,multiset可重复);平均增删查O(1),最坏O(n);需哈希函数 无需排序,需超高效查找(如快速去重、缓存)
unordered_map / unordered_multimap 哈希表(键值对按key哈希) 无序(map key唯一,multimap可重复);平均增删查O(1),效率高于map 键值映射+高频查找,无需排序(如哈希表、缓存表)
容器适配器
stack 默认依赖deque(可指定vector/list) 后进先出(LIFO);仅支持栈顶操作(push/pop/top) 需栈结构的场景(如表达式求值、回溯算法)
queue 默认依赖deque(可指定list) 先进先出(FIFO);支持队尾插入、队头删除(push/pop/front/back) 需队列结构的场景(如广度优先搜索)
priority_queue 默认依赖vector(配合堆结构) 元素按优先级排序(默认最大堆);取顶/增删效率O(log n) 需按优先级处理元素(如任务调度、最大/最小值优先场景)
  • 连续内存(vectorarray):优势是随机访问,劣势是中间操作效率低。
  • 链表(listforward_list):优势是任意位置操作高效,劣势是无法随机访问。
  • 红黑树(setmap):优势是有序+高效平衡操作,适合范围查询。
  • 哈希表(unordered_*):优势是O(1)平均效率,适合高频查找且无需排序。

以下是迭代器和指针的区别:

对比维度 迭代器 指针
核心定义 用于访问容器元素的工具,提供统一顺序访问方式,隐藏容器内部实现细节 原生内存地址变量,直接指向内存中的某个位置
本质 类模板的实例,封装了访问逻辑的对象 原生内存地址变量,直接指向内存位置
功能封装程度 封装指针操作,根据容器底层结构自定义++--等操作(如链表迭代器++为跳转下一个节点,数组迭代器++为地址偏移),对外接口统一 ++--仅为简单地址加减,无法适配复杂数据结构
安全性与抽象层次 避免直接操作内存,减少越界等错误,完全隐藏容器实现细节 直接暴露内存地址,易导致内存泄漏、野指针等问题,需依赖对容器底层结构的了解
返回值与使用方式 通过*返回元素的引用,需解引用(*it)才能访问/输出元素,不能直接输出自身 解引用(*p)获取元素,本身可直接输出内存地址
适配性与通用性 作为模板,实现“一种遍历方式适配所有容器”,支持数据结构与算法分离(如sort算法通过迭代器适配多种容器) 无通用性,无法直接遍历链表、树等非连续内存容器
总结 连接容器与算法的桥梁,“像指针一样工作,但比指针更智能”,是泛型编程的关键机制 原生内存访问工具,功能简单,安全性较低

以下是C++不同容器迭代器失效情况:

容器类型 插入元素后迭代器失效情况 删除元素后迭代器失效情况 核心原因
vector / string 1. 若内存重分配:所有迭代器、指针、引用失效
2. 若未重分配:插入位置后迭代器失效
1. 被删元素后迭代器失效
2. 尾后迭代器(end())必失效
连续内存,元素移位或重分配
deque 1. 中间插入:所有迭代器失效
2. 首尾插入:迭代器失效,指针/引用有效
1. 中间删除:所有迭代器、指针、引用失效
2. 尾删:尾后迭代器失效
3. 首删:无影响
分段连续内存,结构易被破坏
list / forward_list 所有迭代器、指针、引用均有效(包括首尾迭代器) 仅被删元素的迭代器失效,其他均有效 离散节点链表,节点独立
map / set
multimap / multiset
所有迭代器、指针、引用均有效 仅被删元素的迭代器失效,其他均有效 红黑树结构,节点关系稳定
unordered_map / unordered_set
及其multi版本
1. 未扩容:所有迭代器有效
2. 扩容:所有迭代器失效(指针/引用仍有效)
仅被删元素的迭代器失效,其他均有效 哈希表结构,扩容触发重哈希
容器适配器
(stack/queue/priority_queue)
不提供迭代器,无需考虑失效问题 不提供迭代器,无需考虑失效问题 封装底层容器,无迭代器接口

关键结论

  • 连续内存容器(vector/string)和分段内存容器(deque)的迭代器最容易因元素移位或内存变化失效;
  • 离散节点容器(list、map、unordered系列)的迭代器稳定性更好,仅被操作的元素迭代器失效;
  • 使用迭代器时,修改容器后应重新获取迭代器(如利用erase返回值),避免使用已失效的迭代器。

空间配置器

空间配置器(allocator)是C++ STL的“隐形基石”,它隐藏在容器背后,负责内存的分配、释放以及对象的构造、析构,是容器实现高效内存管理的核心组件。其设计目标是将内存管理与对象逻辑管理分离,既保证灵活性,又提升内存使用效率(如减少碎片、优化分配速度)。

一、空间配置器的核心角色:为什么需要它?

在C++中,直接使用newdelete可以完成内存管理,但STL容器并未直接依赖它们,而是通过allocator实现内存操作。这是因为:

  • new/delete是“捆绑操作”:new = 分配内存 + 构造对象,delete = 析构对象 + 释放内存,无法分离这两步;
  • 频繁分配小内存时,new/delete会导致严重的内存碎片(内存块分散,无法高效利用);
  • 不同场景对内存管理的需求不同(如频繁创建销毁小对象 vs 一次性分配大块内存),需要灵活适配。

空间配置器的核心作用是:分离“内存分配/释放”与“对象构造/析构”,并针对不同内存需求提供优化策略,让容器只需关注元素的逻辑组织(如顺序、关联关系),无需关心内存细节。

二、标准接口:allocator必须实现的“契约”

STL规定,任何allocator都需满足特定接口规范(通过模板类实现),才能被容器使用。核心接口如下:

接口类型 成员类型/函数 功能说明
类型定义 value_type 配置器所管理的对象类型(如vector<int>的allocator中,value_typeint
pointer/const_pointer 指向value_type的指针类型(通常为value_type*
reference/const_reference 指向value_type的引用类型(通常为value_type&
size_type 表示内存大小的无符号整数类型(如size_t
核心函数 allocate(n) 分配能存储nvalue_type对象的内存(仅分配,不构造对象)
deallocate(p, n) 释放p指向的、能存储nvalue_type对象的内存(仅释放,不析构对象)
construct(p, args...) p指向的内存位置构造对象(调用value_type的构造函数,参数为args
destroy(p) 析构p指向的对象(调用value_type的析构函数,不释放内存)

示例:容器(如vector)使用allocator的流程

vector<int> vec; 
// 1. 分配内存:通过allocator.allocate(10)分配能存10个int的内存
// 2. 构造对象:通过allocator.construct(p, 5)在内存p处构造int(5)
// 3. 析构对象:通过allocator.destroy(p)销毁p处的int对象
// 4. 释放内存:通过allocator.deallocate(p, 10)释放内存
三、底层实现:从“简单封装”到“二级配置器”的优化

标准库的std::allocator是最基础的实现,但实际中更常用的是编译器特定优化版本(如SGI STL的allocator)。两者的核心差异在于对小内存分配的处理。

1. 基础版本:std::allocator的实现

std::allocator仅简单封装了newdelete,将内存分配与对象构造分离:

  • allocate(n) = operator new(n * sizeof(T))(仅分配内存);
  • deallocate(p, n) = operator delete(p)(仅释放内存);
  • construct(p, args) = new(p) T(args)(定位new,在已有内存构造对象);
  • destroy(p) = p->~T()(显式调用析构函数)。

局限性:未优化小内存分配,频繁分配小内存时仍会产生碎片,效率较低。

2. 优化版本:SGI STL的“二级空间配置器”

SGI STL的allocator是工业级实现,核心采用“二级配置器”策略,根据内存大小选择不同分配方式,解决小内存碎片问题

配置器级别 处理内存大小 分配策略
一级配置器 大内存(>128字节) 直接调用malloc分配、free释放,模拟C++的new_handler机制(内存不足时重试)
二级配置器 小内存(≤128字节) 采用内存池(memory pool)+ 自由链表(free list) 管理,避免碎片

二级配置器的核心机制

  • 内存池:预先向系统分配一大块内存(chunk),再切割成小块供程序使用,减少malloc调用次数;
  • 自由链表:将小内存按8字节对齐分为16种规格(8,16,24,…,128字节),每种规格用链表管理空闲块,分配时直接从对应链表取块,释放时归还给链表;
  • 分配流程:请求小内存时,先检查对应自由链表是否有空闲块,有则直接分配;无则从内存池切割新块补充到链表,再分配;
  • 合并机制:释放内存时,若相邻块空闲则合并为大块,减少碎片(仅对连续块有效)。

优势:小内存分配效率提升(避免频繁malloc),内存碎片显著减少(复用空闲块)。

四、空间配置器的核心优势
  1. 分离内存操作与对象操作
    分配内存(allocate)与构造对象(construct)分离,释放内存(deallocate)与析构对象(destroy)分离,让容器可灵活控制内存(如vector预分配内存但延迟构造对象,减少扩容次数)。

  2. 优化内存效率
    二级配置器通过内存池和自由链表,解决小内存分配的碎片问题,提升分配/释放速度(尤其适合频繁创建销毁小对象的场景,如list的节点、unordered_map的桶元素)。

  3. 适配容器与算法
    作为STL的底层组件,allocator为所有容器提供统一的内存接口,让容器无需关心内存细节,专注于元素管理;同时支持用户自定义allocator(只要符合接口规范),满足特殊内存需求(如共享内存、内存跟踪)。

五、总结:空间配置器是STL的“内存引擎”

空间配置器通过分层设计和优化策略,为STL容器提供了高效、灵活的内存管理能力:

  • 对用户:隐藏内存细节,只需使用容器接口;
  • 对容器:解耦内存管理与逻辑管理,提升复用性;
  • 对系统:减少内存碎片,提高内存利用率。

可以说,没有高效的空间配置器,就没有STL容器的高性能——它是STL“泛型编程”理念在内存管理层面的完美体现。

存储重用

在C++中,存储重用(Storage Reuse)是指在已被某个对象占用的内存位置上,通过placement new(定位new表达式)创建新对象,从而复用该内存空间的行为。这一机制在内存优化(如对象池、变体类型实现)中常见,但C++对其有严格规则——若违反会导致未定义行为(Undefined Behavior)。以下从核心概念、规则、特殊场景、风险及总结维度全面解析。

一、核心概念:存储与对象的关系

要理解存储重用,需先明确“存储”与“对象”的区别及关联:

1. 存储(Storage)

指一块连续的内存空间,用于承载对象的生命周期。来源包括:

  • 自动存储(函数内局部变量)、静态存储(全局变量)、线程局部存储(thread_local变量);
  • 动态分配内存(operator new返回的空间);
  • 数组元素空间(如unsigned char数组的某段区域)。
2. 对象(Object)

指占据一块存储并具有类型的实体。其生命周期从初始化完成开始,到析构结束(或显式销毁)终止。当对象生命周期结束后,其存储可被新对象重用。

3. 存储重用的本质

核心是在旧对象的存储位置上,通过placement new创建新对象,且新对象完全覆盖旧对象的存储。例如:

int x = 10;       // 旧对象x,存储在栈上
x.~int();         // 显式结束x的生命周期(int的析构是平凡的,可省略)
new (&x) int(20); // 重用x的存储,创建新int对象

二、存储重用的核心规则

C++对存储重用的限制围绕旧对象的析构特性、常量性新对象的类型匹配度展开,核心规则可归纳为以下四类:

规则1:非平凡析构对象的“强制续期”要求

若旧对象是非平凡析构(即有用户定义的析构函数,或编译器生成的析构函数会执行资源释放等操作),且程序显式结束了其生命周期(如手动调用~T()),则必须在原存储位置构造同类型新对象

  • 原因:非平凡析构对象在离开作用域、线程退出或程序结束时,会被隐式调用析构函数。若存储已被其他类型对象占用,析构函数会错误操作新对象的内存,导致冲突。
场景 合法? 示例
非平凡析构对象显式销毁后,构造同类型新对象 合法 cpp struct A { ~A() { /* 释放资源 */ } }; void f() { A a; a.~A(); new (&a) A; // 同类型,合法 }
非平凡析构对象显式销毁后,构造不同类型新对象 非法(未定义行为) cpp struct A { ~A() { /* 释放资源 */ } }; void f() { A a; a.~A(); new (&a) int; // 不同类型,析构时出错 }
规则2:平凡析构对象的“宽松”重用

若旧对象是平凡析构(即析构函数不执行任何操作,如intstruct T{};),则:

  • 无需显式调用析构函数即可结束其生命周期;
  • 可重用存储构造任意类型的新对象(无类型限制)。

示例

void f() {
    
      
    long long x; // 平凡析构(long long的析构平凡)
    new (&x) double(3.14); // 合法:重用存储构造不同类型
    new (&x) char('a');    // 再次重用,仍合法
}
规则3:const对象的存储不可重用

const完整对象(即顶层const的独立对象,如const int a;const struct S s;)的存储被C++视为“只读”(可能放在只读内存页),其存储绝对不可重用——即使显式结束生命周期,也不能在原位置构造新对象。

示例(非法)

struct S {
    
       S() {
    
      } ~S() {
    
      } };
const S s; // const完整对象

void f() {
    
      
    s.~S(); // 显式结束s的生命周期(本身合法,但后续操作危险)
    new (const_cast<S*>(&s)) S; // 未定义行为:试图重用const对象存储
}
规则4:存储重用的“时间点”限制

当使用new表达式创建新对象时,从分配函数返回存储到新对象初始化前,存储即被视为“已重用”。此时若访问旧对象的成员或数据,可能引用已失效的内存,导致未定义行为。

示例(非法)

struct S {
    
       int m; };
void f() {
    
      
    S x{
    
      10};
    // 执行new(&x) S(x.m)时,x的存储已被视为重用,x.m可能无效
    new (&x) S(x.m); // 未定义行为
}

三、透明替换:旧指针/引用自动指向新对象的条件

当存储被重用时,旧对象的指针、引用或名称能否自动指向新对象?这取决于“透明替换”(Transparent Replacement)条件——只有满足以下所有要求,旧的指针/引用/名称才能直接操作新对象:

透明替换的6个条件
  1. 存储覆盖:新对象的存储完全覆盖旧对象的存储(无重叠或遗漏);
  2. 类型一致:新对象与旧对象类型相同(忽略顶层const/volatile,如intconst int视为相同);
  3. 非const旧对象:旧对象不是const完整对象;
  4. 非特殊子对象:两者都不是基类子对象,也不是用[[no_unique_address]]声明的成员(C++20起);
  5. 完整对象或嵌套一致
    • 要么都是完整对象(非子对象);
    • 要么都是同一包含对象的直接子对象,且包含对象可被透明替换。
示例:合法的透明替换
struct C {
    
      
    int i;
    void f() {
    
       cout << i << endl; }
};

void f() {
    
      
    C x{
    
      10};
    C* p = &x; // 旧指针指向x

    x.~C();          // 结束x的生命周期
    new (&x) C{
    
      20};  // 新对象覆盖x的存储,满足透明替换条件

    p->f(); // 合法:p自动指向新对象,输出20
    x.f();  // 合法:x的名称自动引用新对象,输出20
}
反例:不满足透明替换
struct Base {
    
      };
struct Derived : Base {
    
      };

void f() {
    
      
    Derived d;
    Base* p = &d; // 指向Derived的基类子对象

    d.~Derived();
    new (&d) Base; // 新对象是Base,与旧对象Derived类型不同

    p->~Base(); // 未定义行为:p指向的旧基类子对象已被新对象覆盖,且类型不一致
}

四、非透明替换:用std::launder获取有效指针

若不满足透明替换条件(如类型不同、涉及基类子对象),旧的指针/引用/名称会失效,此时需用std::launder(C++17起)——它是一个“指针优化屏障”,能返回指向新对象的有效指针。

std::launder的使用场景

当新对象的存储覆盖了旧对象,但因类型不同或涉及子对象导致透明替换失败时,必须通过std::launder获取新对象的指针。

示例

#include <new> // 需包含头文件

struct A {
    
       virtual int get() {
    
       return 1; } };
struct B : A {
    
       int get() override {
    
       return 2; } };

void f() {
    
      
    A a;
    A* p = &a; // 旧指针指向A对象

    a.~A();
    new (&a) B; // 新对象是B(派生类),与旧对象A类型不同,不满足透明替换

    // p->get(); // 未定义行为:p仍指向旧A对象的存储
    auto q = std::launder(p); // 用launder获取新对象的有效指针
    q->get(); // 合法:返回2(B的实现)
}

五、数组作为存储提供者

C++允许在unsigned charstd::byte(C++17起)数组中创建对象——此时数组被称为“存储提供者”,需满足:

  1. 数组的生命周期已开始且未结束;
  2. 新对象的存储完全位于数组内部;
  3. 数组中无嵌套的数组对象。
特点:数组本身的生命周期不受影响

当数组的某部分存储被新对象重用时,旧对象的生命周期会结束,但数组本身的生命周期持续,可继续为其他对象提供存储。

示例

#include <cstddef> // for std::byte

void f() {
    
      
    std::byte buf[10]; // 存储提供者:std::byte数组

    // 第一次重用:在buf[0]处创建int
    int* p = new (buf) int(10);

    // 第二次重用:覆盖int的存储,创建double
    double* q = new (buf) double(3.14); // 结束int的生命周期,合法

    // 第三次重用:使用数组的其他部分
    char* r = new (buf + 8) char('a'); // 合法,与double不重叠

    cout << *q + *r; // 合法:3.14 + 'a'(ASCII值)
}

六、常见风险与避坑指南

存储重用的最大风险是未定义行为(如程序崩溃、数据损坏),需重点规避以下场景:

风险场景 原因 规避方式
非平凡析构对象显式销毁后,未构造同类型新对象 作用域结束时隐式析构函数会操作错误类型的对象 显式销毁后必须用placement new构造同类型对象
重用const完整对象的存储 const对象可能在只读内存,写入会触发硬件错误 绝对禁止对const完整对象执行存储重用
透明替换条件不满足时,直接使用旧指针/引用 旧指针可能指向已失效的存储,导致访问错误 std::launder获取新对象的有效指针
新对象存储未完全覆盖旧对象 部分重叠会导致内存混乱 确保新对象大小 ≤ 旧对象存储大小,且起始地址一致

总结:存储重用的核心原则

  1. 类型匹配优先:非平凡析构对象显式销毁后,必须构造同类型新对象,否则会导致析构函数冲突;
  2. const不可碰:const完整对象的存储绝对不可重用,因其可能位于只读内存;
  3. 透明替换看条件:满足条件时旧指针/引用自动有效,否则需用std::launder
  4. 数组存储需完整覆盖:新对象必须完全位于数组内部,且数组无嵌套数组对象。

掌握这些规则,才能在内存优化(如对象池、变体类型)中安全使用存储重用,避免未定义行为。

特殊问题

在C++中,虚函数的核心作用是实现动态绑定(即运行时多态):通过基类指针或引用调用虚函数时,实际执行的是对象真实类型(派生类)的重写版本。然而,当虚函数在构造函数或析构函数中直接或间接被调用时,其行为会发生特殊变化——动态绑定失效,转而执行当前正在构造或析构的类的虚函数版本。

一、构造与析构期间虚函数调用的核心规则

核心规则:当从构造函数或析构函数直接或间接调用虚函数时(包括在类的非静态数据成员的构造或析构期间,例如在成员初始化列表中),并且调用应用到的对象是正在构造或析构的对象,则调用的函数是构造函数或析构函数所属类中的最终覆盖者,而不是在更派生的类中重写它的函数。

这一规则的本质是:在构造或析构期间,更派生的类“不存在”。构造时,更派生的类尚未初始化;析构时,更派生的类已销毁,因此无法调用其重写的虚函数。

二、构造函数中调用虚函数的行为

1. 对象的构造顺序

C++中,对象的构造遵循“从基类到派生类”的顺序:

  • 先构造基类子对象;
  • 再构造派生类的成员变量;
  • 最后执行派生类的构造函数体。

在基类构造函数执行期间,派生类的成员变量和派生类部分尚未初始化,对象暂时被视为基类类型

2. 虚函数调用的表现

此时若在基类构造函数中直接或间接调用虚函数,实际执行的是基类自身的虚函数版本,而非派生类的重写版本。

示例

#include <iostream>
using namespace std;

class Base {
    
      
public:
    Base() {
    
       
        cout << "Base构造函数调用虚函数f()" << endl;
        f(); // 构造函数中调用虚函数
    }

    virtual void f() {
    
       
        cout << "Base::f()" << endl; 
    }
};

class Derived : public Base {
    
      
public:
    Derived() {
    
       
        cout << "Derived构造函数" << endl;
    }

    void f() override {
    
       // 重写基类虚函数
        cout << "Derived::f()" << endl; 
    }
};

int main() {
    
      
    Derived d; // 构造Derived对象
    return 0;
}

输出结果

Base构造函数调用虚函数f()
Base::f()  // 调用的是Base的f(),而非Derived的f()
Derived构造函数

原因:构造Derived对象时,先执行Base的构造函数。此时Derived的成员和派生部分尚未初始化,对象暂时是Base类型,虚函数表(vtable)指向Base的虚函数,因此f()调用的是Base::f()

3. 间接调用的情况

即使虚函数是被间接调用(如构造函数调用非虚函数,非虚函数再调用虚函数),结果仍相同——调用当前类的虚函数版本。

示例

class Base {
    
      
public:
    Base() {
    
       
        g(); // 构造函数调用非虚函数g()
    }

    void g() {
    
       // 非虚函数
        f(); // 间接调用虚函数f()
    }

    virtual void f() {
    
       
        cout << "Base::f()" << endl; 
    }
};

class Derived : public Base {
    
      
public:
    void f() override {
    
       
        cout << "Derived::f()" << endl; 
    }
};

输出结果与直接调用相同:Base::f()

三、析构函数中调用虚函数的行为

1. 对象的析构顺序

与构造相反,对象的析构遵循“从派生类到基类”的顺序:

  • 先执行派生类的析构函数体;
  • 再销毁派生类的成员变量;
  • 最后析构基类子对象。

在基类析构函数执行期间,派生类的成员和派生部分已被销毁,对象暂时被视为基类类型

2. 虚函数调用的表现

此时若在基类析构函数中直接或间接调用虚函数,实际执行的是基类自身的虚函数版本

示例

class Base {
    
      
public:
    ~Base() {
    
       
        cout << "Base析构函数调用虚函数f()" << endl;
        f(); // 析构函数中调用虚函数
    }

    virtual void f() {
    
       
        cout << "Base::f()" << endl; 
    }
};

class Derived : public Base {
    
      
public:
    ~Derived() {
    
       
        cout << "Derived析构函数" << endl;
    }

    void f() override {
    
       
        cout << "Derived::f()" << endl; 
    }
};

int main() {
    
      
    Derived d; 
    return 0; // 离开作用域,销毁d
}

输出结果

Derived析构函数  // 先析构派生类
Base析构函数调用虚函数f()
Base::f()  // 调用的是Base的f(),而非Derived的f()

原因:销毁Derived对象时,先执行Derived的析构函数,再执行Base的析构函数。此时Derived的成员和派生部分已被销毁,对象暂时是Base类型,虚函数表指向Base的虚函数,因此f()调用的是Base::f()

3. 间接调用的情况

与构造函数类似,析构函数中通过非虚函数间接调用虚函数,结果仍是调用当前类(基类)的版本。

四、复杂类层次结构中的限制

当类层次包含多个分支(如多继承、虚拟继承)时,构造期间的多态性被进一步限制:

  • 仅在当前构造分支(正在构造的类及其直接基类构成的子层次)内有效;
  • 若通过指针/引用访问其他分支的基类子对象并调用虚函数(即使该分支已构造完成),行为是未定义的
代码示例解析
类层次结构
struct V {
    
       virtual void f(); virtual void g(); };
struct A : virtual V {
    
       virtual void f(); }; // A重写V::f
struct B : virtual V {
    
       
    virtual void g(); // B重写V::g
    B(V*, A*); 
};
struct D : A, B {
    
       
    virtual void f(); // D重写V::f(最终覆盖者)
    virtual void g(); // D重写V::g(最终覆盖者)
    D() : B((A*)this, this) {
    
      } // D的构造函数:先初始化A,再初始化B
};
  • 继承关系:V是虚拟基类,A和B虚拟继承自V,D继承自A和B(多重继承+虚拟继承)。
  • 构造顺序:D的构造遵循“虚拟基类→直接基类→自身”,即:V → A → B → D(A在B之前构造,因D的基类列表中A在前)。
B的构造函数中的虚函数调用分析

B的构造函数在D的构造过程中被调用(此时A已构造,但B正在构造,D尚未构造),其中的4个虚函数调用行为如下:

B::B(V* v, A* a) {
    
      
    f();          // 调用1:V::f()
    g();          // 调用2:B::g()
    v->g();       // 调用3:B::g()
    a->f();       // 调用4:未定义行为
}
  1. 调用1:f()

    • 此时正在构造的类是Bf()是V中声明的虚函数,B中未重写f(仍使用V::f),且D尚未构造,因此调用V::f()
  2. 调用2:g()

    • g()是V中声明的虚函数,B中重写了g()B::g是B及其基类中g()的最终覆盖者),因此调用B::g()
  3. 调用3:v->g()

    • vV*类型指针,指向B的虚拟基类子对象(属于当前构造分支),因此调用B::g()
  4. 调用4:a->f()(未定义行为)

    • aA*类型指针,指向D中的A子对象(属于另一个分支),B的构造阶段跨分支调用虚函数,行为未定义。实际运行中,可能错误使用B的虚函数表调用f(),导致不可预期的结果。

五、设计原理与注意事项

设计原理

C++如此设计的核心目的是避免访问未初始化或已销毁的成员

  • 构造期间,派生类的成员变量尚未初始化,调用派生类的虚函数可能访问未初始化的成员,导致未定义行为。
  • 析构期间,派生类的成员变量已被销毁,调用派生类的虚函数可能访问已释放的内存,同样导致未定义行为。
注意事项
  1. 避免在构造/析构中调用虚函数:若必须调用,需明确其行为是“静态绑定到当前类”,仅依赖当前类已初始化的成员。
  2. 警惕间接调用:即使虚函数不是被构造/析构函数直接调用,只要调用链的起点是构造/析构函数,行为仍受上述规则约束。
  3. 复杂层次需谨慎:在多分支继承中,避免跨分支调用虚函数,以防未定义行为。

总结

在构造函数或析构函数中直接或间接调用虚函数时,C++会禁用动态绑定,强制调用当前正在构造/析构的类的虚函数版本。在复杂类层次中,多态性被限制在当前构造分支内,跨分支调用虚函数会导致未定义行为。这一设计是为了避免访问未初始化或已销毁的成员,保障程序安全。开发中应尽量避免在构造/析构中调用虚函数,若必须使用,需清晰认知其特性。

写一个完美的类

#include <iostream>
#include <string>
#include <vector>
#include <memory>
#include <cmath>
#include <stdexcept>
#include <algorithm>
#include <typeinfo>

// 前向声明
template <typename T>
class MathUtility;

// 抽象基类 - 实体
class Entity {
    
      
protected:
    bool isActive;
    static int totalEntities; // 静态成员变量 - 所有实体共享

public:
    // 构造函数
    Entity() : isActive(true) {
    
      
        totalEntities++;
    }

    // 纯虚函数 - 使类成为抽象类
    virtual std::string getID() const = 0;

    // 虚函数
    virtual bool isActiveEntity() const {
    
      
        return isActive;
    }

    // 虚析构函数
    virtual ~Entity() {
    
      
        totalEntities--;
    }

    // 静态成员函数
    static int getTotalEntities() {
    
      
        return totalEntities;
    }

    // 设置活动状态
    void setActive(bool active) {
    
      
        isActive = active;
    }
};

// 初始化静态成员
int Entity::totalEntities = 0;

// 人员类 - 继承自Entity
class Person : public Entity {
    
      
private:
    std::string name;
    int age;
    double height; // 厘米
    double weight; // 千克
    const std::string id; // 常量成员

    // 私有成员函数
    void validateAge(int newAge) const {
    
      
        if (newAge < 0 || newAge > 150) {
    
      
            throw std::invalid_argument("年龄必须在0到150之间");
        }
    }

public:
    // 静态常量
    static const std::string species;

    // 构造函数
    Person(std::string name, int age, double height = 0, double weight = 0)
        : name(std::move(name)), age(age), height(height), weight(weight),
        id(generateID()) {
    
      
        validateAge(age);
    }

    // 拷贝构造函数
    Person(const Person& other)
        : Entity(other), name(other.name), age(other.age),
        height(other.height), weight(other.weight), id(other.id) {
    
      
    }

    // 移动构造函数
    Person(Person&& other) noexcept
        : Entity(std::move(other)), name(std::move(other.name)), age(other.age),
        height(other.height), weight(other.weight), id(std::move(other.id)) {
    
      
    }

    // 析构函数
    ~Person() override = default;

    // 拷贝赋值运算符 (禁用,因为id是const)
    Person& operator=(const Person&) = delete;

    // 移动赋值运算符 (禁用,因为id是const)
    Person& operator=(Person&&) = delete;

    // 静态成员函数
    static std::string generateID() {
    
      
        static int counter = 0;
        return "PERSON_" + std::to_string(++counter);
    }

    // 虚函数重写
    std::string getID() const override {
    
      
        return id;
    }

    // 普通成员函数
    std::string getName() const {
    
       return name; }
    void setName(const std::string& newName) {
    
       name = newName; }

    int getAge() const {
    
       return age; }
    void setAge(int newAge) {
    
      
        validateAge(newAge);
        age = newAge;
    }

    double getHeight() const {
    
       return height; }
    void setHeight(double newHeight) {
    
      
        if (newHeight > 0 && newHeight <= 250) {
    
      
            height = newHeight;
        }
        else {
    
      
            throw std::invalid_argument("身高必须在0到250厘米之间");
        }
    }

    double getWeight() const {
    
       return weight; }
    void setWeight(double newWeight) {
    
      
        if (newWeight > 0 && newWeight <= 300) {
    
      
            weight = newWeight;
        }
        else {
    
      
            throw std::invalid_argument("体重必须在0到300千克之间");
        }
    }

    // 计算BMI
    double calculateBMI() const {
    
      
        if (height <= 0) return 0.0;
        double heightMeters = height / 100.0;
        return weight / (heightMeters * heightMeters);
    }

    // 重载运算符
    bool operator==(const Person& other) const {
    
      
        return id == other.id;
    }

    bool operator!=(const Person& other) const {
    
      
        return !(*this == other);
    }

    // 友元函数 - 可以访问私有成员
    friend std::ostream& operator<<(std::ostream& os, const Person& person);

    // 声明友元类
    friend class MathUtility<double>;
};

// 初始化静态常量
const std::string Person::species = "Homo sapiens";

// 实现友元函数
std::ostream& operator<<(std::ostream& os, const Person& person) {
    
      
    os << person.name << " (" << person.age << "岁) - BMI: "
        << person.calculateBMI() << " - ID: " << person.id;
    return os;
}

// 学生类 - 继承自Person
class Student : public Person {
    
      
private:
    std::string studentID;
    std::string major;
    std::vector<std::pair<std::string, double>> grades; // 课程-成绩对

public:
    // 构造函数
    Student(std::string name, int age, std::string studentID,
        std::string major, double height = 0, double weight = 0)
        : Person(std::move(name), age, height, weight),
        studentID(std::move(studentID)), major(std::move(major)) {
    
      
    }

    // 重写基类方法
    std::string getID() const override {
    
      
        return "STUDENT_" + studentID;
    }

    // 学生特有方法
    void addGrade(const std::string& course, double grade) {
    
      
        if (grade < 0 || grade > 100) {
    
      
            throw std::invalid_argument("成绩必须在0到100之间");
        }
        grades.emplace_back(course, grade);
    }

    double getAverageGrade() const {
    
      
        if (grades.empty()) return 0.0;
        double sum = 0.0;
        for (const auto& [course, grade] : grades) {
    
      
            sum += grade;
        }
        return sum / grades.size();
    }

    std::vector<std::string> getCourses() const {
    
      
        std::vector<std::string> courses;
        for (const auto& [course, grade] : grades) {
    
      
            courses.push_back(course);
        }
        return courses;
    }

    // 重写输出
    friend std::ostream& operator<<(std::ostream& os, const Student& student) {
    
      
        os << static_cast<const Person&>(student) << " - 专业: "
            << student.major << " - 平均成绩: " << student.getAverageGrade();
        return os;
    }
};

// 模板类示例
template <typename T>
class MathUtility {
    
      
public:
    // 模板方法
    static T square(T x) {
    
      
        return x * x;
    }

    static T cube(T x) {
    
      
        return x * x * x;
    }

    // 使用Person类作为参数的模板方法
    static double calculateBMI(const Person& person) {
    
      
        // 可以访问Person的私有成员,因为是友元
        if (person.height <= 0) return 0.0;
        double heightMeters = person.height / 100.0;
        return person.weight / (heightMeters * heightMeters);
    }
};

// 主函数 - 演示用法
int main() {
    
      
    try {
    
      
        // 创建Person对象
        Person person1("张三", 30, 175, 70);
        Person person2("李四", 25, 180, 80);

        std::cout << "总实体数: " << Entity::getTotalEntities() << std::endl;
        std::cout << person1 << std::endl;
        std::cout << person2 << std::endl;

        // 使用setter和getter
        person1.setAge(31);
        std::cout << person1.getName() << "的新年龄: " << person1.getAge() << std::endl;

        // 测试BMI计算
        std::cout << person1.getName() << "的BMI: " << person1.calculateBMI() << std::endl;

        // 使用模板类
        std::cout << "使用模板类计算BMI: " << MathUtility<double>::calculateBMI(person1) << std::endl;
        std::cout << "5的平方: " << MathUtility<int>::square(5) << std::endl;

        // 创建Student对象
        Student student1("王五", 20, "2023001", "计算机科学", 178, 65);
        std::cout << "\n总实体数: " << Entity::getTotalEntities() << std::endl;

        // 使用学生特有方法
        student1.addGrade("数学", 90);
        student1.addGrade("英语", 85);
        student1.addGrade("编程", 95);

        std::cout << student1 << std::endl;

        // 多态示例
        Entity* entityPtr = &student1;
        std::cout << "多态获取ID: " << entityPtr->getID() << std::endl;
        std::cout << "实体是否活跃: " << std::boolalpha << entityPtr->isActiveEntity() << std::endl;

        // 智能指针示例
        std::unique_ptr<Person> personPtr = std::make_unique<Person>("赵六", 40, 170, 75);
        std::cout << "\n智能指针管理的对象: " << *personPtr << std::endl;

        // 容器示例
        std::vector<std::shared_ptr<Person>> people;
        people.push_back(std::make_shared<Person>("钱七", 35, 185, 85));
        people.push_back(std::make_shared<Student>("孙八", 22, "2023002", "电子工程", 182, 72));

        std::cout << "\n容器中的人员:" << std::endl;
        for (const auto& ptr : people) {
    
      
            std::cout << *ptr << std::endl;
        }

        // 类型检查
        for (const auto& ptr : people) {
    
      
            if (typeid(*ptr) == typeid(Student)) {
    
      
                std::cout << ptr->getName() << " 是学生" << std::endl;
            }
            else if (typeid(*ptr) == typeid(Person)) {
    
      
                std::cout << ptr->getName() << " 是普通人员" << std::endl;
            }
        }

    }
    catch (const std::exception& e) {
    
      
        std::cerr << "发生错误: " << e.what() << std::endl;
        return 1;
    }

    return 0;
}