Ch14.1: Ownership: Why a Class Needs a Destructor
1. A Descriptor Is a Resource
::open hands you an int. It is not a number you may do
arithmetic on; it is a handle on something the kernel owns. Three
properties make it a resource:
- It must be released. The kernel keeps a table of open files per
process, and it is finite. An
intthat is never::closed leaks a slot of that table. - It has exactly one meaning while it is alive. The number stays
valid until you close it; after that the same number is free, and the next
::openanywhere in the program may hand it out again. - It is not the file. Two descriptors can name the same file, and two copies of the number name the same descriptor. Both facts matter in this chapter.
2. What a Bare int Does
A plain variable has no idea it owns anything. Nothing closes it, so it lives until the process dies:
#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_);
}
}
[[nodiscard]] ::std::int_least32_t native_handle() const noexcept
{
return fd_;
}
};
int main()
{
// A bare int does not know it owns an operating system resource.
::std::int_least32_t raw{::open("demo.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644)};
println("bare int: ", raw);
// Nothing closes everything. The descriptor is leaked for the rest of the run.
my_posix_file managed{"demo.txt", O_RDONLY};
println("managed: ", managed.native_handle());
{
my_posix_file scoped{"demo.txt", O_RDONLY};
println("in scope: ", scoped.native_handle());
} // the destructor runs here
my_posix_file after{"demo.txt", O_RDONLY};
println("after: ", after.native_handle());
}
Output:
bare int: 3
managed: 4
in scope: 5
after: 5
Read the numbers. The bare int took descriptor 3 and stayed
there for the whole run, so every later open got a fresh number:
4, then 5. The managed file inside the block took
5 and, when the block ended, gave 5 back —
which is why the next open receives 5 again and not 6.
That reuse is the proof that the destructor ran. Nothing in the program calls
::close explicitly; the object is responsible for its own descriptor, and
the responsibility is discharged automatically.
return, a throw throws, a break, a normal
fall-through — the destructor runs in all of them. An explicit
::close at the end of a function runs in none of the first three.
3. The Five Questions
A class that owns a descriptor has to say what happens in these five situations. There is no escape: if you say nothing, the compiler answers all five for you, and its answers are wrong for a resource.
| Question | Written as | Wrong default |
|---|---|---|
| When the object dies, who frees the descriptor? | ~my_posix_file() |
nobody: the descriptor leaks |
| When a new object is made from an existing one, what happens? | my_posix_file(my_posix_file const &) |
the number is copied: two owners, one descriptor |
| When an existing object is assigned from another, what happens? | operator=(my_posix_file const &) |
the old descriptor leaks and the new one is shared |
| When a dying object is used to build a new one, can we just take it? | my_posix_file(my_posix_file &&) |
it copies instead, which is correct but wasteful |
| When an existing object takes over from a dying one, what happens? | operator=(my_posix_file &&) |
it copies instead, and leaks the old descriptor |
Write all five and you have the rule of five. Write none and have the compiler generate all of them — the rule of zero — which is the better outcome, but only possible when every member is already a well-behaved value. A raw descriptor is not (Ch14.11 comes back to this).
4. What We Are Going to Build
The class stays small on purpose. It holds one int, and everything else is
the five rules:
class my_posix_file
{
int fd_{-1}; // -1 means "no descriptor"; it is the empty state
public:
// 1. create: acquire a descriptor from a path
explicit my_posix_file(::fast_io::cstring_view path, ::std::int_least32_t flags) throws;
// 2. destroy: give the descriptor back -> Ch14.2
~my_posix_file() noexcept;
// 3. copy: a second descriptor for the same open file -> Ch14.3
my_posix_file(my_posix_file const &other) throws;
// 4. copy-assign: replace what this object owns -> Ch14.4
my_posix_file &operator=(my_posix_file const &other) throws;
// 5. move: take the descriptor and leave -1 behind -> Ch14.5, Ch14.6
my_posix_file(my_posix_file &&other) noexcept;
my_posix_file &operator=(my_posix_file &&other) noexcept;
// 6. swap: exchange two objects' descriptors -> Ch14.7
void swap(my_posix_file &other) noexcept;
};
The sentinel -1 is doing real work: it is the difference between
“I own a file” and “I own nothing”. Without it there would be
no way to leave a moved-from object in a state where its destructor can run safely.