Moment of Ruby: an argument for nothing

3 min read Original article ↗

Last week I learned a technique in Ruby so small and useful, I couldn't believe I hadn't come across it before.

Suppose a method doubles as a getter and setter. Pass a value to update a setting, or pass nothing to read it:

module Cloakable
  def cloaking(value = nil)
      if value
       @setting = value
      else
       @setting
      end
  end
end

That works until we actually want to set false or nil as a value:

class Spaceship
  extend Cloakable
  
  cloaking(true) # Hide.
  cloaking(false) # Wave hello.
  cloaking(nil) # Clear the setting
  cloaking # Check before waving
end

How do I extend the contract of my method to consider that last case without breaking its existing behavior? Checking value.nil? wouldn't solve the whole problem either. An explicit nil and an omitted argument would still look the same.

One way I've seen this done is like so:

module Cloakable
  NOT_PROVIDED = Object.new.freeze

  def cloaking(value = NOT_PROVIDED)
    unless value.equal?(NOT_PROVIDED)
      @setting = value
    end

    @setting
  end
end

But we don't have to do all that. Here's the trick:

module Cloakable
  def cloaking(value = (nothing_provided = true))
    return @cloaking if nothing_provided
      
    @cloaking = value
 end
end

Ruby evaluates a default argument expression only when that argument is omitted. And that expression can include an assignment.

If we call setting without an argument, nothing_provided = true runs. The assignment also evaluates to true, making that the default value of value — though we don't use it in this branch.

If we supply any argument, even false or nil, Ruby skips the default expression. It still recognizes nothing_provided as a local variable, but its value is nil because the assignment never ran.

We can now distinguish all four calls:

  cloaking(true)  # Stores true.
  cloaking(false) # Stores false.
  cloaking(nil)   # Stores nil.
  cloaking        # Returns the stored value.

I first noticed this in Rails' implementation of index_with:

  def index_with(default = (no_default = true))
    if block_given?
      to_h { |elem| [elem, yield(elem)] } # rubocop:disable Rails/IndexWith
    elsif no_default
      to_enum(:index_with) { size if respond_to?(:size) }
    else
      to_h { |elem| [elem, default] } # rubocop:disable Rails/IndexWith
    end
  end

Ruby gives us a neat way to tell the difference when "a value was supplied" and "nothing was supplied" are different pieces of your method's contract. Sometimes, doing nothing is the wisest thing you can do — even if you need to get into a little argument about it.

Addendum

byroot originally introduced this in Rails PR #44611, and provided some additional guidance on when to use this pattern:

byroot: Yeah, it's used a lot in Ruby-core (for methods we migrated from C to Ruby, as in C it's easier to efficiently check for variadic arguments). Sentinel values have an advantage when the argument is passed to another method or stored in an ivar, but other than that yes that pattern is somewhat superior.


For more insights into how our engineering team works with Ruby, read the Aha! engineering blog.