Syndication Icon
Published September 1, 2020 Updated July 31, 2021
Cover
Ruby Antipatterns

An antipattern is just like a pattern, except that instead of a solution it gives something that looks superficially like a solution but isn’t one. — Andrew Koenig

The Ruby language is rich in functionality, expressiveness, and succinctness, a blend of object-oriented and functional styles. Because of this unique construction, Ruby can have sharp edges that makes your codebase hard to read, debug, and maintain. The following dives into a few such aspects of the Ruby language that should be avoided in professional work. Don’t get me wrong! It’s fun to play with the following problematic examples as local experiments and/or explorations for personal or even debugging purposes. However, when it comes time to committing code that will be used for production purposes, these practices should be avoided.

Global Variables

Ruby has a collection of global variables which are borrowed from Perl and shell scripting in general. These can be viewed via the Kernel#global_variables method. A full description of all variables and their corresponding aliases can be found within the English library source code (i.e. require "English"). Here’s a rough breakdown of each:

$/       # Input record separator. Alias: $INPUT_RECORD_SEPARATOR. Default: newline. Deprecated in Ruby 2.7.0.
$.       # Current input line number of the last file read. Alias: $INPUT_LINE_NUMBER.
$\       # Output record separator. Alias: $OUTPUT_RECORD_SEPARATOR. Default: nil. Deprecated in Ruby 2.7.0.
$;       # String#split default field separator. Alias: $FIELD_SEPARATOR. Deprecated in Ruby 2.7.0.
$,       # Output field separator. Alias: $OUTPUT_FIELD_SEPARATOR. Deprecated in Ruby 2.7.0.
_$       # Input variable for each object within an IO loop.
$!       # Last exception thrown. Alias: $ERROR_INFO.
$@       # Backtrace array of last exception thrown. Alias: $ERROR_POSITION.
$&       # String match of last successful pattern match for current scope. Alias: $MATCH.
$`       # String to the left of last successful match. Alias: $PREMATCH.
$'       # String to the right of last successful match. Alias: $POSTMATCH.
$+       # Last bracket matched by the last successful match. Alias: $LAST_PAREN_MATCH.
$<n>     # nth group of last successful regexp match.
$~       # Last match info for current scope. Alias: $LAST_MATCH_INFO.
$<       # Object access to the concatenation of all file contents given as command-line arguments. Alias: $DEFAULT_INPUT.
$>       # Output destination of Kernel.print and Kernel.printf. Alias: $DEFAULT_OUTPUT. Default: $stdout.
$_       # Last input line of string by gets or readline. Alias: $LAST_READ_LINE.
$0       # Name of the script being executed. Alias: $PROGRAM_NAME.
$-       # Command line arguments given for script. Alias: $ARGV.
$$       # Ruby process number of current script. Alias: $PROCESS_ID or $PID.
$?       # Status of the last executed child process. Alias: $CHILD_STATUS.
$:       # Load path for scripts and binary modules via load or require. Alias: $LOAD_PATH.
$"       # Array of module names as loaded by require. Alias: $LOADED_FEATURES.
$-d      # Status of the -d switch. Alias: $DEBUG.
$-K      # Source code character encoding being used. Alias: $KCODE.
$-v      # Verbose flag (as set by the -v switch). Alias: $VERBOSE.
$-a      # True if option -a ("autosplit" mode) is set.
$-i      # In-place-edit mode. Holds the extension if true, otherwise nil.
$-l      # True if option -l is set ("line-ending processing" is on).
$-p      # True if option -p is set ("loop" mode is on).
$-w      # True if option -w is set (Ruby warnings).
$stdin   # Current standard input.
$stdout  # Current standard output.
$stderr  # Current standard error output.

You can also define and use your own global variables:

$example = "hello"
puts $example # => "hello"

While global variables are good to be aware of, it’s difficult to read and intuit their meaning — at least for predefined global variables that is. Requiring the English library will improve readability but doesn’t prevent the values from being mutated unless you remember to freeze them. Global variable usage breaks encapsulation as the variable can then be used anywhere within the program and even mutated, which leads to unexpected bugs, difficultly in debugging, and resolving issues.

A better approach is to reduce scope to the objects that need these values through dependency injection via default constants or even default configurations provided by gems like XDG or Runcom.

Class Variables

As briefly discussed in an earlier Refinements article, class variables behave a lot like global variables except they are scoped within their own class hierarchy rather than globally. For a closer look, consider the following:

class Parent
  @@example = "defined by parent"

  def self.print = puts(@@example)
end

class Child < Parent
end

Parent.print # => "defined by parent"
Child.print  # => "defined by parent"

Notice both the Parent and Child classes yield the same output, which seems reasonable until the following modification is introduced:

class Child < Parent
  @@example = "overwritten by child"
end

Parent.print # => "overwritten by child"
Child.print  # => "overwritten by child"

Not only did the Child define it’s own value but it mutated the Parent as well. If you haven’t seen this before, it can be an unpleasant surprise. A quick and dirty solution would be to instead use a class instance variable:

class Parent
  @example = "defined by parent"

  def self.print = puts(@example)
end

class Child < Parent
  @example = "defined by child"
end

Parent.print # => "defined by parent"
Child.print  # => "defined by child"

While the above is an improvement, a better approach would be remove state from classes altogether and encapsulate behavior in a barewords instance:

class Parent
  def initialize message = "parent default"
    @message = message
  end

  def print = puts(message)

  private

  attr_reader :message
end

class Child < Parent
  def initialize message = "child default"
    super
  end
end

Parent.new.print # => "parent default"
Child.new.print  # => "child default"

As an added bonus, you now have a way to construct multiple instances of each class with different default messages.

It is worth noting that with Ruby 3.0.0 you’ll get a RuntimeError if attempting to re-assign a variable owned by another class in the hierarchy:

class Parent
  def self.mutate! = @@example = []
end

class Child < Parent
  @@example = {}

  def self.example = @@example
end

Parent.mutate!
Child.example  # => RuntimeError (class variable @@example of Child is overtaken by Parent)

The same is true, with Ruby 3.0.0, if a module attempts to change the class variable:

class Demo
  @@example = []

  def self.example = @@example
end

module DemoModule
  @@example = []
end

Demo.include DemoModule
Demo.example  # => RuntimeError (class variable @@example of Demo is overtaken by DemoModule)

Even with improved support in Ruby 3.0.0, use of class variables should be avoided.

Nested Classes

You can nest classes within each other and still be able to reference them anywhere in your application without fear of being scoped only to the parent namespace. This behavior is very different from languages, like Java for example, where an inner class is scoped to the outer class only. Here’s an example:

class Outer
  class Inner
    def self.speak
      puts "HI"
    end
  end
end

Outer::Inner.speak # => "HI"

A module is similar to using a class:

module Outer
  class Inner
    def self.speak
      puts "HI"
    end
  end
end

Outer::Inner.speak # => "HI"

In both cases above, the difference is between Outer being defined as either a class or a module. Both are acceptable and both are constants that give structure to your code.

The problem with nested classes arises when you truly want to use modules to organize your code and stumble upon some random nested class in your code base that has the same name as your module. This can be frustrating from an organization and refactoring standpoint since Ruby code is typically organized in modules. Here’s an example, along with an associated error, in which we attempt use a module without realizing a class of the same name existed:

class Outer
  class Inner
  end
end

module Outer
  class Other
  end
end

# 6:in `<main>': Outer is not a module (TypeError)
# 1: previous definition of Outer was here

