Perhaps my personal favorite recommendation for learning to program Ruby like a Rubyist, Eloquent Ruby is a book I recommend frequently to this day. That said, it was released in 2011 and things have changed a bit since then.
This series will focus on reading over Eloquent Ruby, noting things that may have changed or been updated since 2011 (around Ruby 1.9.2) to today (2024 — Ruby 3.3.x).
Chapter 20. Use Hooks to Keep Your Program Informed
So we're now into the metaprogramming chapters. The very short version of my opinion on it is that it's very powerful potentially, yes, but it's also capable of making your programs substantially harder to reason about for very little gain. When it's needed it's really useful, but as always use your discretion here.
Waking Up to a New Subclass
The chapter starts in on the subject of hooks. What's a hook? Well a way to respond to something happening in your program like an event. One example the book leads with is inherited
which is called whenever something inherits the class that defines it:
class SimpleBaseClass def self.inherited(new_subclass) puts "Hey #{new_subclass} is now a subclass of #{self}!" end end class ChildClassOne < SimpleBaseClass end # STDOUT: Hey ChildClassOne is now a subclass of SimpleBaseClass!
The book brings up a good point here of what one might do with such a feature. The example case it gives is a registry of subclasses like multiple document file types (txt, yaml, xml, etc.)
To demonstrate this it gives us a few examples of subclasses before it gets into the implementation of the base class:
class PlainTextReader < DocumentReader def self.can_read?(path) # Book - Regex and a potentially unclear operator. `#match?` tends # to be a clearer method here. /.*\.txt/ =~ path # Suggested - File provides the extname method which does a lot of # this work for us. Prefer to use this instead. File.extname(path) == '.txt' end def initialize(path) @path = path end def read(path) File.open(path) do |f| title = f.readline.chomp author = f.readline.chomp content = f.read.chomp Document.new(title:, author:, content:) end end end
Looking at this code the book starts by pointing out the can_read?
method which is used to determine if this class can handle a certain type of file. Personally I'd also be inclined to turn .txt
into a constant and add a few more introspection methods about file types it supports, but that's a preference.
The book goes on to show us a few more example classes for YAML and XML:
class YAMLReader < DocumentReader def self.can_read?(path) File.extname(path) == '.yaml' end def initialize(path) @path = path end def read(path) # Omitted end end class XMLReader < DocumentReader def self.can_read?(path) File.extname(path) == '.xml' end def initialize(path) @path = path end def read(path) # Omitted end end
...and then gets into the underlying base class:
class DocumentReader # When something inherits from this we want to "register" it def self.inherited(subclass) DocumentReader.reader_classes << subclass end class << self attr_reader :reader_class end @reader_classes = [] def self.read(path) reader = reader_for(path) return nil unless reader reader.read(path) end def self.reader_for(path) reader_class = DocumentReader.reader_classes.find do |klass| klass.can_read?(path) end return reader_class.new(path) if reader_class nil end # One critical bit omitted, but stay tuned... end
Notedly it's using a class instance variable to store all of these and uses that registry to determine how to load a file. Personally there are a few things in here that I'd look at doing:
class DocumentReader # This allows us to key a Hash against the supported file # formats instead of having to use the `can_read?` method # which can get slow as we add more file types. def self.inherited(subclass) subclass::SUPPORTED_EXTENTIONS.each do |ext| @reader_classes[ext] = subclass end end @reader_classes = {} class << self attr_reader :reader_classes end # ... def self.reader_for(path) # By using a Hash key we can get rid of a full search here extension = File.extname(path)[1..-1] # trim the dot reader_class = DocumentReader.reader_classes[extension] return unless reader_class reader_class.new(path) end end class XMLReader < DocumentReader SUPPORTED_EXTENSIONS = ['xml'] end class YAMLReader < DocumentReader SUPPORTED_EXTENSIONS = ['yaml', 'yml'] end class PlainTextReader < DocumentReader SUPPORTED_EXTENSIONS = ['txt'] end
Of course this is only a start and a very brief one. More likely I'd look to leverage module nesting and asking the top-level module what its constants are (classes and modules are constants too.)
I'm always a bit wary on making these types of registries, but at the same time I'm also wary of having a manual list I have to remember to update in several places too. It all comes down to making a tactical investment in metaprogramming to make your program (perhaps paradoxically) more maintainable.
Modules Want to be Heard Too
Modules also have hooks like included
and extended
for whenever a class does include ModuleName
. The book gives us an example here:
module WritingQuality def self.included(klass) puts "Hey, I've included in #{klass}" end def number_of_cliches # Body of method omitted end end
The book points out that the most common use of the included
hook is to additionally extend class methods into whatever includes a module. The book gives us this hypothetical example of having to do both:
module UsefulInstanceMethods def an_instance_method; end end module UsefulClassMethods def a_class_method; end end class Host include UsefulInstanceMethods extend UsefulClassMethods end
...but quickly follows with an example of what it meant here:
module UsefulMethods module ClassMethods def a_class_method; end end def self.included(host_class) host_class.extend(ClassMethods) end def an_instance_method; end end class Host include UsefulMethods end
While this is certainly useful use it sparingly because it can really make a mess if you're not careful trying to hunt down where certain behavior is coming from. Very frequently you'll see this for class-level macro methods like you might find in Rails:
class SomeController < ApplicationController before_filter :something end
...or for decorating methods like you might see with Sorbet:
class SomethingElse extend T::Sig sig { params(a: Numeric, b: Numeric).returns(Numeric) } def adds(a:, b:) a + b end end
How do those work? Well that's a subject for a much much longer post, but one I've already written elsewhere in Decorating Ruby.
Knowing When Your Time Is Up
Anyways, back to the book. Ruby has a hook for running things at the end of a program, at_exit
:
at_exit do puts "Have a nice day." end
In fact we can have multiple:
at_exit do puts "Goodbye" end
Why use it? A lot of times you want to clean up loose connections or other running processes to make sure you're shutting down the application safely. If you want an example try hitting ctrl + c
or cmd + c
when RSpec is running and notice what it does. It doesn't immediately end, it tries to wrap things up instead, and only really exits if you try to end it again.
...And a Cast of Thousands
Ah yes, the old and infamous set_trace_func
. The book mentions this variant:
proc_object = proc do |event, file, line, id, binding, klass| puts "#{event} in #{file}/#{line} #{id} #{klass}" end set_trace_func(proc_object) require 'date'
...but it has since been deprecated in favor of an OO version here:
trace = TracePoint.new do |tp| puts "#{tp.event} in #{tp.path}/#{tp.lineno} #{tp.method_id} #{tp.defined_class}" end trace.enable do require 'date' end
If you were to run this code you would see something like this (and a loooot more) pip up.
b_call in (irb)/5 line in (irb)/6 call in <internal:RUBY/GEMS/core_ext/kernel_require.rb>/36 require Kernel line in <internal:RUBY/GEMS/core_ext/kernel_require.rb>/37 require Kernel c_call in <internal:RUBY/GEMS/core_ext/kernel_require.rb>/37 discover_gems_on_require #<Class:Gem> c_return in <internal:RUBY/GEMS/core_ext/kernel_require.rb>/37 discover_gems_on_require #<Class:Gem> line in <internal:RUBY/GEMS/core_ext/kernel_require.rb>/39 require Kernel c_call in <internal:RUBY/GEMS/core_ext/kernel_require.rb>/39 synchronize Monitor b_call in <internal:RUBY/GEMS/core_ext/kernel_require.rb>/39 require Kernel line in <internal:RUBY/GEMS/core_ext/kernel_require.rb>/40 require Kernel c_call in <internal:RUBY/GEMS/core_ext/kernel_require.rb>/40 path #<Class:File> c_return in <internal:RUBY/GEMS/core_ext/kernel_require.rb>/40 path #<Class:File> line in <internal:RUBY/GEMS/core_ext/kernel_require.rb>/42 require Kernel
TracePoint
is far more useful when used in a limited context, and for debugging it can be unmatched in finding where particularly hard to trace metaprogramming has gone awry. There was even one case where I had used TracePoint
recently to listen to every instance of opening a file with a certain path to see what part of an application was loading what I thought were dead fixture files somehow magically within the last month.
If you want to learn more about TracePoint there are some articles, including one I wrote a while back and should probably finish later: Exploring TracePoint.
Staying Out of Trouble
The book mentions that the key factor in using hooks safely is knowing how they work and whether or not they will be called. In the simple cases it's probably fine:
class DocumentReader; end class PlainTextReader < DocumentReader; end class YAMLReader < DocumentReader; end
...but then more files might get involved:
require "document_reader" require "plaintext_reader" require "xml_reader" require "yaml_reader"
...and then subclasses of those, which you might or might not want to happen:
class AsianDocumentReader < DocumentReader; end class JapaneseDocumentReader < AsianDocumentReader; end class ChineseDocumentReader < AsianDocumentReader; end
The book suggests fixing this by having that class never be able to read anything:
class AsianDocumentReader < DocumentReader def self.can_read?(path) false end end
The other issue is around at_exit
if the program crashes, there's no guarantee that it happens to run. It's best effort, same with how RSpec warns us before it actually exits on an at_exit
but we can still kill the program.
In the Wild
The book uses the example of Test::Unit
:
require "test/unit" class SimpleTest < Test::Unit::TestCase def test_addition assert_equal 2, 1 + 1 end end
How is it that given we run that script:
ruby simple_test.rb
...that we get back a full test run?
Loaded suite simple_test Started . Finished in 0.000247 seconds. 1 tests, 1 assertions, 0 failures, 0 errors
That's because it (and RSpec and other similar tools) use at_exit
to kick things off:
at_exit do unless $! || Test::Unit.run? exit Test::Unit::AutoRunner.run end end
It starts by checking if there are any errors using $!
, though you should really use the english variant $ERROR_INFO
instead as it more clearly describes what's going on (and read about other globals here.)
Wrapping Up
This chapter covered a lot of hooks in Ruby, and there are even more still out there to explore. The trick is to use them sparingly and where it makes sense, rather than using them for everything. Chances are high you do not need a class registry early on, and a lot of times you can use block wrappers instead of decoration to get the effects you're after.
That said, when it's needed it's really useful. Knowing where that line is is an art form, and not one I am particularly well versed in either. Just remember the golden rule: Make sure your code is understandable.
Top comments (0)