Ch14.5: The Move Constructor: Taking the Resource

中文(简体)

1. What Moving Means for an Owner

Copying has to produce a second descriptor, because both objects survive. Moving does not: the source is about to be destroyed, so the destination can simply take the descriptor and leave the source empty.

No system call, no second entry in the kernel's table, no failure: two int writes. That is the entire reason the language added && parameters.


	my_posix_file(my_posix_file &&other) noexcept : fd_{other.fd_}
	{
		other.fd_ = -1; // the source no longer owns anything
	}

Both lines matter. Without the second one there would be two owners of the descriptor: the destination would close it, and then the source's destructor would close the same number again — possibly after it had been handed to an unrelated file.

2. If You Do Not Write It, std::move Copies

This is the trap that catches everybody once. ::std::move moves nothing; it is a cast to an rvalue reference, and all it does is make the move constructor eligible. If there is no move constructor, the copy constructor is used instead, and it is not an error:


#include <fast_io.h>
#include <fast_io_dsal/string_view.h>

using namespace ::fast_io::io;

class only_copy
{
	int fd_{-1};

public:
	explicit only_copy(::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;
	}

	only_copy(only_copy const &other) throws : fd_{::dup(other.fd_)}
	{
		if (fd_ == -1) [[unlikely]]
		{
			throw throws static_cast<::std::errc>(errno);
		}
	}

	~only_copy() noexcept
	{
		if (fd_ != -1)
		{
			::close(fd_);
		}
	}

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

	only_copy a{"demo.txt", O_RDONLY};
	println("a = ", a.native_handle());

	only_copy b{::std::move(a)}; // no move constructor: this calls the copy constructor
	println("b = ", b.native_handle(), ", a = ", a.native_handle(), ", a.is_open() = ", a.is_open());
}

a = 3
b = 4, a = 3, a.is_open() = 1

a still owns 3 and b owns a fresh duplicate 4. The move silently became a copy — a wasted system call for a file, and a wasted deep copy for anything bigger.

The compiler generates a move constructor only if you wrote none of: a copy constructor, a copy assignment, a move assignment, or a destructor. Our class writes all of them, so all of them are ours to write. This is also why “write one of the five, get all five” is not folklore: it is the rule that decides what the compiler is willing to generate.

3. Why noexcept Is Not Optional

A move constructor is noexcept in our class, and in every serious class that owns a resource. It is not politeness. Containers reallocate by moving their elements, and they can only do that if a move cannot fail halfway through the reallocation. fast_io's containers check this at compile time:


error: static assertion failed: vector element types must be nothrow move constructible
       or trivially relocatable

Change the move constructor's noexcept to throws and that is the diagnostic you get for ::fast_io::vector<my_posix_file>. The container is not being pedantic; it has no way to put the elements back if the fifth move of a reallocation fails.

With herbceptions the promise is even stronger than usual. A noexcept function cannot call a throws function unless it handles the failure itself:


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

So noexcept is not a promise you can break at run time; it is a claim the compiler verifies by looking at every call in the body. A move constructor could not call ::dup even if you wanted it to.

4. The Source Is Empty, Not Dead

After the move, other.fd_ is -1, and that is a valid state of the class, not a broken one:

That state is why the class has a default constructor and the -1 member initializer in the first place: a moved-from object must be able to live out its lifetime without doing anything.

5. In Use

The program moves a file into a new object, then puts two of them in a ::fast_io::vector, which needs the noexcept move to accept the type at all:


#include <fast_io.h>
#include <fast_io_dsal/string_view.h>
#include <fast_io_dsal/vector.h>

using namespace ::fast_io::io;

class my_posix_file
{
	int fd_{-1};

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(my_posix_file &&other) noexcept : fd_{other.fd_}
	{
		other.fd_ = -1;
	}

	~my_posix_file() noexcept
	{
		if (fd_ != -1)
		{
			::close(fd_);
		}
	}

	[[nodiscard]] bool is_open() const noexcept
	{
		return fd_ != -1;
	}

	[[nodiscard]] ::std::int_least32_t native_handle() const noexcept
	{
		return fd_;
	}
};

static_assert(::std::is_nothrow_move_constructible_v<my_posix_file>);
static_assert(::std::is_copy_constructible_v<my_posix_file>);
static_assert(!::std::is_nothrow_copy_constructible_v<my_posix_file>);
static_assert(::std::is_nothrow_destructible_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);
	}

	my_posix_file a{"demo.txt", O_RDONLY};
	println("a = ", a.native_handle());

	my_posix_file b{::std::move(a)};
	println("b = ", b.native_handle(), ", a = ", a.native_handle(), ", a.is_open() = ", a.is_open());

	::fast_io::vector<my_posix_file> files;
	files.push_back(my_posix_file{"demo.txt", O_RDONLY});
	files.push_back(my_posix_file{"demo.txt", O_RDONLY});
	println("the vector holds ", files.size(), " files:");
	for (my_posix_file const &f : files)
	{
		println("  fd = ", f.native_handle());
	}
}

a = 3
b = 3, a = -1, a.is_open() = 0
the vector holds 2 files:
  fd = 4
  fd = 5

The static_asserts at the top are the chapter in five lines: the type is nothrow-move-constructible, copy-constructible, not nothrow-copy-constructible (copying a file genuinely can fail), and nothrow-destructible. Read them as the class's contract; the rest of the program is a demonstration that the contract holds.

6. Moving From a const Object Copies

A move constructor takes T&&, which cannot bind to a const object: taking the descriptor out would modify it. So my_posix_file const & arguments are copied, and ::std::move on a const object quietly does nothing:


my_posix_file const c{"demo.txt", O_RDONLY};
my_posix_file d{::std::move(c)};   // calls the copy constructor
// c is still open

It is correct, and it is also why taking parameters by my_posix_file (by value) and then ::std::move-ing them into members works: the copy happens at the call, the move happens in the constructor.