Ch14.9: Exception Safety: nothrow, strong, basic, none

English

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

1. Four Levels, Not a Feeling

“This function is exception safe” means nothing on its own. What a caller needs to know is how much of the object's value survives a failure, and there are exactly four answers.

Level What the caller may assume after a failure Declared as
nothrow It cannot fail at all. There is no failure to recover from. noexcept
strong It failed, and the object is exactly as it was before the call (commit or rollback). throws
basic It failed, and the object is still valid — the invariant holds, no resource leaked — but its value may be different, and the old value is gone. throws
none It failed, and the object may be in a state that breaks its own invariant: unusable, or worse, usable but wrong. throws

“Valid” is doing the work in the last two rows. Valid means: every member function may still be called, and the destructor still does the right thing. Our class defines validity as fd_ == -1 or fd_ is an open descriptor this object alone owns.

2. The Levels of my_posix_file

Every member of the finished class, classified. Notice how the specification on the declaration predicts the level:

Member Spec Level Why
~my_posix_file() noexcept nothrow one ::close on a valid descriptor, failure ignored
my_posix_file(T&&) noexcept nothrow two int writes
operator=(T&&) noexcept nothrow ::close plus two int writes
swap(T&) noexcept nothrow three int writes
reset(), is_open(), native_handle() noexcept nothrow no system call that can fail
my_posix_file(path, flags) throws strong a constructor that throws means no object exists; nothing to roll back
my_posix_file(T const&) throws strong ::dup either produces a descriptor or leaves the source and the new object untouched
operator=(T const&) throws strong duplicate first, then the critical line (Ch14.4)
read_some(), write_some() throws strong for the object, basic for the file a failed ::read/::write changes nothing in the object; on the file itself a partial write may already have happened

That last row is worth pausing on. Exception safety is a property of this object. A function that writes to the outside world cannot roll the outside world back, no matter how careful it is with its own members.

3. All Four, Verified


#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);
		}
		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);
	}

	void reopen(::fast_io::cstring_view path, ::std::int_least32_t flags) throws
	{
		reset(); // BASIC: the old file is gone before the new one exists
		::std::int_least32_t fd{::open(path.c_str(), flags, 0644)};
		if (fd == -1) [[unlikely]]
		{
			throw throws static_cast<::std::errc>(errno);
		}
		fd_ = fd;
	}

	::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);
}

// NO GUARANTEE: two resources, assigned one after the other.
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
	{
		reader_ = other.reader_; // succeeds
		writer_ = other.writer_; // may fail, and then the pair is a mix
		return *this;
	}

	two_files &operator=(two_files &&other) noexcept = default;

	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();
	}
};

static_assert(noexcept(::std::declval<my_posix_file &>().swap(::std::declval<my_posix_file &>())));
static_assert(::std::is_nothrow_move_constructible_v<my_posix_file>);
static_assert(::std::is_nothrow_move_assignable_v<my_posix_file>);
static_assert(::std::is_nothrow_destructible_v<my_posix_file>);
static_assert(!::std::is_nothrow_copy_constructible_v<my_posix_file>);

int main()
{
	{ // make sure the example file exists
		::std::int_least32_t fd{::open("demo.txt", O_WRONLY | O_CREAT, 0644)};
		::close(fd);
	}

	// NOTHROW: the compiler proves these cannot fail.
	my_posix_file a{"demo.txt", O_RDONLY};
	my_posix_file b{"demo.txt", O_RDONLY};
	a.swap(b);
	println("nothrow: swap, move and destruction cannot fail");

	// STRONG: when the operation fails, nothing changed.
	my_posix_file source{"demo.txt", O_RDONLY};
	my_posix_file stolen{::std::move(source)};
	try
	{
		a = source; // source has no descriptor left to duplicate
	}
	catch throws (::std::error e)
	{
		print("strong: ", e, ", a is still ", a.native_handle(), "\n");
	}

	// BASIC: the object is still usable, but its old value is gone.
	my_posix_file basic{"demo.txt", O_RDONLY};
	try
	{
		basic.reopen("/nonexistent/file", O_RDONLY);
	}
	catch throws (::std::error e)
	{
		print("basic: ", e, ", is_open = ", basic.is_open(), "\n");
	}

	// NO GUARANTEE: the pair no longer belongs to one file.
	two_files dst{"demo.txt"};
	two_files src{"demo.txt"};
	src.close_writer();
	println("none: before: dst reader = ", dst.reader_handle(), ", writer = ", dst.writer_handle());
	try
	{
		dst = src;
	}
	catch throws (::std::error e)
	{
		print("none: ", e, "\n");
	}
	println("none: after: dst reader = ", dst.reader_handle(), ", writer = ", dst.writer_handle());
}

nothrow: swap, move and destruction cannot fail
strong: [posix](9)Bad file number, a is still 4
basic: [posix](2)No such file or directory, is_open = 0
none: before: dst reader = 6, writer = 7
none: [posix](9)Bad file number
none: after: dst reader = 9, writer = 7

Line by line:

4. How to Get the Strong Guarantee

There are two recipes, and both are the critical line in disguise.

And two ways to lose it, also worth naming:

Strong is not always affordable. Rolling back means keeping the old value alive, and for a 10 GB buffer that is the whole buffer. Choose strong where the object is small and the invariant matters (file handles, sockets, locks, small containers); choose basic where copying is out of the question; and never choose none by accident.

5. Herbceptions Make noexcept a Proof

In legacy C++, noexcept is a promise the compiler takes on trust and punishes at run time with std::terminate. A noexcept function may call anything it likes, and nobody finds out until the night it throws.

With herbceptions the promise is checked where it is made. A noexcept function cannot call a throws function at all, unless it handles the failure inside its own body:


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

Three consequences follow, and they are the reason this chapter can be exact:

Destructors are noexcept, so destructors have exactly the nothrow guarantee. Anything that can fail and that you want to know about must be a named member function the caller can wrap in try { } catch throws. This single rule explains most of the shape of real file, socket and database APIs — including fast_io's, whose close() reports and whose destructor does not.