DEV Community

Cover image for Decorating Ruby - Part One - Symbol Method Decoration
Brandon Weaver
Brandon Weaver

Posted on • Edited on

Decorating Ruby - Part One - Symbol Method Decoration

One precursor of me writing an article is if I keep forgetting how something's done, causing me to write a reference to look back on for later. This is one such article.

What's in Store for Today?

In this particular article we'll be taking a look at various methods of method decoration and some lovely metaprogramming to go along with it to give Ruby that extra little bend.

The

Table of Contents

Next >>

What's Decoration?

Decoration is the idea of "decorating" a method with a little something extra. A little spice, flavor, what have you.

Have you ever written code like this?

def something
  @something ||= expensive_call_here
end
Enter fullscreen mode Exit fullscreen mode

That starts becoming real common real quickly, no? Litter that throughout a codebase and it's become almost a pattern of its own for memoization. Wouldn't it be nice if we could say one word instead of having to repeatedly type out those instance variables?

Decoration would allow us to do something like this:

memoized def something
  expensive_call_here
end
Enter fullscreen mode Exit fullscreen mode

It would do effectively the same thing, but how it does it is something entirely different, something we'll be exploring in this article.

Symbol Method Decoration

Referring to decoration that takes a Symbol of a methods name to redefine it to add extra behavior. This is what we'll be looking at for part one, as there's quite a bit to cover here.

Method Definition returns Symbols

The first example we used relies on a fact introduced in Ruby 2.1+: When a method is defined, it's an expression which returns a Symbol containing the name of the method.

Give it a try in a REPL real quick:

def testing; end
# => :testing
Enter fullscreen mode Exit fullscreen mode

This allows us to prefix the method definition with another method that does something with that fact. You may have commonly seen this pattern in Ruby for private methods:

private

def private_a; end
def private_b; end
Enter fullscreen mode Exit fullscreen mode

Both of those methods will be considered private, but there's another way of using private in Ruby, one I personally prefer for clarity:

private def private_a; end
private def private_b; end
Enter fullscreen mode Exit fullscreen mode

It allows us to explicitly prefix a method as being private, making it easier to move around and tell from a glance what's actually private.

(Well, as private as one gets in Ruby, we are using metaprogramming after all...)

Given that, how does a method that takes a symbol redefine that method? We'll need a few more tools to do that.

The method Method

So we have a Symbol of the method name, great, but how do we do something with that? One way is using the method method:

def testing; 1 end

original_method = method(:testing)
# => #<Method: main.testing>

original_method.call
# => 1
Enter fullscreen mode Exit fullscreen mode

This creates a Proc-like object, an instance of Method that can be invoked just like a Proc with arguments and whatever else you'd normally pass.

There are other very useful things you can do with this little fact, but that's out of the scope for this article, perhaps another day there.

What's important is we have a callable representation of a method via the methods name.

A Method that Eats Methods and define_method

Let's start with something light, a method which will eat whatever method you pass it. We can call it something cute like quiet:

def quiet(method_name)
  define_method(method_name) { nil }
end
Enter fullscreen mode Exit fullscreen mode

This introduces another concept, define_method, which can define a method in place. This includes methods which are already defined, letting us overwrite something that's in place.

If we were to call this on our previous testing method:

quiet def testing; 1 end

testing
# => nil
Enter fullscreen mode Exit fullscreen mode

The 1 is gone, we've just run straight over the old method. Now this isn't particularly useful, we could just use remove_method instead for the same effect.

Effectiveness aside, we know we can redefine a method via its name and use it as a prefix.

What's in Store?

Back to our original cache idea, how would we pull something like that off? Looking back, we have all the tools we need already, we just need to put them together to make something useful.

Consider define_method as a way to "wrap" a method with some additional behavior. All we have to do is capture to original arguments and pass them merrily along and we can do whatever we want with what the original method returns inside of it:

define_method(name) { |*args, &fn| original_method.call(*args, &fn) }
Enter fullscreen mode Exit fullscreen mode

Now it's just a matter of adding that extra little spice to the wrapping. Remember that a cache, at least a basic one, looks like this:

