Skip to content

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

  1. Give Integer the methods minutes and hours that return a number of seconds, as ActiveSupport's 5.minutes does. Use them in an expression, print the owner of minutes, then remove both with remove_method and show that 5.minutes fails again.
  2. Write the same shout as a refinement in a module, activate it with using, and define a method above the using line that calls "x".shout — call it after using and 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.

5.minutes:                    300
2.hours:                      7200
2.hours + 5.minutes:          7500
owner of minutes:             Integer
Integer.method_defined?(:hours): true
after remove_method:          NoMethodError: undefined method 'minutes' for an instance of Integer

See also