skip to content

Call Chains & Tap

Non-mutating calls chain because each returns a new object, while tap, then and a bang method that returns nil change what flows down the chain. Interviewers probe that nil trap.

on this pageshow

explore

questions

6

In Ruby, why does `title.strip!.squeeze!(" ")` raise NoMethodError for some product titles but not others, and how do you rewrite it?

level: middleimportance: must knowfreq 64%

answer

  1. self or nil
  2. nil when nothing changed
  3. data-dependent failure
  4. sort! and map! always return self
  5. separate statements or non-bang chain

basics

~20 s

String#strip! returns nil when there was no surrounding whitespace to remove, so the next link calls squeeze! on nil. Titles that needed stripping pass; clean ones crash. Chain the non-bang forms, or call each bang method as its own statement.

solid answer

~40 s

Many bang methods are documented as returning **`self` or `nil`**: they return the modified receiver when they changed something and `nil` when they did not. `String#strip!`, `squeeze!`, `gsub!`, `sub!`, `capitalize!`, `downcase!` and Array's `uniq!`, `compact!` and `flatten!` all behave this way. So `title.strip!.squeeze!(" ")` works for `" Red Mug "` and raises `NoMethodError` for `"Red Mug"`, because `strip!` returned `nil`. That is why the bug slips through tests with padded fixtures and fails in production. Rewrite it either as a non-mutating chain, `title = title.strip.squeeze(" ")`, or as separate statements, `title.strip!` then `title.squeeze!(" ")`, ignoring their return values. Not every bang returns `nil`: `Array#sort!`, `map!` and `reverse!` and `Hash#merge!` always return `self`, so check the method's documented return.

code

ruby · 17 lines
ruby
clean = "Red Mug".dup
clean.strip!                # => nil, nothing to strip

padded = "  Red  Mug ".dup
padded.strip!               # => "Red  Mug" (the same object)

# Safe chain: non-bang methods always return a String
"Red  Mug".strip.squeeze(" ")   # => "Red Mug"

# Safe in place: statements, return values ignored
title = "Red  Mug".dup
title.strip!
title.squeeze!(" ")
title                       # => "Red Mug"

[3, 1, 2].sort!.first       # => 1   sort! always returns self
[1, 2].uniq!                # => nil no duplicates removed

go deeper

for a junior

Recall that strip! and similar methods return nil when they change nothing, so calling another method on their result can fail.

for a middle

Explain why the failure depends on the input data, list which bang methods return self or nil and which always return self, and give both correct rewrites.

for a senior

Spot the pattern in review and in intermittent production errors, and decide between a non-mutating chain and in-place statements based on sharing and measured cost.

for a principal

Set a team convention that bang methods appear only as statements or conditions, and weigh how that rule is enforced in review.