For this reason alone, it’s better to avoid nesting your classes and use module for organizing your code, reserving class for your actual implementation.

Subclassed Primitives

Primitives are objects like String, Array, Hash, etc. It can be tempting to implement an object by subclassing one of these primitives and adding your own custom behavior. For example, let’s make a subclass of Array:

class Example < Array
end

Example.new.class                  # => Example
Example.new.to_a.class             # => Array
(Example.new + Example.new).class  # => Array

Notice how the Example instances start out reasonable enough but then take a surprising turn. This is because core primitives are implemented in C for performance reasons where the return values of several methods, like #+, are hard coded to answer the super type. This is why we get an Array instead of Example. This can lead to subtle and surprising behavior. Even structs don’t want to be subclassed.

You can avoid hard to debug behavior, like this, by using composition instead of inheritance:

class Example
  include Enumerable

  def initialize(*arguments)
    @list = Array arguments
  end

  def each(*arguments, &block) = list.each(*arguments, &block)

  private

  attr_reader :list
end

example = Example.new 1, 1, 2
example.map { |element| element * 10}  # => [10, 10, 20]
example.min                            # => 1
example.max                            # => 2
example.tally                          # => {1 => 2, 2 => 1}

With the above, we get all the benefits of Enumerable — which is generally what you want — by implementing a single method: #each. Delegation by extending Forwardable, for example, is another viable option.

