design labs ruby

Messages Not Types: Exploring Ruby’s Conversion Protocols

Duck typing is a style of programming that relies on what an object does, rather than on what it is. Avoiding class dependencies results in highly flexible code. Ruby’s conversion protocols, used throughout the core and standard libraries, are a great example of the power of duck typing. In this post, we’ll look at two of Ruby’s conversion protocols: loose and strict.

Loose Conversion

Loose conversion methods are used to convert an object to an object of another class. A class should define a loose conversion method if it has a reasonable representation as another type; e.g., numbers in Ruby provide a String representation via #to_s.

Ruby defines several loose conversion methods:

  • #to_a
  • #to_c
  • #to_f
  • #to_h
  • #to_i
  • #to_proc
  • #to_r
  • #to_s

>> { foo: 1, bar: true }.to_a
=> [[:foo, 1], [:bar, true]]

>> 34.to_c
=> (34+0i)

>> 5.to_f
=> 5.0

>> User = Struct.new(:name, :email)
=> User
>> foo = User.new('foo', '[email protected]')
=> #<struct User name="foo", email="[email protected]">
>> foo.to_h
=> {:name=>"foo", :email=>"[email protected]"}

>> 8.8.to_i
=> 8

>> 0.75.to_r
=> (3/4)

>> [1, 2, 3].to_s
=> "[1, 2, 3]"

Note that loose conversion methods are used in situations where you’re explicitly converting an object, e.g., #to_a is used during array expansion via *, and #to_proc is used during proc coercion in method calls.


>> o = Object.new
=> #<Object:0x007fc3e420b1d8>
>> def to_a; [1, 2]; end
=> nil

>> x, y = *o
=> [1, 2]
>> x
=> 1
>> y
=> 2

>> def o.to_proc; -> n { n * 2 }; end
=> nil
>> f = -> &p { p[2] }
=> #<Proc:0x007fc3e4876108@(irb):99 (lambda)>
>> f[&o]
=> 4

Loose conversion methods don’t imply that an object can be used in place of an object of another class. For those objects, Ruby uses strict conversion methods.

Strict Conversion

Strict conversion methods are used throughout the Ruby core and standard libraries. Methods will attempt to convert arguments to the expected type of their corresponding parameter. A class should define a strict conversion method if it can act like another type of object.

Ruby defines several strict conversion methods. Let’s take a look at a few of the commonly used ones.

#to_ary

Used when Ruby is expecting an Array.


>> o = Object.new
=> #<Object:0x007fc3e4203500>

>> [1, 2] + o
TypeError: no implicit conversion of Object into Array
        from (irb):55
        from /usr/bin/irb:12:in '<main>'

>> def o.to_ary; [3, 4]; end
=> nil

>> [1, 2] + o
=> [1, 2, 3, 4]

>> puts o
3
4
=> nil

>> def o.to_ary; [[:foo, 1], [:bar, 2]]; end
=> nil
>> Hash[o]
=> {:foo=>1, :bar=>2}

Note that #to_ary is used in array destructuring.


>> o = Object.new
=> #<Object:0x007fc3e48b4368>
>> def o.to_ary; [1, [2, 3]]; end
=> nil

>> x, (y, z) = o
=> #<Object:0x007fc3e48b4368>
>> x
=> 1
>> y
=> 2
>> z
=> 3

>> def o.to_ary; [1, 2, 3]; end
=> nil
>> f = -> ((x, *y)) { p x; p y; }
=> #<Proc:0x007fc3e4921b70@(irb):134 (lambda)>
>> f[o]
1
[2, 3]
=> [2, 3]

#to_int

Used when Ruby is expecting an integer.


>> o = Object.new
=> #<Object:0x007fc3e419af00>
>> ['a', 'b', 'c'][o]
TypeError: no implicit conversion of Object into Integer
        from (irb):72:in '[]'
        from (irb):72
        from /usr/bin/irb:12:in '<main>'
>> def o.to_int; 1; end
=> nil
>> ['a', 'b', 'c'][o]
=> "b"

>> io = IO.open(o, 'w')
=> #<IO:fd 1>
>> io.puts 'foo'
foo
=> nil

>> def o.to_int; 3; end
=> nil
>> ['foo'] * o
=> ["foo", "foo", "foo"]

#to_str

Used when Ruby is expecting a String.


>> o = Object.new
=> #<Object:0x007fc3e41e0820>
>> 'foo'.concat o
TypeError: no implicit conversion of Object into String
        from (irb):84:in 'concat'
        from (irb):84
        from /usr/bin/irb:12:in '<main>'
>> def o.to_str; 'bar'; end
=> nil
>> 'foo'.concat o
=> "foobar"

>> f = File.open(o, 'w')
=> #<File:bar>
>> f.path
=> "bar"

>> 'foo bar'.sub o, 'baz'
=> "foo baz"

>> raise o
RuntimeError: bar
        from (irb):138
        from /usr/bin/irb:12:in '<main>'


Ruby API Design

Strict conversion methods should be used in APIs expecting specific types of parameters, e.g., arrays, integers, or strings. Converting arguments to their parameter type creates a more flexible API. This is also in-line with standard Ruby, and what clients would expect.

Use Array.try_convert, which sends #to_ary to its argument, in methods expecting arrays, and String.try_convert, which sends #to_str to its argument, in methods expecting strings. In methods expecting integers, avoid using Object.Integer, which sends #to_int then #to_i; use #to_int instead.

Messages Matter More

By relying on messages, instead of types, the Ruby core and standard libraries can work with any number of disparate objects. The existence of these virtual interfaces prove that in Ruby, messages, not types, are what matter.