## What a bang method returns In Ruby a method ending in `!` is the **"dangerous" twin** of a method without it; for the String and Array methods discussed here, the danger is that it modifies the receiver in place. The part that breaks chains is the **return value**. The core documentation writes it in the call signature: - `String#strip!`: self or nil - `String#squeeze!`: self or nil - `String#gsub!(pattern, replacement)`: self or nil - `Array#uniq!`, `Array#compact!`, `Array#flatten!`: self or nil "Self or nil" means: **the receiver if anything changed, `nil` if nothing did.** The `nil` is a signal, useful in a condition such as `puts "trimmed" if title.strip!`, but it is poison in a chain. ## Why the failure depends on the data ```ruby title = " Red Mug ".dup title.strip!.squeeze!(" ") # works: strip! changed it and returned title title = "Red Mug".dup title.strip!.squeeze!(" ") # NoMethodError: undefined method 'squeeze!' for nil ``` 1. For a padded title, `strip!` removes whitespace and returns the String, so `squeeze!` runs. 2. For an already-trimmed title, `strip!` has nothing to do and returns `nil`. 3. The next link calls `squeeze!` on `nil`, which has no such method. This is why the bug is **intermittent**: it depends on whether each record happened to need the first step. Test fixtures copied from messy input pass; clean production data crashes, or the other way round. ## Two correct rewrites | Style | Code | Result | |---|---|---| | Non-mutating chain | `title = title.strip.squeeze(" ")` | new String assigned; the old one untouched | | Separate statements | `title.strip!` then `title.squeeze!(" ")` | same object edited; return values ignored | - The **chain** is the default: every non-bang link returns a String, so nothing can turn into `nil`. - **Statements** fit when the String must be edited in place, for example because other code holds a reference to it, or to avoid intermediate allocations in a measured hot path. Each call's return value is simply not used. - Using `tap` to keep a chain shape, `title.tap { |t| t.strip! }.tap { |t| t.squeeze!(" ") }`, also works, because `tap` returns its receiver whatever the block returns; it is rarely clearer than two statements. ## Not every bang returns nil The "self or nil" contract is per method, not a rule about the `!` suffix: | Method | Returns | |---|---| | `String#strip!`, `squeeze!`, `gsub!`, `downcase!`, `capitalize!` | `self` or `nil` | | `Array#uniq!`, `compact!`, `flatten!`, `select!`, `reject!` | `self` or `nil` | | `Array#sort!`, `map!`, `reverse!` | always `self` | | `Hash#merge!` | always `self` | So `titles.sort!.first` is safe, while `titles.uniq!.first` fails whenever there were no duplicates. The only reliable guide is the documented return value of the specific method. ## Common wrong fixes - Adding `rescue nil` to the chain hides the failure and skips the remaining steps. - Replacing the chain with `(title.strip! || title).squeeze!(" ")` works but repeats the trap for the next link, and the next reader has to reason about each `||`. - Assuming the non-bang form might also return `nil`; `strip` and `squeeze` always return a String. ## Spotting it in review A reviewer can catch this pattern without running anything: 1. Look for a bang method **followed by a dot**: `strip!.`, `uniq!.`, `gsub!(...).`. Any such link is suspect. 2. Look up the method's documented return. If it says "self or nil", the chain fails whenever the method has nothing to do. 3. Ask what input makes it a no-op: an already trimmed title, an Array without duplicates, a String the pattern does not match. That input is the missing test case. The same reasoning explains why `if title.gsub!(/\s+/, " ")` is a legitimate condition while `title.gsub!(/\s+/, " ").strip` is a latent crash: the `nil` is information when you test it and a trap when you chain on it.

  • Is the nil return value of a bang method ever useful?
    Yes, as a cheap "did anything change?" test in a condition: `log_cleanup(product) if product.title.strip!` runs only when whitespace was actually removed, without comparing before and after copies. The value is a signal for statements and conditions, not something to chain on.
  • How do you know whether a particular bang method can return nil?
    Read its documented call signature: the core docs write `-> self or nil` for methods like `strip!`, `uniq!` and `compact!`, and `-> self` for methods like `sort!`, `map!` and `reverse!`. The `!` itself only marks the dangerous twin of a method; it says nothing about the return value.

saying these in an interview costs you the question

  • Every bang method returns self, so bang calls chain like non-bang ones.
  • Every bang method returns nil when nothing changes, including sort! and map!.
  • strip without the bang can also return nil on an already clean String.
  • Tests passed, so the strip!.squeeze! chain is safe for all inputs.
  • Adding rescue nil to the chain is a proper fix.
open as a page

In Ruby, what is the difference between `Kernel#tap` and `Kernel#then` in a method chain, and when would you use each?

level: middleimportance: must knowfreq 55%

basics

~20 s

Both yield the receiver to a block. tap returns the receiver itself, ignoring the block's value, so it suits side effects such as logging mid-chain; then (alias yield_self) returns the block's value, so it transforms or pipes the value onward.

open as a page

In Ruby, how do you split a long method chain across lines so it stays one expression, and where does a block attach?

level: juniorimportance: should knowfreq 40%

basics

~20 s

Start each continuation line with a dot, or end the previous line with one; either tells the parser the expression continues. A block attaches to the call written just before it, and the chain may continue after its closing brace or end.

open as a page

In Ruby, when does omitting parentheses on a call, or putting a space before them, change which object a chained method runs on?

level: middleimportance: should knowfreq 35%

basics

~20 s

Without parentheses, everything after the method name is its argument list, so format_title raw.upcase upcases raw, not the result. A space before the parenthesis makes it a grouping inside the argument, so format_title (raw).upcase behaves the same way; format_title(raw).upcase chains on the result.

open as a page