Even the object size is reduced:

Array.new.methods.size   # => 191
Enumerable.methods.size  # => 109

That’s a savings of 82 methods you most likely don’t need either. So, yeah, save yourself some heartache, use composition, and keep your objects simple.

Flat Modules

A flat module structure looks like this:

module One
end

module One::Two
end

module One::Two::Three
  puts Module.nesting # => [One::Two::Three]
end

Notice when we print the module structure at point of call we get a single element array: One::Two::Three. To contrast the above, here’s the result when defining nested modules:

module One
  module Two
    module Three
      puts Module.nesting # => [One::Two::Three, One::Two, One]
    end
  end
end

Now we have a three element array where each module in the hierarchy is a unique element in the array. Our object hierarchy is preserved so constant/method lookup is relative to the hierarchy. To illustrate further, consider the following:

module One
  EXAMPLE = "test"
end

module One::Two
end

module One::Two::Three
  puts EXAMPLE
end

# => uninitialized constant One::Two::Three::EXAMPLE (NameError)
# => Did you mean?  One::EXAMPLE

As the error above shows, we could use One::Example to solve the problem. To not repeat ourselves, use a nested module structure instead:

module One
  EXAMPLE = "test"

  module Two
    module Three
      puts EXAMPLE
    end
  end
end

Notice how the above is concise, removes duplication of the earlier example, and provides a unique constant for each level of the hierarchy for relative reference and lookup. Stick to nested modules so you can avoid needless implementation construction and unexpected errors.

Multi-Object Files

Ruby has no limitation with defining multiple objects per file, as shown in the following:

# demo.rb

module One
  module Two
  end
end

class Example
end

There are several disadvantages to this lack of limitation:

  • The One::Two module and Example class are defined in a demo.rb file which is not intuitive when searching for either via a fuzzy file search.

  • The implementation suffers from poor organization as none of the above are remotely related from a naming standpoint. Even if the objects were loosely related, it is better to isolate to a separate file as mentioned in the previous bullet point.

  • The example above, while momentarily small, can get unwieldy to read/maintain if the size of each implementation grows in complexity.

Rather than multiple objects per file, a better solution would be to define each object in a separate file, like so:

# one/two.rb
module One
  module Two
  end
end
# example.rb
class Example
end

Organizing one object per file helps encourage a consistent structure and reduces the strain of finding the associated file with the implementation you are looking for.

Constantizing

In some situations, it can be tempting to automatically load a constant when a constant is not found. Example:

module Example
  # Security exploit since any object within this namespace could be loaded.
  def self.const_missing(name) = puts("Attempted to load: #{self}::#{name}.")
end

Example::Danger  # => Attempted to load: Example::Danger.

The problem with the above is that this can lead to malicious behavior where a hacker could exploit the above by attempting to load unexpected code. Here are further examples:

module Example
  # Memory exploit since constants are never garbage collected.
  def self.const_missing(name) = const_set(name, "default")
