Using custom objects in Ruby Ranges

Using custom objects in Ruby Ranges

Home, home on the Range, where the Ewoks and the Wookies play

Home, home on the Range, where the Ewoks and the Wookies play

In Ruby, ranges represent a range (funny, that) of values. For example,

(1..10)    # integers from 1 to 10
("a".."z") # lowercase characters from "a" to "z"

using three full stop characters means the final value is excluded:

(1...10)    # integers from 1 to 9 (10 is excluded)
("a"..."z") # lowercase characters from "a" to "y" (z is excluded)

What’s more is that ranges can be created from any object which implements the succ and <=> methods. Note that succ is short for successor, and that <=> is also known as the spaceship operator.

Let’s say I would like to create ranges based the theatrical release order of the Star Wars movies. I might write the following code:

class StarWarsMovie

  @@values = ["A New Hope", "The Empire Strikes Back", "Return of the Jedi", "The Phantom Menace", "Attack of the Clones",  "Revenge of the Sith", "The Force Awakens", "Rogue One", "The Last Jedi", "Solo", "The Rise of Skywalker"]

  ERROR_MESSAGE = "Unknown Movie"

  def initialize(value)
    raise ArgumentError.new(ERROR_MESSAGE) unless @@values.include?(value)

    @value = value
  end

  def to_s
    @value
  end

end

There’s not a lot here: we have a class variable, @@values, which contains an array of the movie titles, an initialize method to allow a value to be set when a new StarWarsMovie object is created, and a to_s method to allow the value to be output. The initialize method will raise an error if we try to set up a StarWarsMovie object with a name it doesn’t recognise.

In order to create ranges using StarWarsMovie objects, we need to do three things: include the Comparable module, and implement the succ and <=> methods:

class StarWarsMovie

  include Comparable

  @@values = ["A New Hope", "The Empire Strikes Back", "Return of the Jedi", "The Phantom Menace", "Attack of the Clones",  "Revenge of the Sith", "The Force Awakens", "Rogue One", "The Last Jedi", "Solo", "The Rise of Skywalker"]

  ERROR_MESSAGE = "Unknown Movie"

  def initialize(value)
    raise ArgumentError.new(ERROR_MESSAGE) unless @@values.include?(value)

    @value = value
  end

  def to_s
    @value
  end

  def array_index
    @@values.index(@value)
  end

  def succ
    self.class.new(@@values[array_index+1])
  end

  def <=> other
    array_index <=> @@values.index(other.to_s)
  end

end

The succ method returns a new StarWarsMovie, with its value set to the next movie in the sequence. The <=> method compares the value of the StarWarsMovie with the value of a supplied StarWarsMovie. It will return -1 if the value of the StarWarsMovie comes before that of the supplied StarWarsMovie, 1 if it comes after, and 0 if they match.

With all this now in place, we can go ahead and start creating ranges using StarWarsMovie objects:

$ episode6 = StarWarsMovie.new "Return of the Jedi"
$ episode8 = StarWarsMovie.new "The Last Jedi"

$ (episode6..episode8).each{ |movie| puts movie.to_s }
Return of the Jedi
The Phantom Menace
Attack of the Clones
Revenge of the Sith
The Force Awakens
Rogue One
The Last Jedi

(Instead of writing puts movie.to_s, I could have simply written puts movie as puts will call to_s on its argument, but I left to_s in for clarity.)

I’ve tried to keep the code as agnostic as possible regarding the actual content of the @@value array. This means should I later want to create ranges based on, say, US Presidents, Popes, or months of the year, I could easily refactor in order to reuse as much of this code as possible. I would be able to move all the methods in the StarWarsMovie class into an abstract superclass. The StarWarsMovie class would then be a subclass of this new superclass, and only define the array of values, and the error message displayed by the initialize method when we don’t recognise a value.

Of course, it didn’t turn out to be as simple as that: the @@value array was a class variable and ERROR_MESSAGE was a constant, meaning they would be shared with all the subclasses. I’ve refactored these to be methods to avoid the list of possible values and error messages in the various subclasses conflicting with each other:

class RangeableArray

  include Comparable

  def values
    []
  end

  def error_message
    ""
  end

  def initialize(value)
    raise ArgumentError.new(error_message) unless values.include?(value)

    @value = value
  end

  def to_s
    @value
  end

  def array_index
    values.index(@value)
  end

  def succ
    self.class.new(values[array_index+1])
  end

  def <=> other
    array_index <=> values.index(other.to_s)
  end

end

Creating new objects that can be used in arrays is now as simple as defining the list of values, and an error message to display when the user attempts to initialize an object that isn’t on the list:

class StarWarsMovie < RangeableArray
  def values
    ["A New Hope", "The Empire Strikes Back", "Return of the Jedi", "The Phantom Menace", "Attack of the Clones",  "Revenge of the Sith", "The Force Awakens", "Rogue One", "The Last Jedi", "Solo", "The Rise of Skywalker"]
  end

  def error_message
    "Unknown Movie"
  end
end

class USPresident < RangeableArray
  def values
    ["Truman", "Eisenhower", "Kennedy", "Johnson", "Nixon", "Ford", "Carter", "Reagan", "Bush I", "Clinton", "Bush II", "Obama", "Trump"]
  end

  def error_message
    "Unknown President"
  end
end

Thanks to Rob Nichols for spotting a flaw in a previous version of this post.

I’d love to hear your thoughts on Ruby ranges, and indeed, Star Wars. Why not leave a comment below?


LET’S WORK TOGETHER

We would love to hear from you so let's get in touch!

CONTACT US TODAY!