Ruby Coercion Protocols Part 2
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 , it doesn't represent a number (*see the foot note).String
doesn't implement that method
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
, and0x
) 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
Luca Guidi
Former astronaut, soccer player, superhero. All at the age of 10. For some reason now I write code.
We think domain management should be easy.
That's why we continue building DNSimple.
4.3 out of 5 stars.
Based on Trustpilot.com and G2.com reviews.