Will Roe's blog

Anaλysis Paraλysis » Archives

The problem with null

In programming languages that have the concept of null, any value in a system can be (for example) a number or null; a string or null; an object or null.

Null (or nil in Ruby, undefined in Javascript, etc.) presents a problem for us when working with untyped sources of data. If we’re not careful null values can spread throughout our software, causing errors in remote code locations that expect non-null values.

Let’s examine how null values can enter a system and what to do about it. In the following code snippet, we will try to fetch the “Q Score” of a coffee from an array of coffees:

coffees = [
  {
    name: 'Tanzania AAA',
    roast_date: '2016-09-01',
    flavour: 'very nice',
    metadata: {
      altitude: 1600,
      q_score: 83.1
    }
  }
]

coffees[0][:metadatum][:q_score]
# => NoMethodError: undefined method `[]' for nil:NilClass

# ~> NoMethodError
# ~> undefined method `[]' for nil:NilClass
# ~>
# ~> /var/folders/v5/nl_spw2j3qj0_scbfy6gv_sm0000gn/T/seeing_is_believing_temp_dir20160913-70485-je99qk/program.rb:13:in `<main>'

That didn’t work because we misspelled the :metadata key.

One way to avoid this sort of exception is to explicitly check for keys in the hash, in a defensive style of coding:

coffees = [
  {
    name: 'Tanzania AAA',
    roast_date: '2016-09-01',
    flavour: 'very nice',
    metadata: {
      altitude: 1600,
      q_score: 83.1
    }
  }
]

if coffees[0].key?(:metadata)
  metadatum = coffees[0][:metadata]
  if metadatum.key?(:q_score)
    metadatum[:p_score]
  end
end # => nil

But that’s a lot of code, suffers from quite a bit of duplication and doesn’t really solve the problem (what if we accidentally typo the key when we access the element, but type it correctly when writing the conditional? As we did above with :q_score / :p_score).

Ruby has a Hash#fetch method which is useful in these situations:

coffees = [
  {
    name: 'Tanzania AAA',
    roast_date: '2016-09-01',
    flavour: 'very nice',
    metadata: {
      altitude: 1600,
      q_score: 83.2
    }
  }
]

coffees[0].fetch(:metadatum).fetch(:q_score)
# => KeyError: key not found: :metadatum

# ~> KeyError
# ~> key not found: :metadatum
# ~>
# ~> /var/folders/v5/nl_spw2j3qj0_scbfy6gv_sm0000gn/T/seeing_is_believing_temp_dir20160913-70032-15zv3x0/program.rb:13:in `fetch'
# ~> /var/folders/v5/nl_spw2j3qj0_scbfy6gv_sm0000gn/T/seeing_is_believing_temp_dir20160913-70032-15zv3x0/program.rb:13:in `<main>'

The error we get from that code tells us exactly what’s wrong.

Tolerating nulls

But Ruby also has Array#dig, Hash#dig and Struct#dig, which we could use like this:

coffees = [
  {
    name: 'Tanzania AAA',
    roast_date: '2016-09-01',
    flavour: 'very nice',
    metadata: {
      altitude: 1600,
      q_score: 83.2
    }
  }
]

coffees.dig(1, :metadatum, :q_score) # => nil

We didn’t get an error for that, just nil. Depending on the circumstances that might be ok. We also can’t tell whether the value at [0][:metadatum][:q_score] equals nil or whether one of those steps failed.

Ruby’s dig method (introduced in Ruby 2.3) appears to be so named because you are digging for data with uncertainty as to whether it’s all there. For quickly prototyping something, this is no doubt appropriate, but in a production system we’d like more visibility into errors so we can fix our code or investigate API changes.

This leaves us with 2 choices - fetch and dig - fetch gives us specific errors when keys are missing from Hashes, but is rather verbose when reaching deep into data structures (not uncommon when working with 3rd party data); dig allows us to express the problem quite simply, with a sequence of keys or indexes, but gives us no visibility of missing keys or indexes.

Nulls all the way down

The examples so far have been short so the error has been easy to spot, we get nil, when we expected a value. The problem of null causes havoc far beyond the code that introduces the null. To see how, let’s examine a larger code sample:

all_coffees = [
  {
    name: 'Tanzania AAA',
    roast_date: '2016-09-01',
    flavour: 'very nice',
    metadata: {
      altitude: 1600,
      q_score: 83.2
    }
  },
  {
    name: 'Brazil AAA',
    roast_date: '2016-09-02',
    flavour: 'chocolate and broken dreams',
    metadata: {
      altitude: 1500,
      q_score: 88.1
    }
  },
  {
    name: 'Decaf whatever',
    roast_date: '2016-09-03',
    flavour: 'despair and cherries',
    metadata: {
      altitude: 1450,
      q_score: 72.1
    }
  }
]

