Ruby Coercion Protocols Part 2

In the first part of this article, I introduced Coercion Protocols for Ruby. You are probably familiar with them if it ever happened to use #to_i. But there is also #to_int, right? What are the differences between them?

Explicit vs Implicit Protocols

There are two different types of Coercion Protocols: implicit and explicit.

There are methods that require an explicit invocation and a soft semantic. These are methods like #to_s, #to_f, #to_i, #to_a, etc… Do we want to convert a string into an integer? We do "23".to_i and we get 23.

This is helpful, but sometimes the results can be unpredictable: "foo".to_i returns 0. This soft semantic can lead to subtle bugs.

Sometimes we need an implicit, but stronger semantic to guarantee that the output is really meaningful for our business logic. Ruby Kernel has a set of functions that help with this kind of stronger coercion.

# Explicit Coercion
"23".to_i # => 23
"foo".to_i # => 0 # Unexpected

# Implicit Coercion
Integer(23) # => 23
Integer("foo") # => ArgumentError # It prevents bugs

In this case, Kernel invokes #to_int on the received argument (23 or "foo"). In our example, 23 works because Integer implements #to_int, while "foo" raises an error because String doesn't implement that method, it doesn't represent a number (*see the foot note).

Only the objects that can really coerce themselves into another type expose implicit protocols. These methods are #to_str, #to_hash, etc..

If you need your value object to be coerced into a specific primitive, consider implementing these protocols.

Using our Temperature example from a previous post, let's say we want to be able to sum it with other objects.

class Temperature
  # ...
  def initialize(temperature)
    @temperature = Float(temperature)
  end

  def to_s
    "Temperature: #{temperature}"
  end

  def to_f
    temperature
  end

  def +(other)
    self.class.new(temperature + other.to_f)
  end

  protected

  attr_reader :temperature
end

puts Temperature.new(37) + 5
  # => "Temperature: 42.0"
puts Temperature.new(37) + Temperature.new(9)
  # => "Temperature: 46.0"

Our method #+ is able to accept any object that implements #to_f. The first time we pass 5 and the second time Temperature.new(9). Both these objects implement #to_f coercion protocol and they can be used in this operation.

But what about this case?

puts 5 + Temperature.new(37)
  # => Temperature can't be coerced into Fixnum (TypeError)

Coerce with #coerce

We can use Ruby's #coerce to make the arithmetic operations commutative.

class Temperature
  # ...
  
  def coerce(other)
    [self.class.new(other), self]
  end
end

puts 5 + Temperature.new(37)
  # => "Temperature: 42.0"

Conclusion

We have seen that Ruby is a great language. It ships with powerful primitives that we often abuse. We should prefer to encapsulate these primitives into value objects, by keeping the compatibility with Ruby ecosystem via coercion protocols.

UPDATE:

I made a mistake in the first revision of this article: Kernel.Integer doesn't use #to_int to coerce a String.

If arg is a String, when base is omitted or equals zero, radix indicators (0, 0b, and 0x) are honored. In any case, strings should be strictly conformed to numeric representation.

Source: https://ruby-doc.org/core/Kernel.html#method-i-Integer

Ruby scans the string char by char, looking for numbers or special indicators for the base. All the other chars cause an ArgumentError.

For instance:

Integer("23")
  # => 23 – no base, it assumes base 10
Integer("0xc")
  # => 12 – hex notation, "c" translates to 12 in base 10
Integer("0b10")
  # => 2 – binary notation, "10" translates to 2 in base 10

Integer("0b3")
  # => ArgumentError – "3" doesn't exist for binary notation
Integer("foo")
  # => ArgumentError – because "foo" isn't a number