Option types and Ruby
March 11, 2015I've been learning the Rust programming language over the last several months. One of the great things about learning a new programming language is that it expands your understanding of programming in general by exposing you to new ideas. Sometimes new ideas can result in lightbulb moments for programming in languages you already know. One of the things learning Rust has made me realize is how much I wish Ruby had sum types.
A sum type is a type that has a number of "variants." These variants are alternate constructors for the type that can be differentiated from each other to confer different meaning, while still being the enclosing type. In Rust, sum types are provided through enum
. An enum type can be destructured into a value using pattern matching via Rust's match
operator.
enum Fruit {
Apple,
Banana,
Cherry,
}
fn print_fruit_name(fruit: Fruit) {
match fruit {
Apple => println!("Found an apple!"),
Banana => println!("Found a banana!"),
Cherry => println!("Found a cherry!"),
}
}
We define an enum, Fruit
, with three variants. The print_fruit_name
function takes a Fruit
value and then matches on it, printing a different message depending on which variant this particular Fruit
is. For our purposes here, the reason we use match
instead of a chain of if/else conditions is that match
guarantees that all variants must be accounted for. If one of the three arms of the match were omitted, the program would not compile, citing a non-exhaustive pattern match.
Enum variants can also take arguments which allow them to wrap other types of values. The most common, and probably most useful example of this is the Option
type. This type allows you to represent the idea of a method that sometimes returns a meaningful value, and sometimes returns nothing. The same concept goes by different names sometimes. In Haskell, it's called the Maybe monad.
pub enum Option<T> {
Some(T),
None,
}
An option can have two possible values: "Some" arbitrary value of any type T, or None, representing nothing. An optional value could then be returned from a method like so:
fn find(id: u8) -> Option<User> {
if user_record_for_id_exists(id) {
Some(load_user(id))
} else {
None
}
}
Code calling this method would then have to explicitly account for both possible outcomes:
match find(1) {
Some(user) => user.some_action(),
None => return,
}
What you do in the two cases is, of course, up to you and dependent on the situation. The point is that the caller must handle each case explicitly.
How does this relate to Ruby? Well, how often have you seen this exception when working on a Ruby program?
NoMethodError: undefined method `foo' for nil:NilClass
Chances are, you've seen this a million times, and it's one of the most annoying errors. Part of why it's so bad is that associated stack traces may not make it clear where the nil
was originally emitted. Ruby code tends to use nil
quite liberally. Rails frequently follows the convention of methods returning nil
to indicate either the lack of a value or the failure of some operation. Because there are loose nil
s everywhere, they end up in your code in places you don't expect and tripping you up.
This problem is not unique to Ruby. It's been seen in countless other languages. Java programmers rue the NullPointerException, and Tony Hoare refers to the issue as his billion dollar mistake.
What, then, might we learn from the concept of an option type in regards to Ruby? We could certainly simulate an Option type by creating our own class that wraps another value, but that doesn't really solve anything since it can't force callers to explicitly unwrap it. You'd simply end up with:
NoMethodError: undefined method `foo' for #<Option:0x007fddcc4c1ab0>
But we do have a mechanism in Ruby that will stop a caller cold in its tracks if it doesn't handle a particular case: exceptions. While it's a common adage not to "use exceptions for control flow," let's take a look at how exceptions might be used to bring some of the benefits of avoiding nil
through sum types. Imagine this example using an Active-Record-like User
object:
def message_user(email, message_content)
user = User.find_by_email(email)
message = Message.new(message_content)
message.send_to(user)
end
The find_by_email
method will try looking up a user from the database by their email address, and return either a user object or nil
. It's easy to forget this, and move along assuming our user
variable is bound to a user object. In the case where no user is found by the provided email address, we end up passing nil
to Message#send_to
, which will crash our program, because it always expects a user.
One way to get around this is to just use a condition to check if user
is nil
or not before proceeding. But again, this is easy to forget. If we control the implementation of the User
class, we can force callers to explicitly handle this case by raising an exception when no user is found instead of simply returning nil
.
def message_user(email, message_content)
user = User.find_by_email(email)
message = Message.new(message_content)
message.send_to(user)
rescue UserNotFound
logger.warn("Failed to send message to unknown user with email #{email}.")
end
Now message_user
explicitly handles the "none" case, and if it doesn't, an exception will be raised right where the nil
would otherwise have been introduced. Of course, the program will still run if this exception isn't handled, but it will crash in the case where it does, and the crash will have a more useful exception than the dreaded NoMethodError
on nil
. Forcing the caller to truly account for all cases is something that pattern matching provides in Rust which is not possible in Ruby, but using exceptions to provide earlier failures and better error messages gets us a bit closer to the practical benefit.
There are other approaches to dealing with the propagation of nil
values in Ruby. Another well known approach is to use the null object pattern, returning a "dummy" object (in our example, a User
), that responds to all the same messages as a real user but simply has no effect. Some people would argue that is a more object-oriented or Rubyish approach, but I find that it introduces more complexity than its benefit is worth.
Using exceptions as part of the interfaces of your objects forces callers to handle those behaviors, and causes early errors when they don't, allowing them to get quick, accurate feedback when something goes wrong.