Ch14.2: The Destructor: The One Function That Always Runs

English

提示:本页尚未翻译,当前显示英文原文。

1. The One Function That Always Runs

The destructor has no parameters, no return type, and no way to be called by name. It runs when an object's lifetime ends:

That is the whole reason C++ can manage a resource without a finally block: the release is attached to the lifetime, not to a code path, so there is no path to forget.

2. Writing It

In our class the destructor is three lines, and two of them are the check for the empty state:


	~my_posix_file() noexcept
	{
		if (fd_ != -1)
		{
			::close(fd_);
		}
	}

The check is not defensive noise. ::close(-1) fails with EBADF, and the destructor has no way to report a failure, so the empty state has to be recognised instead of closed. Every other member of the class relies on the same sentinel.

One place closes the descriptor. In the finished class that body lives in reset(newfd = -1) and the destructor is just reset();. The destructor, move assignment, and the “drop the old file” step of copy assignment all go through it, so there is exactly one call to ::close in the whole class.

3. A Destructor Cannot Fail

This is not a style rule, it is enforced:


error: destructor cannot be declared with a herbceptions ('throws'/'return_failure{E}')
       exception specification

A destructor that reported a failure would have nobody to report it to: the object is already gone, the handler is already returning, and there is no second cleanup round. The language simply forbids it, and the compiler agrees.

The consequence is stricter than it sounds. With herbceptions a noexcept function cannot even call a function that may fail, unless it handles the failure itself. So a destructor cannot print:


error: call to a 'throws' or 'return_failure{E}' function must be handled by an enclosing
       'try { } catch throws', or the calling function needs a herbceptions specification
       so the error can propagate

Every fast_io print can fail — the output device might have gone away (Ch2.14) — so println is throws and a destructor may not use it. You can handle the failure inside the destructor if you insist:


	~loud() noexcept
	{
		try
		{
			println("goodbye");
		}
		catch throws (::std::error)
		{
			// nothing left to say, and nobody left to say it to
		}
	}

but note what the handler does: it swallows the error. That is what “noexcept” costs. Reporting is for the code that still has a caller; cleanup is for destructors.

Do not do work that can fail in a destructor. If an operation can fail and you care about the failure, it belongs in a named member function the caller can handle — close(), flush(), commit() — and the destructor only does the part that cannot. That is exactly why fast_io's real posix_file has a close() that is throws while its destructor is noexcept.

4. Watching It Happen

The program below asks the operating system, not the class, whether the descriptor is still open. ::fcntl(fd, F_GETFD) succeeds while the descriptor is valid and fails with EBADF once it is closed, so it answers the question independently of anything our class believes:


#include <fast_io.h>
#include <fast_io_dsal/string_view.h>

using namespace ::fast_io::io;

class my_posix_file
{
	int fd_{-1};

public:
	explicit my_posix_file(::fast_io::cstring_view path, ::std::int_least32_t flags) throws
	{
		::std::int_least32_t fd{::open(path.c_str(), flags, 0644)};
		if (fd == -1) [[unlikely]]
		{
			throw throws static_cast<::std::errc>(errno);
		}
		fd_ = fd;
	}

	~my_posix_file() noexcept
	{
		if (fd_ != -1)
		{
			::close(fd_);
		}
	}

	void reset() noexcept
	{
		if (fd_ != -1)
		{
			::close(fd_);
		}
		fd_ = -1;
	}

	[[nodiscard]] bool is_open() const noexcept
	{
		return fd_ != -1;
	}

	[[nodiscard]] ::std::int_least32_t native_handle() const noexcept
	{
		return fd_;
	}
};

int main()
{
	{ // make sure the example file exists
		::std::int_least32_t fd{::open("demo.txt", O_WRONLY | O_CREAT, 0644)};
		::close(fd);
	}

	::std::int_least32_t raw{};

	{
		my_posix_file f{"demo.txt", O_RDONLY};
		raw = f.native_handle();
		println("inside the block: fd ", raw, ", valid = ", ::fcntl(raw, F_GETFD) != -1);
	}
	println("after the block: fd ", raw, ", valid = ", ::fcntl(raw, F_GETFD) != -1);

	try
	{
		my_posix_file f{"demo.txt", O_RDONLY};
		raw = f.native_handle();
		println("inside the try: fd ", raw, ", valid = ", ::fcntl(raw, F_GETFD) != -1);
		throw throws ::std::errc::invalid_argument;
	}
	catch throws (::std::error e)
	{
		print("caught: ", e, "\n");
		println("in the handler: fd ", raw, ", valid = ", ::fcntl(raw, F_GETFD) != -1);
	}

	my_posix_file g{"demo.txt", O_RDONLY};
	g.reset();
	println("after reset(): is_open = ", g.is_open());
}

inside the block: fd 3, valid = 1
after the block: fd 3, valid = 0
inside the try: fd 3, valid = 1
caught: [posix](22)Invalid argument
in the handler: fd 3, valid = 0
after reset(): is_open = 0

Three things are visible in that output.

5. Order, and What a Reverse Destruction Means

Locals are destroyed in reverse order of construction, and class members in reverse order of declaration. Copy assignment (Ch14.4) depends on that when it hands a temporary the descriptor it is about to stop owning: the temporary dies at the end of the full expression, well after the assignment has finished, so the old descriptor is closed after the new one is in place.


{
	my_posix_file a{"one.txt", O_RDONLY}; // constructed first
	my_posix_file b{"two.txt", O_RDONLY}; // constructed second
}   // b destroyed first, then a

The rule is not a detail: if b does not outlive a in whatever order your code needs, you do not have a bug, you have a lifetime that does not overlap — and no destructor will save you from it.