Sandwich Scheduling: a look at Ruby's Array cycle method
- By Tim Ash
- 28 September
How Ruby can help you schedule your sandwiches
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?
How Ruby can help you schedule your sandwiches
Another way Ruby can help you keep track of your lockdown exercise routine
How Ruby can help you keep track of your lockdown exercise routine
How Ruby can help you mix the drinks and bust some moves at the charity ball
You won't believe what happens when all the animals run away from the zoo.
An example of Ruby metaprogramming, by way of juggling chainsaws.
We would love to hear from you so let's get in touch!