DEV Community

Alex MacArthur
Alex MacArthur

Posted on • Originally published at macarthur.me on

Elegant Memoization with Ruby’s .tap Method

When I was writing the Jekyll integration for JamComments, I started reminiscing about some of the features I really like about Ruby (it had been a minute since I wrote much of it). One of the first that came to mind was the conditional assignment operator, often used to memoize values:

def results
  @results ||= calculate_results
end
Enter fullscreen mode Exit fullscreen mode

If you're unfamiliar, the @results instance variable if will only be set if it's falsey. It's a nice way to ensure an expensive operation is performed only when it's needed and never more than once.

For one-liners like this, it's straightforward. But sometimes, a little more complexity may require multiple lines of code, like if you were to fetch results from an external service. In that case, memoization isn't as elegant. That's where another neat Ruby feature can help retain that elegance. But first, let's flesh out a scenario.

Scenario: Memoizing an HTTP Request

Here's a GitHubRepo class for fetching repository data from the GitHub API. It handles making the request and accessing particular data we want from the response.

require 'httparty'

class GitHubRepo
    attr_reader :name

    def initialize(name:)
        @name = name
    end

    def license
        repo.dig('license', 'key')
    end

    def stars
        repo['stargazers_count']
    end

    private 

    def repo
        puts "fetching repo!"

        response = HTTParty.get("https://api.github.com/repos/#{name}")

        JSON.parse(response.body)
    end
end
Enter fullscreen mode Exit fullscreen mode

Spin it up by passing in a repository name:

repo = GitHubRepo.new(name: 'alexmacarthur/typeit')

puts "License: #{repo.license}"
puts "Star Count: #{repo.stars}"

Enter fullscreen mode Exit fullscreen mode

Unsurprisingly, "fetching repo" would be output twice, since the repo method is being repeatedly used with no memoization. We could solve that by more manually checking & setting a @repo instance variable:

# Inside class...
def repo
    # Check if it's already set.
    return @repo unless @repo.nil?

    puts 'fetching repo!'

    response = HTTParty.get("https://api.github.com/repos/#{name}")

    # Set it.
    @repo = JSON.parse(response.body)
end
Enter fullscreen mode Exit fullscreen mode

But like I said, not as elegant. I don't love needing to check if @repo is nil myself, and then setting it in a different branch of logic.

Where .tap Shines

Ruby's .tap method is really helpful in moments like this. It exists on the Object class, and as the docs describe, it "yields self to the block, and then returns self." So, memoizing an HTTP response cleans up a bit better:

# Inside class...
def repo
    @repo ||= {}.tap do |repo_data|
        puts 'fetching repo!'

        response = HTTParty.get("https://api.github.com/repos/#{name}")

        repo_data.merge!(JSON.parse(response.body))
    end
end
Enter fullscreen mode Exit fullscreen mode

Explained: We start with an empty hash {}, which is then "tapped" and provided to the block as repo_data. Then, we can spend as many lines as we want in that self-contained block to mutate repo_data before it's implicitly returned. And that entire block is behind a conditional assignment operator ||=, so future repo calls will just return the @repo instance variable. No variable checking. One code path. Slightly more cultivated, in my opinion.

But there's a potential kicker in there that's nabbed me a few times: .tap will always return itself from the block, no matter what you do within it. And that means if you want a particular value to be returned from the block, you have to mutate that value. This would be pointless:

repo_data = repo_data.merge(JSON.parse(response.body))
Enter fullscreen mode Exit fullscreen mode

It would have simply reassigned the variable, and the original reference would have still been returned unchanged. But using the "bang" version of merge does work because it's modifying the repo_data reference itself.

Similar Methods in Other Languages

You can tell a feature is valuable when other languages or frameworks adopt their own version of it, and this is one of those. I'm aware of just a couple, but I'm sure there are more.

Laravel (PHP), for example, exposes a global tap()helper method, and there's even a Tabbable trait, which is used to add a tap method to several classes within the framework. Moreover, Taylor Otwell has even said its inspiration was found in Ruby. Here's a snippet robbed straight from their documentation:

$user = tap(User::first(), function (User $user) {
    $user->name = 'taylor';

    $user->save();
});
Enter fullscreen mode Exit fullscreen mode

And here's how that helper could be used to memoize our GitHub API request. As you can see, it works nicely with PHP's null coalescing operator:

// Inside class...
private function repo()
    {
        // Perform request only if property is empty.
        return $this->repo = $this->repo ?? tap([], function(&$repoData) {
            echo 'Fetching repo!';

            $client = new Client();
            $response = $client->request('GET', "https://api.github.com/repos/{$this->name}");

            $repoData += json_decode($response->getBody(), true);
        });
    }
Enter fullscreen mode Exit fullscreen mode

Kotlin's actually has a couple tools similar to .tap. The .apply and .also methods permit you to mutate an object reference that's implicitly returned at the end of a lambda:

val repo = mutableMapOf<Any, Any>().also { repoData ->
    print("fetching repo!")

    val response = get("https://api.github.com/repos/$name")
    jacksonObjectMapper().readValue<Map<String, Any>>(response.text).also { fetchedData ->
        repoData.putAll(fetchedData)
    }
}
Enter fullscreen mode Exit fullscreen mode

But for memoization, you don't even need them. Kotlin's lazy delegate will automatically memoize the result of the proceeding self-contained block.

// Inside class...
private val repoData: Map<String, Any> by lazy {
    print("fetching repo!")

    val response = get("https://api.github.com/repos/$name")
    jacksonObjectMapper().readValue<Map<String, Any>>(response.text)
}
Enter fullscreen mode Exit fullscreen mode

Sadly, God's language, JavaScript, doesn't have a built-in .tap method, but it could easily be leveraged with Lodash's implementation. Or, if you're feeling particularly dangerous, tack it onto the Object prototype yourself. Continuing with the repository fetch example:

Object.prototype.tap = async function(cb) {
  await cb(this);

  return this;
};

// Create an empty object for storing the repo data.
const repoData = await Object.create({}).tap(async function (o) {
  const response = await fetch(
    `https://api.github.com/repos/alexmacarthur/typeit`
  );
  const data = await response.json(response);

  // Mutate tapped object.
  Object.assign(o, data);
});
Enter fullscreen mode Exit fullscreen mode

For memoization, this would pair decently with the new-ish nullish coalescing operator. Say we were in the context of a class like before:

// Inside class...
async getRepo() {
    this.repo = this.repo ?? await Object.create({}).tap(async (o) => {
        console.log("fetching repo!");

        const response = await fetch(
          `https://api.github.com/repos/alexmacarthur/typeit`
        );
        const data = await response.json(response);

        Object.assign(o, data);
      });

    return this.repo;
}
Enter fullscreen mode Exit fullscreen mode

Still not the level of elegance that Ruby offers, but it's getting there.

A Gem in the Rough

Like I mentioned, it's been a little while since I've dabbled in Ruby, spending most of my time as of late in Kotlin, PHP, and JavaScript. But I think that sabbatical has given more comprehensive, renewed perspective on the language, and helped me to appreciate the experience it offers despite the things I don't prefer so much (there are some). Hoping I continue to identify these lost gems!

Top comments (0)