For the past six months or so I've been working an NES emulator in Rust. As you might expect, I've learned a lot about rust, and even more about NES internals. But the experience has also changed the way I look at Ruby.
Specifically, it's made me more than a little paranoid about methods that return nil
.
If You Don't Stand for Something, You'll Fall for Anything
What does nil
mean in Ruby? Almost anything. When a method returns nil, it could mean:
- The method has no return value
- There's usually a return value but not this time
- It returns a value from the database, which is NULL
- Something unexpected happened
This makes code hard to read and is the main cause of the most common Ruby exception in Ruby: NoMethodError
. As part-owner of an exception monitoring service, NoMethodError
is putting my kid through school.
Look at the following code. It returns nil
most of the time because if
statements evaluate to nil
when the conditional doesn't match and there's no else
.
def color_name(rgb)
if rgb == 0x000000
"black"
end
end
color_name("#FFFFFF").titleize
=> NoMethodError: undefined method `titleize' for nil:NilClass
If you're an experienced Ruby developer, you know this rabbit hole goes much deeper. Sometimes these different meanings of nil overlap in strange ways, making it impossible to know whether — for example — a value in a database is NULL
, or there's no value in the database at all.
A Better Way
In Rust there's no such thing as nil
. Instead, when we want to signify that a function sometimes returns a value and sometimes returns "nothing" we use an Option
.
Options
are a type that either contains some specific value, or contains no value. Here's what they look like in code:
Option::Some(42); // Wraps the number 42 in an option
Option::None; // Indicates "no result"
This is already looking better than our ad-hoc nil
usage, but it gets even better. The Rust compiler forces you to consider the None
case. You can't accidentally ignore it.
match my_option {
Some(x) => do_something_with_x(x),
// If you remove the `None` match below, this code
// won't compile.
None => do_the_default_thing()
}
So we could write our color naming example in rust like so:
fn color_name(rgb: u32) -> Option<String> {
if rgb == 0x000000 {
Some("black".to_owned())
} else {
None
}
}
Now we're forced to handle both the Some
and None
conditions:
let name = color_name(0xFFFFFF);
let name = match color_name(0xFFFFFF) {
Some(value) => value,
None => "unknown".to_owned(),
}
Sure this is a little verbose and weird looking, but it makes it impossible to ignore the case when a function doesn't return a useful value. That means code that's easier to understand and maintain.
Implementing Option in Ruby
What Ruby lacks in rigor it makes up for in flexibility. I thought it'd be interesting to try to implement something like Option
in Ruby.
We can't create compile-time errors in Ruby, since it's an interpreted language. But we can cause incorrect code to always raise an exception, instead of only raising an exception when you hit an edge case.
First, let's create two classes. Some
holds a read-only value. None
is empty. They're as simple as they seem.
class Some
attr_reader :value
def initialize(value)
@value = value
end
end
class None
end
Next, we'll create our Option
class which holds either Some
or None
and only lets us access them when we provide handlers for both.
class Option
def initialize(value)
@value = value
end
def self.some(value)
self.new(Some.new(value))
end
def self.none()
self.new(None.new)
end
def match(some_lambda, none_lambda)
if @value.is_a?(Some)
some_lambda.call(@value.value)
elsif @value.is_a?(None)
none_lambda.call()
else
raise "Option value must be either Some or None"
end
end
end
Finally, we can rewrite our color example to use the new Option
class:
def color_name(rgb)
if rgb == 0x000000
Option.some("black")
else
Option.none()
end
end
puts color_name(0x000000).match(
-> value { value },
-> { "no match" })
# Prints "black"
Conclusion
I've yet to try this technique in a real project. I think it could definitely prevent a lot of the NoMethodErrors
that always slip into production. It is a bit cumbersome looking, and not very Rubyish but I imagine that with some refinement a more pleasant syntax would emerge.