Wrapping C-style Handles #
Source code: 1uc/modern_numerics_cxx
In scientific computing we often interface with various nice C libraries. One difference between idiomatic C++ and C is how resources are handled. In C we use a handle, e.g. an opaque pointer or integer. These handles have no meaning to the user. Their only use is to pass them back to the C-library so it can figure out which object we’re referring to. The user is expect to manually open/acquire a resource and then close/release it.
In idiomatic C++ we’d use RAII to acquire the resource during initialization and more importantly release the resource during in the dtor. The rest of the section will explain the idea in more detail.
C-style Resource #
We need a C-style resource:
class FileHandle {
public:
// Open the file.
//
// Must not be called on an open file. Every file that's
// opened must be closed via `close`.
void open(std::string name) {
std::cout << "opening: " << name << "\n";
name_ = std::move(name);
}
// Close the file.
//
// Must not be called on a closed file.
void close() {
std::cout << "closing: " << name_ << "\n";
name_ = "<closed>";
}
// Write to the file.
//
// Requires that the file is open.
void write() { std::cout << "writing to: " << name_ << "\n"; }
private:
std::string name_ = "<uninitialized>";
};
The following will compile:
{
FileHandle foo; // uninitialized
foo.write(); // crash.
// This doesn't look too evil, but you can forget, and if
// a `FileHandle` is passed to a function, inside the function
// one can't be sure that file is open.
foo.open("foo (fh)"); // fine.
foo.write(); // fine.
foo.close(); // fine.
foo.write(); // crash.
foo.close(); // closed twice, also crashes.
foo.open("foo (reborn)"); // fine.
FileHandle bar;
bar.open("bar (fh)");
foo = bar; // sure... but what does this mean?
foo.close(); // fine.
bar.close(); // crash.
// Also "foo (reborn)" got leaked.
}
When written as a straight sequence of instructions, it’s not overly complex. However, once one passes these handle to function, stores then in other objects, or returns them from functions, it gets very error prone quickly.
RAII-style Resource #
Let’s create a RAII-style wrapper for the resource handle FileHandle.
class File {
public:
File() = delete; // (1)
File(std::string name); // (2)
File(const File &) = delete; // (3)
File(File &&other); // (4)
~File(); // (5)
File &operator=(const File &other) = delete; // (6)
File &operator=(File &&other); // (7)
void write() { fh_->write(); } // (8)
private:
std::unique_ptr<FileHandle> fh_; // (9)
};
Let’s comment on each choice:
- We chose to not have a default ctor, because we don’t want there to ever be an uninitialized resource. This choice is not without drawbacks, and it’s not without alternatives.
- This is the ctor for opening the file.
File::File(std::string name) : fh_(std::make_unique<FileHandle>()) { fh_->open(std::move(name)); } - We need to decide what it means to copy a
File. Here the choice is that we don’t want to assign any semantics to it. We can’t have two files that have the same handle. If we did allow it, it would be possible to write to the same file via two differentFileobject. This seems like error-prone behaviour, if it’s implicit. If we want to share theFile, we can use anstd::shared_ptr. - We can move the
File, because that doesn’t change the number of references to the resource handle. It’s useful because it enables us to storeFileobjects in containers. It can be implemented in terms of the move-assignment operator:File::File(File &&other) { (*this) = std::move(other); } - The dtor releases the resource. That way the lifetime of the resource is
tided accurately to the lifetime of the object. There’s never an object that
invalid, and never a resource that can’t be reached anymore, i.e. hasn’t been
released.
~File() { if (fh_ != nullptr) { fh_->close(); } } - No copy-assignment, see copy-ctor.
- Move assignment is semantically simple. The implementation has a few traps.
Therefore, let’s look at it closely.
File &operator=(File &&other) { // Avoid self assignment, this is a common trap. Therefore, // we should remember to make it part of any test suite. if (this == &other) { return *this; } // This `File` should already be referring to an open // file. If so it needs to be close. However, if this file // was move from before, it could be closed. Hence the check. // Skipping the closing and going straight to the move, is an // easy resource leak. if (fh_ != nullptr) { fh_->close(); } fh_ = std::move(other.fh_); return *this; } - Wrap the API of the wrapped handle. Here just
write. - Keeping the handle inside a unique pointer is not essential. If there’s a value of the handle which signifies “invalid”, then one can use the handle directly.
Demonstration #
We can write the following:
{
File foo("foo (file)");
File bar("bar (file)");
bar.write();
foo.write();
foo = std::move(bar);
foo.write();
}
There’s no resource that isn’t ready to be used. There’s no double closing. There’s no leak. By design, the compiler will refuse to let us write code that has any of those defects.
That’s almost true. We must not use bar after moving away from it. Therefore,
we try to not move, or structure the code such that the move happens such that
the object that has been moved away from goes out of scope quickly. Compilers
can usually warn about this mistake, but they’re not allowed to threat is as an
error.
Let’s look at more examples of when this is advantageous:
struct Writer {
Writer(File file) : file(std::move(file)) {}
void operator()(double x) {
file.write(fmt::format("{:.3e}", x));
}
private:
File file;
};
auto writer = Writer(File("foo.txt"));
We can also easily return this resource from a function
File make_file(const std::string& stem, const std::string& suffix) {
return File(stem + suffix);
}
and the user doesn’t need to know if they need to free the resource and which API they’d need to use.
Lastly we can store them in containers:
std::vector<File> files;
files.emplace_back("f1.txt");
files.emplace_back("f2.txt");
File foo("f3.txt");
files.push_back(std::move(foo));