end

Example::ONE  # => "default"
Example::TWO  # => "default"
module Example
# Larger memory exploit since these objects will not be garbage collected either.
  def self.const_missing(name) = const_set(name, Class.new(StandardError))
end

Example::One  # => Example::One
Example::Two  # => Example::Two

Dynamically loading constants or other objects can be useful in limited situations. To prevent malicious attacks, ensure safety checks are put in place. For example, all of the above could be avoided with the following check:

module Example
  ALLOWED_CONSTANTS = %i[ONE TWO]

  def self.const_missing name
    fail StandardError, "Invalid constant: #{name}." unless ALLOWED_CONSTANTS.include?(name)
    # Implementation details go here.
  end
end

Example::ONE    # => nil
Example::BOGUS  # => `const_missing': Invalid constant: BOGUS. (StandardError)

The dynamic nature of Ruby provides great power but with this this power comes great responsibility. When reaching for functionality like this, keep usage limited along with restricted access to narrow any potential for exploit.

Redundant Naming

Naming objects, methods, arguments, etc. can be a difficult task when implementing code. In addition to using descriptive and intuitive names, you’ll want to ensure you don’t repeat terminology either. Take the following, for example:

module Products
  module ProductCategories
    class CategoryQuery
      CATEGORY_ORDER = :desc
      CATEGORY_LIMIT = 15

      def initialize product_model, category_order: CATEGORY_ORDER, category_limit: CATEGORY_LIMIT
        @product_model = product_model
        @category_order = category_order
        @category_limit = category_limit
      end

      def to_query = product_model.order(category_order).limit(category_limit)

      private

      attr_reader :product_model, :category_order, :category_limit
    end
  end
end

Notice how the above example violates all of the following:

  • Use of product and category is repeated multiple time within the module namespace and even the query object itself.

  • The constants unnecessarily repeat the class name in which they are defined.

  • The constructor unnecessarily repeats the use of the product and category prefixes for all arguments.

  • The public #to_query method unnecessarily repeats the object name for which it belongs and poorly defines the type of object answered back when messaged.

As you have probably concluded by now, the design of the namespace, object, methods, and related arguments is too verbose. There is no need to hit the reader over the head with redundant terminology. Instead we can simplify the above by leveraging the power of the namespaces in which the implementation is defined. Here’s a better way to write the above without sacrificing usability or readability:

module Products
  module Categories
    class Query
      ORDER = :desc
      LIMIT = 15

      def initialize order: ORDER, limit: LIMIT
        @order = order
        @limit = limit
      end

      def call(model = Category) = model.order(order).limit(limit)

      private

      attr_reader :order, :limit
    end
  end
end

Notice how the above eliminates all of the previous redundant terminology and greatly decreases the verbosity and noise within the implementation. Not only do we have a short and concise namespace for which to add and organize future objects but we also have query objects and can be constructed to answer scopes which might be chain-able by downstream objects. 🎉

Misused Arguments

Related to Redundant Naming, mentioned above, is the misuse of positional and/or keyword arguments. The pattern you want to follow when defining method arguments is:

<required>, <optional with safe defaults>

Consider the following API client:

class Client
  def initialize url:, http:
    @url = url
    @http = http
  end
end

Notice both url and http are required keyword arguments which means we can’t easily construct a new client via Client.new which makes obtaining an instance of this object much harder. Each time we need an instance of this client, we’d have to type the following:

client = Client.new url: "https://api.example.com", http: HTTP

That’s overly verbose for little gain. Here’s an alternative approach that provides an improved developer experience:

require "http"

class Client
  DEFAULT_URL = "https://api.example.com"

  def initialize url = DEFAULT_URL, http: HTTP
    @url = url
    @http = http
  end
end

Notice url is a positional argument with a safe default while http is keyword argument with a safe default. The distinction is subtle but powerful. Consider the following usage:

