Using custom objects in Ruby Ranges

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

30 April 2019

30 April 2019 By Tim Ash

By Tim Ash Development

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?