Skip to content

Duck typing asks respond_to?, not class

Level: 201 · anyone who has written if x.is_a?(Array) and suspects there is a better question

One line: Ruby code asks an object what it can do — respond_to?(:each) — rather than what it is, and the core library does the same through the implicit conversions to_str, to_ary, to_proc, to_int and to_path, with Array() and Integer() as the strict conversions on request; Python asks with hasattr, with isinstance against an ABC or a runtime_checkable Protocol, and its __iter__, __call__, __index__ and __fspath__ are the same idea under other names.

In Ruby the type of an object is, for most purposes, the set of messages it answers. respond_to? asks that directly. is_a? asks about the ancestor chain, which includes modules, so a class that does include Enumerable is an Enumerable as well as acting like one; instance_of? asks for the exact class and is almost never the right question. The core library is the biggest duck-typer of all: "a" + obj works for anything with to_str, destructuring calls to_ary, &obj calls to_proc, an index calls to_int, File methods call to_path — and none of them call to_s, because "can be shown as text" is a weaker promise than "is text". For input you do not trust, Array() and Integer() convert strictly and loudly. The program prints six numbered groups, and the Python twin prints the same rows.

Verified output of duck_typing_and_respond_to_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. ask what it does, not what it is
   [].respond_to?(:each)                                true
   {}.respond_to?(:each)                                true
   (1..2).respond_to?(:each)                            true
   "".respond_to?(:each)  (each_char, not each)         false
   1.respond_to?(:each)                                 false
2. the three what-is-it tests
   1.is_a?(Numeric)                                     true
   1.kind_of?(Numeric)  (the same test)                 true
   1.instance_of?(Numeric)                              false
   1.instance_of?(Integer)                              true
   1.is_a?(Comparable)  (a module counts)               true
3. anything that quacks
   flock.map { |d| d.quack if d.respond_to?(:quack) }   ["Quack", "Beep"]
   Rock.new.respond_to?(:quack)                         false
   Rock.new.quack                                       NoMethodError
   flock.map { |d| d.is_a?(Duck) }  (class test)        [true, false]
4. each + Enumerable makes a collection
   c = Countdown.new(3); c.respond_to?(:map)            true
   c.map { |i| i * 10 }                                 [30, 20, 10]
   c.include?(2)                                        true
   c.sort                                               [1, 2, 3]
   c.is_a?(Enumerable)                                  true
   c.is_a?(Array)                                       false
5. the implicit conversions the core calls for you
   "Hello, " + Name.new("Ada")  (to_str)                "Hello, Ada"
   a, b = Pair.new  (to_ary)                            [1, 2]
   [1, 2].map(&Doubler.new)  (to_proc)                  [2, 4]
   [10, 20, 30][Idx.new]  (to_int)                      20
   File.basename(Loc.new)  (to_path)                    "path.txt"
   "x" + Plain.new  (to_s is not implicit)              TypeError: no implicit conversion of Plain into String
6. Array() and Integer() convert on request
   Array(nil)                                           []
   Array([1])                                           [1]
   Array(1)                                             [1]
   Array({a: 1})                                        [[:a, 1]]
   Integer("42")                                        42
   Integer("4x")                                        ArgumentError
   Integer(nil)                                         TypeError
   "4x".to_i  (never fails)                             4

Reading the output

  • Row 1. Arrays, hashes and ranges answer each; a string does not — String has each_char but no each, because Ruby refuses to guess whether you meant characters, lines or bytes — and an integer does not either.
  • Row 2. is_a? and kind_of? are one method under two names and walk the ancestors, so 1.is_a?(Numeric) and 1.is_a?(Comparable) are both true; instance_of? is true only for the object's own class.
  • Row 3. A Duck and a Robot share no ancestor but both quack, so respond_to? lets both through and a Rock raises NoMethodError if you skip the check. The class test in the last line would have turned the Robot away.
  • Row 4. One each plus include Enumerable gives Countdown map, include?, sort and the rest, and makes is_a?(Enumerable) true — without Countdown being an Array or holding one.
  • Row 5. The implicit conversions: String#+ asked Name for to_str, multiple assignment asked Pair for to_ary, & asked Doubler for to_proc, Array#[] asked Idx for to_int, and File.basename asked Loc for to_path. Plain offers only to_s, and "x" + Plain.new refuses it with a message worth memorising: no implicit conversion of Plain into String.
  • Row 6. Array() wraps a non-array, unwraps nil to [] and turns a hash into pairs; Integer() parses strictly, raising ArgumentError on "4x" and TypeError on nil, where "4x".to_i shrugs and returns 4. The Ruby text library's to_i never fails ↗ page follows that shrug.

Compared with Python

