数据模型
在计算机编程和系统架构中,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位系统主流)
- 特点:
int、long、指针均为32位(三者位数相同)。 - 背景:32位处理器(如x86)普及后成为标准,被32位Linux、Windows、macOS等广泛采用。
- 优势:简化了编程,无需区分
int和long的范围差异,指针能直接寻址4GB内存(2³²字节)。
3. LLP64(64位Windows的独特选择)
- 特点:
int和long仍为32位,仅指针和long long为64位(LL代表long long为64位)。 - 背景:微软为兼容大量32位Windows程序,在64位系统中保留了
int和long的32位特性,仅扩展指针至64位以支持大内存。 - 影响:在64位Windows中,
long与int位数相同(32位),需用long long或int64_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代替1,Status::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),是一种特殊的对象,其行为类似于函数。仿函数本质是一个类或结构体,通过重载函数调用运算符(),使得该类的实例可以像函数一样被调用。
核心特点
- 可调用性:通过
()运算符重载实现。 - 状态存储:可以包含成员变量,存储调用状态或参数。
- 类型安全:作为模板参数传递时比普通函数更灵活。
- 性能优化:内联展开可能比普通函数调用更高效。
基本语法
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::sort、std::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 Derived:public 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 Derived:public 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; // 输出:资源已释放
}
}
三、智能指针的底层实现核心
所有智能指针本质是模板类,核心实现包括:
- 封装原始指针:存储指向资源的原始指针;
- 重载运算符:
*(解引用)、->(成员访问),模拟原始指针的行为; - 析构函数:在智能指针对象销毁时,自动释放资源(
unique_ptr直接释放,shared_ptr检查引用计数); - 禁用/控制复制:
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以“低延迟”为核心目标,其理论基础是可达性分析和并发处理,关键技术包括:
- 三色标记法:
- 用“白、灰、黑”三色标记对象状态:白色(未标记)、灰色(待处理)、黑色(已处理)。
- 从根对象(如全局变量、栈上引用)开始,递归标记所有可达对象,最终白色对象即为可回收对象。
- 写屏障:
- 在并发标记时,若程序修改对象引用(如将黑色对象指向白色对象),通过写屏障将被引用的白色对象标记为灰色,避免漏标。
- 并发执行:
- 标记阶段与程序运行并发进行,仅在初始标记和最终清理阶段有短暂停顿(毫秒级)。
但是 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) | 需按优先级处理元素(如任务调度、最大/最小值优先场景) |
- 连续内存(
vector、array):优势是随机访问,劣势是中间操作效率低。 - 链表(
list、forward_list):优势是任意位置操作高效,劣势是无法随机访问。 - 红黑树(
set、map):优势是有序+高效平衡操作,适合范围查询。 - 哈希表(
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++中,直接使用new和delete可以完成内存管理,但STL容器并未直接依赖它们,而是通过allocator实现内存操作。这是因为:
new/delete是“捆绑操作”:new= 分配内存 + 构造对象,delete= 析构对象 + 释放内存,无法分离这两步;- 频繁分配小内存时,
new/delete会导致严重的内存碎片(内存块分散,无法高效利用); - 不同场景对内存管理的需求不同(如频繁创建销毁小对象 vs 一次性分配大块内存),需要灵活适配。
空间配置器的核心作用是:分离“内存分配/释放”与“对象构造/析构”,并针对不同内存需求提供优化策略,让容器只需关注元素的逻辑组织(如顺序、关联关系),无需关心内存细节。
二、标准接口:allocator必须实现的“契约”
STL规定,任何allocator都需满足特定接口规范(通过模板类实现),才能被容器使用。核心接口如下:
| 接口类型 | 成员类型/函数 | 功能说明 |
|---|---|---|
| 类型定义 | value_type |
配置器所管理的对象类型(如vector<int>的allocator中,value_type为int) |
pointer/const_pointer |
指向value_type的指针类型(通常为value_type*) |
|
reference/const_reference |
指向value_type的引用类型(通常为value_type&) |
|
size_type |
表示内存大小的无符号整数类型(如size_t) |
|
| 核心函数 | allocate(n) |
分配能存储n个value_type对象的内存(仅分配,不构造对象) |
deallocate(p, n) |
释放p指向的、能存储n个value_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仅简单封装了new和delete,将内存分配与对象构造分离:
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),内存碎片显著减少(复用空闲块)。
四、空间配置器的核心优势
-
分离内存操作与对象操作
分配内存(allocate)与构造对象(construct)分离,释放内存(deallocate)与析构对象(destroy)分离,让容器可灵活控制内存(如vector预分配内存但延迟构造对象,减少扩容次数)。 -
优化内存效率
二级配置器通过内存池和自由链表,解决小内存分配的碎片问题,提升分配/释放速度(尤其适合频繁创建销毁小对象的场景,如list的节点、unordered_map的桶元素)。 -
适配容器与算法
作为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:平凡析构对象的“宽松”重用
若旧对象是平凡析构(即析构函数不执行任何操作,如int、struct 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个条件
- 存储覆盖:新对象的存储完全覆盖旧对象的存储(无重叠或遗漏);
- 类型一致:新对象与旧对象类型相同(忽略顶层
const/volatile,如int与const int视为相同); - 非const旧对象:旧对象不是const完整对象;
- 非特殊子对象:两者都不是基类子对象,也不是用
[[no_unique_address]]声明的成员(C++20起); - 完整对象或嵌套一致:
- 要么都是完整对象(非子对象);
- 要么都是同一包含对象的直接子对象,且包含对象可被透明替换。
示例:合法的透明替换
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 char或std::byte(C++17起)数组中创建对象——此时数组被称为“存储提供者”,需满足:
- 数组的生命周期已开始且未结束;
- 新对象的存储完全位于数组内部;
- 数组中无嵌套的数组对象。
特点:数组本身的生命周期不受影响
当数组的某部分存储被新对象重用时,旧对象的生命周期会结束,但数组本身的生命周期持续,可继续为其他对象提供存储。
示例:
#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获取新对象的有效指针 |
| 新对象存储未完全覆盖旧对象 | 部分重叠会导致内存混乱 | 确保新对象大小 ≤ 旧对象存储大小,且起始地址一致 |
总结:存储重用的核心原则
- 类型匹配优先:非平凡析构对象显式销毁后,必须构造同类型新对象,否则会导致析构函数冲突;
- const不可碰:const完整对象的存储绝对不可重用,因其可能位于只读内存;
- 透明替换看条件:满足条件时旧指针/引用自动有效,否则需用
std::launder; - 数组存储需完整覆盖:新对象必须完全位于数组内部,且数组无嵌套数组对象。
掌握这些规则,才能在内存优化(如对象池、变体类型)中安全使用存储重用,避免未定义行为。
特殊问题
在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:
f()- 此时正在构造的类是
B,f()是V中声明的虚函数,B中未重写f(仍使用V::f),且D尚未构造,因此调用V::f()。
- 此时正在构造的类是
-
调用2:
g()g()是V中声明的虚函数,B中重写了g()(B::g是B及其基类中g()的最终覆盖者),因此调用B::g()。
-
调用3:
v->g()v是V*类型指针,指向B的虚拟基类子对象(属于当前构造分支),因此调用B::g()。
-
调用4:
a->f()(未定义行为)a是A*类型指针,指向D中的A子对象(属于另一个分支),B的构造阶段跨分支调用虚函数,行为未定义。实际运行中,可能错误使用B的虚函数表调用f(),导致不可预期的结果。
五、设计原理与注意事项
设计原理
C++如此设计的核心目的是避免访问未初始化或已销毁的成员:
- 构造期间,派生类的成员变量尚未初始化,调用派生类的虚函数可能访问未初始化的成员,导致未定义行为。
- 析构期间,派生类的成员变量已被销毁,调用派生类的虚函数可能访问已释放的内存,同样导致未定义行为。
注意事项
- 避免在构造/析构中调用虚函数:若必须调用,需明确其行为是“静态绑定到当前类”,仅依赖当前类已初始化的成员。
- 警惕间接调用:即使虚函数不是被构造/析构函数直接调用,只要调用链的起点是构造/析构函数,行为仍受上述规则约束。
- 复杂层次需谨慎:在多分支继承中,避免跨分支调用虚函数,以防未定义行为。
总结
在构造函数或析构函数中直接或间接调用虚函数时,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;
}