client = Client.new                                          # <= Fastest usable instance.
client = Client.new "https://api.other.io"                   # <= Instance with custom API URL.
client = Client.new http: Net::HTTP                          # <= Instance with custom HTTP handler.
client = Client.new "https://api.other.io", http: Net::HTTP  # <= Fully customized.

Due to the nature of an API client always needing to interface with an URL, you can avoid stating the obvious by making url a positional instead of keyword argument. On the flip side, http needs to be a keyword argument because it’s not entirely intuitive the second argument is meant for injecting a different HTTP handler. In both cases, the positional and keyword arguments are optional which is a nice win in terms of usability without sacrificing readability.

Misused Casting

You can cast objects to different types through explicit (standard) and implicit (rare) casting. Here’s a quick breakdown of primitive types with their explicit and implicit equivalents:

Type Explicit Implicit

Integer

to_i

to_int

String

to_s

to_str

Array

to_a

to_ary

Hash

to_h

to_hash

Where this goes terribly wrong is when implicit casting is misused as the object’s primary API. Example:

class Version
  def initialize major, minor, patch
    @major = major
    @minor = minor
    @patch = patch
  end

  def to_str = "#{major}.#{minor}.#{patch}"

  private

  attr_reader :major, :minor, :patch
end

version = Version.new 1, 2, 3
puts "Version: #{version.to_str}"  # => "Version: 1.2.3"

Notice the implicit #to_str method is explicitly called. Ruby will happily evaluate your implementation but explicitly messaging an implicit method, as shown above, should be avoided. Here’s a more appropriate implementation:

class Version
  def initialize major, minor, patch
    @major = major
    @minor = minor
    @patch = patch
  end

  def to_s = "#{major}.#{minor}.#{patch}"

  alias_method :to_str, :to_s

  private

  attr_reader :major, :minor, :patch
end

version = Version.new 1, 2, 3
puts "Version: " + version # => "Version: 1.2.3"

Notice #to_s is the explicit implementation provided for downstream callers while the implicit #to_str method is an alias of #to_s so Ruby can perform the implicit casting of version to a string when printed on the last line. Without the implicit alias, you’d end up with a TypeError.

The main point here is to always default to implementing the explicit version of the type you desire while only aliasing implicit support if you want to allow Ruby to conveniently cast your object into another type without having to force explicit use.

Should you want a fully fledged implementation of the Version implementation show above with additional examples, I encourage you to check out the Versionaire gem.

Endless Methods

Introduced in Ruby 3.0.0, these should not be used for multi-line message chains:

def read(path) = path.read
                     .split("\n")
                     .map(&:strip)
                     .reject(&:empty?)
                     .uniq
                     .sort

Instead, it is better to use a normal method if multiple lines are necessary:

def read path
  path.read
      .split("\n")
      .map(&:strip)
      .reject(&:empty?)
      .uniq
      .sort
end

Even better, if an endless method can fit on a single line then use the single line:

def read(path) = path.read.split("\n").map(&:strip).reject(&:empty?).uniq.sort

This improves readability, ensures endless methods remain a single line, and doesn’t cause exceptions when copying and pasting in IRB. To see what I mean, try copying and pasting the multi-line endless method above in IRB while the second example, using a normal block, will not error.

Private Methods

Private methods communicate the following in object design:

  • Functionality supports the current object only.

  • Method is off limits and potentially volatile.

  • Implementation and method signature might change.

While the following is a contrived example, notice how private clearly delineates between the objects’s public versus private API:

class WordBuilder
  DELIMITER = "-"

  def initialize delimiter: DELIMITER
    @delimiter = delimiter
  end

  def call(text) = split(text).map(&:capitalize)

  private

  attr_reader :delimiter

  def split(text) = text.split(delimiter)
end

While you can use BasicObject#__send__ to message the private method, you should avoid doing this for all of the reasons mentioned above. This also includes attempting to test a private method. To emphasize further, avoid the following:

  • Never break encapsulation by reaching into an object to message a private method via BasicObject#__send__. BasicObject#__send__ should only be used by the object that implements the private method since the object owns it.

  • Never test an object’s private method in your specs. The private method can be tested indirectly via the object’s public API instead.

