Range
Video transcript & code
When I think about Ruby's core data structures, I immediately think about arrays and hashes. Sometimes I think about sets as well, although technically Set
isn't one of the core types. Conspicuously absent from this list is the Range
class. Which is a bit unfair, because Ruby ranges are uniquely useful data structures.
A Range
object represents exactly what its name suggests: a range with a beginning and an end. We can explicitly instantiate a Range using Range.new
with a begin and end value:
Range.new(1, 10) # => 1..10
But it's more common and idiomatic to use Range
literal syntax, which is simply two values separated by a pair of dots.
1..10 # => 1..10
This syntax doesn't always play well with larger expressions. For instance, let's ask this range if it includes the number 5.
1..10.include?(5) # => # ~> -:1:in `<main>': undefined method `include?' for 10:Fixnum (NoMethodError)
We get an error saying that the method #include?
is undefined for the number 10. What happened here is that the message send syntax bound more tightly than the range literal syntax. So Ruby parsed the expression like this:
1..(10.include?(5))
Because of these parsing issues, we commonly see ranges included in parentheses, even though the parens aren't technically part of the Range
syntax. It's a good idea to get into the habit of using parens around ranges, since they are so prone to parsing issues otherwise.
The ranges we've constructed so far are inclusive ranges, meaning that the end value is considered part of the range. Ruby also has exclusive ranges, meaning that the end value is treated as a boundary, but is not considered part of the range. This is more easily demonstrated than explained. Given an inclusive range from 1 to 10, 10 is included in the range. But given an exclusive range from 1 to 10, the ending value 10 is not included.
(1..10).include?(10) # => true (1...10).include?(10) # => false
We've been using the #include?
predicate to determine whether a value is contained in a range. We can also use the alias #member?
if we prefer.
(1..10).member?(10) # => true
There's another way to check if a value is included in a range. The threequals operator performs the same operation as #member?
and #include?
predicates.
(1..10) === 5 # => true
As we discussed in episode #202, this is also known as the "case equality" operator, and it enables us to use Ranges as patterns in case statements.
case spaceball_one_speed when 0...299_792_458 puts "sublight speed" when 299_792_458 puts "light speed" when 299_792_459...1_000_000_000 puts "rediculous speed" when 1_000_000_000...10_000_000_000 puts "ludicrous speed" end
And as you may remember from episode #215, this also makes ranges useful with the Enumerable#grep
method. For instance, we can filter out some outlier readings using #grep
and a range of allowable values.
readings = [26, 72, 9000, 8, 17, -3400] readings.grep(0..100) # => [26, 72, 8, 17]
So far we've just been using numbers as our beginning and ending values, but ranges are not limited to using numbers. In fact, we can use any comparable value in a range. For instance, we can check to see if a string is a lower-case ASCII character with a range from 'a' to 'z'.
("a".."z").include?("x") # => true
One particularly useful type to use in a Range
is Time
. For instance, here's a range that represents this week.
this_week = Time.new(2014, 7, 20)...Time.new(2014, 7, 27)
Let's ask it if it includes today.
this_week = Time.new(2014, 7, 20)...Time.new(2014, 7, 27) this_week.include?(Time.new(2014, 7, 22)) # => # ~> -:2:in `each': can't iterate from Time (TypeError) # ~> from -:2:in `include?' # ~> from -:2:in `include?' # ~> from -:2:in `<main>'
As we can see, there is a complication. We get an error "can't iterate from Time". This message reveals a little of how Range
works. When we ask a Range of something other than basic integers or strings whether it includes some value, it makes the determination by iterating from the start value to the end value and seeing if any of the increments match the given value.
But this means it has to know how to increment the start value. It uses the #succ
method for this. As we can see, this method is implemented for integers and strings. Technically it is also implemented for Time
, but this is deprecated. The deprecation is presumably because the successor for a given point in time is ambiguous; is it the next second? The next hour? The next day?
As a result of this deprecation, Ruby doesn't allow Times to be iterated in Ranges.
1.succ # => 2 "a".succ # => "b" Time.now.succ # => 2014-07-22 11:50:03 -0400 # !> Time#succ is obsolete; use time + 1
This might seem like it would make Time ranges useless. But there is another predicate we can use instead of #include?
, called #cover?
. This predicate doesn't try to interpolate the values in between the beginning and the end of the range. Instead it just checks that the given value is between the beginning and end values, respecting usual rules for inclusive or exclusive ranges.
this_week = Time.new(2014, 7, 20)...Time.new(2014, 7, 27) this_week.cover?(Time.new(2014, 7, 22)) # => true
Here's a hash representing a set of calendar entries. We can use our Time
range to select only the entries for this week.
marvin_calendar = { Time.new(2014, 7, 20) => "Mope", Time.new(2014, 7, 22) => "Rust", Time.new(2014, 7, 28) => "Write sad poem" } this_week = Time.new(2014, 7, 20)...Time.new(2014, 7, 27) marvin_calendar.select{|date, _| this_week.cover?(date) }.values # => ["Mope", "Rust"]
For ranges that can be iterated, Ruby provides an #each
method that we can use to iterate over every value in the range.
(0..10).each do |n| puts n end # >> 0 # >> 1 # >> 2 # >> 3 # >> 4 # >> 5 # >> 6 # >> 7 # >> 8 # >> 9 # >> 10
Along with #each
comes all the usual Enumerable
methods. This means, for instance, that we can easily expand an iterable Range
into an array.
("a".."z").to_a # => ["a", # "b", # "c", # "d", # "e", # "f", # "g", # "h", # "i", # "j", # "k", # "l", # "m", # "n", # "o", # "p", # "q", # "r", # "s", # "t", # "u", # "v", # "w", # "x", # "y", # "z"]
What's that you say? You want to iterate over a numeric range by some increment other than 1? Range can accommodate that. Instead of #each
, we use #step
.
(0..10).step(2) do |n| puts n end # >> 0 # >> 2 # >> 4 # >> 6 # >> 8 # >> 10
Without a block, this returns an enumerator which also has the full suite of Enumerable
methods.
(0..10).step(2).to_a # => [0, 2, 4, 6, 8, 10]
Alright, this has been a lot to go over, so it's time to bring this episode to a close. Hopefully these examples have given you some ideas about how to make your code more expressive by making greater use of Ranges. Happy hacking!
Responses