Ch14.2: The Destructor: The One Function That Always Runs
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:
- at the end of the block the object was declared in;
- when
throw throwsleaves that block, including the block of atrywhose handler is about to run; - when
returnleaves the function that owns it; - for members, in reverse order of declaration, when their owner dies.
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.
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.
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.
-
The destructor runs at the end of the block. Inside the block,
descriptor
3is valid; on the line after the block it is not. Nobody called::close. -
It also runs when a herbception leaves the block. Inside the
trythe descriptor is valid; in the handler it is already closed. The local objects of atryblock are destroyed before the handler runs, which is the whole reason a handler can trust that it is looking at a clean state. -
reset()is the same close, on demand. Afterg.reset()the file is closed and the object is empty, and it stays empty until it is given a descriptor again.
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.