现代 C++ 从拒绝 new 开始
使用 new 和 delete 是一种过时的内存管理方式,容易导致内存泄漏和悬空指针,应当永不使用。
优秀的现代 C++ 项目,都使用智能指针和容器管理内存,从来不需要直接创建原始指针。
下列三种情况下,你可以使用 new 和 delete:
- 你在封装一个非常底层的内存分配器库。
- 你是 C++98 用户,且你的老板不允许使用 boost(其提供了智能指针)。
- 你想要创造一些内存泄漏来惩罚拖欠工资的脑板。
同理,malloc 和 free 也是不允许的。
不仅 new 不应该出现,原始指针也应该少出现,而是更安全,用法更单一的 引用 或 span 代替。
用法更单一为什么是好事?请看强类型 API 设计专题章。
案例 1
同学:我想要分配一段内存空间,你不让我 new,我还能怎么办呢?
char *mem = new char[1024]; // 同学想要 1024 字节的缓冲区
read(1, mem, 1024); // 用于供 C 语言的读文件函数使用
delete[] mem; // 需要手动 delete
可以看到,他所谓的“内存空间”实际上就是一个“char 数组”。
小彭老师:有没有一种可能,vector 就可以分配内存空间。
vector<char> mem(1024);
read(1, mem.data(), mem.size());
vector 一样符合 RAII 思想,构造时自动申请内存,离开作用域时自动释放。
只需在调用 C 语言接口时,取出原始指针:
- 用 data() 即可获取出首个 char 元素的指针,用于传递给 C 语言函数使用。
- 用 size() 取出数组的长度,即是内存空间的字节数,因为我们的元素类型是 char,char 刚好就是 1 字节的,size() 刚好就是字节的数量。
此处 read 函数读完后,数据就直接进入了 vector 中,根本不需要什么 new。
更现代的 C++ 思想家还会用
vector<std::byte>
,明确区分这是“字节”不是“字符”。如果你读出来的目的是当作字符串,可以用std::string
。注意:一些愚蠢的教材中,用
shared_ptr
和unique_ptr
来管理数组,这是错误的。
shared_ptr
和unique_ptr
智能指针主要是用于管理“单个对象”的,不是管理“数组”的。
vector
一直都是数组的管理方式,且从 C++98 就有。不要看到 “new 的替代品” 只想到智能指针啊!“new [] 的替代品” 是vector
啊!
此处放出一个利用 std::wstring
分配 wchar_t *
内存的案例:
std::wstring utf8_to_wstring(std::string const &s) {
int len = MultiByteToWideChar(CP_UTF8, 0,
s.data(), s.size(),
nullptr, 0); // 先确定长度
std::wstring ws(len, 0);
MultiByteToWideChar(CP_UTF8, 0,
s.data(), s.size(),
ws.data(), ws.size()); // 再读出数据
return ws;
}
贴士 1.1
我一般会用更直观的 auto 写法,这样更能明确这是在创建一个 vector 对象,然后保存到 mem 这个变量名中。
auto mem = vector<char>(1024);
read(1, mem.data(), mem.size());
这被称为 auto-idiom:始终使用 auto 初始化变量,永远别使用可能引发歧义的类型前置。
贴士 1.2
有的同学会想当然地提出,用智能指针代替 new。
auto mem = make_shared<char[]>(1024);
read(1, mem.get(), 1024);
可 new 的替代品从来不只有智能指针一个,也可以是 vector 容器!
- 智能指针只会用于单个对象!
- 动态长度的数组,正常人都是用 vector 管理的。
很多劣质的所谓 “现代 C++ 教材” 都忽略了这一点,总是刻意夸大了智能指针的覆盖范围,为了新而新,而对实际上更适合管理 动态长度内存空间 的 vector 只字不提。
vector 管理动态长度内存空间的优势:
- 你可以随时随地 resize 和 push_back,加入新元素,而智能指针管理的数组要重新调整大小就比较困难。
- vector 的拷贝构造函数是深拷贝,符合 C++ 容器的一般约定。而 unique_ptr 完全不支持拷贝,深拷贝需要额外的代码,shared_ptr 则是浅拷贝,有时会导致数据覆盖。
其实 shared_ptr<char[]>
也不是不可以用,然而,智能指针管理的数组,并不能方便地通过 .size()
获取数组的长度,必须用另一个变量单独存储这个长度。这就违背了封装原则,那会使你的代码变得不可维护。
绝大多数情况下,可维护性总是比性能重要的,你只需要比较 你重构代码花的时间 和 计算机运行这段代码所需时间 就明白值不值了。
贴士 1.3
如果是其他类型,可能需要乘以 sizeof(元素类型)
,取决于那个 C 函数要求的是“字节数”还是“元素数”。
auto mem = vector<int>(1024);
read(1, mem.data(), mem.size() * sizeof(mem[0]));
auto max = find_max(mem.data(), mem.size());
贴士 1.4
对于你自己的 C++ 函数,就没必要再提供
TODO: span, gsl::span, boost::span
案例 2
同学:我需要在“堆”上分配一个对象,让他持久存在。你不让我用 new,我只能在“栈”上创建临时对象了,如果要返回或存起来的话根本用不了啊。
Foo *hello() {
Foo *foo = new Foo();
return foo;
}
小彭老师:你可以使用智能指针,最适合新人上手的智能指针是 shared_ptr。 当没有任何函数或对象持有该 shared_ptr 指向的对象时,也就是当调用者存储 hello() 返回值的函数体退出时,指向的对象会被自动释放。
shared_ptr<Foo> hello() {
shared_ptr<Foo> foo = make_shared<Foo>();
return foo;
}
总之,这样替换你的代码:
T *
换成shared_ptr<T>
new T(...)
换成make_shared<T>(...)
你的代码就基本上安全了,再也不用手动 delete 了。
有个用了 shared_ptr 还会内存泄漏的边缘情况:循环引用,通常是实现双向链表时,weak_ptr 可以解决,稍后介绍。
贴士 2.1
unique_ptr 和 shared_ptr 有什么区别?初学者应该先学哪个?
unique_ptr 是独占所有权,他的限制更多,比如:
- 不允许拷贝,只允许移动。
- 不允许赋值,只允许移动赋值。
- 用 unique_ptr 主要是出于性能优势。
然而性能总是不如安全重要的,你是想要一个造在火星的豪华宫殿,还是一个地球的安全老家?
所以,建议你先全部替换成泛用性强、易用的 shared_ptr。等确实出现性能瓶颈时,再对瓶颈部分单独调试优化也不迟。
先把老家造好了,然后再想办法移民火星,而不是反过来。
贴士 2.2
有些老式的所谓 “现代 C++ 教程” 中,会看到这样 new 与智能指针并用的写法:
shared_ptr<Foo> foo(new Foo());
从 C++14 开始,这已经是过时的!具有安全隐患(如果构造函数可能抛出异常),且写起来也不够直观。
现在人们一般都会用 make_shared 函数,其内部封装不仅保证了异常安全,而且会使 shared_ptr 的控制块与 Foo 对象前后紧挨着,只需一次内存分配,不仅更直观,还提升了性能。
auto foo = make_shared<Foo>();
有趣的是,make_shared 在 C++11 就引入了,make_unique 却直到 C++14 才引入。
从 C++14 开始,内存安全的现代 C++ 程序中就不会出现任何显式的 new 了,哪怕是包在 shared_ptr 或 unique_ptr 内的也不行。(除了最上面说的 3 种特殊情况)
贴士 2.3
如果你需要调用的 C 语言接口还需要原始指针的话,用 .get()
可以从智能指针中获取原始指针。建议只在和 C 语言打交道时 .get()
,其余时间一律 shared_ptr 保证安全。
extern "C" void some_c_function(Foo *foo);
auto foo = make_shared<Foo>();
some_c_function(foo.get());
RAII 比起手动 delete 的优势
在日常代码中,我们常常会使用“如果错误了就提前返回”的写法。这被称为提前返回 (early-return),一种优质的代码写法,比弄个很大的 else 分支要可维护得多。
在错误处理专题中有进一步的详解。
然而这有时我们会忘记在提前返回的分支中 delete 之前分配过的所有指向动态内存的指针。
int func() {
Foo *foo = new Foo();
...
if (出错) {
// 提前返回的分支中忘记 delete foo!
return -1;
}
...
delete foo;
return 0;
}
过去,人们使用 goto
大法拙劣地在提前返回时 delete
动态内存:
int main() {
Foo *foo1, *foo2;
int ret = 0;
foo1 = new Foo();
...
if (出错) {
ret = -1;
goto out_foo1;
}
...
Foo *foo2 = new Foo();
...
if (出错) {
ret = -2;
goto out_foo2;
}
...
out_foo2: delete foo2;
out_foo1: delete foo1;
return ret;
}
这对于“写”程序的人,其实还不算什么,无非就是注意匹配,反正都是一次性写完就得了。
真正遭罪的是“改”程序的人,如果他要删掉foo1,那么他需要在两个地方来回跳转,如果foo1变成 new[]
了,那么他需要跳到下面把 delete foo1
也改成 delete[] foo1
。如果foo1要改名,那么还需要跳到下面 out_foo1:
标签也改了。如果要新增一个foo3指针,那还需要跳到上面加个 Foo *foo3;
,下面加个标签和 delete。
BUG漫漫其修远兮,吾将上下而求索。
你是否遇到过写程序梭哈,事后“上下求索”的情况?同一个变量 foo 的生命周期被极限撕扯,分居两地,极大的妨碍了我们改程序的效率。
而统计表明,程序员10%的时间用于写代码,90%的时间花在改代码上。节约改代码的时间,就是节约程序员90%的生命。改代码不容易出错,可以省去软件中90%的BUG。
所以,除非你是一次性交差项目不打算更新了,或者确认了改代码的人不会是你,否则必然要用包括智能指针、设计模式在内的各种手段竭力避免代码分散化。
在一个庞大的代码系统中看到原始指针是最头疼的:
Student *getstu(const char *name);
没有任何信息告诉我:
- 这个指针指向对象生命周期如何?
- 要我负责释放内存吗?
- 如何释放,
delete
、delete[]
、free
、还是fclose
? - 可以是空指针吗?
- 数组还是单个对象?
- 如果是数组,那么长度多少?是C风格0结尾字符串吗?
- 是否移交所有权?
- 共享或独占?
- 该资源可以拷贝吗?
- 如果可以,如何拷贝?
而你只能通过查阅文档,才能确认这些信息,拖慢了开发效率。
而使用 RAII 容器(不仅是只能指针)作为类型,就能让人一眼就看出以上10个信息。
gsl::not_null<std::unique_ptr<Student>> getstu1(std::string_view name);
Student &getstu2(std::string_view name);
以上代码中,一看就明白,getstu1会移交所有权,且不可能为空;getstu2不转移所有权,仅仅只是提供给调用者访问,且不可能为空。
传统指针因为语义不明确,功能多样化,有用错的可能,例如用户可能偷懒不看文档,就擅自瞎写:
char name;
Student *stu = getstu(&name);
if (stu == NULL) exit(1);
free(stu);
实际上 getstu
的参数 name
需要是一个 C 风格 0 结尾字符串,用户却不小心当作单个 char
的指针写了,编译器没有报错。
而 stu
实际上是 getstu
返回给调用者的临时引用,并不移交所有权,而用户却想当然的释放了。
并且实际上 getstu
从不返回空指针,用户根本不用提心吊胆地检查。
一个优质的函数接口,就不应该给用户这种犯错的机会。
我知道,你会说,std::string
不也能从 &name
构造,放任编译通过,不也会犯错吗?
是这样的,你甚至可以从 0
构造 std::string
,编译一样通过,程序会直接崩溃:
std::string s = 0; // 0 被当作 NULL,从而调用构造函数 std::string(const char *)
标准库里不符合小彭老师设计模式的多了去了,标准库的垃圾是历史遗留问题,不是小彭老师的问题。
而智能指针,不论是提前返回还是最终的返回,只要是函数结束了,都能自动释放。智能指针使得程序员写出“提前返回式”毫无精神压力,再也不用惦记着哪些需要释放。
int func() {
shared_ptr<Foo> foo = make_shared<Foo>();
...
if (出错) {
return -1;
}
...
return 0;
}
shared_ptr 小课堂
自动释放
void func() {
shared_ptr<Foo> fooPtr = make_shared<Foo>();
...
}
离开 func 作用域,fooPtr 就销毁了。
fooPtr 是唯一也是最后一个持有 foo 对象的智能指针。
所以离开 func 作用域时,其指向的 foo 对象就会销毁。
保存续命
shared_ptr<Foo> globalPtr;
void func() {
shared_ptr<Foo> fooPtr = make_shared<Foo>();
...
globalPtr = fooPtr;
}
- 离开 func 作用域,fooPtr 就销毁了。
- 但是 globalPtr 是全局变量,直到程序退出才会销毁。
- 相当于帮原 fooPtr 指向的对象帮续命了!
提前释放
void other() {
globalPtr = nullptr; // 相当于传统指针的 delete
}
但是如果现在又一个函数给 globalPtr 写入空指针。 这时之前对原对象的引用就没有了。
对智能指针写入一个空指针可以使其指向的对象释放。
对智能指针写入空指针的效果和 delete 很像,区别在于:
- 如果你忘了 delete 就完了!
- 你就算不写入空指针,智能指针也会自动释放,写入空指针只是把死期提前了一点而已……
shared_ptr<Foo> p = make_shared<Foo>();
p = nullptr; // 1
p.reset(); // 2
} // 3
P.S. 同理,vector 也可以通过
v = {}
或v.clear()
来提前释放内存。
总结
- 当你需要分配一段内存空间:vector
- 当你需要创建单个对象:shared_ptr
- 当你想提前 delete:写入空指针
线程安全?
似乎很多三脚猫教材都在模棱两可地辩论一个问题:shared_ptr 到底是不是线程安全的?
不论什么类型,都要看你的用况,才能知道是不是线程安全,这里分为三种情况讨论:
- 多个线程同时从同一个地方拷贝 shared_ptr 出来是安全的(多线程只读永远安全定律):
shared_ptr<T> a;
void t1() {
shared_ptr<T> b1 = a;
}
void t1() {
shared_ptr<T> b2 = a;
}
- 多个线程同时从往同一个地方写入 shared_ptr 是不安全的(多线程 1 写 n 读定律):
shared_ptr<T> a;
void t1() {
shared_ptr<T> b1;
a = b1;
}
void t1() {
shared_ptr<T> b2;
a = b2;
}
这种情况下,你应该考虑的是
atomic<shared_ptr<T>>
。
- shared_ptr 并不保护其指向 T 类型的线程安全(你自己 T 实现的就不安全怪我指针???):
shared_ptr<T> a;
void t1() {
a->b1 = 0;
}
void t1() {
a->b1 = 1;
}
这种情况下,你应该考虑的是给你的 T 类型里面加个 mutex 保护好自己,而不是来怪我指针。
直接的答案:他们说的是,shared_ptr 的拷贝构造函数、析构函数是线程安全的,这不是废话吗?我只是拷贝另一个 shared_ptr,对那个 shared_ptr 又不进行更改,当然不会发生线程冲突咯。我自己析构关你其他 shared_ptr 什么事,当然就没有线程冲突咯。这是非常直观的,和普通指针的线程安全没有任何不同。
之所以这些狗币教材会辩论,是因为他们老爱多管闲事,他们了解到 shared_ptr 的底层细节中有个控制块的存在,而拷贝构造函数、析构函数需要修改控制块的计数值,所以实际标准库的实现中,会把这个计数器设为原子的,最终结果是使得 shared_ptr 在多线程中和普通指针一样安全。这是标准库底层实现细节,我们作为高层用户并不需要考虑他底层如何实现,我们只需要记住原始指针怎样用是线程安全的,shared_ptr 就怎样线程安全。
- 你会两个线程同时写入同一个原始指针吗?同样地,如果你原始指针不会犯错,shared_ptr 为什么会犯错?
- 你可以两个线程同时读取同一个全局的原始指针变量,同样地,shared_ptr 也可以,有任何区别吗?
反正,shared_ptr 内部专门为线程安全做过设计,你不用去操心。
placement new
placement new 和 placement delete 也可以用 std::construct_at 和 std::destroy_at 代替:
#include <new>
struct Foo {
explicit Foo(int age) { ... }
Foo(Foo &&) = delete;
~Foo() { ... }
};
void func() {
alignas(Foo) unsigned char buffer[sizeof(Foo)];
Foo *foo = std::construct_at(reinterpret_cast<Foo*>(buffer), 42, "hello"); // 等价于 new (buffer) Foo(42);
...
std::destroy_at(foo); // 等价于 foo->~Foo();
}
在内存模型专题中有进一步的详解。