In Progress
Unit 1, Lesson 21
In Progress

Keyword Sugar

Video transcript & code

I'm a little short on time this week, so I thought I'd just share a quick little tip with you.

Let's haul out everyone's favorite example class, a Point object. Let's say this object is part of a library that's already in wide use. Up until now, everyone who wanted a point object has passed in two positional arguments: one for the X coordinate, and one for the Y coordinate.

class Point
  def initialize(x, y)
    @x, @y = x, y
  end

  def inspect
    "Point(#{@x}x#{@y})"
  end
end

Point.new(42, 23)               # => Point(42x23)

Lately we've been playing with the features in Ruby 2.0, and we thought it might be nice to release a version of this library which takes keyword arguments for the X and Y coordinates. This would make it explicitly clear which argument is which when creating a new Point.

The drawback is that this will break all the existing code that expects a Point to accept positional arguments.

class Point
  def initialize(x:, y:)
    @x, @y = x, y
  end

  def inspect
    "Point(#{@x}x#{@y})"
  end
end

Point.new(x: 2, y: 3)           # => Point(2x3)
Point.new(42, 23)               # =>

# ~> ArgumentError
# ~> missing keywords: x, y
# ~>
# ~> xmptmp-in22392_Yv.rb:12:in `new'
# ~> xmptmp-in22392_Yv.rb:12:in `<main>'

Fortunately, there is an easy way to have our cake and eat it to. One of the cool features of Ruby arguments is that we can actually reference earlier argument values in the defaults for later parameters.

This is easier to show than to explain. We start out with some positional arguments for X and Y. We prefix them with underscores, because we're going to need the parameter names "x" and "y" for keyword We also give them each a default value of nil.

Next we add keyword arguments for x and y. This time, we used the un-prefixed names. For the default values of these keywords, we reference the value of the earlier positional arguments. This means that if positional arguments are supplied, they will also become the value of the un-specified keyword arguments. But if keyword arguments are used, it will override the default nil values from the positional parameters.

The rest of the code stays the same. We can now initialize Point objects using either positional arguments… or keywords. We've remained backward compatible with all the existing clients of our code, while also providing a new, more explicit interface.

class Point
  def initialize(_x=nil, _y=nil, x: _x, y: _y)
    @x, @y = x, y
  end

  def inspect
    "Point(#{@x}x#{@y})"
  end
end

Point.new(42, 23)               # => Point(42x23)
Point.new(x: 2, y: 3)           # => Point(2x3)

There's only one drawback to our new version of the Point class. It's now possible to omit arguments entirely, and Ruby won't catch this case for us.

class Point
  def initialize(_x=nil, _y=nil, x: _x, y: _y)
    @x, @y = x, y
  end

  def inspect
    "Point(#{@x}x#{@y})"
  end
end

Point.new                       # => Point(x)

If we want to ensure that a Point will always have non-nil X and Y coordinates, we have to add an explicit precondition for it. This code will raise an error if either X or Y are missing.

class Point
  def initialize(_x=nil, _y=nil, x: _x, y: _y)
    x && y or raise ArgumentError, "Must supply X and Y coordinates"
    @x, @y = x, y
  end

  def inspect
    "Point(#{@x}x#{@y})"
  end
end

Point.new                       # =>

# ~> ArgumentError
# ~> Must supply X and Y coordinates
# ~>
# ~> xmptmp-in22392lSL.rb:3:in `initialize'
# ~> xmptmp-in22392lSL.rb:12:in `new'
# ~> xmptmp-in22392lSL.rb:12:in `<main>'

And that's it for today: an easy way to provide both positional and keyword arguments for the same parameters. Happy hacking!

Responses