Ch18.1: new, delete, and ::std::unique_ptr

中文(简体)

1. What new Actually Does

One expression, four steps:


square *p = new square{2.0};
  1. call ::operator new(sizeof(square)), which asks the implementation's heap for memory;
  2. run the constructor in that memory;
  3. hand you the pointer;
  4. and if step 1 fails, throw ::std::bad_alloc.

Step 4 is one of the biggest historical mistakes in C++. Failure to allocate memory is not recoverable and not exceptional in any useful sense: it is the end of the program. Making it a thrown exception means every new in your program is a hidden control-flow edge that can leave a function, every function that calls new must be prepared to throw, and every caller must decide what "out of memory" means in a design that cannot do anything about it. The honest answer is that allocation failure terminates (Ch18.7), and C++ chose the opposite in 1998, when it had no usable error channel at all.

That is why new is not 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

and why, under herbceptions, the failure of new does not arrive as a throws error at all: it arrives as a legacy exception wrapped in an itanium_exception payload:


#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

Read that carefully. A feature of the language core, allocation, drags the entire legacy exception machinery — and therefore the unwinder and RTTI — into a program that is otherwise written entirely with herbceptions. There is no way to ask new for a return value. (You can ask ::operator new(n, ::std::nothrow), but Ch18.7 explains why that is worse: the nothrow overloads are implemented by calling the throwing ones and catching, and the standard does not even let you replace them.)

2. delete Is Worse, Because It Is Unchecked

Every one of those is a rule the programmer must remember, in every function, forever. That is the definition of a bad API.

3. ::std::unique_ptr: A Pointer With a Destructor

::std::unique_ptr<T> wraps one raw pointer and one deleter, and does what a C++ RAII wrapper does:


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
}

It solves leaks, and only leaks. It does not solve the four delete problems above: it cannot stop you from keeping a second raw pointer to the same object (use-after-free), it cannot stop you from moving it into two owners' hands at once if you cheat with release(), it does not tell you what a unique_ptr<shape> means at the call site, and it does not stop type confusion, because the pointee's dynamic type is still not checked by anything.

4. The Only Reason People Reach For It

Heterogeneity. You have several shapes and you want them in one container, and the container holds one type:


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"; }
};

and then, in 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

It works. It is also, measured, one heap allocation for the container and one for every single element, an 8-byte pointer plus allocator header per shape, a virtual call per element, RTTI for both classes, and a type the call site cannot describe. Ch18.6 writes the same program as ::fast_io::vector<shape> — a vector of values, with no new in your code and no virtual anywhere.

5. Why We Do Not Use ::std::unique_ptr

From Portable-Cpp-Guideline, with the reasoning that matters here:

And the summary sentence from the guideline: ::std::unique_ptr is not a smart pointer, it is a questionable pointer type that can be harmful.

6. What To Write Instead

You want Not this This
many objects of one type ::fast_io::vector<::std::unique_ptr<T>> ::fast_io::vector<T>
many representations behind one type vector<unique_ptr<base>> + virtual a type-erased value type (Ch18.6)
a resource with a lifetime unique_ptr<X, deleter> a class with a destructor that owns exactly that resource (Ch14.1)
optional ownership unique_ptr<T> that may be null an empty container, or ::std::optional<T> for a cheap trivially-copyable value
dynamic allocation of a buffer unique_ptr<::std::byte[]> ::fast_io::vector<::std::byte>, or an arena you own

7. Rules of Thumb