def something
  @something ||= expensive_call
end
Enter fullscreen mode Exit fullscreen mode

The idea is to use ||= to store the value and not make another expensive call. This is precisely what we want to emulate to make a caching prefix:

def cache(method_name)
  original_method = method(method_name)

  define_method(method_name) do |*args, &fn|
    ivar = :"@#{method_name}"
    cached_value = instance_variable_get(ivar)

    return cached_value if cached_value

    value = original_method.call(*args, &fn)
    instance_variable_set(ivar, value)
  end
end
Enter fullscreen mode Exit fullscreen mode

Break Down!

Breaking that down, we have a few other novel concepts here, mostly to do with instance variables and dynamically handling them.

The first line we remember from above, we want the original method:

original_method = method(method_name)
Enter fullscreen mode Exit fullscreen mode

Next we're redefining the original method by name and proxying the arguments on through:

define_method(method_name) do |*args, &fn|
Enter fullscreen mode Exit fullscreen mode

Where we get a bit interesting is when we get to the next line, we're naming an instance variable by prefixing the method name with an @:

ivar = :"@#{method_name}"
Enter fullscreen mode Exit fullscreen mode

This allows us to see if a value already exists:

cached_value = instance_variable_get(ivar)
Enter fullscreen mode Exit fullscreen mode

Now be careful here, because the next line does something subtle you might not like:

return cached_value if cached_value
Enter fullscreen mode Exit fullscreen mode

If that value is falsy, it's going to skip that and redefine the value. This is the same issue with ||=, and can be solved by changing that line to this instead:

return cached_value if instance_variable_defined?(ivar)
Enter fullscreen mode Exit fullscreen mode

That'll use the fact of whether or not the variable was defined instead of the truthiness of its value.

The next few lines are where we make sure to remember the value:

value = original_method.call(*args, &fn)
instance_variable_set(ivar, value)
Enter fullscreen mode Exit fullscreen mode

We pull the value out of the method, and we set an instance variable to remember it for us later.

The Run Around

Let's give it a try:

cache def excruciatingly_long_method_call
  (1..1_000_000).to_a.sample(10)
end

excruciatingly_long_method_call
# => [116266, 583871, 537296, 296408, 563441, 92172, 511762, 597596, 681279, 951614]

excruciatingly_long_method_call
# => [116266, 583871, 537296, 296408, 563441, 92172, 511762, 597596, 681279, 951614]

excruciatingly_long_method_call
# => [116266, 583871, 537296, 296408, 563441, 92172, 511762, 597596, 681279, 951614]


excruciatingly_long_method_call
# => [116266, 583871, 537296, 296408, 563441, 92172, 511762, 597596, 681279, 951614]
Enter fullscreen mode Exit fullscreen mode

Now unless you're really lucky, you'll get some different numbers there. Add 3 to 6 extra 0s to make it actually take a while, but I'm impatient, so a million or so should do just fine.

(With srand seeds you could reproduce it but that gets us a bit off track)

With that we have a cached method, and we only had to put cache in front of it to do all that. Nifty, no?

Wrapping Up

Next time around we'll be taking a look into a bit more advanced of a method of decorating, involving method_added, alias_method, and instance_exec among some other nifty tricks.

Table of Contents

Next >>

Top comments (4)

Collapse
 
peter profile image
Peter Kim Frank

One precursor of me writing an article is if I keep forgetting how something's done, causing me to write a reference to look back on for later. This is one such article.

This is an awesome pattern. Writing for yourself and benefitting an entire community. Thanks for sharing.

Collapse
 
edisonywh profile image
Edison Yap

Wow that's some really great content Brandon! I had absolutely no idea this was possible in Ruby, TIL!

Collapse
 
teoulas profile image
Theodoros Orfanidis

Nice article! You reminded me that Rails had a memoization module until version 3.2: apidock.com/rails/ActiveSupport/Me...

Collapse
 
jacobherrington profile image
Jacob Herrington (he/him) • Edited

Really interested in this after your Ruby Grimoire talk! I haven't read this article yet, but I will be back this evening to read more about your Ruby machinations. Bookmarked!