def coffees_roasted_at(coffees, date)
  coffees.select { |coffee| coffee[:roasted_at_date] == date }
end

def lowest_altitude_coffee(coffees)
  coffees.sort_by { |coffee| coffee[:metadata][:altitude] }.first
end

lowest_altitude_coffee(
  coffees_roasted_at(all_coffees, '2016-09-02')
)[:name] # => NoMethodError: undefined method `[]' for nil:NilClass

# ~> NoMethodError
# ~> undefined method `[]' for nil:NilClass
# ~>
# ~> /var/folders/3l/1lt4f3dx4658n0qw4lpx_6t00000gn/T/seeing_is_believing_temp_dir20160913-59940-dtfhjy/program.rb:39:in `<main>'

The last line of that program has raised a NoMethodError on nil (we’re calling [:name] on nil), but where did that nil come from? Now we have to hunt up the stack of data and method calls to find what introduced a null value. Replacing any call to Hash#[] with Hash#fetch will solve most of these issues and result in stacktraces that point to the introduction of the null value.

Null objects

What happens when you call a method on nil? In Ruby, you get a NoMethodError, which you would expect (nil is an instance of NilClass and that likely does not implement the method you want). One way to deal with the problem of nulls, instead of sprinkling null checks throughout your code, is to use the Null Object Pattern. The fundamental idea behind the Null Object pattern is to create Null classes that implement the same interface as your non-null objects do. An example of that would be:

Coffee = Struct.new(:name, :roast_date, :flavour, :metadata) do
  def self.from_hash(attrs:)
    coffee = new
    coffee.members.each do |key|
      coffee[key] = attrs.fetch(key)
    end
    coffee
  end

  def altitude
    metadata.fetch(:altitude)
  end

  def q_score
    metadata.fetch(:q_score)
  end
end

class NullCoffee
  attr_reader :name, :roast_date, :flavour, :altitude, :q_score
end

def coffee_attrs
  [
    {
      name: 'Tanzania AAA',
      roast_date: '2016-09-01',
      flavour: 'very nice',
      metadata: {
        altitude: 1600,
        q_score: 83.2
      }
    },
    {
      name: 'Brazil AAA',
      roast_date: '2016-09-02',
      flavour: 'chocolate and broken dreams',
      metadata: {
        altitude: 1500,
        q_score: 88.1
      }
    },
    nil
  ]
end

coffee_attrs.map(&:class)
# => [Hash, Hash, NilClass]

def coffees
  coffee_attrs.map { |coffee_attributes|
    if coffee_attributes
      Coffee.from_hash(attrs: coffee_attributes)
    else
      NullCoffee.new
    end
  }
end

coffees.map(&:class)
# => [Coffee, Coffee, NullCoffee]

def earliest_roast_date
  coffees.select(&:roast_date).sort_by(&:roast_date).first
end

earliest_roast_date
# => #<struct Coffee
#     name="Tanzania AAA",
#     roast_date="2016-09-01",
#     flavour="very nice",
#     metadata={:altitude=>1600, :q_score=>83.2}>

def names_match?(pattern:)
  coffees.select { |coffee| coffee.name =~ pattern }
end

names_match?(pattern: /AAA/)
# => [#<struct Coffee
#      name="Tanzania AAA",
#      roast_date="2016-09-01",
#      flavour="very nice",
#      metadata={:altitude=>1600, :q_score=>83.2}>,
#     #<struct Coffee
#      name="Brazil AAA",
#      roast_date="2016-09-02",
#      flavour="chocolate and broken dreams",
#      metadata={:altitude=>1500, :q_score=>88.1}>]

In the above example, using NullCoffee objects instead of nil itself allows us to write code that works using the basic principle of Duck Typing.

coffees.select(&:roast_date).sort_by(&:roast_date).first could be read as “select all the coffees that have a roast date, sort them and take the first one”. There’s no mention there of nil values or (crucially for Duck Typing) the type/class of each coffee (Coffee or NullCoffee). We haven’t banished null entirely though as NullCoffee.new.roast_date returns nil (that’s what the call to select relies on to filter for coffees with valid roasting dates), we’ve just prevented calling our object’s methods on a nil value.

Banishing null using a type system

In statically typed programming languages, types can be used to distinguish between what would be null and what would be a value. Rust is a programming language that uses types to avoid nulls and it has two main optional types: Option and Result.

