第18.1章 new、delete 与 ::std::unique_ptr
1. new 到底做了什么
一个表达式,四步:
square *p = new square{2.0};
- 调用
::operator new(sizeof(square)),向实现的堆要内存; - 在这块内存上运行构造函数;
- 把指针交给你;
- 如果第 1 步失败,抛出
::std::bad_alloc。
第 4 步是 C++ 历史上最大的错误之一。分配内存失败不可恢复,
在任何有用的意义上都不算异常:它就是程序的结束。
把它做成抛出异常,意味着你程序里的每个 new 都是一条能离开函数的
隐藏控制流边,每个调用 new 的函数都必须做好抛的准备,
而每个调用者都必须在一个根本无能为力的设计里决定“内存耗尽”意味着什么。
诚实的答案是分配失败就终止(第18.7章),
而 C++ 在 1998 年选了相反的做法,那时候它根本没有可用的错误通道。
这就是为什么 new 不是 noexcept:
::std::printf("noexcept(new square{2.0}) = %d\n", noexcept(new square{2.0}));
::std::printf("noexcept(::operator new(8)) = %d\n", noexcept(::operator new(8)));
noexcept(new square{2.0}) = 0
noexcept(::operator new(8)) = 0
也是为什么在赫布异常下,new 的失败根本不是以 throws
错误的形式出现的:它以一个包在 itanium_exception 载荷里的遗留异常形式出现:
#include <fast_io.h>
int main()
{
try
{
void *p = ::operator new(static_cast<::std::size_t>(-1) / 2u);
::fast_io::io::println("allocated ", p != nullptr);
::operator delete(p);
}
catch throws (::std::error e)
{
::fast_io::io::perrln("caught by herbception: ", e);
}
}
caught by herbception: [itanium_exception](St9bad_alloc)std::bad_alloc
仔细看这一行。语言核心里的一个特性 —— 分配 —— 把一个本来完全用赫布异常写的程序,
硬拽进了整套遗留异常机制(因而是 unwinder 和 RTTI)。
你没有办法让 new 返回一个值。(你可以用
::operator new(n, ::std::nothrow),但
第18.7章解释了为什么那更糟:
nothrow 重载是靠调用会抛的版本并捕获来实现的,
而标准甚至不允许你替换它们。)
2. delete 更糟,因为它不做任何检查
delete p先调析构函数,再调::operator delete(p);- 它不知道分配的大小,除非编译器把它传进来(带大小的释放),
所以不匹配的
operator delete会破坏堆; - 它不知道
p是否还活着,所以重复delete是一次常常能活到生产环境的堆破坏; - 它不知道动态类型,所以通过没有虚析构的基类指针 delete 只会销毁基类子对象 —— 未定义行为加一次泄漏;
delete和delete[]是一个没有任何诊断的拼写错误。
上面每一条都是程序员必须在每个函数里、永远记住的规则。这就是烂 API 的定义。
3. ::std::unique_ptr:带析构函数的指针
::std::unique_ptr<T> 包一个裸指针和一个删除器,做的是 C++ RAII
包装器该做的事:
void use() throws
{
::std::unique_ptr<square> p{::std::make_unique<square>(2.0)};
::fast_io::io::println(p->area()); // p is destroyed here, exactly once
}
它解决泄漏,而且只解决泄漏。上面 delete 的四个问题它一个都不解决:
它挡不住你留第二个裸指针指向同一个对象(释放后使用),
你用 release() 作弊时它挡不住你把所有权交给两个人,
它在调用点说不清 unique_ptr<shape> 是什么意思,
它也挡不住类型混淆,因为被指对象的动态类型仍然没有任何东西检查。
4. 人们伸手去拿它的唯一理由
异构。你有好几种形状,想把它们放进一个容器,而容器的元素类型只有一个:
struct shape
{
virtual double area() const noexcept = 0;
virtual ::std::string_view name() const noexcept = 0;
virtual ~shape() noexcept = default;
};
struct square final : shape
{
double side{};
explicit square(double s) noexcept : side{s} {}
double area() const noexcept override { return side * side; }
::std::string_view name() const noexcept override { return "square"; }
};
struct circle final : shape
{
double r{};
explicit circle(double x) noexcept : r{x} {}
double area() const noexcept override { return 3.0 * r * r; }
::std::string_view name() const noexcept override { return "circle"; }
};
然后在 main 里:
::fast_io::vector<::std::unique_ptr<shape>> shapes;
shapes.push_back(::std::make_unique<square>(2.0));
shapes.push_back(::std::make_unique<circle>(1.0));
double total{};
for (auto const &p : shapes)
{
total += p->area();
}
total=7 sizeof(unique_ptr)=8 sizeof(vector)=24
它能用。但实测下来,容器一次堆分配,每个元素再一次堆分配,
每个形状一个 8 字节指针加分配器头部,每个元素一次虚调用,两个类都要 RTTI,
而调用点看到的类型还说不清是什么。
第18.6章把同一个程序写成
::fast_io::vector<shape> —— 一个装值的向量,
你代码里没有 new,任何地方都没有 virtual。
5. 为什么我们不用 ::std::unique_ptr
来自 Portable-Cpp-Guideline,这里只留真正重要的理由:
- 它鼓励 OOP。它最自然的落脚点是“指向基类的指针”, 而这正是我们想摆脱的设计。
- 它的 ABI 不稳定,而且实测很慢。按值传一个
unique_ptr是非平凡类型,所以它被间接传递,被调方还要跑析构; libc++ 关于给unique_ptr加[[clang::trivial_abi]]的那份文档说,光这一项改动就让大型服务器宏基准快了最多 1.6%。 - 它不是“指向内存的指针”,它是“指向某个由别人释放的东西的指针”,
所以它表达不了 POSIX 文件描述符、
HANDLE、数据库连接或 mmap 区域: 创建资源的 API 才是必须释放它的 API,而那不是delete。 答案是给资源写一个带析构的类,而不是一个带自定义删除器的unique_ptr。 - 自定义删除器是性能陷阱。lambda、函数指针、函数对象各有各的代码生成、 各有各的大小、各有各的内联行为,而且很容易不小心用了最慢的那个。
unique_ptr<T[]>是个坑:那个[]很容易忘,忘了就是未定义行为。- 你不能拿它建数据结构。
unique_ptr节点组成的树 递归销毁,直接把栈打爆;由它们组成的链表是两头好处都没有。 - 它在 freestanding 环境里不可用,因为
::std::make_unique不是 freestanding 的,而new并不保证存在(第20章 freestanding)。 - 它把调用点的类型信息毁掉了。
unique_ptr<shape>说的是“一个形状,在别的地方,而且我拥有它”。 值shape说的是“一个形状”。前者是读者能用的句子,后者是关于堆的传闻。 - 它逼你用
nullptr表示“空”,于是每次使用都要检查, 于是检查总会被忘掉。
指南里的总结句:::std::unique_ptr 不是智能指针,
而是一个可疑、可能有害的指针类型。
6. 那该写什么
| 你想要 | 不要这个 | 要这个 |
|---|---|---|
| 同一种类型的很多对象 | ::fast_io::vector<::std::unique_ptr<T>> |
::fast_io::vector<T> |
| 一个类型背后的多种表示 | vector<unique_ptr<base>> 加 virtual |
类型擦除的值类型(第18.6章) |
| 一个有生命周期的资源 | unique_ptr<X, deleter> |
一个析构函数只拥有那一个资源的类 (第14.1章) |
| 可空的所有权 | 可能为空的 unique_ptr<T> |
空容器,或者给便宜的平凡可拷贝值用
::std::optional<T> |
| 动态分配的缓冲区 | unique_ptr<::std::byte[]> |
::fast_io::vector<::std::byte>,或者你自己的区域 |
7. 经验法则
- 应用代码里永远不要写
new或delete。 如果库给了你一个要你自己释放的裸指针,立刻在你自己的拥有型类里包起来, 只在一个地方包,不要让裸指针到处跑。 - 永远不要写
::std::unique_ptr。它是症状而不是解法: 症状是你在建模对象图,而不是值。 - 如果你从遗留代码里继承了
::std::unique_ptr,不要在原地“改进”它。 把它包住:给你自己的类型一个值接口,把指针藏进私有成员。 - 记住
new是一个遗留的会抛函数,所以noexcept函数不能调用它 —— 如果你的契约写得诚实,编译器会拦住你。 - 当你需要分配时,说清楚内存从哪来、没有内存时会发生什么。那是一个设计决定,
而
new替你、一次、永远地、悄悄地做掉了它。