Ch14.9: Exception Safety: nothrow, strong, basic, none
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:
-
nothrow.
swap, the moves and the destructor are asserted at compile time, not tested at run time. There is nothing to observe because nothing can happen. -
strong. The assignment failed with
[posix](9)Bad file numberandais still4— the same descriptor it held before the statement. The critical line did its job. -
basic.
reopencloses the old file first, then opens the new one; the open failed with[posix](2)No such file or directoryand the object is now closed. It is perfectly valid — you can destroy it, assign to it, askis_open()— but the file it used to own is gone. That is the honest price of a function that cannot afford to open the new thing before closing the old one. -
none. The pair was assigned member by member. The reader was
replaced (
6became9, a duplicate of the source's reader), the writer was not (7stayed), and the assignment then threw. Both members are individually valid; the pair is not, and no code outside the class can tell or fix it.
4. How to Get the Strong Guarantee
There are two recipes, and both are the critical line in disguise.
-
Do all fallible work into locals first. Then perform the changes
with operations that cannot fail. That is
my_posix_file's copy assignment. - Build a complete temporary, then swap it in. That is copy and swap (Ch14.8), and it scales to any number of members.
And two ways to lose it, also worth naming:
-
Modify first, then do something fallible. That is
reopenabove; the level drops to basic immediately. - Modify some members, then fail on the rest. That is the naive two-member assignment; the level drops to none.
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:
-
“This function is nothrow” is a fact about the code, not
a comment. If you can compile it with
noexcept, it cannot fail. -
The critical line is enforced. Everything below it must be
noexcept-callable, and the compiler refuses anything else — the sequence of a commit is checked mechanically. -
The level is visible at the declaration.
noexceptin a header tells the reader nothrow;throwstells the reader “strong or basic, and the documentation says which”. That is why every member function in this tutorial carries one of the two (Ch6.16).
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.