Python's own glossary ↗ defines duck-typing, and the twin's rows are the same questions with other spellings. hasattr(x, "__iter__") is respond_to?(:each), with one difference in the answers: a str is iterable in Python, character by character, where Ruby's String makes you choose each_char (row 1). isinstance walks the class hierarchy the way is_a? walks the ancestors, an abstract base class such as Hashable counts the way a module does, and type(x) is int is the exact-class test that bool fails (row 2). Python then goes one step further than Ruby: a runtime_checkable Protocol turns "has a quack method" into a type that isinstance can check, so the Robot passes a type test it never subscribed to (row 3). __iter__ is each, but the toolkit arrives as builtin functions — sorted(c), 2 in c, a comprehension — rather than as methods mixed into the class, and collections.abc.Iterable plays the part of Enumerable for isinstance (row 4). The dunder protocols of row 5 map one to one — __iter__ for to_ary, __call__ for to_proc, __index__ for to_int, __fspath__ for to_path — except that Python has no implicit string conversion at all: __str__ is consulted by str(), never by +, so "Hello, " + Name("Ada") needs the explicit str() (row 5). list() and int() are as strict as Array() and Integer(), and differ in three places: list(1) raises where Array(1) wraps, list({"a": 1}) gives the keys where Array({a: 1}) gives pairs, and there is no forgiving to_i at all — int("4x") is a ValueError and the polite check first is isdigit() (row 6).

Verified output of duck_typing_and_respond_to_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. ask what it does, not what it is
   hasattr([], "__iter__")                              True
   hasattr({}, "__iter__")                              True
   hasattr(range(2), "__iter__")                        True
   hasattr("", "__iter__")  (a str is iterable)         True
   hasattr(1, "__iter__")                               False
2. the three what-is-it tests
   isinstance(1, numbers.Number)                        True
   isinstance(True, int)  (a subclass counts)           True
   type(True) is int                                    False
   type(1) is int                                       True
   isinstance(1, Hashable)  (an ABC counts)             True
3. anything that quacks
   [d.quack() for d in flock if hasattr(d, "quack")]    ['Quack', 'Beep']
   hasattr(Rock(), "quack")                             False
   Rock().quack()                                       AttributeError
   [isinstance(d, Quacker) for d in flock]  (Protocol)  [True, True]
4. __iter__ makes a collection
   c = Countdown(3); hasattr(c, "__iter__")             True
   [i * 10 for i in c]                                  [30, 20, 10]
   2 in c                                               True
   sorted(c)                                            [1, 2, 3]
   isinstance(c, Iterable)                              True
   isinstance(c, list)                                  False
5. the implicit protocols the core calls for you
   "Hello, " + str(Name("Ada"))  (no to_str)            'Hello, Ada'
   a, b = Pair()  (__iter__)                            [1, 2]
   list(map(Doubler(), [1, 2]))  (__call__)             [2, 4]
   [10, 20, 30][Idx()]  (__index__)                     20
   os.path.basename(Loc())  (__fspath__)                'path.txt'
   "x" + Plain()  (__str__ is not implicit)             TypeError
6. list() and int() convert on request
   list(None)                                           TypeError
   list([1])                                            [1]
   list(1)                                              TypeError
   list({"a": 1})  (the keys)                           ['a']
   int("42")                                            42
   int("4x")                                            ValueError
   int(None)                                            TypeError
   "4x".isdigit()  (no lenient to_i)                    False

The typing ↗ reference documents Protocol and runtime_checkable; the collections.abc ↗ reference lists the abstract base classes that row 2 and row 4 test against.

Try it

  1. Write a Fibs class whose each yields the first count Fibonacci numbers and which does include Enumerable. Print to_a, select(&:even?), each_slice(4).to_a, include?(8), first(3), max, respond_to?(:lazy), is_a?(Array) and Enumerable === fibs. How many of those did you have to write yourself?
  2. Write a Loc class with both to_path and to_str, and find out which core methods accept it: File.basename(loc), File.join("dir", loc), "dir/" + loc, [loc].join("/") and File.exist?(loc). Then remove to_str and print which of the five stop working, with the class of each error.
Solution to 1

duck_typing_and_respond_to_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: a class with each and `include Enumerable` is a collection as far as
# every Enumerable method is concerned, without being an Array.

class Fibs
  include Enumerable

  def initialize(count) = @count = count

  def each
    return to_enum(:each) unless block_given?

    a, b = 0, 1
    @count.times do
      yield a
      a, b = b, a + b
    end
  end
end

fibs = Fibs.new(10)
puts "   to_a                 #{fibs.to_a.inspect}"
puts "   select(&:even?)      #{fibs.select(&:even?).inspect}"
puts "   each_slice(4).to_a   #{fibs.each_slice(4).to_a.inspect}"
puts "   include?(8)          #{fibs.include?(8)}"
puts "   first(3)             #{fibs.first(3).inspect}"
puts "   max                  #{fibs.max}"
puts "   respond_to?(:lazy)   #{fibs.respond_to?(:lazy)}"
puts "   is_a?(Array)         #{fibs.is_a?(Array)}"
puts "   Enumerable === fibs  #{Enumerable === fibs}"

Verified output of duck_typing_and_respond_to_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

   to_a                 [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
   select(&:even?)      [0, 2, 8, 34]
   each_slice(4).to_a   [[0, 1, 1, 2], [3, 5, 8, 13], [21, 34]]
   include?(8)          true
   first(3)             [0, 1, 1]
   max                  34
   respond_to?(:lazy)   true
   is_a?(Array)         false
   Enumerable === fibs  true

See also