DEV Community

Brandon Weaver
Brandon Weaver

Posted on

Let's Read - Eloquent Ruby - Ch 22

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 22. Use method_missing for Delegation

Y'know after reading that intro paragraph in the book I'm questioning whether or not I want to pursue management again, but then again a lot of my job nowadays is writing Documents (heh) and delegating work to others and trying to distill context into where in the world we're going next and why we should be doing it.

The book uses that aside to lead into the idea of delegation in programming, which is to give a task to another object to either do the entire task or a part of it for you. Frequently you'll find this used for wrappers in Ruby that change maybe a few things about an original object but otherwise send everything along as-is.

The Promise and Pain of Delegation

We're back with the idea of our Document class, except this time we want to wrap it with an idea that the content should be locked down after a certain time limit as the contents are especially sensitive:

class SuperSecretDocument
  def initialize(original_document:, time_limit_seconds:)
    @original_document = original_document
    @time_limit_seconds = time_limit_seconds
    @create_time = Time.now
  end

  def time_expired?
    Time.now - @create_time >= @time_limit_in_seconds
  end

  def check_for_expiration
    raise "Document no longer available" if time_expired?
  end

  def content
    check_for_expiration
    @original_document.content
  end

  def title
    check_for_expiration
    @original_document.title
  end

  def author
    check_for_expiration
    @original_document.author
  end

  # and so on...
end

original_instructions = get_instructions
instructions = SuperSecretDocument.new(
  original_document: original_instructions,
  time_limit_in_seconds: 5
)
Enter fullscreen mode Exit fullscreen mode

We keep a reference to the original document, but for a lot of you your DRY sense is going to be going off right about now. It seems like we're doing the same thing fairly frequently no?

That's where delegation comes in.

The Trouble with Old-Fashioned Delegation

Right now this doesn't seem so bad, but what if we added a few more methods?:

class SuperSecretDocument
  def initialize(original_document:, time_limit_seconds:)
    @original_document = original_document
    @time_limit_seconds = time_limit_seconds
    @create_time = Time.now
  end

  def time_expired?
    Time.now - @create_time >= @time_limit_in_seconds
  end

  def check_for_expiration
    raise "Document no longer available" if time_expired?
  end

  # content, title, and author methods omitted
  # to keep from adding more scrolling...

  # And some new methods...

  def page_layout
    check_for_expiration
    @original_document.page_layout
  end

  def page_size
    check_for_expiration
    @original_document.page_size
  end

  # and so on...
end
Enter fullscreen mode Exit fullscreen mode

...or maybe 10 or 20 more? At what point does this start becoming unmaintainable? Sure, we're forwarding things to the original document, but in a fairly cumbersome way.

The method_missing Method to the Rescue

This is where method_missing can come in handy:

class SuperSecretDocument
  def initialize(original_document:, time_limit_seconds:)
    @original_document = original_document
    @time_limit_seconds = time_limit_seconds
    @create_time = Time.now
  end

  def time_expired?
    Time.now - @create_time >= @time_limit_in_seconds
  end

  def check_for_expiration
    raise "Document no longer available" if time_expired?
  end

  def method_missing(name, *args)
    check_for_expiration
    @original_document.send(name, *args)
  end
end
Enter fullscreen mode Exit fullscreen mode

Now any method which we don't know about automatically goes right on to the base object. We can even test it like so:

string = "Good morning, Mr. Phelps"
secret_string = SuperSecretDocument.new(
  original_document: string,
  time_limit_in_seconds: 5
)

puts secret_string.length # Works fine
sleep 6
puts secret_string.length # Raises exception
Enter fullscreen mode Exit fullscreen mode

More Discriminating Delegation

But sending everything, like mentioned in previous chapters, can be dangerous so we probably want a limiter around that. Frequently, as the book mentions, method_missing will have a list or allowed methods or perhaps a pattern they need to follow to be considered valid:

class SuperSecretDocument
  DELEGATED_METHODS = %i[content words]

  def method_missing(name, *args)
    check_for_exipration

    return super unless DELEGATED_METHODS.include?(name)

    @original_document.send(name, *args)
  end
end
Enter fullscreen mode Exit fullscreen mode

The nice thing in this case about forwarding to the original document is that if we happen to have a method get through that we did not intend it's going to fail as that method doesn't exist on the underlying object either.

That said allow lists are the way to go. You want to limit how much method_missing actually catches and deals with as the more limited it is the more you can guarantee it's not going to surprise you down the line. Really that's a good rule for a lot of programming: limit things to predictable outcomes whenever possible, and if not test and monitor the heck out of the thing.

Staying Out of Trouble

The problem here is that while some classes do have the method somewhere up the chain, like to_s is present all the way up in BasicObject:

original_instructions = get_instructions
instructions = SuperSecretDocument.new(
  original_document: original_instructions,
  time_limit_in_seconds: 5
)

puts instructions.to_s
#<SuperSecretDocument:0x123456789>
Enter fullscreen mode Exit fullscreen mode

If we wanted to get around that we'd have to inherit from BasicObject to hack around it, or define our own to_s method closer to home:

class SuperSecretDocument < BasicObject
  # ...
end
Enter fullscreen mode Exit fullscreen mode

The book mentions you have to patch Ruby 1.8 to get this behavior, but nowadays 1.8 is (hopefully) long gone and has been for years. If you're still on it you have my condolences.

In the Wild

As with a lot of things in Ruby there's always a built in tool or gem to do something that seems really handy. In this case we have SimpleDelegator which does exactly that:

require 'delegate'

class DocumentWrapper < SimpleDelegator
  def initialize(real_doc)
    super(real_doc)
  end
end

text = "The Hare was once boasting of his speed..."
real_doc = Document.new(title: "Hare & Tortise", author: "Aesop", content: text)

wrapper_doc = DocumentWrapper.new(real_doc)

puts wrapper_doc.title
# STDOUT: Hare & Tortise
puts wrapper_doc.author
# STDOUT: Aesop
puts wrapper_doc.content
# STDOUT: The Hare was once boasting of his speed...
Enter fullscreen mode Exit fullscreen mode

Any method we don't explicitly overwrite is going right to the underlying object. This has been very useful for me in the past when refactoring where I need to emulate a new contract with old code and change only a few methods on a base object before removing it completely later. Bonus points when using it with feature flagging too.

The other example the book gives us is Rails where it would previously lazy load attributes for objects using method_missing, but that's changed a while ago to where the methods are defined immediately, albeit still dynamically based on the columns of the class.

Wrapping Up

Seriously, delegation is fantastic for refactoring things. Go and read the docs for Forwardable and SimpleDelegator some time. I've gotten a ton of mileage out of them, and they're perhaps the single most useful way to use method_missing directly in Ruby.

The next chapter continues on the exploration of method_missing with building flexible APIs.

Top comments (0)