RAII Was a Mistake

Recently I've been revisiting C++ after writing some Zig code. As part of the transition, I got frustrated with the C++ way of doing things and started doing "Zig in C++" style of coding. After removing a lot of move destructors to manually call "deinit" methods, I realized something. C++ and Rust got resource management horribly wrong. Both C++ and Rust have RAII, while Zig doesn't. And it's the lack of RAII in Zig which makes it so much easier to use.
What is RAII?
RAII is a basic concept that the acronym poorly explains, so I'll just explain the concept and not the acronym. With RAII, when you get a resource (memory, file handle, socket, etc.), it's initialized and put in a variable. When that variable "leaves scope" (either block scope or ownership scope), it's deinitialized automatically. Often this is expressed by calling a constructor (or initializer) to get a resource, and the compiler inserting a call to a destructor for you automatically. Below is an example:
#include <cstdio>
#include <span>
#include <string>
#include <stdexcept>
#include <string.h>
class FileHandle {
FILE* fp;
public:
// Create a file handle
FileHandle(std::string path) {
fp = fopen(path.c_str(), "r");
if (!fp) {
throw std::runtime_error(
"Could not open the file"
);
}
}
~FileHandle() {
if (fp) {
// Auto clean up
fclose(fp);
}
}
FILE* file_pointer() { return fp; }
};
int main() {
// We don't have to explicitly close anything
// since the destructor will handle it when we
// leave the function scope
auto handle = FileHandle("example.txt");
char buff[100];
memset(buff, 0, std::size(buff));
fread(
buff,
sizeof( buff[0]),
std::size(buff) - 1,
handle.file_pointer()
);
printf("%s\n", buff);
}
In the above example, we don't have to close the file handle since RAII makes it so we close automatically. Awesome, right?
Except, what happens when we try to do an assignment (aka. make a copy)?
int main() {
FileHandle handle2;
auto handle1 = FileHandle("example.txt");
if (true) {
// Assignment inside a scoped block
// Destructor will be called on handle2,
// which closes the file descriptor
handle2 = handle1;
}
char buff[100];
memset(buff, 0, std::size(buff));
// This will error since the file handle was
// closed by the handle2 destructor already
fread(
buff,
sizeof( buff[0]),
std::size(buff) - 1,
handle1.file_pointer()
);
printf("%s\n", buff);
}
It turns out, that this introduces a bug. The solution should be to just never make copies, right? Well, not quite. It turns out, this copy behavior crops up a lot, including function parameters.
// This will result in a copy
std::string read_contents(FileHandle handle);
This is a problem that crops up with RAII a lot. Anytime you do a resource free in the destructor, it creates a risk of use-after free bugs. A lot of care has to be made to prevent accidental shallow copies that clean up the shared resource, such as passing by reference (or pointer), moving lifetimes, etc.
"Solving" RAII in C++
C++ has three ways to address the RAII problem: deep copies, moving, turning off operators. I'll cover these quickly. The point is less about C++ semantics, and more about the concept of trying to "control" RAII.
Deep Copies
The first solution is obvious. Instead of doing shallow copies which leads to use-after-free bugs, we just copy everything. These "deep" copies that give us a way to free resources independently. It can't be done for everything (how do you deep copy a file handle or a socket?), but it can be done for some things (like memory). Quick example:
class CopyArray {
int* ptr;
size_t len;
size_t capacity;
public:
CopyArray(size_t initCapacity = 10) {
assert(initCapacity > 0);
ptr = new int[initCapacity];
len = 0;
capacity = initCapacity;
}
// Creates a new array from an existing array
CopyArray(const CopyArray& other) {
len = other.len;
capacity = other.capacity;
ptr = new int[capacity];
for (size_t i = 0; i < len; ++i) {
ptr[i] = other.ptr[i];
}
}
// reassigns an existing arry
CopyArray& operator=(const CopyArray& other) {
delete[] ptr;
len = other.len;
capacity = other.capacity;
ptr = new int[capacity];
for (size_t i = 0; i < len; ++i) {
ptr[i] = other.ptr[i];
}
return *this;
}
~CopyArray() {
delete[] ptr;
}
// ...Other methods excluded for brevity
};
Above we define two new methods, a "copy constructor" (CopyArray(const CopyArray&)
), and a "copy assignment" operator (CopyArray& operator=(const CopyArray&)
). These two methods will do a "deep copy," which is slow. Especially if we're accidentally introducing copies when constructing items (such as creating new items in a std::vector
with push_back
rather than emplace_back
). So, modern C++ introduces a new pattern of "moving" or "rrefs".
Moving
C++ move semantics are different than Rust's move semantics. Unlike Rust, which actually turns off accessing the old memory, C++ doesn't. It just states that the old memory may not be valid to use anymore, but nothing actually enforces this.
Additionally, unlike Rust, C++ will still run the destructor on the old value after a move (at least in every compiler I've tested this with). I'm not sure if this was intended by the standard, but it's what I've seen happen in practice. This means that the move operator needs to set the old object to a "destructible" state (and one that isn't still pointing to the moved data). Here's an example of an array that uses move semantics.
class MoveArray {
int* ptr;
size_t len;
size_t capacity;
public:
MoveArray(size_t initCapacity = 10) {
assert(initCapacity > 0);
ptr = new int[initCapacity];
len = 0;
capacity = initCapacity;
}
// Creates a new array from an existing array
MoveArray(MoveArray&& other) {
ptr = other.ptr;
len = other.len;
capacity = other.capacity;
other.ptr = nullptr;
other.len = 0;
other.capacity = 0;
}
// reassigns an existing arry
MoveArray& operator=(MoveArray&& other) {
// This takes advantage of the fact the
// destructor will still be called on other.
// Most coding standards I've seen don't like this though,
// and they have move assignment free current
// resources, move other's resources, and then
// "reset" other to a null state
std::swap(ptr, other.ptr);
std::swap(len, other.len);
std::swap(capacity, other.capacity);
return *this;
}
~MoveArray() {
delete[] ptr;
}
// ...Other methods excluded for brevity
}
In C++, in order to differentiate between moving and copying we need to use std::move
.
auto arr1 = MoveArray{10};
auto arr2 = std::move(arr1);
// arr1 shouldn't be used at this point
Of course, both of these solutions require a lot of code. What if we don't want this amount of code? Well, we can simply "disable" operators.
Disable Operators
Disabling operators is simple, we just assign the move and assignment operators to be delete
.
class DisabledArray {
int* ptr;
size_t len;
size_t capacity;
public:
DisabledArray(size_t initCapacity = 10) {
assert(initCapacity > 0);
ptr = new int[initCapacity];
len = 0;
capacity = initCapacity;
}
!DisabledArray() { delete[] ptr; }
DisabledArray(const DisabledArray&) = delete;
DisabledArray&(const DisabledArray&) = delete;
DisabledArray(DisabledArray&&) = delete;
DisabledArray& const operator=(DisabledArray&&) = delete;
}
This will prevent copies and moves of a class, so when we pass instances of it around we have to pass it by reference (either with pointers or references).
This also brings up an important point of the other example solutions. Since we only defined a move or copy behavior, we need to disable (or define) the other behaviors as well. In other words, we need to make the following changes:
class CopyArray {
// ... Rest of the code is the same
public:
CopyArray(CopyArray&&) = delete;
CopyArray& operator=(CopyArray&&) = delete;
}
class MoveArray {
// ... Rest of the code is the same
public:
MoveArray(const MoveArray&) = delete;
MoveArray& operator=(const MoveArray&) = delete;
}
When to Define Copy and Move
When do we define (or delete) copy and move? Anytime we define a destructor. Which is another way to say anytime we use RAII.
That's a lot of overhead and work to use RAII. There are workarounds, such as smart pointers (shared and unique) to help conditionally turn off RAII. But, they require memory allocations and don't help with variables on the stack. We still have to do solve for variables that don't live in the heap.
The mental and coding overhead of RAII makes working with RAII in C++ tiresome, cumbersome, and bug-prone - which is ironic for a feature meant to reduce bugs. So, is there a different way to do RAII? Yes, let's look at Rust.
Rust and the Borrow Checker
An alternative to defining copy and move operators is to use ownership semantics. By default, we transfer (move) ownership. We can also lend ownership (via a reference), or we can copy (create new ownership). The idea of ownership allows Rust's compiler to better understand when to insert destructor calls. Rust has a lot of semantic sugar around ownership. I'll just keep it basic.
#[derive(Clone)]
struct Person {
name: String,
age: i32
}
fn move_person(person: Person) {
println!("Move Ref: {} (age: {})", person.name, person.age)
}
fn write_ref_person(person: &mut Person) {
println!("Write Ref: {} (age: {})", person.name, person.age)
}
fn read_ref_person(person: &Person) {
println!("Read Ref: {} (age: {})", person.name, person.age)
}
fn main() {
let mut p1 = Person{name: "hello".to_string(), age:34};
read_ref_person(&p1); // read reference
write_ref_person(&mut p1); // write reference
move_person(p1.clone()); // Deep copy
move_person(p1); // move, cannot use p1 after this
}
Well, that's a lot simpler than C++, so it should be easy to use, right?
Rust has a reputation for having a steep learning curve. To give an idea of why, try to catch the bug in the following code:
fn print(person: Person) {
// ...
}
fn main() {
let p1 = Person{name: "hello".to_string(), age: 34};
print(p1);
print(p1);
}
The bug is that print "moves" p1
which means the second print call is a compilation error. Here's another code example where you can try to find the bug:
fn doSomething(person: &mut Person) {
// ...
}
fn main() {
let mut person = Person{name: "hello".to_string(), age: 34};
let name = person.name;
doSomething(&mut person);
println!("{}", name);
}
The bug is that we aren't copying or referencing the person's name, we're moving the person's name. Okay, so let's create a reference, no need to move it.
fn doSomething(person: &mut Person) {
// ...
}
fn main() {
let mut person = Person{name: "hello".to_string(), age: 34};
let name = &person.name;
doSomething(&mut person);
println!("{}", name);
}
Well, no. In Rust, we can't have a read-only reference and a mutable reference at the same time. Think of it as basically a compile-time read-write lock. We can't get a write lock if there are one or more readers.
These are only a few of the unintuitive bugs that programmers new to Rust will encounter. As it turns out, Rust's move-by-default behavior is not present in most programming languages, and switching the mental model takes a while.
Sure, move-by-default is less bug-prone than C++'s shallow copies, but it's still problematic. Getting code to compile can be difficult, and developers often fall into the trap of least resistance. In Rust, this usually means either creating shared reference counts (Arc), or doing deep copies (clone) to try to get around the move-by-default behavior. These can lead to developers leaving a lot of performance on the table in the pursuit of getting something out the door.
That's not to say Rust is more problematic than C++ (C++ has the same issues and same least-resistance workarounds), but rather it's to say it's the same underlying problem but with a different skin.
Not to mention that Rust's borrow checker itself is a complex beast that's difficult to make, and requires special language design to accomodate. The very premise of "move-by-default" would break the majority of programming languages if they tried to adopt it. Not to mention the amount of code which would need to be rewritten for ownership semantics to work. Good luck trying to get Rust's patterns added to any existing language.
None if this is Rust's fault anymore than they are C++'s fault. It's not the failing of the language, no, it's much deeper and more fundamental than that. These failings are due to a fundamental flaw with RAII.
The RAII Flaw
The fundamental flaw of RAII, which causes so many workarounds and complexity in C++ and Rust, is quite simple.
RAII automatically cleans up resoures with compiler inserted commands.
All of the move and copy constructors in C++, move-by-default in Rust, shared reference counting, and deep copies exists to simply turn off or modify compiler inserted RAII commands.
Moves exist to tell the compiler to not insert clean up commands (or to make it clean up nothing). Copies exist to workaround magically inserted commands that are hard to turn off. Smart pointers and reference counting exist to move those commands from compile time to runtime.
All of these workarounds are trying to get around some command that the compiler siliently inserted. There is no line of code, no smoking gun, which documents where these commands are coming from. They're instead inferred from the rules of the language and compiler. The workarounds exist to bypass the compiler, to tame it, and to try to control these unseen instructions. Developers are left fighting ghosts they cannot see, haunting their codebase with undefined behavior and use-after-free. Those invisible commands, the unseen instructions, they are the RAII problem.
RAII causes a lot of headaches for otherwise simple tasks. Whether it's fighting/understanding Rust's borrow checker, or tracking down an obnoxious C++ use-after-free, there is a lot of pain. Sure, I've used GC languages and been frustrated that the GC doesn't clean up file handles. But, at least the commands to clean up those file handles are explicit. I know which file handle was cleaned up and when instead of trying to figure out what it is the compiler is doing.
Possible Alternatives
There are other ways of cleaning resources up which aren't RAII, or the way Java does things. The most inspirational language in this matter I've seen is Zig. Zig comes with the the antithesis of RAII, it's polar opposite: defer.
Defer
Defer will take a statement or expression, and defer it's execution until some future point in time (usually at the end of the block scope - unless you're Go). This allows us to write the cleanup code right next to the instantiation code, like so:
// Psuedo-code showing defer
var fileHandle = fopen("example.txt", "r");
defer fclose(fileHandle);
Zig also introduces the idea of errdefer, which only executes a block of code if there was an exception. This is primarily helpful for allocation methods which allocate a resource (e.g. open a file handle), and then return that resouce on a success. If something goes wrong before the resource is returned, errdefer allows it to be easily cleaned up.
// Psuedo-code showing errdefer
fn openExample() !FileHandle {
var fileHandle = fopen("example.txt", "r");
errdefer fclose(fileHandle);
if (fsize(fileHandle) < MIN_FILE_SIZE) {
return error(FILE_TOO_SMALL);
}
return fileHandle;
}
The above code is clear (as in easy to review and debug), explicit, and known to be "bug-free." Defer is magical.
And the magic of defer boils down to that it's the direct inverse of RAII.
With RAII, you have to opt-out of automatic cleanup. With defer, you have to opt-in. With RAII, the clean-up is hidden from the developer with magically inserted commands. With defer, the clean-up is explicit to the developer with visible and documented commands. With RAII, you're having to work around unwanted commands inserted by the compiler through copies, references, and move semantics. With defer, you're working with the compiler to only insert the commands you want.
Defer ends up simplifying code a lot. Shallow copies work well since the developer knows (and picks) which copy gets cleaned up. Move semantics aren't needed since a move boils down to a shallow copy where the copy is cleaned up, not the original. Zig's errdefer also provides some much welcome semantic sugar when it comes to error handling.
Personally, I find defer to be really useful, especially when combined with good debug tooling - like automatic memory leak detection and reporting. I've found most languages lack good debug tooling in the standard distributions, so it usually needs to be built. Fortunately, the most useful tools are pretty basic and aren't too hard to build. For C++, I just have my own allocator class I pass around. Under the hood, it's just a wrapper for malloc but adds memory allocation tracking. It's not that hard to wrap other resources either.
Fortunately, defer isn't the only option, just what I found to be the most compelling. I'll cover some other options as well.
Arenas and "Action Based Lifetimes"
Another tool to use are arena allocators. Arena allocators are generally used for memory management. They allow allocating more and more memory, and then instead of freeing individually you free all at once.
Arenas turns lifetimes from an individual object lifetime (like a "FileHandler" or an "ArrayList") into a task based lifetime (an incoming HTTP request, a game loop frame, processing an event, etc.). By turning things from individual resource management into task-based resource management, we are able to write task code without worrying about resources. The task manager (or initiator) instead handles all of the resource management. Here's an example:
struct SomeTask {
String input;
// Pass in the arena so we can do memory allocations
void run(ArenaAllocator arena) {
// use the arena
CharBuffer buffer = input.toBuffer(arena);
val parser = arena.new<IntBufferParser>();
List<int> parsedInts = parser.parse(arena, buffer);
println(parsedInts.join(arena, ", "));
// We didn't have to free anything because the arena takes care of the freeing
}
}
class TaskManager<T> {
Queue<T> tasks;
void processTasks() {
while (!tasks.empty()) {
T task = tasks.pop();
// create an arena for the task to use
ArenaAllocator arena = ArenaAllocator();
try {
task.run(arena);
}
finally {
// here we clean up the arena which cleans up all task memory
arena.freeAll();
}
}
}
}
val taskManager = TaskManager();
taskManager.tasks.push(SomeTask("1 23 43 69"));
taskManager.processTasks();
We aren't restricted to a single task layer either. We can layer the task-based lifetimes.
A game is going to have resources used for the entire time the application is opened (such as the OS window), resources loaded for a specific phase (playing game or in main menu), resources for a specific level, and resources for a specific frame. Each of those levels are separate "tasks" which get their own arena. They are freed when each task is done. And if an arena is based off of a parent arena, we can clean everything up by just clearing the parent arena. Pretty cool.
Arenas are much easier to implement than defer. They can often be made as a library rather than changing the compiler, which makes them really nice to try out. That said, there are some drawbacks.
First, resources become task-based, and it takes care to ensure they stay tied to that task. If you create an array in an HTTP request arena, you can't just pass that array to a background task scheduler, otherwise the memory will be cleaned up before the task runs. Instead, you have to introduce additional copies whenever you transition from one task context to another.
Of course, alternatives don't have to be limited to what actually exists. What I've shown are both still really manual, and some people may want a more "automated" alternative. I have a theoretical alternative, but I'm unaware of any language which currently offers it.
Actual Garbage Collection
I'm not going to state whether garbage collection is good or bad. But what I am suggesting is that it's really wierd that "garbage collection" is limited to only memory allocations, not file handles, sockets, or any other resources you might care about. An actual, true garbage collector should keep track of those other resources too - or at least any resources you have to reserve from the host operating system.
But, isn't GC automatically cleaning up memory similar to RAII?
No. RAII is a compile-time process which inserts invisible instructions when the program is built. Those instructions always execute, whether or not it's correct. It errs on the side of aggression as RAII languages believe it's worse to leak than to double free. This then makes it the responsibility of developers to fix the compiler's mistakes.
In contrast, GC is a run-time process which does a lot of runtime checks to ensure a resource is definitively not used before being cleaned up. It errs on the side of caution. As a result, developers don't have to think about if a GC is going to insert a buggy use-after-free or double-free condition. Sure, it slows down the program and results in memory staying around longer than necessary, but it's proven itself really valuable for dozens (if not hundreds) of programming languages over the years.
My main point here is to say, if so many languages claim that GC works super well, and if so many developers rely on GC for memory management, why aren't we extending it to all resources? Why doesn't Java know what a file handle is, and keeps track of when it becomes an unreferenced file handle? Java already does it for pointers, why not file handles too? Why not network sockets? Why do these languages mandate developers need to track those resources?
I think if we want the next mainstream way of managing resources, a GC that manages everything is a really good way to do that.
Wrap Up
My point is, RAII is not a great option. It's a pretty bad option. While the idea of "have the compiler insert invisible clean-up commands" sounds good on the surface, in practice it's a pain to get right. It turns out, we don't actually want to be cleaning things up automatically when they go out of scope all the time, only some of the time. And trying to express that nuance to the compiler is really, really hard. There's a lot of complexity that comes with turning RAII off properly, and getting it wrong leads to bugs or performance-harming workarounds.
Defer is a great alternative, as well as arenas. A "GC all the things" would be interesting to see as well. But, these are only a few possibilities. I doubt we (as an industry and discipline) have exhausted the possibility space here. It feels like we've only reached a few local minima and have stuck to them.
RAII has been seen for a long time as "the GC alternative to resource management" and has been touted by both C++ and Rust. Yet both struggle with ever-growing complexity in trying to tame the RAII beast.
Fortunately, there's a growing wave of defer-based languages, such as Zig, Odin, and C3. These languages claim that automatically inserted clean up should be opt-in and explicit, rather than opt-out and implicit. And I agree with that.
I also want to see other ideas start to take hold. We're a young industry, and we should be rethinking how we do resource management instead of staying with the status-quo.