Skip to content

Hooks fire when code is defined, not when it runs

Level: 301 · for a reader who has seen def self.included(base) in a gem and wondered who calls it

One line: inherited, included, extended, prepended, method_added, singleton_method_added and const_added are private methods Ruby calls while a class body is being executed — one hook per event — and Python's __init_subclass__ is a single hook for the first of these, with a metaclass's __prepare__ and __new__ standing in for all the others.

A Ruby class body is code that runs, and while it runs the interpreter reports what it is doing to a set of hooks. Class#inherited(sub) runs on the parent the moment class Sub < Parent (or Class.new(Parent)) creates the subclass — before the body, and for Class.new before the class has a name. Module#included(base), extended(obj) and prepended(base) run after the module has been wired into the ancestor chain (append_features runs just before, and does the wiring). Module#method_added(name) runs after every method definition, whether it came from def, attr_accessor, alias_method or define_method; singleton_method_added after every def self.x; and const_added after every constant assignment, nested classes included — this last one since Ruby 3.2 (the version is not machine-checked here). All of them are private, all default to doing nothing, and all are meant to be overridden by whoever wants a registry, a plugin system or a class-level option. The one hook you write most is included with base.extend(ClassMethods): it is how a module hands its includer class methods as well as instance methods.

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

 1. Exporter.registry after two `class X < Exporter` ["CsvExporter", "JsonExporter"]
 2. Class.new(Exporter): registry.last, anon.name  nil / nil
    XmlExporter = anon; anon.name                  "XmlExporter"
 3. class CsvExporter < Exporter; tag "csv"; end   CsvExporter.tag = "csv"
 4. include Persist: Model.table_name, .new.save   "models", "saved models"
 5. LOG order during include                       ["append_features Model", "included in Model, already in ancestors: true"]
 6. Object.new.extend(Persist)                     ["extended a Object"]
 7. class Audited; prepend Persist; end            ["prepended to Audited"]; ancestors.first(2) = [Persist, Audited]
 8. method_added saw (def, attr_accessor, alias, define_method, private) [:load, :id, :id=, :fetch, :touch, :hidden]
 9. singleton_method_added saw                     [:create]
10. const_added saw                                [:VERSION, :Inner]
11. Exporter.subclasses.map(&:name).sort           ["CsvExporter", "JsonExporter", "XmlExporter"]
12. class Leaf < Mid: Base2.all, and Mid's own hook ["Mid", "Leaf"], ["Mid saw Leaf"]
13. the hooks are private: inherited/included/method_added [true, true, true]

Reading the output

Row 1: inherited fired twice, once per class X < Exporter, and the registry holds the names in definition order. Row 2 shows the timing trap: Class.new(Exporter) fires the hook before any constant names the class, so the registry recorded nil — assigning XmlExporter = anon afterwards names the class but does not re-run the hook. Row 3 is how Ruby passes a class-level option: there is no keyword syntax on class, so tag "csv" is just a class method called inside the body. Rows 4 and 5 are the included pattern and its timing: Model.table_name exists because included called base.extend(ClassMethods), and append_features ran before included, by which time the module was already in the ancestors. Rows 6 and 7 are the same idea for the other two ways of mixing in — extend on a single object fires extended, prepend fires prepended and puts the module before the class in ancestors. Rows 8–10 are the per-definition hooks, kept out of the way by living in a Watch module the class extends: method_added saw the def, the two methods attr_accessor made, the alias, the define_method and the private one; singleton_method_added saw create; const_added saw the constant and the nested class. Row 11 is the read-only view, Class#subclasses — printed sorted, because the order it returns is not part of its contract; it arrived in Ruby 3.1 (the version is not machine-checked here). Row 12 is the rule every hook needs: call super, so a hook in a subclass does not silence the parent's. Row 13: the hooks are private methods; you never call them, Ruby does.

Compared with Python

