堆与动态分配:new 到底做了什么 / Heap and Dynamic Allocation
堆不是“高级一点的栈”。
堆解决的是:对象大小或存在时间在编译时、在当前作用域里不好固定。代价是:你必须明确谁负责清理。
1. 为什么需要动态分配
栈上的局部对象适合这种场景:
void f() {
int value = 42;
}对象随函数进入而创建,随函数离开而销毁。
这很清楚,也很高效。
但有些问题不适合只靠栈:
运行时才知道要多少元素
对象需要跨越当前函数继续存在
容器需要不断增长
对象太大,不适合放在栈帧中
需要通过多态接口创建派生类对象例如:
#include <vector>
std::vector<int> read_values(int n) {
std::vector<int> values;
for (int i = 0; i < n; ++i) {
values.push_back(i);
}
return values;
}values 这个 vector 对象本体是局部对象。
但它内部元素缓冲区会根据需要动态申请。
vector object
|
v
dynamic buffer: [0][1][2][3]...这就是堆常见出现位置:你未必手写 new,但标准库容器可能在内部使用动态存储。
2. new 至少做两件事
看代码:
int* p = new int(42);不要只把它理解成“在堆上弄了个 int”。
更准确的步骤是:
1. 调用分配函数,申请足够放 int 的原始存储
2. 在那块存储上构造 int 对象,初值为 42
3. 返回这个对象的地址图:
new int(42)
|
+-- allocate storage
|
+-- construct int object
|
v
return pointer对应的:
delete p;也至少做两件事:
1. 调用析构逻辑,结束对象生命周期
2. 释放那块动态存储对 int 来说析构几乎没有存在感。
换成 std::string 就更明显:
#include <string>
std::string* p = new std::string("hello");
delete p;delete p 必须让 std::string 的析构函数执行,否则它内部拥有的资源可能无法正确释放。
3. 分配和构造不是一回事
C++ 初学阶段很容易把这两个动作合在一起。
但后面学习 allocator、placement new、容器实现时,必须拆开。
allocation
只关心字节:给我一块足够大、对齐合适的存储
construction
关心类型:在这块存储上真正创建一个 T 对象可以把它想成盖房子:
土地
像原始存储
房子
像对象
有土地不等于已经有房子。
房子拆了,土地也不一定立刻消失。这不是比喻游戏,它对应真实 C++ 机制。
std::vector 扩容时,往往会:
申请更大的原始存储
|
v
把旧元素移动或复制构造到新存储
|
v
析构旧元素
|
v
释放旧存储4. malloc/free 和 new/delete 的区别
C 语言常用:
#include <cstdlib>
int* p = static_cast<int*>(std::malloc(sizeof(int)));
std::free(p);malloc 只申请原始字节。
它不会调用 C++ 构造函数。
free 只释放原始字节。
它不会调用 C++ 析构函数。
对平凡类型也许暂时看不出差别。
对 std::string 这种类型就危险:
#include <cstdlib>
#include <string>
void bad() {
void* raw = std::malloc(sizeof(std::string));
std::string* p = static_cast<std::string*>(raw);
// 这里还没有真正构造 std::string 对象
// 直接使用 *p 是错误的
std::free(raw);
}这就是为什么 C++ 普通对象不要随便用 malloc/free 管。
5. new[] 和 delete[] 必须配对
数组动态分配:
int* values = new int[10]{};
delete[] values;这里必须用 delete[]。
不能写:
delete values; // 错原因是数组里有多个元素。
对非平凡类型,每个元素都需要析构。
delete[] 才表达“这是数组形式分配的对象序列”。
不过现代 C++ 里,普通代码更推荐:
#include <vector>
std::vector<int> values(10);容器会负责处理大小、析构和释放。
6. 裸 new 的问题不是它邪恶,而是责任不清
看代码:
Widget* create_widget() {
return new Widget{};
}调用者必须知道:
这个指针需要 delete 吗?
什么时候 delete?
异常发生时谁 delete?
能不能传给别人?
能不能保存起来?裸指针没有把答案写在类型里。
更清楚的接口是:
#include <memory>
std::unique_ptr<Widget> create_widget() {
return std::make_unique<Widget>();
}这个返回类型直接表达:
调用者得到一个独占拥有者。
它离开作用域时会自动释放对象。7. 动态分配的常见错误
内存泄漏:
void leak() {
int* p = new int(42);
// 忘记 delete
}重复释放:
int* p = new int(42);
delete p;
delete p;释放后使用:
int* p = new int(42);
delete p;
int x = *p;配对错误:
int* values = new int[10];
delete values; // 应该 delete[]这些错误的共同点是:
动态存储的清理责任没有被一个明确对象稳定持有。8. 本篇总结
堆解决灵活性问题:
大小运行时决定
对象跨作用域存在
容器扩容
多态对象创建
大块数据存储但堆带来清理责任:
new
分配存储 + 构造对象
delete
析构对象 + 释放存储现代 C++ 的基本方向是:
少在业务代码里裸写 new/delete。
用容器、string、unique_ptr、shared_ptr、RAII 类型表达所有权。下一篇讲对象生命周期。因为很多内存错误不是“不知道 delete”,而是“不知道对象什么时候已经不活了”。