Ok that’s a bit of an exaggeration. It’s not that I don’t like defer, I think I might just prefer something else. But I’m not sure why. In this blog post we’ll take a look defer and its alternatives, and hopefully I’ll come out with a better idea of my own preferences.
what is defer?
Okay, the necessary part. What am I even talking about?
defer is a statement in Go that defers some function call to the end of the current function. The reason I believe defer exists is for resource cleanup. Often in programming you’ll have some kind of value for which there’s some necessary cleanup logic. An example of this might be a file which you have to eventually close, or a mutex you’ll have to eventually unlock or a connection you’ll have to eventually disconnect from.
Take the following example:
func Foo(path string) error {
// We open a file.
myFile, err := os.Open(path)
if err != nil {
return err
}
// Read out all the data
data, err := io.ReadAll(myFile)
if err != nil {
myFile.Close()
return err
}
// Call some other function that may return an error
err = Bar(data)
if err != nil {
myFile.Close()
return err
}
myFile.Close()
return nil
}
Since we have to close myFile eventually, we need to remember to do it at every return site. The designers of Go upon us recognised this repetition and blessed us with defer:
func Foo(path string) error {
// We open a file.
myFile, err := os.Open(path)
if err != nil {
return err
}
defer myFile.Close()
// Read out all the data
data, err := io.ReadAll(myFile)
if err != nil {
return err
}
// Call some other function
err = Bar(data)
if err != nil {
return err
}
return nil
}
And you can maybe see how, especially in larger functions, this could be helpful:
- It reduces repetition;
- And by reducing repetition, it means you’re less likely to forget to close your file, since you only have to remember to write one line instead of several.
But you still have to remember to write defer myFile.Close() alongside your os.Open, so this design doesn’t protect you against forgetting to write defer myFile.Close(). Let’s take a look at another language to understand some other approaches.
Python’s with
Python has this thing called with. Let’s look at a similar example:
def foo(path):
# We open a file
with open(path) as my_file:
# Read out all the data
data = my_file.read()
# Call some other function
bar(data)
There’s a few things going on here that are different:
- Python uses exceptions, which hides the error handling logic away. Go makes it explicit.
- The file is closed at the end of the with block. No explicit
.Close().
Now this is interesting. Python is grouping together creation and cleanup of a resource in a single construct. Unlike the Go example, you can’t forget to close after opening, it’s all handled by the with block.
Although the problem isn’t completely solved. You can still forget to use with in the first place, which means you can still forget to close the file. For example, there’s nothing stopping you from writing:
def foo(path):
# We open a file
my_file = open(path)
# Call some other function
bar(data)
# We forget to close the file here!
This is because open(path) is a regular function that returns a regular value, so you can use it in regular assignment. Go and Python share this problem, where file values aren’t special in any way, so you have to remember to read the documentation to know you have to treat them differently - and that’s assuming you actually have quality documentation!
Let’s keep the ball rolling and take a look at another language.
C++’s …. nothing?
void foo(const std::string& path) {
// Open the file (if = input file)
std::ifstream my_file(path);
if (!my_file) throw std::runtime_error("open failed");
// Read out all the data
std::stringstream buf;
buf << my_file.rdbuf();
std::string data = buf.str();
// Call some other function
bar(data);
}
What is going on here? Which part of this deals with resource cleanup? Have I forgotten something?
Well, just like with Go and Python, my_file is just a regular value. But in C++ values have a superpower - they can define what’s called a ‘destructor’. A destructor is code that will execute when a value reaches the end of its lifetime.
In the example above my_file is a local variable, so it only exists (or ’lives’) until the end of the function. The destructor for std::ifstream closes the file at the end of our function body. This technique is called RAII (Resource-Acquisition-Is-Initialisation[^1]).
As C++ has this idea of destructors, it doesn’t need any special language features like defer or with to reason about resource management. Instead it provides robust tools for reasoning with the lifetime of a value, and we can leverage that to manage resources.
Why don’t Go and Python have destructors? This is because they have a fundamental design difference, Python and Go are garbage collected languages. C++ provides destructors alongside other tools for managing lifetimes, to help manage one of the most important resources: memory. C++’s memory model is mostly manual, meaning the responsibility is on the programmer to make sure memory is cleaned up when no longer needed. A garbage collector (GC) is a language feature that takes care of cleaning up your memory for you by periodically scanning memory for unused values.
This means that as a programmer you don’t need to worry about cleaning up memory, as it will eventually be cleaned up for you. But it also means it’s hard to predict exactly when the cleanup will actually happen. Some GC languages offer something similar to a destructor called a ‘finaliser’, but the unpredictability of when the finaliser runs makes it error-prone to use. (TODO mention finalisers example of being bad?)
Go and Python are designed primarily for you to not have to think about the lifetime of values. But this means when you find you need to care about the lifetime of a resource, such as ensuring a mutex is unlocked in a timely manner, these languages need to add constructs like defer and with to help manage this added complexity. But in C++, managing resource lifetimes is woven throughout the language, due to needing to manage memory.
Whether you choose to use a garbage-collected language or a manual language is a trade-off. Which side of this trade-off to take depends on you and the problem you’re trying to solve. I would argue that the experience of managing resources is better in languages where you can use RAII, however that’s just my opinion. So it makes sense that I might prefer C++ to Go, at least when it comes to managing certain kinds of resources. But one of the key benefits of Go and Python is that they’re memory safe, you can easily shoot yourself in the foot with C++.
If only there was a language with robust tools for managing memory, but was also memory safe…
Rust!
I almost had you. This is a blog post about Rust! Now be prepared to listen to why Rust is the only language we ever need for the rest of time-
No, I’m joking.
Rust is nice though. A language that provides us with robust tools for dealing with lifetimes, but unlike C++, is memory safe! That’s pretty neat. For completeness, here’s the Rust equivalent of our example:
fn foo() -> Result<(), Box<dyn std::error::Error>> {
// Open the file
let mut my_file = File::open("path/to/file")?;
// Read out all the data
let mut data = String::new();
my_file.read_to_string(&mut data)?;
// Call some other function
bar(data)?;
Ok(())
}
So. Is Rust the perfect language for resource management? Obviously not. There’s a use-case that none of the languages mentioned so far never really tackle. What happens if cleaning up a resource can return an error? In the examples above, if closing a file errors:
- The C++ and Rust examples all silently ignore the error. Not ideal!
- In the Go example with
deferthe error is also silently ignored. To get around this you either have to not usedeferor write some very ugly code! - The Python example is the only one that propagates the error, using an exception. But there’s no way to handle exceptions just from the cleanup without mixing it with other potential exceptions.
Rust already has this idea of the Result type, which forces explicit error handling by design. File::open doesn’t return a File, instead it returns a Result<File> - if you want to get at the File within, you need to handle the error case. Broadly speaking, there’s three options:
let mut my_file = File::open("/path/to/file").unwrap();, which will crash the program if there’s an error.let mut my_file = File::open("/path/to/file")?;, which will bubble the error up.- Or below, which will run the top block with the file, or the bottom block with the error.
match File::open("/path/to/file") {
Ok(my_file) => {
// `my_file` is a `File`
},
Err(err) => {
// `err` is the error returned
}
}
What if we took the Go approach of having the cleanup be an explicit call (i.e. myFile.Close()) which can return an error like other functions, but somehow design the compiler to guarantee that a value will be eventually ‘closed’. Here’s an mockup of what that might look like in a Rust-style language:
// Not-a-real-lang
fn foo() -> Result<(), Box<dyn std::error::Error>> {
// Open the file
let mut my_file = File::open("path/to/file")?;
// Read out all the data
let mut data = String::new();
my_file.read_to_string(&mut data)?;
// Call some other function
bar(data)?;
// Bubble up any errors during closing
my_file.close()?; // <- the program wouldn't compile without this.
Ok(())
}
In this design, if the programmer forgets to write my_file.close(), then the compiler will throw an error. This makes it impossible to forget to clean up! But also by making it an explicit function call, it can now return errors like a normal function, which can be handled like errors everywhere else.
These are called linear types. This means that the compile guarantees it’ll get consumed - in this case, my_file.close() consumes my_file, so you can’t use it again afterwards. It turns out that there are people working on bringing a form of linear types to Rust, and I hope they succeed!
i respect defer
I think I’ve come away from this with some respect for defer. It’s incredibly simple and fits the Go ideology well. I still think I prefer alternatives, but ultimately I’m still going to be writing Go for years to come, it’s concurrency primitives just so nice. Now if someone could make a language with Go’s concurrency primitives and Rust’s type system, that would be something nice…
further reading
- The Pain Of Real Linear Types In Rust - I had heard of ’linear types’ before but this blog post grounded it for me.