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 —Stringhaseach_charbut noeach, because Ruby refuses to guess whether you meant characters, lines or bytes — and an integer does not either. - Row 2.
is_a?andkind_of?are one method under two names and walk the ancestors, so1.is_a?(Numeric)and1.is_a?(Comparable)are bothtrue;instance_of?istrueonly for the object's own class. - Row 3. A
Duckand aRobotshare no ancestor but bothquack, sorespond_to?lets both through and aRockraisesNoMethodErrorif you skip the check. The class test in the last line would have turned theRobotaway. - Row 4. One
eachplusinclude EnumerablegivesCountdownmap,include?,sortand the rest, and makesis_a?(Enumerable)true — withoutCountdownbeing anArrayor holding one. - Row 5. The implicit conversions:
String#+askedNameforto_str, multiple assignment askedPairforto_ary,&askedDoublerforto_proc,Array#[]askedIdxforto_int, andFile.basenameaskedLocforto_path.Plainoffers onlyto_s, and"x" + Plain.newrefuses it with a message worth memorising:no implicit conversion of Plain into String. - Row 6.
Array()wraps a non-array, unwrapsnilto[]and turns a hash into pairs;Integer()parses strictly, raisingArgumentErroron"4x"andTypeErroronnil, where"4x".to_ishrugs and returns4. The Ruby text library'sto_inever 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¶
- Write a
Fibsclass whoseeachyields the firstcountFibonacci numbers and which doesinclude Enumerable. Printto_a,select(&:even?),each_slice(4).to_a,include?(8),first(3),max,respond_to?(:lazy),is_a?(Array)andEnumerable === fibs. How many of those did you have to write yourself? - Write a
Locclass with bothto_pathandto_str, and find out which core methods accept it:File.basename(loc),File.join("dir", loc),"dir/" + loc,[loc].join("/")andFile.exist?(loc). Then removeto_strand 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.
See also¶
- Everything is an object, even
1andnil— the object model that makes "what does it answer?" the natural question - Ruby has four kinds of equality —
===isis_a?for a class, which is whatcase/whenon a class tests - Operators are methods — the other half of row 5: how
+becomes a method that can ask forto_str - Enumerable is a mixin — row 4 as a whole page: everything one
eachbuys you - Numeric coercion —
coerce,to_intand the twoTypeErrormessages that mixed arithmetic raises deconstructanddeconstruct_keys— pattern matching's own duck-typed protocolto_inever fails ↗ — the Ruby text library on row 6's lenient conversion- Duck-typing in the Python glossary ↗ and Object ↗ — where each language spells out
respond_to?and its conversions - Only
nilandfalseare falsy — Ruby has exactly two falsy values,nilandfalse;0,0.0,"",[]and{}are all truthy and… &:symcallsSymbol#to_proc—&xin an argument list callsx.to_procand attaches the result as the block; a symbol'sto_procis a…- Common error messages — Twenty-nine errors you will meet in your first month of Ruby, each with the code that raises it, the…