Of Absence of Value and Hidden Footguns
Programming languages differ in the expressiveness of their type system, ranging from very strict ones to those which just wing it.
In this post, I am not going to discuss this topic in any detail as I am not nearly qualified in this area to give it any justice. However, I would like to share my thoughts about one aspect related to the type system: representing the absence of value and one concrete example of how it impacts our programs.
Since I mostly work with Go and Rust this article touches on some specific aspects of Go and compares it to Rust, but since some other languages use similar constructs to Go and/or Rust the principles might apply to some of them as well.
Introduction
Be it a value that is set only in certain scenarios, a completely optional value, or one that is loaded lazily at some later stage of execution, in our programs we often need to represent whether something is or is not there.
To set some foundation, if you are not familiar with Go or Rust, here is a quick introduction without going into much detail.
In Go, we usually express that the value is not there with pointers, specifically with a special value nil
that we can assign to any pointer type.
type SomeType struct {
Name *string
SomeField *OtherType
}
...
something := SomeType{
Name: nil,
SomeField: nil,
}
We then can check if the value is present by comparing it to nil
. To access the value directly we dereference the pointer:
if something.Name != nil {
*something.Name = "foo"
}
if something.SomeField != nil {
// SomeField is not nil
}
Rust gives us a dedicated type to represent the value that might not be present -- Option<T>
. Our fields can be set to either Some(T)
or None
, which represents the absence. Similar constructs exist in many modern languages:
pub struct SomeType {
name: Option<String>,
some_field: Option<OtherType>,
}
With Rust, we get several "options" on how to handle this wrapper type, and depending on the situation one might be more fitting than the others. The most straightforward approach would be to use a match
expression, or if let Some(...)
construct, but there are multiple other possibilities that can be very useful in certain situations such as using a function like map
or propagating None
with ?
operator, or even outright checking if value is_none()
.
// Using match statement:
match something.some_field {
Some(some_field) => {
// Value is not absent
}
None => {
// Value is absent
}
}
...
// Using `if let Some(...)`:
if let Some(some_field) = something.some_field {
// Value is not absent
} else {
// Value is absent
}
// Using ? operator:
fn do_something(something: SomeType) -> Option<String> {
// If value is None, the function will return None right away
let some_field = something.some_field?;
...
}
// There are few of other options...
Footguns
Our programs grow, sometimes we are tired or in a hurry, sometimes we jump into unknown codebases, or simply do other things for some time and our brain decides to discard pieces of context from the limited address space assigned to the project.
Sooner or later we forget that SomeField
might not have any value and somewhere in our code we try to call a method on it or access its fields:
...
something.SomeField.DoSomething()
// or
if something.SomeField.Value == "foo" {
...
}
...
and then we may get an unpleasant surprise:
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x1029f6b64]
...
But we did not dereference the pointer, did we? Well, no, but Go did it for us as calling methods or accessing fields on a pointer type will dereference it and if the pointer value is nil
, cause a runtime panic. The same holds for interfaces
.
- If x is of pointer type and has the value nil and x.f denotes a struct field, assigning to or evaluating x.f causes a run-time panic.
- If x is of interface type and has the value nil, calling or evaluating the method x.f causes a run-time panic.
To be clear implicit dereference happens only in certain scenarios as described in the docs linked above. Simply comparing pointer type to non-pointer type will result in a compilation error:
// This does not work, we need to dereference explicitly wiht * if something.Name == "foo" { ... }
invalid operation: something.Name == "foo" (mismatched types *string and untyped string)
It is easy to forget a nil
check for a field, and because of implicit dereferencing we might not even notice that the field is a pointer. Moreover, pointers are quite ubiquitous in Go, so if we are working with other people's code it might not be clear without some context if we need to perform a nil
check or if the value is expected to be there always.
What happens in Rust's case, then?
...
something.some_field.do_something();
// or
if something.some_field.value == "foo" {
...
}
...
error[E0599]: no method named `do_something` found for enum `Option` in the current scope
--> src/main.rs:27:26
|
27 | something.some_field.do_something();
| ^^^^^^^^^^^^ method not found in `Option<OtherType>`
|
note: the method `do_something` exists on the type `OtherType`
--> src/main.rs:9:5
|
9 | pub fn do_something(&mut self) {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: consider using `Option::expect` to unwrap the `OtherType` value, panicking if the value is an `Option::None`
|
27 | something.some_field.expect("REASON").do_something();
| +++++++++++++++++
Rust will not compile the program as we are trying to call a function on a type that does not define it. We can still choose to unwrap()
or expect(...)
if we know value cannot be absent, if we are okay with our software crashing in this case, or if we just like living on the edge. However, Rust requires us to make this choice explicitly, eliminating a whole bunch of possible mistakes, or in other words, it politely asks us before we shoot ourselves in a foot.
Summary
Go does not have a clear way to represent the absence of value other than a nil
assigned to the pointer type. And in some cases, the default behavior of the language is dereferencing the pointer, which frankly makes sense in a lot of scenarios, because pointers main purpose is not to represent that the value is not there but to reference data stored on the heap. This causes problems when we use pointers for other purposes, such as representing the absence of value. Whether the nil
or NULL
is a good idea in the first place is a different discussion.
Rust Option<T>
on the other hand is very specific in its purpose of signaling that the value may or may not be there, so the default behavior of how it is handled can be tailored to this purpose, which in turn makes it easier to avoid mistakes.
Top comments (0)