Let’s talk about Type Coercion in Ruby
It’s a bit unusual to talk about type coercion in Ruby. Being a dynamically typed “message passing” language you generally don’t need to think about types too much. But a couple of days ago I was implementing my own “number like” class
and I needed to be able to sum a large list. In Ruby overloading operators is easy and intuitive but there is one case that’s a bit more complicated. It’s pretty rare so I always forget how to do it. Also it’s not intuitive (IMO) so here’s an explanation for the next time I forget how it works.
As I said I needed to implement numeric class. For this example let’s implement a Money
class.1
class Money
attr_reader :cents
def initialize(amount)
@cents = (amount * 100).to_i
end
# Make our output look pretty
def inspect = format("$%.2f", @cents / 100.0)
end
Money.new(10.50) # => $10.50
And I needed to be able to total a list.
[Money.new(10.50), Money.new(25.25), Money.new(1.99)].sum
# in 'Integer#+': Money can't be coerced into Integer (TypeError)
This obviously won’t work. We don’t have a way to add Money
. Operator overloading is easy in Ruby, we define a +
method
class Money
def +(other)
Money.new((self.cents + other.cents) / 100.0)
end
end
This enables us to do the following
Money.new(10.50) + Money.new(25.25) # => $35.75
But this still doesn’t work
[Money.new(10.50), Money.new(25.25), Money.new(1.99)].sum
# in 'Integer#+': Money can't be coerced into Integer (TypeError)
If we look closely at the error the problem isn’t with Money
it’s Integer#+
. We could try overloading the Integer#+
method but that’s a guaranteed way to lose friends.
So why doesn’t this work and what does Integer
have to do with it anyway? When you sum
a list of things you have to start with a value and in the case of numbers that’s obviously zero (Integer 0
). So the first thing sum
tries to do is
0 + Money.new(10.50)
# in 'Integer#+': Money can't be coerced into Integer (TypeError)
We need some way to make these two classes play nice together. That’s where Ruby’s coerce
method comes in. Ruby’s coerce
returns an Array
of two items; the first is the left-hand operand, 0
in our case, converted into our class and the second is the right-hand operand.
class Money
def coerce(other)
[Money.new(other), self]
end
end
For this to work we needed a way to convert the left-hand operand into an instance of our class. In this case it’s easy because we’re only concerned with Integer
but if we want to be able to coerce other types our method will become more complicated.
0 + Money.new(10.50) # => $10.50
[Money.new(10.50), Money.new(25.25), Money.new(1.99)].sum # => $37.74
Fixed 🎉
Returning an Array seems a bit odd to me. Couldn’t we just return the converted left-hand operand; why an Array
? According to the error message for this expression 0 + Money.new(10.50)
Ruby is trying to do something like this
# in 'Integer#+': Money can't be coerced into Integer (TypeError)
0.+(Money.new(10.50).to_int)
And to solve the problem Ruby is actually doing this
left_hand, right_hand = Money.new(10.50).coerce(0) # [Money.new(0), Money.new(10.50)]
left_hand + right_hand
How does that work!? This is one of the few things in Ruby that isn’t intuitive to me. Incidentally adding a to_int
method to Money
doesn’t work and we don’t want that anyway because externally our Money
is a Float
I’m sure I read somewhere that there’s a good reason for this solution, but I haven’t been able to find the explanation. Anyway this is a pretty straight forward solution as long as you don’t think about it too hard and you don’t won’t to handle too many types.
"$25.50" + Money.new(10.50)
Operators in most (all?) languages are syntactic sugar. For example the Ruby +
operator is really a method call
irb(main):001> 1.+(2)
=> 3
irb(main):002>
Ruby operator overloading feels nice though. You define the +
method
def +(other)
...
end
Same in Rust, +
is converted into a function call by the compiler. But IMO operator overloading in RUST isn’t as nice as Ruby. It’s a bit more jarring to me.
impl Add for &Money {
type Output = Money;
fn add(self, other: &Money) -> Self::Output {
Money {
cents: &self.cents + &other.cents
}
}
}
It’s not really true that 1 + 2
is the same as 1.+(2)
. If we look at the syntax tree from the parser we can see these two expressions are different
RubyVM::AbstractSyntaxTree.parse('1 + 2')
(SCOPE@1:0-1:5
tbl: []
args: nil
body:
(OPCALL@1:0-1:5
(INTEGER@1:0-1:1 1)
:+
(LIST@1:4-1:5
(INTEGER@1:4-1:5 2)
nil)
)
)
RubyVM::AbstractSyntaxTree.parse('1.+(2)')
(SCOPE@1:0-1:6
tbl: []
args: nil
body:
(CALL@1:0-1:6
(INTEGER@1:0-1:1 1)
:+
(LIST@1:4-1:5
(INTEGER@1:4-1:5 2)
nil)
)
)
The first has an OPCALL
and the second a CALL
. Though if we look at the virtual machine instructions they are the same
iseq = RubyVM::InstructionSequence.compile('1 + 2')
puts iseq.disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,5)>
0000 putobject_INT2FIX_1_ ( 1)[Li]
0001 putobject 2
0003 opt_plus <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
0005 leave
iseq = RubyVM::InstructionSequence.compile('1.+(2)')
puts iseq.disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,6)>
0000 putobject_INT2FIX_1_ ( 1)[Li]
0001 putobject 2
0003 opt_plus <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
0005 leave
Line 0003
in both has mid:+
which means “method identifier “+””.
That’s enough yak shaving!
The internals aren’t important but if I don’t show the conversion to
cents
someone is going to complain.↩