A title for your blog

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!


  1. The internals aren’t important but if I don’t show the conversion to cents someone is going to complain.

#ruby