Ch14.4: Copy Assignment and the Critical Line

English

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

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:

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.

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;
	}
The critical line is the boundary inside a function that may fail:
— 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.

Do not add the check just because everyone does. It is needed when the operation cannot tolerate reading what it is about to destroy — move assignment is exactly such a case (Ch14.6), because it releases first and has no copy to fall back on. Copy assignment here duplicates first, so it is self-safe by construction.

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.

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 &amp;&amp;) = 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.