DEV Community

Brandon Weaver
Brandon Weaver

Posted on

Let's Read - Eloquent Ruby - Ch 20

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

...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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

...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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

...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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

In fact we can have multiple:

at_exit do
  puts "Goodbye"
end
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

...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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

...but then more files might get involved:

require "document_reader"

require "plaintext_reader"
require "xml_reader"
require "yaml_reader"
Enter fullscreen mode Exit fullscreen mode

...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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

How is it that given we run that script:

ruby simple_test.rb
Enter fullscreen mode Exit fullscreen mode

...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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)