Message Chains

When chaining multiple messages together, default to using a single line as long as the line doesn’t violate your style guide’s column count. For example, take the following which reads from a file and splits on new lines:

lines = path.read.split "\n"

The above is short and readable but if the implementation became more complex, you’d risk violating your column count and incur a horizontal scrolling cost in order to read the implementation. By breaking up your messages per line, you improve readability. Example:

lines = path.read
            .split("\n")
            .map(&:strip)
            .reject(&:empty?)
            .uniq
            .sort

You’ll also want to break your message chains into single lines when using anonymous functions. Example:

# Avoid
lines = path.read.split("\n").tap { |lines| logger.debug "Initial lines: #{lines}." }.map(&:strip)

# Use
lines = path.read
            .split("\n")
            .tap { |lines| logger.debug "Initial lines: #{lines}." }
            .map(&:strip)

While the one-liner is syntactically correct, it decreases readability. Even worse, imagine a one-liner with multiple anonymous functions all chained together on a single line. The readability and maintainability would get out of hand rather quickly. One common example of multiple anonymous functions chained together is when using RSpec. Take the following example:

it "adds membership" do
  expect {
    organization.memberships.create user: user
  }.to change {
    Membership.count
  }.by(1)
end

The above abuses dot notation to chain together multiple anonymous messages. A better solution is to use local variables to reduce complexity. Example:

it "adds membership" do
  expectation = proc { organization.memberships.create user: user }
  count = proc { Membership.count }

  expect(&expectation).to change(&count).by(1)
end

While the above incurs a local variable cost, the trade-off is that the entire expectation is readable via a single line. 🎉

There is an obscure style worth mentioning which is Jim Weirich’s semantic rule for {} versus do…​end. The semantic rule is less common and much harder to enforce. Plus, as you’ve seen above, there is value in being able to use {} for message chains that might return a value or cause a side effect in terms of readability and succinctness.

Finally — and related to the above — when needing to chain a multi-line anonymous function, ensure the call is at the end of a message chain. Example:

# Avoid
lines = path.read
            .split("\n")
            .tap do |lines|
              logger.debug "Initial lines: #{lines}."
              instrumenter.instrument "file.read", count: lines.size
            end
            .map(&:strip)

# Use
lines = path.read
            .split("\n")
            .map(&:strip)
            .tap do |lines|
              logger.debug "Initial lines: #{lines}."
              instrumenter.instrument "file.read", count: lines.size
            end

While the above is a contrived example, you see how readability improves as the eye traces — top to bottom — through the dot notation with the multi-line do…​end signaling the end of the chain. Should you need multi-line functionality in the middle of your message chain, then you can give this functionality a name by using a method. Example:

def record lines
  logger.debug "Initial lines: #{lines}."
  instrumenter.instrument "file.read", count: lines.size
end

lines = path.read
            .split("\n")
            .tap { |lines| record lines }
            .map(&:strip)

Monkey Patching

Ruby has always allowed objects to be be monkey patched. Example:

class String
  def inspect = "You've been hacked!"
end

Monkey patching is hard to debug and can introduce surprising behavior, especially when patched deep within the internals of your application or some dependent library. Luckily, these unpleasant surprises can be safely avoided via Refinements, which were fully implemented in Ruby 2.1.0. This has been discussed in detail before, so follow the link to learn more.

Lazy Operator

The lazy operator — or more formally known as the Safe Navigation Operator — was introduced in Ruby 2.3.0, this operator is particularly egregious because it encourages several violations of good object oriented design. Example:

author = nil
author&.address&.street # => nil

