As you sit down to write a new class in Ruby, you’re very likely going to be calling out to other objects (which in turn call out to other objects). Sometimes this is referred to as an object graph.
The outside objects created or required by a particular class in order for it to function broadly are called dependencies. There are various schools of thought around how best to define those dependencies. Let’s learn about the one I prefer to use the majority of the time. It takes advantage of three techniques Ruby provides for us: variable-like method calls, lazy instantiation, and memoization.
Let’s Get Object Oriented
First of all, what do I mean by “variable-like method calls”? I mean that this:
could refer either to
thing (a locally-scoped variable) or
thing (a method of the current object). What’s groovy about this is when I instantiate
thing, I can chose how to instantiate it. I could either set it up like this:
def some_method thing = Thing.new(:abc) thing.do_something(123) end
def some_method thing.do_something(123) end def thing Thing.new(:abc) end
The beauty of the second example is it makes
thing available from more than one method—all while using the same initialization values. The problem with this example however is if I access
thing more than once, it will create a new object instance.
def some_method thing.do_something(123) thing.finalize! end
Oh no! The
thing of the second line will be a different object than the
thing of the first line! Yikes! Thankfully, we have a technique to fix that: “memoization via instance variable”.
Memoization is a technique used to cache the result of a potentially-expensive operation. In our particular case, we’re less concerned with performance-improving caching as we are with saving a unique value for reuse. We want the
thing which gets used repeatedly to always refer to the same object. So let’s rewrite our
thing method this way:
def thing @thing ||= Thing.new(:abc) end
This code uses Ruby’s conditional assignment operator to either (a) return the value of the
@thing instance variable, or (b) assign it and then return it. Now it’s assured we’ll never receive more than a single object instance of the
Thing class. Let’s put it all together:
def some_method thing.do_something(123) # first call instantiates @thing thing.finalize! # second call uses the same @thing end def thing @thing ||= Thing.new(:abc) end
What’s Lazy About This?
Let’s take a look at what we might do if we weren’t using the above technique and we needed
thing available across multiple methods. We might use an approach like this:
class ThingWrangler attr_reader :thing # create a read-only accessor method def initialize @thing = Thing.new(:abc) # create @thing when this object is created end def some_method thing.do_something(123) thing.finalize! end end
Arguably this is an anti-pattern. Because if
some_method never actually gets called,
thing was instantiated for nothing—wasting memory and CPU resources. In addition, it makes swapping out the
Thing class challenging in tests or subclasses because the
Thing constant is hard-coded into the
Some might recommend that you reach for the DI (Dependency Injection) pattern instead:
class ThingWrangler attr_reader :thing def initialize(thing:) @thing = thing end def some_method thing.do_something(123) # first call instantiates @thing thing.finalize! # second call uses the same @thing end end
Then you’d simply need to pass an initialized object to the
new method of
ThingWrangler from a higher-level:
wrangler = ThingWrangler.new(thing: Thing.new(:important_value)) wrangler.some_method
Honestly, I really don’t like DI. It often makes for cumbersome APIs which are harder to comprehend as well as exposes implementation details to higher levels in situations where it might not even make sense. Do I really need to know that
ThingWrangler doesn’t work without a
Thing to rely on? Probably not. Contrast that with our friend the “lazily-instantiated memoized dependency” solution:
class ThingWrangler def initialize(value) @important_value = value # we store useful data for future use end def some_method thing.do_something(123) # first call instantiates @thing thing.finalize! # second call uses the same @thing end def thing @thing ||= Thing.new(@important_value) # aha! time to use saved data end end # This level doesn't need to know about the Thing class! # It also doesn't cause any premature instantiation of @thing: wrangler = ThingWrangler.new(:abc) # NOW we call a method which in turn instantiates @thing: wrangler.some_method
This is one of the solutions to writing “loosely-coupled” object-oriented code talked about in Sandi Metz’ book Practical Object-Oriented Design in Ruby.
What’s great about this pattern is it affords you many opportunities for customization. For example, you can write a subclass which swaps
Thing out entirely! Dig this:
class HugeThingWrangler < ThingWrangler def thing @thing ||= HugeThing.new(@important_value) end end wrangler = HugeThingWrangler.new(:abc) wrangler.some_method # uses HugeThing under the hood
Or when testing
ThingWrangler where you want
Thing to be a mock object under your control, you could simply stub the
thing method so it returns your mock instead of the usual
Or if you wanted to get real wild, here’s a bit of metaprogramming to add custom functionality around the original method:
ThingWrangler.class_eval do alias_method :__original_thing, :thing def thing puts "ThingWrangler#thing has been called!" obj = __original_thing puts "Now returning the thing object!" obj end end
Now every time
thing internally, your custom code will get run. (Careful out there!)
Some Important Caveats
A memoized method shouldn’t be reliant on changing data, because its job is to return a single instance of
Thing that gets cached and won’t ever change. So if you had code that looks like this:
def value_change(new_value) thing = Thing.new(new_value) thing.perform_work end
You can’t memoize that instantiation, because you need a new
Thing instance every time. However, what you could do instead is memoize the class itself! 🤯
def changing_values(new_value) thing = thing_klass.new(new_value) thing.perform_work end def thing_klass @thing_klass ||= Thing end
This still provides many of the benefits of the techniques we’ve described in terms of allowing subclasses to alter functionality, mock objects in tests, etc. Depending on the needs of your API, you might even want to create a configuration DSL to allow that
Thing constant to be officially customizable by consumers of your API. (And to reiterate, still no DI techniques required!)
One other caveat is if the original memoization method is overly complicated or reliant on internal implementation details, you could get into trouble with future subclasses.
class ParentClass def dependency @dependency ||= DependentClass.new(lots, of, input, values) end end class ChildClass < ParentClass def dependency # Hmm, what if the parent class changes internally and I don't?! @dependency ||= AnotherDependentClass.new(what, should, go, here) end end
In fact, expensive custom logic typically isn’t compatible with the memoization technique as-is. Instead, a good pattern (if possible) to use for your dependency is simply to be given a reference to the calling object itself:
class ParentClass def dependency @dependency ||= DependentClass.new(self) end end class ChildClass < ParentClass def dependency @dependency ||= AnotherDependentClass.new(self) end end
That way, it’s up to the dependency to glean any relevant data from the calling object in order to perform its work when required. This technique is used frequently across the Bridgetown project which I maintain.
For more on the benefits and caveats around memoization, read this article by “another” Jared (Norman). 😄
Conclusion: Trust Your LIM
The Lazily-Instantiated Memoization technique is a powerful one and, when used appropriately and in a consistent fashion, it will help your objects become more modular and more easily customized and tested. Consider it whenever you need to manage dependencies within your Ruby code.
“Ruby is simple in appearance, but is very complex inside, just like our human body.”
Subscribe to receive a timely tip you can apply directly to your Ruby site or application each week:
Banner image by Nathan Van Egmond on Unsplash
Other Recent Articles
It kinda sorta works—with several asterisks. Hence the reason it took me so long to even write an article about Ruby 3 typing. I think I’m onboard with where this is all headed, but we have a ways to get there.
Historically, the only way you could truly achieve async parallelism in Ruby would be to fork multiple processes or schedule background jobs. Until now.