Ch14.8: Copy and Swap: One Infallible Commit
1. The Problem With One Member Too Many
The commit step of my_posix_file's copy assignment was a single
reset(newfd), because the class owns exactly one thing. Give a class two
resources and that step disappears: there is no single call that replaces both
without the possibility of being half done.
class two_files
{
my_posix_file reader_;
my_posix_file writer_; // two independent resources
};
Writing the assignment member by member is the mistake everyone makes once:
// NO GUARANTEE
two_files &operator=(two_files const &other) throws
{
reader_ = other.reader_; // succeeds, and *this is now changed
writer_ = other.writer_; // fails, and now the pair is a mix
return *this;
}
When the second line fails, the object is not what it was and not what it was meant to become: a reader for one file, a writer for another. The object is still usable — both members are valid objects — but its invariant (“both handles name the same file”) is broken, and nobody can repair it from the outside. This is the level the next-but-one subchapter calls no guarantee, and it is a genuinely bad place to be.
2. Copy and Swap
There is a way to get the commit back, and it works for any number of members:
build the new value somewhere else, then exchange it with *this.
two_files &operator=(two_files const &other) throws
{
two_files tmp{other}; // may fail: *this has not been touched
// ---- critical line ----
swap(tmp); // cannot fail: from here on the assignment happens
return *this;
}
Every fallible operation is in the construction of tmp, and
tmp is a different object: if the copy throws, *this
has not been touched, and tmp's own destructor cleans up whatever it had
managed to acquire. After the critical line there is exactly one call, and it is
noexcept.
The old contents do not disappear at the end of operator=; they are handed
to tmp, which is a local, and locals die at the end of the full
expression. So the descriptors that *this used to own are closed a moment
later, by tmp's destructor, after the new ones are already installed.
swap being
noexcept.
tmp does all the work that can fail; swap is the one call that
cannot. Move that call above any fallible statement and the guarantee evaporates.
3. Copy and Swap in Practice
#include <fast_io.h>
#include <fast_io_dsal/string_view.h>
using namespace ::fast_io::io;
class my_posix_file
{
int fd_{-1};
public:
void reset(::std::int_least32_t newfd = -1) noexcept
{
if (fd_ != -1)
{
::close(fd_);
}
fd_ = newfd;
}
my_posix_file() noexcept = default;
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);
}
// ---- critical line ----
reset(newfd);
return *this;
}
my_posix_file(my_posix_file &&other) noexcept : fd_{other.fd_}
{
other.fd_ = -1;
}
my_posix_file &operator=(my_posix_file &&other) noexcept
{
if (this == ::std::addressof(other)) [[unlikely]]
{
return *this;
}
reset(other.fd_);
other.fd_ = -1;
return *this;
}
~my_posix_file() noexcept
{
reset();
}
void swap(my_posix_file &other) noexcept
{
::std::int_least32_t const tmp{fd_};
fd_ = other.fd_;
other.fd_ = tmp;
}
[[nodiscard]] bool is_open() const noexcept
{
return fd_ != -1;
}
[[nodiscard]] ::std::int_least32_t native_handle() const noexcept
{
return fd_;
}
::std::size_t read_some(char *buf, ::std::size_t n) throws
{
auto ret{::read(fd_, buf, n)};
if (ret == -1) [[unlikely]]
{
throw throws static_cast<::std::errc>(errno);
}
return static_cast<::std::size_t>(ret);
}
::std::size_t write_some(char const *buf, ::std::size_t n) throws
{
auto ret{::write(fd_, buf, n)};
if (ret == -1) [[unlikely]]
{
throw throws static_cast<::std::errc>(errno);
}
return static_cast<::std::size_t>(ret);
}
};
inline void swap(my_posix_file &a, my_posix_file &b) noexcept
{
a.swap(b);
}
// A class that owns two resources: one handle for reading, one for writing.
class two_files
{
my_posix_file reader_;
my_posix_file writer_;
public:
explicit two_files(::fast_io::cstring_view path) throws
: reader_{path, O_RDONLY}, writer_{path, O_WRONLY}
{}
two_files(two_files const &other) throws : reader_{other.reader_}, writer_{other.writer_} {}
two_files(two_files &&) noexcept = default;
~two_files() = default;
two_files &operator=(two_files const &other) throws
{
two_files tmp{other}; // may fail: *this has not been touched
// ---- critical line ----
swap(tmp); // cannot fail: from here on the assignment happens
return *this;
}
two_files &operator=(two_files &&other) noexcept = default;
void swap(two_files &other) noexcept
{
reader_.swap(other.reader_);
writer_.swap(other.writer_);
}
void close_writer() noexcept
{
writer_.reset();
}
[[nodiscard]] ::std::int_least32_t reader_handle() const noexcept
{
return reader_.native_handle();
}
[[nodiscard]] ::std::int_least32_t writer_handle() const noexcept
{
return writer_.native_handle();
}
};
int main()
{
{ // make sure the example file exists
::std::int_least32_t fd{::open("demo.txt", O_WRONLY | O_CREAT, 0644)};
::close(fd);
}
two_files dst{"demo.txt"};
two_files src{"demo.txt"};
println("before: dst reader = ", dst.reader_handle(), ", writer = ", dst.writer_handle());
dst = src;
println("dst = src: dst reader = ", dst.reader_handle(), ", writer = ", dst.writer_handle());
src.close_writer(); // src no longer has a writer to duplicate
try
{
dst = src;
}
catch throws (::std::error e)
{
print("dst = src failed: ", e, "\n");
}
println("dst survived: reader = ", dst.reader_handle(), ", writer = ", dst.writer_handle());
}
before: dst reader = 3, writer = 4
dst = src: dst reader = 7, writer = 8
dst = src failed: [posix](9)Bad file number
dst survived: reader = 7, writer = 8
dst = src worked, replacing both handles. Then src was
stripped of its writer, so duplicating it must fail — and the last line shows
what matters: dst still owns exactly the descriptors it owned before the
failed statement (7 and 8), not one of them replaced. The
temporary that could not be completed never became dst.
Note that nothing in operator= had to know how many members
two_files has, or what they are. Add a third handle tomorrow and the
assignment operator still works — as long as swap and the copy
constructor are updated, and the compiler will tell you if they are not.
4. Copy and Swap or the Direct Commit?
| direct commit (Ch14.4) | copy and swap (here) | |
|---|---|---|
| how many resources | one | any number |
| what sits after the critical line | a reset that installs the new handle |
one swap |
| temporary resource held | the new descriptor only | a complete second copy of the object |
| needs a copy constructor | no | yes |
needs noexcept swap |
no | yes, mandatory |
| self-assignment | free | free (a wasted copy, then a self-swap) |
For a class with a single resource the direct form is smaller and does less work, so
that is what my_posix_file does. The moment a class owns two things,
copy and swap is the version you can still read six months later, and the version whose
guarantee does not depend on the order of two lines somebody may swap one day.
5. What Copy and Swap Requires
- A copy constructor that can throw. That is the part that does the fallible work. If your type cannot be copied, this idiom is not available — and neither is copy assignment.
-
A
noexceptswap(Ch14.7). Without it the commit can fail and the guarantee is gone. -
A
noexceptdestructor.tmpmust be able to die while a herbception is in flight, which is not optional for destructors anyway (Ch14.2).
All three are properties you can check in one line each with
::std::is_nothrow_swappable_v,
::std::is_nothrow_destructible_v and
::std::is_copy_constructible_v — and the compiler checks the first
two for you the moment something tries to use them.