Classes are open¶
Level: 201 · anyone who has wondered how 5.minutes can possibly work
One line: class String written a second time does not make a new class — it reopens the one String, and a method defined there appears on every string in the program, literals included.
A class statement in Ruby means "the class of this name, creating it if needed"; the body then runs against that class, whether it was created a moment ago or is String itself. So class String; def shout; end; end adds shout to every string, class Integer adds to every integer literal, and reopening your own class twice merges the two bodies into one method table that existing instances see immediately. Redefining a method replaces it silently unless Ruby runs with -w, in which case it warns method redefined; discarding old … and points at the previous definition. The one thing a reopening may not do is change the superclass: that is a TypeError. The power reaches all the way up — reopening Object puts a method on nil — which is exactly why the idiom is called monkey-patching and why Ruby added refinements (in 2.1) as the scoped alternative. (Not machine-checked here.) The program does each of these in turn, runs the redefinition in a child Ruby under -w so the warning is captured on stdout, and finishes with the refinement.
Verified output of classes_are_open_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. "hello".shout before anyone defines it NoMethodError: undefined method 'shout' for an instance of String
2. class String; def shout; end -- "hello".shout "HELLO!"
3. class Integer; def double; end -- 21.double 42 (owner: Integer)
4. Widget reopened: instance_methods(false) / early.b [:a, :b] / :b
5. redefining shout: the child prints / ruby -w says "X?" / warning: method redefined; discarding old shout; warning: previous definition of shout was here
6. a second class Widget statement -- same class? true
7. class Widget < String (another superclass) TypeError: superclass mismatch for class Widget
8. class Object; def yell; end -- nil.yell / 1.yell "!" / "1!"
9. String.remove_method(:shout) -- "hello".shout NoMethodError: undefined method 'shout' for an instance of String
10. refine String + using -- "hello".shout in this file "HELLO!!"
Reading the output¶
Row 1 establishes the starting point — String has no shout — and row 2 adds it with the same class String … end syntax that would define a new class. Row 3 does the same to Integer, and instance_method(:double).owner confirms the method lives in Integer itself, not in some wrapper. Row 4 reopens a user class: instance_methods(false) lists a from the first body and b from the second, and early, an instance built between the two, answers early.b because there is only ever one Widget.
Row 5 is the warning. The program writes two definitions of shout into a child Ruby started with -w and prints what the child printed ("X?", so the second definition won) and the two warnings the child wrote to stderr, with the file and line prefix stripped. Row 6 proves that a second class Widget statement is the same object (equal?), and row 7 that the only thing it may not do is name a different superclass: class Widget < String after class Widget is a TypeError with the message superclass mismatch.
Row 8 is the reach of the mechanism: a method added to Object is a method on nil and on 1. Row 9 takes shout away again with remove_method, a public method of Module (it needed send before Ruby 2.5) (the version is not machine-checked here), and row 10 shows the scoped alternative — a refinement, activated with using, whose shout is visible only in the file that says so. Refinements measures that scoping in detail.
The risks are the reason for the caution around this feature. A method added to String is added for every gem in the process, so two libraries defining String#shout differently fight over one slot, and the last one loaded wins without an error — a -w warning at most. A patch that changes an existing core method (Integer#+, Array#each) can break code that has never heard of your program. The conventions that keep the feature usable are: add, do not replace; put the patch in its own file whose name says what it patches; prefer a refinement or a plain method when either will do. (Not machine-checked here.)
Compared with Python¶
Python draws the line at built-in types: row 2's str.shout = shout and row 3's setattr(int, "double", double) are both TypeError, and so is row 8's object.yell — the method tables of str, int and object are read-only. A user class is as open as Ruby's: row 4 assigns a function to Widget.b and early, built before, answers early.b(), just as in Ruby. Row 5 replaces Widget.a and nothing warns, because there is no -w for this. The divergence that bites Python programmers arriving in Ruby is row 6: a second class Widget: statement does not reopen anything — it builds a new class object and rebinds the name, so Widget is before is False and early still belongs to the old class with the old methods. Row 7 follows from that: class Widget(str): is simply a third class, no mismatch error. Row 9 shows removal is asymmetric in the same way as addition (del Widget.b works, delattr(str, "upper") is a TypeError), and row 10 is the Python route to a shout on strings: a subclass LoudStr(str), or a plain function that takes the string.
Verified output of classes_are_open_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. "hello".shout() before anyone defines it AttributeError
2. str.shout = shout (a built-in type) TypeError
3. setattr(int, "double", double) TypeError
4. Widget.b = f: methods / early.b() ['a', 'b'] / 'b'
5. reassigning Widget.a: early.a() / warning 'a, again' / none
6. a second class Widget statement -- same class? False (early.b() still 'b')
7. class Widget(str) (another base) no error; Widget.__mro__[1] is str
8. object.yell = f TypeError
9. del Widget.b / delattr(str, "upper") AttributeError / TypeError
10. class LoudStr(str) -- LoudStr("hello").shout() 'HELLO!!'
The Python data model explains why built-in types are closed in Customizing class creation ↗ and the class statement's rebinding in Class definitions ↗.
Try it¶
- Give
Integerthe methodsminutesandhoursthat return a number of seconds, as ActiveSupport's5.minutesdoes. Use them in an expression, print the owner ofminutes, then remove both withremove_methodand show that5.minutesfails again. - Write the same
shoutas a refinement in a module, activate it withusing, and define a method above theusingline that calls"x".shout— call it afterusingand explain what you see. (Refinements has the answer.)
Solution to 1
classes_are_open_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Exercise 1: give Integer `minutes` and `hours` (in seconds, as ActiveSupport
# does), use them, then take them away again with remove_method.
class Integer
def minutes = self * 60
def hours = self * 3600
end
puts "5.minutes: #{5.minutes}"
puts "2.hours: #{2.hours}"
puts "2.hours + 5.minutes: #{2.hours + 5.minutes}"
puts "owner of minutes: #{Integer.instance_method(:minutes).owner}"
puts "Integer.method_defined?(:hours): #{Integer.method_defined?(:hours)}"
class Integer
remove_method :minutes, :hours
end
begin
5.minutes
rescue NoMethodError => e
puts "after remove_method: #{e.class}: #{e.message}"
end
Verified output of classes_are_open_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
See also¶
- Mixins:
include,extend,prepend— the other way to add methods to a class, with a module that can be shared - Method lookup and
super— where a reopened class's method sits in the chain a call walks - Refinements — row 10 as a whole lesson: a patch that is visible only where
usingsays define_method— reopening a class from inside a loop, one generated method at a time- Warnings and
-w— themethod redefinedwarning of row 5 and the other things-wreports - Extension traits ↗ — how Rust adds a method to a type it does not own, without opening it
- Ruby shows you its bytecode and its syntax tree —
RubyVM::InstructionSequence.compile(src).disasmlists the YARV instructions the interpreter will execute…