An Option type in Rust can be either Some(value) or None (the closest thing Rust has to a null value). Let’s try this type out:

fn operation_that_could_fail1(_in: String) -> Option<String> {
    Some("that worked".to_string())
}

fn operation_that_could_fail2(_in: String) -> Option<String> {
    None
}

fn main() {
    let result = operation_that_could_fail1("input".to_string())
        .and_then(|val| operation_that_could_fail2(val));

    if let Some(value) = result {
        println!("We got '{}' back", value);
    } else {
        println!("Nothing came back, oh dear.");
    }
}

The idea behind an optional type is that it can either contain a value or can be None. This isn’t conceptually all that different from null at first glance, until you realise that code such as let a: i32 = 10 does not allow that variable to ever hold a null-like value - it can only ever be a 32-bit-wide integer, anything else is a compile-time error.

If the if let Some(value) = result code above seemed a bit verbose, then Rust’s Option implementation includes a method unwrap for situations where you might want to quickly write some code and write more defensive error checking later:

fn main() {
    let first: Option<&str> = Some("ada");

    println!("first name is {}", first.unwrap());

    let last: Option<&str> = None;

    println!("name is {}", last.unwrap());

    println!("This line will never be reached");
}

The preceding example will panic on the line that does last.unwrap() because last is a None value (the line that prints the first name will work without issue however). This means you can write quite simple code without checking for None values, which will crash when something goes wrong.

What about default values? Option provides a method called unwrap_or which allows you to provide a default value in case the value is None:

fn main() {
    let name: Option<&str> = None;

    println!("Name is: {}", name.unwrap_or("Unknown"));
}

The preceding snippet will fail to find a name, but the default value will be returned instead, it will print out “Name is: Unknown”.

Silent failures

Fundamentally, all this treatment of nulls comes down to how we deal with failures. In certain circumstances, a null value doesn’t indicate a failure and sometimes it does. Picking the right tools and code patterns to match can be quite challenging. Rust deals with this situation by providing the Result type. A Result is either Ok(T) or Err(Err) - this is very similar to Option types except that errors have data associated with them. These error types allow the cause of an error to be provided to the function’s caller. However, the same tricks can be performed on Result types, including chaining a pipeline of functions together so that data gets passed from function to function (short-circuiting out upon the first error):

fn main() {
    let numbers = "4".parse::<u32>().and_then(|num| {
        "hello".parse::<u32>().map(|another| num + another)
    });

    match numbers {
        Ok(n) => {
            println!("That went well, the result is: {}", n);
        }
        Err(e) => {
            println!("Oh dear! Something bad happened: {:?}", e);
        }
    }
}

In the above example, when the string "hello" fails to parse as a number, the entire expression returns an Err value, short-circuiting the call to map which would otherwise add the 2 numbers together. This example is quite small and contrived, but hopefully the pattern is clear. The way Option and Result types can be chained together is quite similar, but if you need to know the cause of a failure (such as a number failing to parse), then Result is useful.

Conclusion

The situation with null is a tricky one. For many programming languages, both static and dynamic, any variable can be null and they require extra care when writing code. For those statically typed languages that don’t provide null values, we are always encouraged by the compiler to handle all eventualities. The end result of strict and explicit treatment of None or Error values is that we never see simple programming errors related to unexpected null in production code.

A tool that prevents whole classes of errors that can occur in production is something we should all be seriously considering and weighing up with other factors around their use. This is why the Elm programming language makes such a big deal of the fact that code written in Elm never throws exceptions in production code. Compared to the runtime experience with Javascript, that’s a very compelling advantage.

The story on the backend is not as straightforward - I’ve illustrated the advantages to static languages without null using Rust and although there are other options here (Haskell, Swift, Scala, etc.) for one reason or another these languages are not in widespread use yet (for backend services). Up until recently, if you wanted a good static programming language for your backend service, your choices were limited and involved seemingly overwhelmingly academic concerns. For better or worse, choosing a programming language for a system often has less to do with the semantics of the language itself and more to do with the tooling, developer experience (a highly subjective criteria) and community (involving everything from the available libraries and frameworks, to the inclusiveness and friendliness of prominent programmers and their approach to engaging newcomers). For all of those criteria, I see Rust as an excellent future option for teams that value stability, speed and excellent tooling. Currently, the ecosystem is young and there isn’t a clear set of mature libraries that teams could use to build backend services in the same fashion as they can currently with Ruby or Javascript, but it is definitely a technology to keep an eye on.