Class Coercion in Ruby

By Zach Church on 24 01 2011

I often run into situations where I'm working with something that's essentially a number, but I want it to behave just a little differently. For example, in triathlon training I often deal with time intervals. I may be repeating a certain swimming distance and need to get faster each time. I swim a certain distance in 1:15, and need to swim it 5 seconds faster each successive time.

What I want is pretty much an integer, but I want it to display differently. I also want an easy way to parse something like "1:15" into a time interval that represents 75 seconds. In order to make the time faster each time I swim, I also want to add or subtract integers with TimeIntervals. For example, adding 5 to a "1:15" TimeInterval should give me a "1:20" TimeInterval.

I'm going to start with a TimeInterval class that does the most basic of these things -- parsing and displaying the time interval, and then expand it to cover the rest of the requirements as we go.

class TimeInterval
attr_reader :value # in total seconds

def initialize(value)
@value = value.to_i
end

def self.parse(string)
minutes, seconds = string.split(':').map(&:to_i)
new((minutes * 60) + seconds)
end

def minutes
value / 60
end

def seconds
value % 60
end

def to_s
"#{minutes}:#{seconds}"
end
end

Now we're able to create a new TimeInterval by either calling TimeInterval.new and pass the number of total seconds, or I can call TimeInterval.parse and pass a string. The to_s function works well enough for our purposes.

>> TimeInterval.new(75)
=> #<0xb758bb5c value="75">
>> TimeInterval.parse("1:15")
=> #<0xb7587fe8 value="75">
>> TimeInterval.parse("1:15").to_s
=> "1:15"

That's a pretty simple class that covers most of what we want to do, except for addition with other numbers. We can do this by adding an addition method to TimeInverval. We want to be able to add both integers and other TimeIntervals, so we'll check the type and do slightly different things for each of these types:

class TimeInterval
...

def +(other)
if other.is_a? TimeInterval
TimeInterval.new(value + other.value)
elsif other.is_a? Numeric
TimeInterval.new(value + other)
else
raise TypeError, "#{other.class} can't be coerced into TimeInterval"
end
end

end

Simple enough. Now we should be able to add them:

>> i = TimeInterval.parse "1:15"
=> #<0xb73da4c0 value="75">
>> (i + 5).to_s
=> "1:20"
>> (5 + i).to_s
TypeError: TimeInterval can't be coerced into Fixnum
from (irb):9:in `+'
from (irb):9
from :0

Uh-oh! Everything works fine when our object is on the left since it's using our addition method. But it's using Fixnum's addition method when the integer is on the left. Fixnum's addition operation doesn't know anything about how to add our object to itself. Fortunately, Ruby provides us with a way to deal with this without modifying the built-in numeric classes. We can convert, or coerce, either of the values in the operation into different objects that are compatible with each other.

Coercion to the rescue

Fixnum's addition will first see if it can add the two objects together. Since it doesn't know what our object is, it won't be able to. Instead, it will call the coerce method on our object so we can change the values into something that's compatible. We'll return an array of the two (compatible) objects, then Fixnum will try the addition again. Our coerce method has access to both of the objects used in the addition. One is accessible via the argument passed, and the other is self.

For our coerce function, we need to return both objects as TimeIntervals:

class TimeInterval
...

def coerce(other)
[TimeInterval.new(other), self]
end
end

So, we create a new TimeInterval from other (which is presumably a number), and leave self unchanged. Now when Fixnum's addition method tries to add the two together, both objects will be TimeIntervals, and our TimeInterval addition method will be used.

>> (5 + i).to_s
=> "1:20"
>> (5.0 + i).to_s
=> "1:20"
>> (5 + i + 5 + i).to_s
=> "2:40"

Ta-da! We can add them in any order, any number of times, and the result will be a TimeInterval.

One final step: Another developer comes along and wants to make a class that's compatible with TimeInterval. He's providing an addition operation to handle adding TimeIntervals to his objects, but he's also going to expect our addition operation to attempt the same coercion that we expect from Ruby's built-in numeric types. We'll change our addition method to accommodate this:

class TimeInterval
...

def +(other)
if other.is_a? TimeInterval
TimeInterval.new(value + other.value)
elsif other.is_a? Numeric
TimeInterval.new(value + other)
else
if other.respond_to? :coerce
a, b = other.coerce(self)
a + b
else
raise TypeError, "#{other.class} can't be coerced into TimeInterval"
end
end
end
end

Our new addition method first checks to see if we can handle the argument. We can if it's a TimeInterval or a Numeric. Otherwise we give the other object a chance to coerce the objects into compatible objects, and attempt the addition again. We'll raise a TypeError with an informative message if the other class doesn't provide a coerce method.

Our object now meets all of the requirements that we set above. It displays differently, and we can add it to other integers. I'll leave the subtraction and the rest of the mathematical operations to you. Check out ruby's built-in Numeric class for a list of other operations that you may want to implement.

While we used coercion to wrap a simple integer in another class, it can be used in many other cases. One common example is with coordinate systems. The point (2, 3) multiplied by 2 could become (4, 6). In most cases where you want to implement mathematical operations with your class, coercion would help.

Things to watch out for:

  1. self should always be returned as the last element in the array. While it wouldn't make a difference for addition, it would for non-commutative operations like subtraction and division.
  2. If your coerce method doesn't successfully coerce the objects into two things that can be added, and instead returns incompatible objects your coerce method would be called again. The result could be infinite recursion, with a "Stack level too deep" error. If you see this problem, make sure that the first argument coerce returns is an instance of your class.