DEV Community

Isa Levine
Isa Levine

Posted on • Edited on

Ruby Tap Pros and Cons of Ruby's .tap Method

anime gif of water running from a faucet

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.

anime gif of water pouring from faucet into two cupped hands

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
Enter fullscreen mode Exit fullscreen mode

Pros

gif of cat drinking water from a faucet

  • 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

gif of polygon-rendered skeleton in a bathtub turning a faucet handle

  • limited use because sometimes ActiveRecord patterns are more recognizable/readable than the .tap syntax

  • 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:

  1. When creating a new Character instance, to more clearly communicate our intent behind several variables, and to reduce some redundant code
  2. 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 a display_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
Enter fullscreen mode Exit fullscreen mode

Now let's look at some places where we can use .tap to (potentially) tie our code together more clearly!
gif of popeye the sailor tying two streams of water together in a knot

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
Enter fullscreen mode Exit fullscreen mode

Now, we have several potential advantages:

  • Our intent with stats_array, points_pool, and max_roll is clearer, because the variables only exist in the .tap block's scope, and are immediately passed into roll_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 return character at the end of new_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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

anime gif of two golden faucets powerfully streaming water

Special thanks to Anthony Hernandez for introducing me to the .tap method! :)

Further reading/links/discussions/debates about Ruby's .tap method:

Got any tips, tricks, or instances for using Ruby's .tap method? Please feel free to comment and share below! <3

Top comments (4)

Collapse
 
jfouse profile image
Joel Fouse

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:


# using .concat
ary = [1, 2, 3]
ary.concat([4, 5, 6])
#> [1, 2, 3, 4, 5, 6]

# using +=
ary = [1, 2, 3]
ary += [4, 5, 6]
#> [1, 2, 3, 4, 5, 6]

Enter fullscreen mode Exit fullscreen mode

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:


ary = [1, 2, 3]
ary.tap do |a|
  # do some things
  a.concat([4, 5, 6])
  # do more things
end
#> [1, 2, 3, 4, 5, 6]
# ...as expected

ary = [1, 2, 3]
ary.tap do |a|
  # do some things
  a += [4, 5, 6]
  # do more things
end
#> [1, 2, 3]
# ...wait, what?

Enter fullscreen mode Exit fullscreen mode

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.

Collapse
 
natewallis profile image
natewallis • Edited

Is this really a gotcha of tap though? It doesn't change the behaviour of tap, it still returns self, its just that you used .concat vs += - or did I miss the point?

Collapse
 
bensandeen profile image
BenSandeen

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 the end 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?

Collapse
 
arthurvkasper profile image
Arthur Valentim Kasper

Where did you handle with the attr name and player on this exemple?

 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
Enter fullscreen mode Exit fullscreen mode