During a recent technical challenge, I got the chance to learn about and use Ruby's .tap
method. This method allows you to "tap" into the result of a method call, usually to perform additional operations on that result, or to debug that result (i.e. by printing to the console).
A quick Google search for ruby tap method
will return a lot of articles and StackOverflow discussions about when to use .tap
. There's a pretty clear divide between folks who think it's useful for communicating intent and thus improving readability, and others who think it is overly-used / rarely-useful syntactic sugar that harms readability.
In this post, I'll explore some of the pros and cons of the .tap
method.
What is the .tap method?
From the Ruby documentation for Object:
tap{|x|...} โ obj
Yields self to the block, and then returns self. The primary purpose of this method is to โtap intoโ a method chain, in order to perform operations on intermediate results within the chain.
So, when we call .tap
on an Object, we can insert extra operations before any other code executes. This is especially useful with things like ActiveRecord creation/lookups, where we may immediately want to do something to a new/found record.
For example, if we have a library app with Book and Checkout models, we could check for an existing Checkout record with Checkout.find_by(book: book)
, then use .tap
to create a block to manipulate the found Checkout (and the Book we used to find it!):
Checkout.find_by(book: book).tap do |checkout|
checkout.update!(check_in_date: date)
book.update!(checked_out: false)
end
Pros
-
wrap ActiveRecord creation/lookup patterns in a block that communicates intent
-
per this StackOverflow example, this:
user = User.new.tap do |u| u.build_profile u.process_credit_card u.ship_out_item u.send_email_confirmation u.blahblahyougetmypoint end
could be easier to understand at a glance than seeing this:
user = User.new user.build_profile user.process_credit_card user.ship_out_item user.send_email_confirmation user.blahblahyougetmypoint
-
-
provide a way to debug code step-by-step (as an alternative to
pry
)-
Again from the Ruby documentation for
.tap
, this example is designed to write step-by-step outputs to the console to help us debug:
(1..10) .tap {|x| puts "original: #{x.inspect}"} .to_a .tap {|x| puts "array: #{x.inspect}"} .select {|x| x%2==0} .tap {|x| puts "evens: #{x.inspect}"} .map {|x| x*x} .tap {|x| puts "squares: #{x.inspect}"}
-
Cons
-
limited use because sometimes ActiveRecord patterns are more recognizable/readable than the .tap syntax
-
per this soapbox rant, the "traditional" pattern may be more recognizable to people familiar with Rails, despite (or perhaps because of) the redundant return of
self
at the end:
obj = SomeClass.new obj.blah = true obj
-
-
when debugging, must carefully format to be readable without disrupting the rest of the code (as intended)
-
per the same article as the previous con, using
.tap
to debug chains of ActiveRecord methods is very useful, and doesn't get in the way of reading the code it's testing--but writing this code, and keeping it tidy and readable while maintaining/changing it, is extra effort:
User .active .tap { |users| puts "Users so far: #{users.size}" } .non_admin .tap { |users| puts "Users so far: #{users.size}" } .at_least_years_old(25) .tap { |users| puts "Users so far: #{users.size}" } .residing_in('USA')
-
Use case: character-generator API
In this use case, I build a character-generator API where Players can create randomly-generated Characters. Here, we will see two places to (potentially) use .tap
:
- When creating a new Character instance, to more clearly communicate our intent behind several variables, and to reduce some redundant code
- When using a set number of points to assign to a Character's stats, while keeping track of (a) the random "6-sided die roll", (b) the remaining number of points, and (c) the Character's current and remaining stats.
API overview: models and relationships
Our two main models will be Players and Characters.
-
Players simply have a
user_name
and adisplay_name
:
# /db/schema.rb create_table "players", force: :cascade do |t| t.string "user_name" t.string "display_name" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false end
-
Characters have a name, 4 stats, and a Player they belong to:
# /db/schema.rb create_table "characters", force: :cascade do |t| t.string "name" t.integer "strength" t.integer "dexterity" t.integer "intelligence" t.integer "charisma" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.integer "player_id", null: false t.index ["player_id"], name: "index_characters_on_player_id" end
RandomCharacterGenerator service object
We will be using .tap
inside a service object, RandomCharacterGenerator
, which will provide a .new_character()
method that takes in a name and player, and automatically creates and saves a Character in the database:
# /app/services/random_character_generator.rb
class RandomCharacterGenerator
def new_character(name, player)
character = Character.new(name: name, player: player)
stats_array = ["strength", "dexterity", "intelligence", "charisma"]
points_pool = 9
max_roll = 6
roll_stats(character, stats_array, points_pool, max_roll)
character.save!
character
end
private
def roll_stats(character, stats_array, points_pool, max_roll)
stats_array.each_with_index do |stat, index|
roll = rand(1..max_roll)
remaining_stats = (stats_array.length - 1) - index
if remaining_stats == 0
character[stat] = points_pool
points_pool = 0
elsif points_pool - roll < remaining_stats
max_points = points_pool - remaining_stats
character[stat] = max_points
points_pool -= max_points
else
character[stat] = roll
points_pool -= roll
end
end
end
end
Now let's look at some places where we can use .tap
to (potentially) tie our code together more clearly!
Case 1: communicating intent and context of variables during Character.new()
In .new_character()
, we're performing several operations as soon as we create a new Character instance. Right after Character.new()
, we:
- define 3 variables (stats_array, points_pool, max_roll)
- call the private method
roll_stats()
with those 3 variables - call
save!
on our new Character - return the Character
However, it's a little unclear whether those 3 new variables need to be used anywhere else. Plus, you may be the kind of person who hates that redundant hanging line that just returns character
!
So, let's use .tap
to wrap those variables, the roll_stats()
call that uses them, and the save!
call in a block--and eliminate that final character
line while we're at it:
# /app/services/random_character_generator.rb
def new_character(name, player)
Character.new.tap do |character|
stats_array = ["strength", "dexterity", "intelligence", "charisma"]
points_pool = 9
max_roll = 6
roll_stats(character, stats_array, points_pool, max_roll)
save!
end
end
Now, we have several potential advantages:
- Our intent with
stats_array
,points_pool
, andmax_roll
is clearer, because the variables only exist in the.tap
block's scope, and are immediately passed intoroll_stats()
. We can be more confident that those variables won't be needed anywhere else in our code. - We know that
.tap
always returns the object it's called on, so we don't have to worry about forgetting to explicitly returncharacter
at the end ofnew_character()
. We can be more confident that our new Character will be returned as expected.
However...
- We saved...0 lines of code.
Pro or con: do you think the ORIGINAL or NEW version is more readable? (Feel free to comment below!)
Case 2: debugging values step-by-step during iteration in roll_stats()
In the private method roll_stats()
, we have several values that need to be tracked as each Character stat is randomly assigned a number 1 through 6:
- how many points are left to allocate
- how many stats are left to assign points to (so no stat ends up with 0)
- what number 1 through 6 is randomly "rolled"
- is the stat being correctly assigned the "rolled" value
- is the number of points left to spend decreasing by the "rolled" value
Faced with the option to insert a puts "value: #{value}"
or pry
line in-between each step, I realized that using .tap
instead would let me add debugging messages to the console without breaking up the code I'm testing.
So, after each step (and in a few extra places), I tabbed way over to the side and added a .tap
call with a puts "value: #{value}"
message to check the values step-by-step: (scroll to the right)
# /app/services/random_character_generator.rb
def roll_stats(character, stats_array, points_pool, max_roll)
stats_array.each_with_index do |stat, index|
roll = rand(1..max_roll) .tap {|r| puts "roll: #{r}"}
remaining_stats = ((stats_array.length - 1) - index) .tap {|r| puts "remaining_stats: #{r}"}
.tap {|r| puts "points_pool (before): #{points_pool}"}
if remaining_stats == 0
character[stat] = points_pool
points_pool = 0
elsif points_pool - roll < remaining_stats
max_points = points_pool - remaining_stats
character[stat] = max_points
points_pool -= max_points
else
character[stat] = roll
points_pool -= roll
end .tap {|r| puts "character[#{stat}]: #{character[stat]}"}
.tap {|r| puts "points_pool (after): #{points_pool}\n\n"}
end
end
Now, when I'm running tests with RSpec, I can get this console output to help me debug:
[18:36:52] (master) tap-example-character-creator-api
// โฅ bundle exec rspec
roll: 3
remaining_stats: 3
points_pool (before): 9
character[strength]: 3
points_pool (after): 6
roll: 2
remaining_stats: 2
points_pool (before): 6
character[dexterity]: 2
points_pool (after): 4
roll: 1
remaining_stats: 1
points_pool (before): 4
character[intelligence]: 1
points_pool (after): 3
roll: 2
remaining_stats: 0
points_pool (before): 3
character[charisma]: 3
points_pool (after): 0
....
Finished in 0.02678 seconds (files took 3.61 seconds to load)
4 examples, 0 failures
The main advantage here is that the readability of the original code is not changed--in fact, it's easy to ignore that those .tap
calls are even there!
However, it took time to format those .tap
calls to be out of the way but still formatted in a readable way--and any changes to those lines of code will inevitably change the indentation that keeps them lined up. Maintaining that readability, or wanting to comment out/delete those .tap
calls, costs extra time and effort.
Pro or con: do you think it takes MORE or LESS time to write and maintain this syntax for debugging? (Feel free to comment below!)
Conclusion
Honestly, I can't sum up the .tap
method any better than Kartik Jagdale does in this article, so I'll simply quote him:
Just like any other ruby syntactic sugar , I would say
tap
is a pretty cool ruby method which can not just be used for readability but also to debug chained methods. Give it a shot.
Special thanks to Anthony Hernandez for introducing me to the .tap method! :)
Further reading/links/discussions/debates about Ruby's .tap method:
- https://docs.ruby-lang.org/en/2.4.0/Object.html#method-i-tap
- https://stackoverflow.com/questions/17493080/advantage-of-tap-method-in-ruby
- https://redningja.com/dev/rubys-object-tap-a-better-use
- https://medium.com/aviabird/ruby-tap-that-method-90c8a801fd6a
- https://www.engineyard.com/blog/five-ruby-methods-you-should-be-using
- https://www.ruby-forum.com/t/tap-method-good-or-bad-practice/228410
- https://www.rubyguides.com/2017/10/7-powerful-ruby-methods/
- GitHub repo for the character-generator API use case: https://github.com/isalevine/rails-tap-example-character-api
Top comments (4)
Ran into a quick "gotcha" with .tap that seems worth sharing in case it trips anyone up. As you mentioned above, the documentation says that .tap: "Yields self to the block, and then returns self." This is important to remember, because sometimes we think we're modifying an object when we actually aren't.
For example, let's say we want to add elements from a new array to an existing array:
By all appearances they look like they do the same thing; one might suppose += is simply a "syntactic sugar" shortcut for .concat. Besides, += is quicker to type and easy to conceptualize what you're doing, right? Let's try both approaches with .tap:
Why the difference? Concat does what we expect because it works by appending the new elements to the existing array. But + (and therefore +=) creates a new array with elements from both originals and returns the new one, which we're just throwing away here because we're not assigning it to anything. That means the original array object we tapped from remains unchanged, and that's what .tap then returns or passes on to the next method in the chain.
In short, if you're expecting the tapped object to be modified within the tap block, make sure you're using methods that will actually modify self instead of returning a modified copy.
Is this really a gotcha of
tap
though? It doesn't change the behaviour oftap
, it still returns self, its just that you used.concat
vs+=
- or did I miss the point?As someone who's still learning Ruby, the
tap
method seems like nothing but a syntactic sugar that adds nothing but some indentations and an extra line for theend
at the end of the block. Why not just use the simple equivalent that is fairly universal across OOP languages, which takes no extra cognitive load to understand?Where did you handle with the attr name and player on this exemple?