In Progress
Unit 1, Lesson 1
In Progress

Extract Default to Method

Want to write Ruby libraries that other programmers love to use? In this episode, you’ll get some pointers on making your method parameter defaults self-documenting and explorable.

Video transcript & code

In Episode #482 we ended up with this code here.

class HitPresenter
  def initialize(hand, new_card, card_formatter: nil)
    @hand           = hand
    @new_card       = new_card
    @card_formatter = card_formatter ||
      Object.new.extend(CardHelper)
  end
end

As you can see, one of the initialization parameters is an optional collaborator object called card_formatter. This parameter defaults to nil. Later in the initializer, a nil value is treated as a special flag meaning "use the default card formatter".

Let's take a closer look at that default argument.

This idiom, of having a nil value act as a flag for "use the default", is a fairly common one in Ruby libraries. It can even be found in some of Ruby's standard libraries.

But even widely practiced idioms can have their flaws. And I find this one to be less than ideal.

As we talked about back in Episode #108, the trouble with nil is that it can mean so many things that it winds up meaning nothing at all. in this case, the meaning of the nil default is completely opaque to someone scanning through the source code.

Imagine we're reading this code for the first time, looking to understand how to instantiate and customize a HitPresenter. Does nil here mean that ordinarily, no card_formatter is used? Or does it mean that there is a secret default, hidden somewhere in the code? If we are reading generated documentation it's even less useful, since that documentation is likely to show default argument values, but with the method body either missing or hidden.

Of course, a documentation comment could help clear things up. But it's always better to look for ways to make code self-documenting before we turn to comments.

Making this particular initializer self-documenting turns out to be quite easy. All we have to do is move the default into the parameter list.

class HitPresenter
  def initialize(hand, new_card,
                 card_formatter: Object.new.extend(CardHelper))
    @hand           = hand
    @new_card       = new_card
    @card_formatter = card_formatter
  end
end

Since in Ruby any expression that is valid inside a method body is also valid as a parameter default, we can safely move this code without worrying about breakage.

This works well when the creation code for the default collaborator object is a single short expression. But sometimes the code to build the default is more complex than this.

For instance, here's a client for some web API. We've made it open for extension by providing an optional parameter to specify the underlying HTTP connection object it should use.

require "net/http"
require "uri"

class Client
  def initialize(login, password, connection: nil)
    @login = login
    @password = password
    @connection = connection || begin
      uri = URI("http://example.com")
      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      http
    end
  end
end

But because the construction code for the default HTTP connection object is complex, we've just left an opaque nil in its place.

Later, down in the initialization code we use a begin...end block, which we learned about in Episode #227, to treat a multiline block of code as a single expression. This block builds up an SSL-enabled Net::HTTP connection object before returning it.

Even if it's technically legal to move this entire begin...end block into the default parameter position, we're not going to do that, because it would turn the method signature into an unreadable mess.

But leaving the method as is, the signature doesn't tell readers anything about what kind of connection collaborator it might expect.

This is a case where I think it's worthwhile to extract a method even though it is only going to be used in one place. If we extract this connection builder code into a default_connection method …

…we can then replace our nil flag with an invocation of the extracted method.

require "net/http"
require "uri"

class Client
  def initialize(login, password,
                 connection: default_connection)
    @login = login
    @password = password
    @connection = connection
  end

  def default_connection
    uri = URI("http://example.com")
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    http
  end
end

Coming at it as a reader of this method, this version has two advantages: first, it makes it clear that there is a default value for this attribute. It's not some optional object, as the nil flag hinted.

And second,it gives the reader a clear signpost for where to find out more about what sort of object is expected for this parameter. all they need to do is look up the default_connection method.

There is one more variation on this which I think can be worthwhile. Imagine a user of our code is learning about it by playing with it in a REPL. In order to interactively discover exactly what sort of object normally plays the role of connection, they first have to instantiate a client object. Then they have to send the default connection message to it.

And if it turns out to be a private method, they will have even more work to do to play with it.

c = Client.new("bob", "xyzzy")
c.default_connection
# => #<Net::HTTP example.com:80 open=false>

If, on the other hand, we convert the default_connection method to a class method - updating the parameter default to match …

require "net/http"
require "uri"

class Client
  def initialize(login, password,
                 connection: self.class.default_connection)
    @login = login
    @password = password
    @connection = connection
  end

  def self.default_connection
    uri = URI("http://example.com")
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    http
  end
end

…It now becomes possible to interactively inspect the default HTTP connection value without any extra steps.

Client.default_connection
# => #<Net::HTTP example.com:80 open=false>

This new version is both self-documenting and very explorable, two properties I find highly desirable in a Ruby library. Happy hacking!

Responses