The above violates/encourages all of the following:

  • Connascence of Name/Type - We now have a tight coupling to the names of the objects via the messages being sent. We also need to know the object hierarchy and types of objects we are messaging (i.e. address and street). If this code were to be refactored so the names and/or types changed, the above code snippet would still work. This false positive can be hard to detect if not thoroughly tested or, worse, discovered in production only.

  • Null Object - Instead of messaging the author for address and then street, address could answer back a null object that acts like an address but has safe default values. Same goes for street. At least, in this case, you’d be dealing with the same types of objects for validation purposes.

  • Broken Window - Once a nil has been introduced, the use of the nil tends to leak into more code that needs to deal with a nil thus perpetuating more broken windows within the code base. This is also known as the Billion Dollar Mistake.

  • Law of Demeter - This harkens back to the connascence issue where we have to dig into the author and then address to pluck out the value of street. We could teach author to answer back the street address. Example: author.street_address. Now the underlying semantics can be refactored as necessary with the calling object being none the wiser. Even better, the Adapter Pattern could be used to consume an address and provide a #street_address message with the appropriate validation and error handling.

  • NoMethodError: undefined method for nil:NilClass - Finding a solution becomes significantly harder because the root cause of NoMethodError is typically obscured several layers down the stack where the lazy operator was first introduced.

While there isn’t an automated way to discourage this practice entirely, you can disable the spread of this via Rubocop:

Style/SafeNavigation:
  Enabled: false

Argument Forwarding

Argument forwarding was first introduced in Ruby 2.7.0 and later enhanced in Ruby 3.0.0 to support leading arguments. The problem is this can make code hard to read in terms of what arguments are supported via an object’s public API. Example:

class Example
  def initialize tagger: Tagger.new
    @tagger = tagger
  end

  def call(...) = tagger.call(...)

  private

  attr_reader :tagger
end

Notice how Example#call obscures what arguments are required. You can only figure that out by reading the Tagger source code and noticing the label and version arguments are required. It would be better to define the necessary arguments via #call instead of forcing the reader to figure that out. Example:

class Example
  def initialize tagger: Tagger.new
    @tagger = tagger
  end

  def call(label, version) = tagger.call(label, version)

  private

  attr_reader :tagger
end

Notice how the above is easier to read and understand at a glance without having to hunt down the Tagger implementation for details. A slightly different twist, where argument forwarding can be useful, is with command objects. Example:

class Example
  def self.call(text, ...) = new(...).call(text)

  def initialize io: Kernel
    @io = io
  end

  def call(text) = io.puts(text)

  private

  attr_reader :io
end

The above allows you to write code like this: Demo.call "Hi", io: StringIO.new which gives you the flexibility to optionally change behavior or use the default Demo.call("Hi") behavior. The difference is you can quickly grok what arguments are required in .call by looking down at the #initialize method definition. Situations in which you can forward arguments within the same object is better in terms of encapsulation than being strewn across multiple objects to assemble the complete picture in your head.

Numbered Parameters

These were introduced in Ruby 2.7.0. Here’s a simple example:

["Zoe Alleyne Washburne", "Kaywinnet Lee Frye"].each { puts _1 }

# Yields:
# Zoe Alleyne Washburne
# Kaywinnet Lee Frye

To improve readability, you could write it this way:

["Zoe Alleyne Washburne", "Kaywinnet Lee Frye"].each { |character| puts character }

While this solution requires more typing, good variable names help convey meaning and serve as a form of documentation. In this case, we’re not only printing names of people but we can see these are fictional characters. The value of this cannot be understated, especially if you were to iterate all enumerables using numbered parameters throughout your entire codebase. The use of _1, _2, _3, etc. would get monotonous quickly and make it hard to maintain context of each code block.

Nested blocks aren’t supported either because the following fails:

1.times do
  _1.times { puts _1 }
end

# Yields:
# SyntaxError...numbered parameter is already used in...outer block

Similarly, you can’t use _1 as a variable name — not that you’d want to anyway. Example:

_1 = "test"
# Yields: warning: `_1' is reserved for numbered parameter; consider another name

Focus on readability. Your fellow colleagues and future self will thank you.

Conclusion

May the above improve your code quality and make you a better engineer!