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
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
Spin it up by passing in a repository name:
repo = GitHubRepo.new(name: 'alexmacarthur/typeit')
puts "License: #{repo.license}"
puts "Star Count: #{repo.stars}"
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
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
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))
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();
});
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);
});
}
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)
}
}
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)
}
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);
});
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;
}
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)