第18.1章 new、delete 与 ::std::unique_ptr

English

1. new 到底做了什么

一个表达式,四步:


square *p = new square{2.0};
  1. 调用 ::operator new(sizeof(square)),向实现的堆要内存;
  2. 在这块内存上运行构造函数;
  3. 把指针交给你;
  4. 如果第 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 更糟,因为它不做任何检查

上面每一条都是程序员必须在每个函数里、永远记住的规则。这就是烂 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,这里只留真正重要的理由:

指南里的总结句:::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. 经验法则