Python has one hook where Ruby has seven, and it is a class method on the parent: __init_subclass__(cls, **kwargs) runs whenever a subclass is created, by class statement or by type() (rows 1 and 2), and the subclass always has its name by then — there are no anonymous classes to catch out a registry. It also receives keyword arguments written on the class line, class CsvExporter(Exporter, tag="csv") (row 3), which is Python's spelling of Ruby's tag "csv" call — and an option nobody consumes is a TypeError, because object.__init_subclass__ accepts none. A mixin is a base class, so included has no separate twin: the mixin's own __init_subclass__ fires when a class lists it as a base, and class methods come along by ordinary inheritance with no extend(ClassMethods) dance (row 4). Row 5's log is the order Python builds a class in: the body's assignments first (logged by the dictionary __prepare__ returned), then __set_name__ on each descriptor, then __init_subclass__ on the bases — the same "wiring first, hook after" order as Ruby's append_features then included. Extending one object is a class swap, o.__class__ = type(..., (Loud, Model), {}), which fires the same hook (row 6); prepend has no twin — a subclass whose MRO lists Audit first is the closest, and super().save() chains through it (row 7). The per-definition hooks are where the models diverge: Python reports nothing per def, but a metaclass sees the whole body at once — __prepare__ returns the dictionary the body writes into, so every assignment can be logged in order, and __new__ receives the finished namespace and can sort it into functions, classmethods and the rest (rows 8–10). __set_name__ is the one per-attribute hook, on descriptors rather than on the class: Field learns it was assigned to Model.id. __subclasses__() is Class#subclasses (row 11), super().__init_subclass__(**kwargs) is the super of row 12, and row 13 is the reason the hook needs no decorator: Python makes __init_subclass__ a classmethod implicitly.

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

 1. Exporter.registry after two `class X(Exporter)` ['CsvExporter', 'JsonExporter']
 2. type("Anon", (Exporter,), {}): registry[-1]    'Anon' -- named at birth
    anon.__name__                                  'Anon'
 3. class CsvExporter(Exporter, tag="csv")         CsvExporter.tag = 'csv'
    an option nobody consumes                      TypeError
 4. class Model(Persist): table_name(), ().save()  'models', 'saved models'
 5. LOG order while the class was built            ['body defined load', 'body defined id', 'body defined fetch', 'body defined touch', 'body defined _hidden', 'body defined create', 'body defined VERSION', 'set_name Model.id', 'Persist mixed into Model, already in mro: True']
 6. o.__class__ = type('LoudModel', (Loud, Model), {}) ['Loud mixed into LoudModel', 'Persist mixed into LoudModel, already in mro: True']; isinstance(o, Loud) = True
 7. Audited = type(..., (Audit, Model), {})        ['Persist mixed into Audited, already in mro: True']; mro[:2] = ['Audited', 'Audit']
    Audited().save()                               'audit then saved auditeds'
 8. metaclass saw functions (def, alias, lambda, _hidden) ['load', 'fetch', 'touch', '_hidden']
 9. metaclass saw classmethods                     ['create']
10. metaclass saw the rest of the body             ['id', 'VERSION']
11. sorted(c.__name__ for c in Exporter.__subclasses__()) ['Anon', 'CsvExporter', 'JsonExporter']
12. class Leaf(Mid): Base2.all, and Mid's own hook ['Mid', 'Leaf'], ['Mid saw Leaf']
13. __init_subclass__ is implicitly a classmethod  classmethod

Try it

  1. Build a Command base class whose inherited hook registers each subclass under a snake_case name derived from the class name (ListFiles → "list_files"), and a Command.run(name, *args) that finds and runs one. Print the registry keys, two dispatches, what an unknown name raises, and Command.subclasses.
  2. Write a Validations module whose included hook gives the class a validates(:attr, presence: true) class method (kept in ClassMethods) and an instance-level valid? that checks every registered attribute. Then re-do it as a Python mixin with __init_subclass__ reading a VALIDATES class attribute. Which version needed the extend step, and why?
Solution to 1

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

# hooks_inherited_included_method_added_kata_rb.rb — a command registry filled by `inherited`.

class Command
  def self.registry = @registry ||= {}

  def self.inherited(sub)
    super
    name = sub.name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase   # ListFiles -> list_files
    Command.registry[name] = sub
  end

  def self.run(name, *args)
    klass = Command.registry.fetch(name) { raise ArgumentError, "no command #{name.inspect}" }
    klass.new.call(*args)
  end
end

class ListFiles < Command
  def call(dir = ".") = "listing #{dir}"
end

class ShowVersion < Command
  def call = "version 1.0"
end

puts "1. Command.registry.keys       -> #{Command.registry.keys.inspect}"
puts "2. Command.run(\"list_files\", \"/tmp\") -> #{Command.run("list_files", "/tmp").inspect}"
puts "3. Command.run(\"show_version\")    -> #{Command.run("show_version").inspect}"
begin
  Command.run("nope")
rescue ArgumentError => e
  puts "4. Command.run(\"nope\")            -> #{e.class}: #{e.message}"
end
puts "5. Command.subclasses.map(&:name).sort -> #{Command.subclasses.map(&:name).sort.inspect}"

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

1. Command.registry.keys       -> ["list_files", "show_version"]
2. Command.run("list_files", "/tmp") -> "listing /tmp"
3. Command.run("show_version")    -> "version 1.0"
4. Command.run("nope")            -> ArgumentError: no command "nope"
5. Command.subclasses.map(&:name).sort -> ["ListFiles", "ShowVersion"]

See also