Ch14.4: Copy Assignment and the Critical Line
1. Assignment Is Not Construction
my_posix_file a{"demo.txt", O_RDONLY}; my_posix_file b{"other.txt", O_RDONLY};
then a = b; is a completely different operation from
my_posix_file a{b};.
In the assignment, a already owns a descriptor. Whatever
the function does, that descriptor must end up closed — otherwise it leaks —
and a must end up owning a duplicate of b's. So three things
have to happen, and the only real question is in what order:
- acquire the new descriptor (
::dup, can fail); - release the old one (
::close, cannot fail in any way we care about); - install the new one (an
intassignment, cannot fail).
2. The Order Everyone Writes First
It reads naturally: get rid of the old file, then take the new one.
// WRONG
my_posix_file &operator=(my_posix_file const &other) throws
{
if (fd_ != -1)
{
::close(fd_); // the old file is gone
}
fd_ = ::dup(other.fd_); // may fail -- and now there is nothing left
if (fd_ == -1) [[unlikely]]
{
throw throws static_cast<::std::errc>(errno);
}
return *this;
}
Three things are wrong with it, and they are worth separating.
-
The object is destroyed before the new resource exists. If
::dupfails, the assignment fails and the file that was open a moment ago is gone. The caller is left with a broken object and no way back. -
Self-assignment destroys the file.
a = aclosesa's descriptor and then duplicates-1, which fails withEBADF. The perfectly legal statementa = aleavesaclosed and throws. -
It looks like it cannot be fixed by adding a check. Adding
if (this == &other) return *this;hides the second problem and leaves the first one untouched. The order is what is wrong.
3. The Critical Line
Reverse the order and everything falls into place. Do the fallible work first, into a local variable, while the object is still untouched; then, once nothing can fail any more, commit:
my_posix_file &operator=(my_posix_file const &other) throws
{
::std::int_least32_t newfd{::dup(other.fd_)}; // may fail, *this untouched
if (newfd == -1) [[unlikely]]
{
throw throws static_cast<::std::errc>(errno);
}
// ---- critical line ----
reset(newfd); // cannot fail, *this changes
return *this;
}
— above it, operations may fail, and nothing has been modified yet;
— below it, operations cannot fail, and they are the ones that modify the object.
Put it in the right place and a failure can only happen while there is still nothing to undo. That is what the strong exception safety guarantee is, and the critical line is how you get it.
Count the statements on each side. Above: a call to ::dup and a check,
both fallible, neither touching *this. Below: one call to
reset, which is noexcept and does the whole commit —
close the old descriptor, store the new one. There is no state in which
*this is half-assigned, because there is no fallible operation after the
old value has been touched.
4. Self-Assignment Needs No Special Case
Most tutorials tell you to start an assignment operator with a self-assignment check.
With this order you do not need one: a = a works by itself.
Trace it. ::dup(a.fd_) produces a second descriptor for the same open
file; the check passes; then reset closes the old descriptor and stores
the new one. a now owns a different descriptor for the
same file. Nothing was lost, nothing leaked, and the only observable
difference is a new number.
5. Both Orders, Side by Side
One program, two classes: the correct my_posix_file and a
naive_file that closes first. It exercises self-assignment, a failing
assignment, and the same two cases on the naive class.
#include <fast_io.h>
#include <fast_io_dsal/string_view.h>
using namespace ::fast_io::io;
class my_posix_file
{
int fd_{-1};
void reset(::std::int_least32_t newfd = -1) noexcept
{
if (fd_ != -1)
{
::close(fd_);
}
fd_ = newfd;
}
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(my_posix_file const &other) throws : fd_{::dup(other.fd_)}
{
if (fd_ == -1) [[unlikely]]
{
throw throws static_cast<::std::errc>(errno);
}
}
my_posix_file &operator=(my_posix_file const &other) throws
{
::std::int_least32_t newfd{::dup(other.fd_)};
if (newfd == -1) [[unlikely]]
{
throw throws static_cast<::std::errc>(errno);
}
reset(newfd);
return *this;
}
my_posix_file(my_posix_file &&other) noexcept : fd_{other.fd_}
{
other.fd_ = -1;
}
~my_posix_file() noexcept
{
reset();
}
[[nodiscard]] bool is_open() const noexcept
{
return fd_ != -1;
}
[[nodiscard]] ::std::int_least32_t native_handle() const noexcept
{
return fd_;
}
};
class naive_file
{
int fd_{-1};
public:
explicit naive_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;
}
~naive_file() noexcept
{
if (fd_ != -1)
{
::close(fd_);
}
}
naive_file &operator=(naive_file const &other) throws
{
// WRONG: the old descriptor is gone before the new one exists.
if (fd_ != -1)
{
::close(fd_);
}
fd_ = ::dup(other.fd_);
if (fd_ == -1) [[unlikely]]
{
throw throws static_cast<::std::errc>(errno);
}
return *this;
}
[[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);
}
my_posix_file a{"demo.txt", O_RDONLY};
println("a before = ", a.native_handle());
a = a; // self-assignment
println("a after a=a = ", a.native_handle(), ", is_open = ", a.is_open());
my_posix_file moved_from{"demo.txt", O_RDONLY};
my_posix_file b{::std::move(moved_from)};
println("b = ", b.native_handle(), ", moved_from = ", moved_from.native_handle());
try
{
a = moved_from;
}
catch throws (::std::error e)
{
print("a = moved_from failed: ", e, "\n");
}
println("a survived = ", a.native_handle(), ", is_open = ", a.is_open());
naive_file n{"demo.txt", O_RDONLY};
println("n before = ", n.native_handle());
try
{
n = n; // self-assignment, the naive way
}
catch throws (::std::error e)
{
print("n=n failed: ", e, "\n");
}
println("n after n=n = ", n.native_handle(), ", is_open = ", n.is_open());
}
a before = 3
a after a=a = 4, is_open = 1
b = 3, moved_from = -1
a = moved_from failed: [posix](9)Bad file number
a survived = 4, is_open = 1
n before = 5
n=n failed: [posix](9)Bad file number
n after n=n = -1, is_open = 0
Four lines carry the lesson.
-
a after a=a = 4, is_open = 1— self-assignment worked with no check. The number changed because::duphanded out4(the old3was closed in the same call), and the file is the same file. -
a = moved_from failed: [posix](9)Bad file number— the assignment failed, because the right-hand side had no descriptor (Ch14.10). -
a survived = 4, is_open = 1— and that is the whole point: the failure changed nothing.astill holds exactly what it held before the statement. -
n after n=n = -1, is_open = 0— the naive class closed its own file and then failed, leaving a valid but empty object where a file used to be. Nothing is corrupt, but the caller lost a resource it never asked to give up.
6. Why the Return Type Is a Reference
operator= returns my_posix_file & — a reference to
the left-hand object — and return *this; is the implementation. That
is what makes chained assignment work at all:
a = b = c; // means a = (b = c), with b = c yielding b
(a = b).read_some(buf, n); // the result of the assignment is a
Return by value and this breaks: the expression would be a copy of
a, so chaining would duplicate a descriptor and throw away the duplicate.
Return *this, always.
7. When Assignment Should Not Exist
Not every resource can be replaced. A lock cannot be re-pointed at another lock; an
open operation cannot be swapped out from under its caller. For such types the right
answer is = delete, exactly as with the copy constructor
(Ch14.3), and the compiler says so
at the call site instead of at 3 a.m.
my_lock &operator=(my_lock const &) = delete;
my_lock &operator=(my_lock &&) = delete;
Deleting copy assignment does not mean the object cannot be copied — that is the copy constructor's business. It means the object cannot be reassigned, which is a weaker statement.