Idioms, Style and Clean Code
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them.
Idiomatic Ruby is code that uses the language’s own vocabulary: blocks and Enumerable instead of counters, expressions
instead of statements, small objects with clear names instead of long procedures with comments. Clean code is code
whose next change is cheap. The two overlap most of the time, and where they differ this page says so. It covers the
daily habits (naming, small methods, guard clauses, conditionals, iteration, return values, value objects, small
idioms), how to comment, and how to improve existing code in small, test-protected steps.
Most advice here is convention, not language rule. The language rules are on the pages this one builds on: Design Philosophy and Coming from Other Languages explains the intent behind the language, Methods and Parameters and Calling Methods and Argument Forwarding define the parameter kinds used below, Control Expressions and Operators and Precedence the conditionals, Enumerable and Blocks, Procs and Lambdas the iteration vocabulary, Exceptions and Error Handling the error side of guard clauses, and Struct, Data and Value Objects and Equality, Hashing and Comparison the value-object tools. Visibility is in Visibility and Encapsulation. The tools that enforce much of this automatically are in Style, Linting and Static Analysis, the tests that make refactoring safe in Testing with Minitest, and the design principles that sit one level above idioms in SOLID and Design Patterns. Typical traps are collected in Common Mistakes and Gotchas.
Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop
1.92 using the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation
off). The output shown in # => comments, and in the output blocks, is the real output. The style rules quoted come
from the community Ruby Style Guide (https://rubystyle.guide/) and from RuboCop’s default configuration, both labelled as
community material: neither is part of Ruby itself. Each file is self-contained and uses the names of the shelf
library catalogue used throughout this section (Shelf, Book, Isbn, Author, Catalog, Loan), run with
ruby -Ilib so that require "shelf" finds the gem (see the shelf gem source).
What "idiomatic" means in Ruby
Ruby’s design goal is programmer happiness and the principle of least surprise: code should do what a reader who knows the core library expects. In practice that gives four habits that every section below applies.
-
Everything is an object, and objects answer messages.
5.nonzero?,nil.to_a,"text".then { it.strip }and[1, 2].sumare ordinary calls. Prefer asking an object over testing its type, and rely on duck typing: if it responds toeach, it is enumerable for your purposes. -
Expressions over statements.
if,case,begin, method bodies and blocks all produce a value. Using that value removes temporary variables andreturnkeywords. -
Say what, not how.
books.select { ... }.map { ... }states the intent; a counter, an index and an accumulator state the mechanism. The mechanism is still available (and right) when the algorithm is the mechanism. -
Small, named pieces. A method name is documentation that the interpreter keeps honest. Extract until the name is better than a comment would be.
None of these is a licence for cleverness. The measure is the next reader: a one-line chain of five Enumerable calls is
idiomatic only if it is still readable in a code review. Idioms also change. What was idiomatic before the keyword
argument separation of Ruby 3.0, before Data (3.2) or before the it block parameter (3.4) is now often a smell; the
last section lists what older code and older books do differently.
Naming
Conventions
Names are the main API of a codebase. Ruby’s conventions are few and almost universal; RuboCop’s Naming cops enforce
the mechanical ones, and the Ruby Style Guide
Naming section is the reference for the rest.
| What | Convention | Example |
|---|---|---|
Local variables, method names, symbols, keyword arguments, file names |
|
|
Classes and modules |
|
|
Constants that are not classes or modules |
|
|
Predicate methods |
End in |
|
The more dangerous twin of a method |
End in |
|
Conversion methods |
|
|
Unused block parameters and variables |
Leading |
|
Binary operators and their parameter |
The parameter is named |
|
Names should be English and describe what the object is or what the method does for the caller, not how it works. A
getter is title, not get_title; a setter is title= (RuboCop’s Naming/AccessorMethodName flags get_ readers and
set_ writers). A predicate says what is asked: overdue?, not is_overdue?. The default RuboCop configuration enforces this
with Naming/PredicatePrefix; Ruby’s own empty?, include?, frozen? and nil? are the model.
Very short names are acceptable as the parameter of a one-line block (and a block that only calls one method is better
written books.map(&:title)), but not in a method signature: Naming/MethodParameterName requires at least three
characters, with a few allowed names such as io, id and to.
? and ! as they really are
The folklore says "a method ending in ? returns a boolean" and "a method ending in ! mutates its receiver". Neither
is the rule, and treating them as rules produces wrong code.
-
?means "asks a question". The result is truthy or falsy, not necessarilytrueorfalse. In the core library,Integer#nonzero?returns the number ornil, andFile.size?returns a size ornil. Return a real boolean from your own predicates when callers may print or serialise the result, but do not insist on it when the truthy value is useful. RuboCop’sNaming/PredicateMethod(added in 1.76, still pending, so it is on only withNewCops: enable) checks that predicates return booleans and that non-predicates do not. -
!means "the more dangerous version of a method that has a safe version". Mutation is the most common danger, but not the only one:exit!skipsat_exithandlers, and anupdate!that raises whereupdatereturnsfalseis dangerous in the same sense. Do not add a bang to a method just because it mutates:Array#pop,Array#pushandHash#deletemutate and have no bang because there is no safe twin. Conversely, a bang method does not always change anything:[1, 2, 3].uniq!returnsnilwhen it makes no change, so never chain on the result of a bang method.
The example defines a Reservation with a safe renew (returns a new object) and a dangerous renew! (raises), and
shows the core-library cases that break the folklore.
# frozen_string_literal: true
require "shelf"
module Shelf
# Names carry meaning: the reader should guess what a call does.
class Reservation
MAX_RENEWALS = 2
attr_reader :book, :renewals
def initialize(book, renewals: 0)
@book = book
@renewals = renewals
end
# Predicate: asks a question, has no side effects.
def renewable? = renewals < MAX_RENEWALS
# Safe version: returns a new object, never raises.
def renew = renewable? ? self.class.new(book, renewals: renewals + 1) : self
# Bang version: the more dangerous twin, raises when it cannot renew.
def renew!
raise Shelf::Error, "no renewals left for #{book.title}" unless renewable?
renew
end
# Conversion methods use the to_ prefix and return a new value.
def to_h = { title: book.title, renewals: }
end
end
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "The Dispossessed", author:)
reservation = Shelf::Reservation.new(book, renewals: 2)
reservation.renewable? # => false
reservation.renew.renewals # => 2
reservation.to_h # => {title: "The Dispossessed", renewals: 2}
begin
reservation.renew!
rescue Shelf::Error => e
e.message # => "no renewals left for The Dispossessed"
end
# Core predicates and bang methods do not follow the folklore either.
5.nonzero? # => 5
0.nonzero? # => nil
[1, 2, 3].sort! # => [1, 2, 3]
[1, 2, 3].uniq! # => nil
[1, 2, 3].pop # => 3
Source: Numeric#nonzero?
When both versions exist, the Style Guide recommends defining the safe one in terms of the dangerous one or the other way
round, whichever keeps one copy of the logic. Here renew! guards and delegates to renew.
Small methods and clear parameters
One level of abstraction per method
A method should do one thing at one level of abstraction. The Style Guide’s limit is ten lines, ideally under five; the
RuboCop defaults put numbers on it. These are the thresholds the examples on this page were linted against (all are
RuboCop’s defaults, overridable in .rubocop.yml):
| Cop | Default | What it measures |
|---|---|---|
|
10 |
Lines of code in a method body, blank lines and comments not counted |
|
17 |
Assignments, branches (calls) and conditions combined into one score |
|
7 |
Number of independent paths through the method |
|
5 (3 optional) |
Parameters, keywords included; a maximum of 3 optional positional ones |
|
100 |
Lines in a class body |
|
120 |
Characters per line |
The numbers are guidance: a long case that maps codes to names may be fine at fifteen lines, and a five-line method with
four nested conditionals is not. When a method grows past the limit, read it for the sections that have a name (a comment
above a paragraph of code is a method name waiting to be written) and extract those. Extracted helpers are usually
private, so the public interface does not grow.
Parameters: keywords, defaults and parameter objects
Ruby offers four ways to pass information, and the idiomatic choice follows the call site:
-
One or two required positional parameters whose meaning is obvious from the method name:
Isbn.new(digits). -
Keyword arguments from the third parameter on, for every optional value, and for boolean flags.
lend(book, true)tells the reader nothing;lend(book, renewable: true)does. Required keywords (days_late:) document what is mandatory; optional ones carry a default. Since Ruby 3.0 keywords are separate from positional hashes, so an options hash as the last parameter (options = {}) is a legacy pattern: the Style Guide recommends keywords instead, and RuboCop’sStyle/OptionalBooleanParameterflags positional boolean defaults. -
Hash and keyword value omission (3.1) when a variable already has the key’s name:
Book.new(isbn:, title:, author:). -
A parameter object when the same group of values travels together through several methods, or a list grows past three or four parameters. In modern Ruby this is a
Dataclass (3.2): immutable, with keyword construction and value equality built in. Defaults can be given by overridinginitializewith keyword defaults and callingsuper.
A method should also return one kind of value. A search that returns an Array for hits and nil or false for "no
hits" forces every caller to test the type; return an empty Array. Mixed returns are the usual reason nil shows up in
the wrong place.
The Search below shows these points together: a SearchOptions parameter object with defaults, a short public call
that reads as a sentence, and three small private helpers, one per decision.
# frozen_string_literal: true
require "shelf"
module Shelf
# Keyword arguments name the call site; Data groups related parameters.
SearchOptions = Data.define(:title_contains, :limit, :sort_by) do
def initialize(title_contains: nil, limit: 10, sort_by: :title)
super
end
end
# One level of abstraction per method, each short enough to read at a glance.
class Search
def initialize(catalog) = @catalog = catalog
def call(options = SearchOptions.new)
matching(options.title_contains)
.sort_by { |book| sort_key(book, options.sort_by) }
.first(options.limit)
end
private
def matching(fragment)
return @catalog.to_a if fragment.nil?
@catalog.select { |book| book.title.downcase.include?(fragment.downcase) }
end
def sort_key(book, field) = field == :author ? [book.author.name, book.title] : [book.title]
end
end
catalog = Shelf::Catalog.new
[["The Dispossessed", "Ursula K. Le Guin", "978-0-306-40615-7"],
["A Wizard of Earthsea", "Ursula K. Le Guin", "978-0-14-131026-8"],
["Dune", "Frank Herbert", "978-0-441-17271-9"]].each do |title, name, digits|
catalog.add(Shelf::Book.new(isbn: Shelf::Isbn.new(digits), title:, author: Shelf::Author.new(name)))
end
search = Shelf::Search.new(catalog)
search.call.map(&:title) # => ["A Wizard of Earthsea", "Dune", "The Dispossessed"]
search.call(Shelf::SearchOptions.new(title_contains: "the", limit: 1)).map(&:title) # => ["The Dispossessed"]
search.call(Shelf::SearchOptions.new(sort_by: :author)).map(&:title)
# => ["Dune", "A Wizard of Earthsea", "The Dispossessed"]
Source: Data
Details to notice:
-
SearchOptions.newwith no arguments is valid because the overriddeninitializesupplies defaults for all three keywords; a baresuperforwards them to `Data’s own initialiser. -
matchingreturns@catalog.to_awhen there is no filter, so both branches return anArray. The earlyreturnis a guard clause (next section), not a stylistic habit. -
sort_keyis an endless method, introduced in Ruby 3.0. The Style Guide limits them to a single side-effect-free expression; anything longer takes a normal multi-linedef. -
The sort key is an
Array([author, title]) so ties are broken deterministically.sort_byis not stable, and relying on insertion order of equal keys is a common source of tests that fail on another Ruby version.
For more on the parameter kinds and on ... and anonymous argument forwarding, see the pages linked at the top. A
wrapper that only passes arguments along should use the forwarding syntax instead of re-declaring them.
Guard clauses and fail-fast code
A guard clause handles the exceptional case first and leaves the method, so the rest of the body is the happy path at
the left margin. RuboCop’s Style/GuardClause suggests it whenever a method wraps its whole body in a conditional, and
the Style Guide asks to avoid nested conditionals for flow of control.
The same idea applies to data: check at the boundary and fail fast with a clear error rather than let a bad value travel until it fails somewhere far from its cause. The tools:
-
raise ArgumentError, "message"for a bad argument. Put the offending value in the message, usinginspect, so thatniland""are distinguishable. -
Hash#fetchwhen a key must be present.options[:name]returnsnilfor both "missing" and "present butnil`" (and for `false, as the||default below shows);fetchraisesKeyErrorwith the key in the message, or takes a default or a block that states what a missing key means. -
Kernel#Arrayto accept "one or many":Array(nil)is[],Array(book)is[book]and anArraystays as it is. Beware thatArrayconverts aHashinto pairs and does not split aString. -
Kernel#IntegerandKernel#Floatfor strict parsing, withexception: falsewhennilon failure is what you want. -
Hash#digandArray#digfor nested data; they raiseTypeErrorif an intermediate value cannot be dug into (for example anInteger), and returnnilfor a missing key. -
The safe navigation operator
&.whennilis a legitimate answer in a chain:config&.fetch(:name). It skips the call and its arguments when the receiver isnil, but it is not a general null guard:false&.upcaseraisesNoMethodError. Long chains of&.mean the model has too many optional parts; give the object a method that hides them.
# frozen_string_literal: true
require "shelf"
module Shelf
# Guard clauses reject bad input first, so the main path is not nested.
module Lending
module_function
def due_date(loan_days, from: Time.now)
unless loan_days.is_a?(Integer) && loan_days.positive?
raise ArgumentError, "loan_days must be a positive Integer, got #{loan_days.inspect}"
end
from + (loan_days * 86_400)
end
def titles(books)
return [] if books.nil?
Array(books).map(&:title)
end
# fetch states what a missing key means instead of letting nil travel on.
def setting(options, name)
options.fetch(name) { raise Shelf::Error, "missing setting: #{name}" }
end
end
end
Shelf::Lending.due_date(14, from: Time.utc(2026, 1, 1)) # => 2026-01-15 00:00:00 UTC
begin
Shelf::Lending.due_date(0)
rescue ArgumentError => e
e.message # => "loan_days must be a positive Integer, got 0"
end
author = Shelf::Author.new("Frank Herbert")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "Dune", author:)
Shelf::Lending.titles(nil) # => []
Shelf::Lending.titles(book) # => ["Dune"]
Shelf::Lending.titles([book, book]) # => ["Dune", "Dune"]
options = { loan_days: 14, reminders: { email: false } }
Shelf::Lending.setting(options, :loan_days) # => 14
options[:renewals] # => nil
options.dig(:reminders, :email) # => false
options.dig(:reminders, :sms)&.upcase # => nil
options[:reminders][:email] || "default" # => "default"
options.fetch(:reminders).fetch(:email, "default") # => false
begin
Shelf::Lending.setting(options, :renewals)
rescue Shelf::Error => e
e.message # => "missing setting: renewals"
end
begin
options.fetch(:renewals)
rescue KeyError => e
e.message # => "key not found: :renewals"
end
Source: Hash#fetch
Two details in the output: options[:reminders][:email] || "default" yields "default" even though the key holds false
(a bug the version with fetch(:email, "default") does not have), and options.dig(:reminders, :sms)&.upcase is nil
without raising, because both the missing key and the safe navigation operator return nil.
unless and raise are the usual guard keywords; rescue is for conditions you can recover from, not for
validating input. For the exception hierarchy, custom error classes and retry, see
Exceptions and Error Handling.
Conditionals and truthiness
Truthiness and short-circuiting
In Ruby only nil and false are falsy. 0, "", [] and {} are truthy, which is why the idiomatic test for "has
content" is a predicate (empty?, zero?, nil?, positive?) and not the value itself. && and || return one of their
operands, not a boolean, so name || "anonymous" is a default, value && value.upcase is a guard (better written with
&.), and list.first || fallback is a lookup with a fallback. !!value converts to a real boolean; the Style Guide
allows it for a deliberate reason (a JSON field, the result of a predicate) and discourages it in a condition, where it
only adds noise.
and and or are not the readable spelling of && and ||: they bind more loosely than assignment, so
value = lookup or "fallback" assigns lookup and then evaluates the fallback for nothing. Use them, if at all, as
flow-control keywords (save or raise "failed"); Style/AndOr flags the rest. The precedence table is on
Operators and Precedence.
Reading conditions
-
Name a complicated condition. A predicate method (
waived?) or an explaining local variable replaces a comment and can be tested separately. -
Use
unlessonly for a simple negative, and never withelse(Style/UnlessElse).unless a && bis hard to read;if !(a && b)or a predicate is clearer. -
Use the ternary operator for a short value choice, never nested (
Style/NestedTernaryOperator) and never across several lines. -
Prefer
caseto a chain ofelsifon one value;whenuses===, so ranges, classes, regexps and lambdas work as patterns. For structured data,case/inpattern matching (stable since 3.0) says more; see Pattern Matching. A constant cannot be the end of a range in aninpattern (the parser rejectsin 1..GRACE_DAYS); usewhen, or a guard such asin Integer => days if days <= GRACE_DAYS. -
Let
ifandcasereturn their value. Assigning the result of the expression replaces assignments in every branch. -
Modifier
ifandunlessare for short one-line statements (return if done?), not for long conditions. -
A leading
||or&&continues the previous line (Ruby 4.0), like a leading.in a method chain. In 3.4 and earlier the parser rejects it, so code that has to run on older Rubies puts the operator at the end of the line.
# frozen_string_literal: true
require "shelf"
module Shelf
# Conditionals that read like the rule they implement.
class Fine
GRACE_DAYS = 3
CENTS_PER_DAY = 25
CAP_CENTS = 1_000
def initialize(days_late:, member_since_years:, damaged: false)
@days_late = days_late
@member_since_years = member_since_years
@damaged = damaged
end
# An explaining name for a compound condition.
def waived?
within_grace_period? || (loyal_member? && !damaged?)
end
# if/else is an expression: the value is the last evaluated expression.
def cents
if waived?
0
else
[chargeable_days * CENTS_PER_DAY, CAP_CENTS].min
end
end
# case/when returning a value; ranges match through ===.
def label
case @days_late
when ..0 then "on time"
when 1..GRACE_DAYS then "grace period"
when (GRACE_DAYS + 1)..14 then "late"
else "very late"
end
end
# A leading || continues the previous line (Ruby 4.0).
def exempt?
waived?
|| @member_since_years >= 25
end
private
def within_grace_period? = @days_late <= GRACE_DAYS
def loyal_member? = @member_since_years >= 5
def damaged? = @damaged
def chargeable_days = @days_late - GRACE_DAYS
end
end
Shelf::Fine.new(days_late: 2, member_since_years: 1).cents # => 0
Shelf::Fine.new(days_late: 10, member_since_years: 1).cents # => 175
Shelf::Fine.new(days_late: 10, member_since_years: 8).cents # => 0
Shelf::Fine.new(days_late: 10, member_since_years: 8, damaged: true).cents # => 175
Shelf::Fine.new(days_late: 100, member_since_years: 1).cents # => 1000
Shelf::Fine.new(days_late: 10, member_since_years: 1).label # => "late"
Shelf::Fine.new(days_late: 30, member_since_years: 30, damaged: true).exempt? # => true
# Truthiness: only nil and false are falsy.
[0, "", [], {}].map { it ? "truthy" : "falsy" } # => ["truthy", "truthy", "truthy", "truthy"]
[nil, false].map { it ? "truthy" : "falsy" } # => ["falsy", "falsy"]
# && and || return one of their operands, which makes defaults and guards short.
name = nil
name || "anonymous" # => "anonymous"
name&.upcase # => nil
# or/and bind more loosely than =: they are flow control, not boolean operators.
missing = {}[:key]
wrong = missing or "fallback"
right = missing || "fallback"
puts "wrong=#{wrong.inspect} right=#{right.inspect}" # prints: wrong=nil right="fallback"
The leading || in exempt? is the Ruby 4.0 form listed in the 4.0 NEWS (logical binary operators at the beginning of a
line continue the previous line). Run the same method body on 3.4 and the parser reports a syntax error.
Enumerable instead of counting loops
The idiomatic way to walk a collection is to give a block to a method of Enumerable. The method names say what the
loop is for, and the temporary state (index, accumulator, result array) lives inside the method.
| Intent | Idiom |
|---|---|
Transform every element |
|
Keep elements that match, drop the rest |
|
Transform and drop |
|
Find the first match |
|
Any, all or none match |
|
Count matches |
|
Fold into one value |
|
Split or group |
|
Build a Hash |
|
Repeat a number of times |
|
Four rules complete the table.
-
One block parameter, one name. The
itimplicit parameter (3.4) is the shortest readable form for a one-argument block;_1(2.7) is the older numbered spelling, andSymbol#to_proc(map(&:title)) is best when the block would only call one method. RuboCop’sStyle/ItBlockParameter(pending, so on withNewCops: enable) defaults to allowingitin single-line blocks. In Ruby 3.3 and earlieritis an ordinary method call, so code that still runs there must name the parameter. -
Do not build an array with
eachand<<whenmap,selectorfilter_mapis what you mean (RuboCop’sStyle/MapIntoArray), and do not mutate the argument you iterate. -
sizeis the total,countwith a block is the number of matches. Usesize(orlength) for the whole collection:countwith no block walks anEnumerablethat is not anArray. -
foris not aneachalias. It does not create a scope, so the loop variable leaks into the enclosing method (Style/For). Useeach.
while and until remain the right tool when the algorithm really is a loop with a changing state, such as a numeric
iteration to a fixed point. The example compares the same task written both ways, then lists the idioms in use.
# frozen_string_literal: true
require "shelf"
rows = [["The Dispossessed", "Ursula K. Le Guin", "978-0-306-40615-7", 1974],
["A Wizard of Earthsea", "Ursula K. Le Guin", "978-0-14-131026-8", 1968],
["Dune", "Frank Herbert", "978-0-441-17271-9", 1965],
["Neuromancer", "William Gibson", "978-0-553-38256-3", 1984]]
books = rows.map do |title, name, digits, _year|
Shelf::Book.new(isbn: Shelf::Isbn.new(digits), title:, author: Shelf::Author.new(name))
end
years = rows.to_h { |title, _name, _digits, year| [title, year] }
# C-style: index, accumulator and mutation all visible.
def ursula_titles(books)
result = []
i = 0
while i < books.size
result << books[i].title.upcase if books[i].author.name.start_with?("Ursula")
i += 1
end
result
end
ursula_titles(books) # => ["THE DISPOSSESSED", "A WIZARD OF EARTHSEA"]
# Enumerable: say what you want. Same result, no counter, no accumulator.
books.select { it.author.name.start_with?("Ursula") }.map { it.title.upcase }
# => ["THE DISPOSSESSED", "A WIZARD OF EARTHSEA"]
# filter_map: select and map in one pass.
books.filter_map { it.title.upcase if it.author.name.start_with?("Ursula") }
# => ["THE DISPOSSESSED", "A WIZARD OF EARTHSEA"]
# Symbol#to_proc for a single message.
books.map(&:title) # => ["The Dispossessed", "A Wizard of Earthsea", "Dune", "Neuromancer"]
books.find { it.title == "Dune" }&.author&.name # => "Frank Herbert"
books.any? { years.fetch(it.title) < 1970 } # => true
books.count { years.fetch(it.title) < 1970 } # => 2
books.group_by { it.author.name }.transform_values(&:size)
# => {"Ursula K. Le Guin" => 2, "Frank Herbert" => 1, "William Gibson" => 1}
books.map { it.author.name }.tally.max_by(&:last) # => ["Ursula K. Le Guin", 2]
books.sum { years.fetch(it.title) } # => 7891
books.partition { years.fetch(it.title) < 1970 }.map { |part| part.map(&:title) }
# => [["A Wizard of Earthsea", "Dune"], ["The Dispossessed", "Neuromancer"]]
books.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |book, by_decade|
by_decade[years.fetch(book.title) / 10 * 10] << book.title
end
# => {1970 => ["The Dispossessed"], 1960 => ["A Wizard of Earthsea", "Dune"], 1980 => ["Neuromancer"]}
books.each_slice(2).map { |pair| pair.map(&:title) }
# => [["The Dispossessed", "A Wizard of Earthsea"], ["Dune", "Neuromancer"]]
# Numbered counting loops belong to Integer, not to a while loop.
3.times.map { it * 2 } # => [0, 2, 4]
1.upto(3).to_a # => [1, 2, 3]
3.downto(1).to_a # => [3, 2, 1]
(1..10).step(3).to_a # => [1, 4, 7, 10]
# Where an explicit loop is right: the algorithm is the loop.
def collatz_steps(number)
steps = 0
until number == 1
number = number.even? ? number / 2 : (3 * number) + 1
steps += 1
end
steps
end
collatz_steps(6) # => 8
Source: Enumerable
The last method, collatz_steps, is a loop that cannot be written with an iterator without hiding the algorithm. Searching
a sorted array is another case, but there Array#bsearch is the idiom. Lazy enumerators, infinite sequences and
Enumerator::Lazy are in
Enumerators and Lazy Evaluation.
Return values, implicit return and method chains
The value of a method is the value of its last expression, so an explicit return is for leaving early, not for returning
at the end (Style/RedundantReturn; the Style Guide says to avoid return where it is not needed for flow of control).
Two corollaries:
-
Decide what a method returns, on purpose. A command that changes state returns
selfwhen the object is built by chaining, or something meaningful (a count, the new value). A method that ends withputsorifwithoutelsereturnsnil, which is rarely what a reader expects. -
A setter’s value is the argument, not the return value.
obj.x = 5evaluates to5even if thex=method returns something else; only a call throughsend(orpublic_send) sees the real return value. Do not rely on a setter’s return value.
Returning self from mutators enables fluent chains (builders, Enumerator::Lazy, Comparable pipelines). Chains that
span lines put the dot at the start of the continuation line (Layout/DotPosition default is leading), which makes
every line read as a step and lets comments sit between steps. With tap for a peek and then for a pipeline step, a
chain avoids temporary variables, but the Style Guide recommends removing tap from production code once it was added
for debugging.
# frozen_string_literal: true
require "shelf"
module Shelf
# The value of a method is its last expression; return is for leaving early.
class Bookcase
attr_reader :books
def initialize = @books = []
def add(book)
@books << book
self
end
def count = @books.size
# Assignment methods always evaluate to the right-hand side, whatever they return.
def label=(value)
@label = value.to_s.strip
:ignored
end
def label = @label || "unlabelled"
def empty_report
puts "nothing to report" if books.empty?
end
end
end
author = Shelf::Author.new("Frank Herbert")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-441-17271-9"), title: "Dune", author:)
shelf = Shelf::Bookcase.new
shelf.add(book).add(book).count # => 2
echoed = shelf.label = " fiction "
echoed.length # => 10
shelf.label # => "fiction"
shelf.send(:label=, " x ") # => :ignored
shelf.empty_report # => nil
Shelf::Bookcase.new.empty_report # prints: nothing to report, returns nil
titles = shelf.books
.map(&:title)
.uniq
.join(", ")
titles.upcase # => "DUNE"
Source: Ruby 4.0 documentation: Methods
Getting the indentation of a multi-line chain right is RuboCop’s Layout/MultilineMethodCallIndentation job; the default
aligned style, used above, lines the dots up under the first call after the receiver.
Value objects and small classes
Most domain concepts in the shelf gem (an ISBN, a loan period, an author) are values: defined by their contents,
interchangeable when equal, and never changed after creation. Ruby’s tool for them is Data.define (3.2); see
Struct, Data and Value Objects for the full API. The
clean-code points:
-
Define the class with a block assigned to a constant,
LoanPeriod = Data.define(:days) doplus the body andend, and do not inherit from the result ofData.defineorStruct.new. Inheriting adds an anonymous class toancestors, which the Style Guide advises against. Pre-3.2 code that subclassesStruct.new(...)or relies onkeyword_init: truecan be converted to the form shown below. -
Validate in
initializeand callsuper; every instance that exists is then valid. -
Include
Comparableand define<=>(returnnilfor an incomparable operand) to get<,>,between?andclamp.Dataalready supplies==,eql?,hash,inspectandwith. -
Define
to_sfor domain objects: it is what string interpolation andputscall. Keepinspectfor debugging. -
Use
attr_reader(orDatamembers) instead of hand-written getters,attr_writerorattr_accessoronly where external code really needs to assign, and group them at the top of the class. Do not call the deprecated one-argumentattr. -
Keep construction side-effect free. No file or network access in
initialize; offer a factory method (self.from_file) for those.
A fluent builder (every step returns self) is the one place where mutable state with chained setters is idiomatic.
# frozen_string_literal: true
require "shelf"
module Shelf
# A small immutable value object: equality, ordering and a readable to_s for free.
LoanPeriod = Data.define(:days) do
include Comparable
def initialize(days:)
raise ArgumentError, "days must be positive, got #{days.inspect}" unless days.is_a?(Integer) && days.positive?
super
end
def <=>(other) = other.is_a?(LoanPeriod) ? days <=> other.days : nil
def +(other) = with(days: days + other.days)
def to_s = "#{days} #{days == 1 ? 'day' : 'days'}"
end
# A fluent builder: every step returns self, so calls chain.
class LoanRequest
def initialize = @attributes = {}
def for_book(book)
@attributes[:book] = book
self
end
def for_period(period)
@attributes[:period] = period
self
end
def build = Loan.new(@attributes.fetch(:book), state: :lent)
end
end
week = Shelf::LoanPeriod.new(days: 7)
fortnight = Shelf::LoanPeriod.new(days: 14)
week.eql?(Shelf::LoanPeriod.new(days: 7)) # => true
week.between?(week, fortnight) # => true
[week, fortnight].max # => #<data Shelf::LoanPeriod days=14>
[fortnight, week].min # => #<data Shelf::LoanPeriod days=7>
[week, fortnight].reduce(:+) # => #<data Shelf::LoanPeriod days=21>
week.with(days: 1).to_s # => "1 day"
fortnight.clamp(week, week).to_s # => "7 days"
"Loan for #{week}".delete_prefix("Loan for ") # => "7 days"
begin
week.instance_variable_set(:@days, 1)
rescue FrozenError => e
e.message # => "can't modify frozen Shelf::LoanPeriod: #<data Shelf::LoanPeriod days=7>"
end
begin
Shelf::LoanPeriod.new(days: 0)
rescue ArgumentError => e
e.message # => "days must be positive, got 0"
end
author = Shelf::Author.new("Frank Herbert")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-441-17271-9"), title: "Dune", author:)
loan = Shelf::LoanRequest.new.for_book(book).for_period(week).build
loan.describe # => "Dune is lent"
case book
in { title:, author: { name: } }
"#{title} / #{name}" # => "Dune / Frank Herbert"
end
Source: Comparable
Notice the one-line pattern in the last case: Data classes expose deconstruct_keys, so in { title:, author: { name: } }
binds both values without any code in Book or Author.
Class bodies are easier to read when they follow a stable order: constants, include/extend/prepend, attribute macros
(attr_reader), class-level methods, initialize, public instance methods, then protected and private ones. The
public methods read from the top as a table of contents, and each private helper appears below the first method that
uses it, so the code reads from the general to the specific (the "stepdown" order). Prefer composition (an object that
holds and delegates to another) to deep inheritance, and private_class_method :new for objects that should only be
created through a factory method. Visibility details are in
Visibility and Encapsulation, inheritance and mixins
in Classes and Objects and
Modules and Mixins.
Small idioms worth knowing
The following idioms are short enough to learn together. The example uses each in context.
-
%w[...]and%i[...]build arrays of strings and symbols;.map(&:to_sym)converts when the source is a string list. -
ENV.fetch("NAME", default)documents the default of an environment variable. With a block it computes the default lazily or raises. -
A heredoc with
<<~strips the common leading whitespace;format(aliassprintf) with named references (%<name>s,%<amount>7.2f) keeps text and numbers apart;<<on a mutable buffer (+""orString.new) avoids creating a string per iteration. With# frozen_string_literal: true, a plain""literal is frozen. -
||=memoizes a value that is nevernilorfalse. For values that can be falsy, usedefined?(@x)or a sentinel, or compute eagerly. -
thenpasses the receiver to a block and returns its result (a pipeline step);tappasses it and returns the receiver (a peek). Both are useful, neither should replace a named local variable that explains a value. -
Hash.new { |hash, key| ... }provides a computed default;Hash.new([])shares one array among all keys, which is almost never intended. -
Comparable#clampbounds a value (120.clamp(0, 100), or-5.clamp(0..)with an endless range), which is clearer than nestedmin/max.
# frozen_string_literal: true
require "shelf"
# Small idioms that make Ruby read like the problem.
author = Shelf::Author.new("Ursula K. Le Guin")
isbn = Shelf::Isbn.new("978-0-306-40615-7")
title = "The Dispossessed"
# Hash and keyword shorthand (3.1): name the value once.
Shelf::Book.new(isbn:, title:, author:).to_s # => "The Dispossessed by Ursula K. Le Guin"
# Word and symbol lists.
%w[available lent overdue].map(&:to_sym) # => [:available, :lent, :overdue]
%i[available lent overdue].include?(:lent) # => true
# Configuration with a default: ENV.fetch with a block states the policy.
loan_days = Integer(ENV.fetch("SHELF_LOAN_DAYS", "14"), exception: false) || 14
loan_days.to_s # => "14"
# Building text: a heredoc for prose, format for numbers, << on a mutable buffer in loops.
fine = 1750
report = +"Fines\n"
[["Ana", fine], ["Ben", 0]].each do |name, cents|
report << format("%-5<name>s %<amount>7.2f\n", name:, amount: cents / 100.0)
end
report.lines.map(&:chomp) # => ["Fines", "Ana 17.50", "Ben 0.00"]
help = <<~TEXT
Usage: shelf [options]
-v print the version
TEXT
help.lines.size # => 2
# Memoization with ||= is fine for values that are never nil or false.
class Report
def initialize(catalog) = @catalog = catalog
def titles = @titles ||= @catalog.map(&:title).sort
end
catalog = Shelf::Catalog.new.add(Shelf::Book.new(isbn:, title:, author:))
reports = Report.new(catalog)
reports.titles.equal?(reports.titles) # => true
# then and tap: a pipeline and a peek, without temporary variables.
" the dispossessed ".strip.then { |text| text.split.map(&:capitalize).join(" ") } # => "The Dispossessed"
sizes = []
[3, 1, 2].sort.tap { |sorted| sizes << sorted.size }.first # => 1
sizes.first # => 3
# Block-local defaults with Hash.new and a block; tally for counting.
%w[a b a].tally # => {"a" => 2, "b" => 1}
Hash.new { |hash, key| hash[key] = key.to_s * 2 }[:ab] # => "abab"
# Comparable#clamp keeps a value within bounds, which is clearer than min and max.
120.clamp(0, 100) # => 100
-5.clamp(0..) # => 0
Source: Kernel#format
Comments and documentation
Comments explain why, not what. A comment that restates the next line goes stale the first time the code changes. The Style Guide’s advice is to refactor instead of commenting bad code, to keep comments for rationale (a workaround for non-obvious library behaviour, the source of an algorithm, a performance trade-off) and to keep them up to date or delete them. A few conventions are worth following:
-
Magic comments go at the top of the file, one per line:
# frozen_string_literal: truein every library and script file. Strings are still chilled in 4.0 without it (a mutation warns only with-W:deprecated), so the comment is not obsolete. See Strings and Program Structure and Lexical Elements. -
Annotations (
TODO,FIXME,OPTIMIZE,HACK,REVIEW) are written as the first word of the comment, in capitals, followed by a colon and a space (# TODO: support renewals); RuboCop’sStyle/CommentAnnotationchecks the format. -
Public API comments use RDoc or YARD syntax, so that
riand generated documentation show them. They describe the contract (arguments, return value, errors), not the implementation. See Documentation with RDoc and YARD. -
Name instead of comment. An explaining local variable or an extracted method with a good name is better than a comment above a block.
-
Delete dead code instead of commenting it out; version control keeps the history.
Overriding to_s and inspect (see the value-object example) is also documentation: a readable to_s makes log lines,
error messages and test failures explain themselves.
Refactoring in small steps, with tests
Refactoring is changing the structure of code without changing what it does. The discipline matters more than the techniques:
-
Get the code under test first. For code without tests, write characterization tests that pin down what it does today, including the surprising cases. They are not a statement of what the code should do; they are a safety net.
-
Change one thing at a time: extract a method, rename a variable, replace a condition with a guard clause, replace a hash with a value object. Run the tests after every step. A step that cannot be undone quickly is too big.
-
Separate structural changes from behaviour changes (and from formatting changes), so a failing test points at one cause.
-
Rename in two moves for public code. Add the new name, make the old one call it, mark the old one deprecated, and remove it in a later release.
Kernel#warnwithcategory: :deprecatedanduplevel:reports the caller’s line, and shows only when deprecation warnings are on (ruby -w,-W:deprecatedorWarning[:deprecated] = true; Minitest turns them on by itself, which is why the warnings appear in the run below). -
Narrow the interface as you go. Methods that are only used inside the class become
private; constructors that should not be called directly becomeprivate_class_method :newwith a factory method. -
Let the tools help, with care.
rubocop -aapplies only the corrections RuboCop considers safe;rubocop -Aalso applies unsafe ones that can change behaviour. Run the tests between them, and commit before a large automatic fix.
The following example is a small, realistic refactoring. A legacy calculator works but is hard to read.
# frozen_string_literal: true
module Shelf
# Works, but nobody dares to touch it.
class FineCalculator
def self.calc(days, yrs, dmg)
fine = 0
if days > 3
if yrs >= 5 && !dmg
fine = 0
else
fine = (days - 3) * 25
fine = 1000 if fine > 1000
end
end
fine
end
end
end
Its characterization tests (Minitest 6, the gem version installed here) cover the grace period, the daily rate, the cap
and the loyal-member waiver. Two further tests describe the refactoring target: a keyword-based call and a private
new. They are written first, so they fail first.
# frozen_string_literal: true
require "minitest/autorun"
# The first four tests pin down what the code does today (characterization tests).
class FineCalculatorTest < Minitest::Test
def test_no_fine_inside_the_grace_period
assert_equal 0, Shelf::FineCalculator.calc(3, 1, false)
end
def test_charges_per_day_after_the_grace_period
assert_equal 175, Shelf::FineCalculator.calc(10, 1, false)
end
def test_fine_is_capped
assert_equal 1000, Shelf::FineCalculator.calc(100, 1, false)
end
def test_loyal_members_are_waived_unless_the_book_is_damaged
assert_equal 0, Shelf::FineCalculator.calc(10, 8, false)
assert_equal 175, Shelf::FineCalculator.calc(10, 8, true)
end
# Added during the refactoring, for the new keyword API: red until the refactoring is done.
def test_call_matches_calc
assert_equal 175, Shelf::FineCalculator.call(days_late: 10, member_since_years: 1)
end
def test_new_is_no_longer_public
assert_raises(NoMethodError) { Shelf::FineCalculator.new(1, 1, false) }
end
end
Source: Module#private_class_method
Running the test against the legacy class (ruby -w -r./legacy fine_test.rb --seed 1) is the red step: the four
characterization tests pass and the two new ones fail.
Run options: --seed 1
# Running:
..FE..
Finished in 0.000325s, 18461.5368 runs/s, 18461.5368 assertions/s.
1) Failure:
FineCalculatorTest#test_new_is_no_longer_public [fine_test.rb:30]:
[NoMethodError] exception expected, not
Class: <ArgumentError>
Message: <"wrong number of arguments (given 3, expected 0)">
---Backtrace---
fine_test.rb:30:in 'BasicObject#initialize'
fine_test.rb:30:in 'block in FineCalculatorTest#test_new_is_no_longer_public'
---------------
2) Error:
FineCalculatorTest#test_call_matches_calc:
NoMethodError: undefined method 'call' for class Shelf::FineCalculator
fine_test.rb:26:in 'FineCalculatorTest#test_call_matches_calc'
6 runs, 6 assertions, 1 failures, 1 errors, 0 skips
Source: Minitest::Assertions
The refactored class keeps calc for existing callers, delegates it to the new call, and reports the deprecation. The
logic is split into two private methods with names from the domain.
# frozen_string_literal: true
module Shelf
# The same behaviour, split into named steps, with a keyword API.
class FineCalculator
GRACE_DAYS = 3
CENTS_PER_DAY = 25
CAP_CENTS = 1_000
LOYAL_YEARS = 5
def self.call(days_late:, member_since_years:, damaged: false)
new(days_late, member_since_years, damaged).cents
end
# The old entry point stays for one release and says so.
def self.calc(days, years, damaged)
warn "FineCalculator.calc is deprecated, use FineCalculator.call", category: :deprecated, uplevel: 1
call(days_late: days, member_since_years: years, damaged:)
end
private_class_method :new
def initialize(days_late, member_since_years, damaged)
@days_late = days_late
@member_since_years = member_since_years
@damaged = damaged
end
def cents
return 0 if waived?
[chargeable_days * CENTS_PER_DAY, CAP_CENTS].min
end
private
def waived? = chargeable_days <= 0 || (@member_since_years >= LOYAL_YEARS && !@damaged)
def chargeable_days = @days_late - GRACE_DAYS
end
end
Source: Kernel#warn
The same test file, unchanged, now passes (ruby -w -r./refactored fine_test.rb --seed 1). The deprecation warnings
are the output of the warn call in calc, each pointing at the line in the test that still uses the old name:
fine_test.rb:16: warning: FineCalculator.calc is deprecated, use FineCalculator.call
Run options: --seed 1
# Running:
.fine_test.rb:12: warning: FineCalculator.calc is deprecated, use FineCalculator.call
...fine_test.rb:20: warning: FineCalculator.calc is deprecated, use FineCalculator.call
fine_test.rb:21: warning: FineCalculator.calc is deprecated, use FineCalculator.call
.fine_test.rb:8: warning: FineCalculator.calc is deprecated, use FineCalculator.call
.
Finished in 0.000356s, 16853.9310 runs/s, 19662.9195 assertions/s.
6 runs, 7 assertions, 0 failures, 0 errors, 0 skips
Source: Kernel#warn
The next steps would be to migrate the callers of calc to call one at a time, then delete calc in the next release
and its tests. Each is one small, reversible change.
Spotting what to change
RuboCop finds many of the mechanical smells for you. The file below is deliberately bad (it is valid Ruby, and ruby -w
is silent about it) and shows which cops flag the usual problems. The output is RuboCop 1.92’s emacs format with the
file name removed.
# frozen_string_literal: true
class Reservation
def is_active?(status)
if status != nil
if status == :lent
return true
else
return false
end
end
end
def titles(books)
result = []
for book in books
result << book.title
end
result
end
def notice(overdue)
unless overdue
"ok"
else
"late"
end
end
def present?(value) = !!value
end
4:7: C: Naming/PredicatePrefix: Rename `is_active?` to `active?`.
5:5: C: [Correctable] Style/GuardClause: Use a guard clause (`return unless status != nil`) instead of wrapping the code inside a conditional expression.
5:8: C: [Correctable] Style/NonNilCheck: Prefer `!status.nil?` over `status != nil`.
6:7: C: [Correctable] Style/GuardClause: Use a guard clause (`return true if status == :lent`) instead of wrapping the code inside a conditional expression.
7:9: C: [Correctable] Style/RedundantReturn: Redundant `return` detected.
9:9: C: [Correctable] Style/RedundantReturn: Redundant `return` detected.
16:5: C: [Correctable] Style/For: Prefer `each` over `for`.
23:5: C: [Correctable] Style/UnlessElse: Do not use `unless` with `else`. Rewrite these with the positive case first.
Source: RuboCop documentation: Cops
Not every smell is mechanical. Look also for: a class that needs "and" in its description; a method whose parameters are
passed unchanged to three other methods (a missing parameter object); the same case on a type in several places
(a missing polymorphic method, see SOLID and Design Patterns);
a comment that apologises; and a test that needs to know the internals of the class it covers. reek, flay and
flog look for such patterns; they are community gems and are described on
Style, Linting and Static Analysis.
Where older code and books differ
Books and tutorials on idiomatic Ruby age. This table lists what to translate when you meet them; the "current" column is what the examples on this page use.
| Topic | Older advice or output | Ruby 4.0 |
|---|---|---|
Block parameters |
Numbered parameters |
|
Frozen string literals |
"String literals are frozen by default since 3.4" |
Chilled strings (3.4): mutation warns only with |
Error messages |
|
|
Hash inspection |
|
|
Value objects |
|
|
Keyword and option arguments |
|
Keywords are separate from positional hashes since 3.0; hash and keyword value omission (3.1); anonymous |
Short methods |
A multi-line |
Endless methods (3.0) for single, side-effect-free expressions |
Pattern matching |
"Introduced in 2.7" (it was experimental there) |
Stable since 3.0; find pattern stable in 3.2; pin expressions in 3.1 |
Abstract methods |
|
|
Predicate and bang folklore |
"`?` must return a boolean", "`!` means it mutates" |
|
Standard library |
|
They are bundled gems or were moved out of the default set in 3.4 and 4.0; under Bundler they need a Gemfile entry (see the standard library tour) |
Dates |
|
|
Some book advice is also simply wrong and should not be repeated: a ? predicate that "must return a Boolean"; ! that
"means mutation"; safe_constantize (an ActiveSupport method, not core Ruby) as a way to turn input into a class, which
is a security hazard; and NotImplementedError for abstract methods. These are corrected in the sections above. See
Standard Library Tour for the library changes and
Versions, Releases and Compatibility for the
release schedule.
Guidelines
-
Name for the reader:
snake_casemethods and variables,CapitalCaseclasses,SCREAMING_SNAKE_CASEconstants,?for questions,!only next to a safer twin. -
Keep methods short and at one level of abstraction; give three or more related parameters a
Dataclass, use keywords from the third parameter and for every flag. -
Check input at the boundary with guard clauses and
ArgumentError; usefetchfor keys that must exist,digand&.for data that may be missing,Array()for one-or-many. -
Remember that only
nilandfalseare falsy; name complicated conditions, avoidunlesswithelse, never nest ternaries, and prefercasefor one value against many. -
Prefer
Enumerablemethods (map,select,filter_map,sum,group_by,tally) to counting loops; useitor&:symfor one-argument blocks; keepwhilefor real algorithms. -
Let the last expression be the return value; return
selffrom chained mutators; never rely on a setter’s return value. -
Model values with
Data, validate ininitialize, includeComparable, defineto_s, and do not subclass the generated class. -
Comment the reason, document the public API with RDoc or YARD, and delete commented-out code.
-
Refactor in small steps behind tests, rename public API in two moves with a deprecation, and narrow visibility as you go.
-
Run
rubocopandruby -win CI; treat the Style Guide as a default, the project’s.rubocop.ymlas the decision.
References
-
Ruby 4.0 documentation: Control Expressions, Methods, Calling Methods (safe navigation), Operator Precedence and Miscellaneous (magic comments)
-
Kernel (
Array,Integer,format,tap,then,warn), Hash#fetch, Hash#dig, Enumerable, Data, Comparable, Module#private_class_method and ENV -
Release notes for the versions named on this page: 3.0.0, 3.1.0, 3.2.0, 3.4.0 and 4.0.0 (leading logical operators)
-
Community: Ruby Style Guide: Naming, Nested Conditionals, Hash#fetch, Predicate Methods, Dangerous Methods, Explicit return, Don’t Extend Data.define and Comments
-
Community: RuboCop Style/GuardClause, Naming/PredicatePrefix, Naming/PredicateMethod, Style/ItBlockParameter and Metrics/MethodLength