The objects of the class Range implement sequential intervals of values.

Ever wondered how to build ranges out of custom class objects?

Yes, this is possible and it can be achieved by following a few simple steps.

Ranges of custom objects example

Let's imagine that we need to have the ability to create ranges of this sort (Identifier.new(3)..Identifier.new(10)).

Our class Identifier creates strings like this one: "65-A" where the integers at the start are the ASCII index for the character shown at the end of the string.

  class Identifier
    attr_reader :index

    def initialize(index)
      @index = index
    end

    def to_s
      "#{index}-#{index.chr}"
    end
  end

The three steps are to:

  • include the Comparable module
  • define the <=> operator.
  • define the method succ
  class Identifier
    include Comparable

    def <=>(other_identifier)
      # TODO
    end

    def succ
      # TODO
    end
  end

Here is a brief description of what happens here:

The Comparable module

The module Comparable adds the instance methods: <, >, <=, >=, ==, between?, clamp.

  Comparable.instance_methods # => [:<, :>, :<=, :>=, :==, :between?, :clamp]

The three-way comparison operator

The <=> three-way comparison operator, (a.k.a. the spaceship operator) gives us the ability to compare two instances of the same class in the sense of <, >, <=, >=, == operators.

The expression a <=> b will output either -1, 0, or 1 depending which of the elements has a lesser value, compared to the other.

The method Comparable#clamp relies on the <=> operator.

The succ method

The succ method evaluates the "next" instance from the sequence. Think of it as an increment operator.

For example: 1.succ, will evaluate to 2 in the context of integers.

There are some data types such as Float, where there is no way to determine the "next" element due to the nature of the elements.

Example

  class Identifier
    include Comparable

    attr_reader :index

    def initialize(index)
      @index = index
    end

    def <=>(other_identifier)
      index <=> other_identifier.index
    end

    def succ
      Identifier.new(index.succ)
    end

    def to_s
      "#{index}-#{index.chr}"
    end
  end

Now we can do fancy stuff, for example:

  (Identifier.new(65)..Identifier.new(70)).to_a
  (Identifier.new(65)..Identifier.new(70)).member?(Identifier.new(69)) # => true

  Identifier.new(65) > Identifier.new(64) # => true
  Identifier.new(65) > Identifier.new(66) # => false

This is what happens when we invoke to_s on every instance from the (Identifier.new(65)..Identifier.new(90)) range:

  (Identifier.new(65)..Identifier.new(90)).to_a.map(&:to_s)
  # => ["65-A", "66-B", "67-C", "68-D", "69-E", "70-F", "71-G", "72-H", "73-I", "74-J", "75-K", "76-L", "77-M", "78-N", "79-O", "80-P", "81-Q", "82-R", "83-S", "84-T", "85-U", "86-V", "87-W", "88-X", "89-Y", "90-Z"]

Conclusion

Mixing the Comparable module and defining the succ and <=> gives us the ability to create ranges out of custom class instances.

References