Assignment and Multiple Assignment
|
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. Assignment looks like the simplest thing in the
language, yet it hides most of the rules that surprise newcomers: an assignment is an expression with a value, a local
variable comes into existence when the parser sees it and not when the line runs, ||= is not the shorthand for
a = a || b that most people believe, a setter without self silently creates a local variable, and the left side of
= can be a whole destructuring pattern. This page covers every assignment form in the language: simple, abbreviated,
multiple (with splats and nesting), attribute and index assignment, and rightward assignment with =>.
It builds on Variables, Constants and Scope (what
each kind of variable name means and where it is visible) and
Program Structure and Lexical Elements
(how the parser reads a program). Later pages use it:
Operators and Precedence (where = sits in the
precedence table), Methods and Parameters (parameters
destructure with the same rules), Blocks, Procs and Lambdas
(block parameters), Arrays and
Hashes (the containers you assign into and out of), and
Pattern Matching (rightward assignment is a one-line pattern
match).
Every example was checked with ruby -wc, run on Ruby 4.0.7 with warnings enabled (ruby -w), and linted with RuboCop
using the default cops of the shelf gem configuration (target Ruby 4.0, new cops enabled, double-quoted strings); the
output shown is the real output. Where an example intentionally breaks a cop (RuboCop dislikes parallel assignment
that is not a swap), an inline rubocop:disable comment says so.
Assignment is an expression
In Ruby, = does not introduce a statement: target = value is an expression whose result is always the assigned
value, and it can sit inside another expression. The documentation states that an assignment expression result is always
the assigned value, including assignment methods (setter calls). The right side is evaluated first, the target is
bound, and the value flows out.
# frozen_string_literal: true
title = +"Dune"
copy = title
copy << " (1965)"
puts title
p title.equal?(copy)
a = b = []
a << 1
p b
x = (y = 5) + 1
p [x, y]
Output:
Dune (1965)
true
[1]
[6, 5]
Source: Assignment
Three consequences follow from the example:
-
Chained assignment (
a = b = []) is just two nested assignments, and both names refer to the same object. Assigning never copies; it binds a name to an object. Mutating the object through one name is visible through the other (see Object Lifecycle, Freezing and Copying fordupandclone). -
An assignment can be used where a value is expected (
x = (y = 5) + 1), but doing so hurts readability; RuboCop’sLint/AssignmentInConditionflagsif x = computefor the same reason. -
title = +"Dune"is the idiomatic way to get a mutable string when the file has# frozen_string_literal: true: unary plus returns an unfrozen copy (andcopy << ...then mutates the one object both names refer to).
Local variables are created by the parser
A local variable exists from the point where the parser sees an assignment to it, not from the moment the assignment runs. A name that has been assigned to earlier in the same scope is read as a variable; a name that has not is read as a method call.
# frozen_string_literal: true
ghost = 1 if ENV.fetch("NEVER_SET", nil)
p ghost
copies = nil
copies ||= 3
copies ||= 9
p copies
stock = { "Dune" => 2 }
stock["Dune"] += 1
stock["Emma"] ||= 0
stock["Emma"] += 4
p stock
flag = false
flag ||= true
flag &&= !flag
p flag
Output:
nil
3
{"Dune" => 3, "Emma" => 4}
false
Source: Local Variables and Methods
The first two lines show the consequence: ghost = 1 if ENV.fetch("NEVER_SET", nil) never runs the assignment, yet ghost
is a defined local variable whose value is nil, so p ghost prints nil instead of raising NameError. The
remaining lines of the example are covered in the next sections. Two related rules follow:
-
p a if a = 0.zero?raisesNameError(undefined local variable or methoda): the parser reads the bareaon the left of the modifierifbefore it has seen the assignment to its right, so it takesafor a method call. -
Because blocks do not start a new scope for names already visible outside them, a block that assigns to an existing outer variable changes that variable; a name first assigned inside a block is local to the block.
def,classandmodulestart a new scope that sees none of the outer locals. See Variables, Constants and Scope.
Variable or method: the setter trap
Local variable names and method names look the same, and assignment always wins: once the parser has seen
name = ..., a bare name later in the scope is a variable. The mistake that bites most often is forgetting self
on a setter, because value = 1 inside a method defines a local, never calls value=.
# frozen_string_literal: true
# Without self, an assignment inside a method creates a local variable.
class Shelf
attr_accessor :label
def broken
label = "Fiction"
[label, local_variables, @label]
end
def fixed
self.label = "Fiction"
[local_variables, @label]
end
end
shelf = Shelf.new
p shelf.broken
p shelf.fixed
def total = 42
total = total.to_i
p total
def pages = 100
pages = pages()
p pages
Output:
["Fiction", [:label], nil]
[[], "Fiction"]
0
100
Source: Assignment Methods
In the example broken returns ["Fiction", [:label], nil]: the local label was created, the instance variable was
never set, and local_variables lists :label. fixed calls the writer through self.label = ..., so there are no
locals and @label is set. When the local is never read afterwards, ruby -w warns assigned but unused variable, and
RuboCop reports Lint/UselessAssignment; that warning is usually the first symptom of this mistake. Two more details:
-
total = total.to_iwheretotalis also a method (it returns42): the right sidetotalis already a local (the parser registered the assignment before reading the right side), so it isniland the method is never called;nil.to_igives0, not42. Writepages = pages()(empty parentheses force a method call, and the example prints100) or use an explicit receiver such asself.pages. -
A setter is the one place
self.is mandatory even though it is a public method. Reading (self.label) does not need it; only assignment does, as doesself.label += 1(the read and the write are both explicit calls).
Abbreviated (operator) assignment
target op= value combines a binary operator with assignment. The operators that can be used this way are
+ - * / % ** & | ^ << >>
Source: Abbreviated Assignment
plus the two logical forms ||= and &&=. They expand as follows:
| Form | Meaning | Typical use |
|---|---|---|
arithmetic and bitwise forms ( |
|
counters, accumulators, flags ( |
|
|
defaults, memoization |
|
|
transform a value that may be absent |
|
the receiver (and index arguments) are evaluated once; the reader and then, only if needed, the writer are called |
lazy defaults on attributes and hashes |
The documentation makes the key point explicitly: the logical operators behave more like a || a = b than
a = a || b. When a is already truthy, a ||= b performs no assignment at all: the right side is not evaluated, and
for an attribute or an index the writer is not called, so a frozen object or a read-only attribute raises nothing.
a = a || b always assigns, which matters for setters with side effects and for frozen receivers.
# frozen_string_literal: true
hits = Hash.new(0)
hits[:dune] += 1
p hits
calls = []
store = Hash.new do |hash, key|
calls << key
hash[key] = []
end
store[:a] ||= [:x]
p calls, store
# A reader/writer pair that reports every call.
class Counter
def initialize = @n = nil
def n
puts "read"
@n
end
def n=(value)
puts "write #{value}"
@n = value
end
end
c = Counter.new
c.n ||= 5
c.n ||= 7
c.n += 1
Output:
{dune: 1}
[:a]
{a: []}
read
write 5
read
read
write 6
Source: Abbreviated Assignment
The Counter trace shows the expansion on a writer: the first c.n ||= 5 reads (read), finds nil, then writes
(write 5); the second c.n ||= 7 reads, finds 5, and does not write; c.n = 1` reads and then writes `6`.
The `Hash.new(0)` line uses the default value so `hits[:dune] += 1` reads `0`, adds and stores `1`. The `store[:a] ||= [:x]`
line is a classic trap: the default block assigns `[]` (truthy) while *reading* the missing key, so `||=` finds a truthy
value and never stores `[:x]`. Prefer `+store.fetch(:a) { ... } or a plain Hash.new { |h, k| h[k] = [] } with
<<.
Memoization and its limits
@value ||= compute is the standard memoization idiom, and it works only while the cached value is truthy. A result
of nil or false is recomputed on every call. When nil or false is a legitimate answer, test for the instance
variable with defined?:
# frozen_string_literal: true
# Memoization with ||= recomputes when the cached value is nil or false.
class Stats
attr_reader :calls
def initialize = @calls = 0
def slow_nil
@calls += 1
@slow_nil ||= nil
end
def cached
return @cached if defined?(@cached)
@calls += 1
@cached = nil
end
end
s = Stats.new
2.times { s.slow_nil }
2.times { s.cached }
p s.calls
# Safe navigation in an abbreviated assignment skips everything when the receiver is nil.
Person = Struct.new(:nickname)
person = Person.new(nil)
person&.nickname ||= "reader"
p person.nickname
nobody = nil
nobody&.nickname ||= "reader"
p nobody
# and/or bind more loosely than =.
off = false
flag = off || true
other = (off or true)
low = off or true
p [flag, other, low]
Output:
3
"reader"
nil
[true, true, false]
Source: Precedence
s.calls is 3: slow_nil ran its body on both calls (a cached nil counts as "not set"), while cached ran it
once (the second call returned early because defined?(@cached) was true). The rest of the example shows two more
details:
-
person&.nickname ||= "reader"uses safe navigation in an abbreviated assignment. Whenpersonisnilthe whole expression is skipped (noNoMethodError, no assignment, andnobodystaysnil); otherwise it behaves likeperson.nickname ||= "reader". -
=binds tighter thanand/or/not.flag = off || truestorestrue, butlow = off or trueis(low = off) or trueand storesfalse. This is why style guides avoidand/orfor logic and reserve them for control flow (do_work or raise "failed"). See Operators and Precedence.
Memoizing in a threaded program is a separate topic: @x ||= compute is not atomic, so two threads can both compute.
The Singleton module and Mutex are covered in
Threads and the GVL. Class-level memoization of a
singleton with @instance ||= new (a pattern shown in older books) is not thread-safe either; use Singleton or
a constant created at load time.
Multiple assignment
With more than one target on the left, Ruby performs multiple assignment (also called parallel assignment or
destructuring). The right side is evaluated into a list of values, then the targets receive them in order. A
comma-separated list on the right of a single target builds an Array implicitly (list = 1, 2, 3), and * on the
right expands an array in place; the example covers both.
# frozen_string_literal: true
# rubocop:disable Style/ParallelAssignment
title, author = "Dune", "Herbert"
p [title, author]
# rubocop:enable Style/ParallelAssignment
first, = %w[a b c]
p first
a, b = 1
p [a, b]
x, y = [10, 20, 30]
p [x, y]
head, *tail = [1, 2, 3, 4]
p head, tail
*init, last = [1, 2, 3, 4]
p init, last
_, *mid, _ = 1, 2, 3, 4, 5
p mid
_, *rest = [9]
p rest
list = 1, 2, 3
p list
*wrapped = nil
p wrapped
word = "x"
m, n = *word
p [m, n]
value = (q, r = 4, 5) # rubocop:disable Style/ParallelAssignment
p value
p [q, r]
Output:
["Dune", "Herbert"]
"a"
[1, nil]
[10, 20]
1
[2, 3, 4]
[1, 2, 3]
4
[2, 3, 4]
[]
[1, 2, 3]
[nil]
["x", nil]
[4, 5]
[4, 5]
Source: Multiple Assignment (see also Implicit Array Assignment)
The rules, in the order the example demonstrates them:
-
Values to targets, left to right.
title, author = "Dune", "Herbert"assigns one value per target. -
A single trailing comma (
first, = %w[a b c]) means "take only the first element"; it is the idiom for ignoring the rest without a splat. -
Too few values leave the extra targets
nil(a, b = 1gives[1, nil]); too many values are silently ignored (a, b = 1, 2, 3keeps1and2). -
A single right-side value that responds to
to_ary(anArray, or any object that implementsto_ary) is expanded across the targets (x, y = [10, 20, 30]). Anything else, including aHashor aDatainstance, is one value:a, b = { k: 1 }givesa = { k: 1 }andb = nil(a, b = databehaves the same way for aDatainstance). Useto_a,valuesordeconstructexplicitly when you mean it. -
A splat target collects the leftovers into a new array:
head, *tail = [1, 2, 3, 4](empty array when nothing is left,*init, last = ...collects from the front, andfirst, *mid, last = ...keeps both ends). Only one splat target is allowed per level;_, *mid, _ignores the ends with underscores. Names starting with_are exempt from the unused-variable warning and from RuboCop’sLint/UselessAssignment. -
A splat on the right expands an object into the list (
m, n = *word); it usesto_aand wraps a non-array value (*nilis[],*"x"is["x"]). -
The value of the whole expression is the right side.
value = (q, r = 4, 5)stores[4, 5]invalueand assignsqandr. Assigning a list to one target (list = 1, 2, 3) builds an array, and*wrapped = nilgives[nil], which differs fromwrapped = *nil(that one gives[]).
Any assignable target may appear on the left: local, instance, class (@@) and global variables, constants,
attributes (obj.attr), indexed elements (h[:k]) and nested patterns. That means the $g, @i = 3, 4 and
h[:a], h[:b] = 1, 2 forms all work; constants can only be assigned where the language allows it (not inside a method
body), and constant assignment evaluation order is consistent with attributes since Ruby 3.2.
Nested destructuring
Parentheses on the left open a nested multiple assignment. Each parenthesised group is its own multiple assignment
over the matching element, with its own splat allowed. The same grammar is used by block parameters, which is why
each_with_index { |(key, value), i| ... } works.
# frozen_string_literal: true
a, (b, *c), d = 1, [2, 3, 4], 5
p [a, b, c, d]
(w, x), (y, z) = [1, 2], [3, 4]
p [w, x, y, z]
key, value = { name: "Dune" }.first
p [key, value]
pairs = [[:a, 1], [:b, 2]]
pairs.each { |(k, v)| puts "#{k}=#{v}" }
pairs.each_with_index { |(k, v), i| puts "#{i}: #{k}=#{v}" }
pairs.each { |k, v| puts "#{k}:#{v}" }
def bounds(values) = values.minmax
low, high = bounds([3, 1, 2])
p [low, high]
Output:
[1, 2, [3, 4], 5]
[1, 2, 3, 4]
[:name, "Dune"]
a=1
b=2
0: a=1
1: b=2
a:1
b:2
[1, 3]
Source: Array Decomposition
Notes on the example:
-
a, (b, *c), d = 1, [2, 3, 4], 5puts2inband[3, 4]inc; the splat in the group does not compete with one at the outer level. -
{ name: "Dune" }.firstreturns the pair[:name, "Dune"], which is whykey, value = hash.firstworks; for a whole hash, iterate witheach { |k, v| ... }, where the block receives a pair and destructures it. -
A block written
|k, v|auto-destructures a single array argument (pairs.each { |k, v| ... }); a lambda passed as a block does not, because lambdas are strict about arity. The explicit parentheses form|(k, v), i|is what you need when the pair is one of several block arguments. -
A method returning several values returns an array and the caller destructures it (
low, high = bounds(values)), which is how Ruby avoids Go-style multiple returns.return a, bisreturn [a, b]. -
Destructuring a
HashorDatawitha, b = objdoes not work; that needs rightward assignment with a pattern, covered below.
Swapping and evaluation order
Because the whole right side is evaluated before any target is assigned, multiple assignment swaps two values without a temporary variable. It is the cleanest way to express Fibonacci-style steps and in-place swaps in algorithms.
# frozen_string_literal: true
a = 1
b = 2
a, b = b, a
p [a, b]
arr = %w[x y z]
arr[0], arr[2] = arr[2], arr[0]
p arr
i = 0 # rubocop:disable Lint/UselessAssignment
list = [10, 20, 30]
i, list[i] = 1, 99 # rubocop:disable Style/ParallelAssignment
p [i, list]
fib_a = 0
fib_b = 1
5.times { fib_a, fib_b = fib_b, fib_a + fib_b }
p fib_a
Output:
[2, 1]
["z", "y", "x"]
[1, [99, 20, 30]]
5
Source: Multiple Assignment
RuboCop’s Style/ParallelAssignment reports parallel assignments that could be written as separate statements. It
stays silent for a real swap (a, b = b, a, arr[0], arr[2] = arr[2], arr[0]) and for Fibonacci-style steps where the
right side reads the targets, so the file above needs a rubocop:disable directive only for the third statement
(i, list[i] = 1, 99). The rule of thumb: use parallel assignment when the values depend on each other or the right side
is one expression returning several values; otherwise write separate lines.
The third statement shows the subtle part: in i, list[i] = 1, 99, the list[i] target uses the old value of i
because the left-hand side is evaluated before the assignment happens. Ruby 3.1 made this consistent: the receiver
and arguments of each target are evaluated first, left to right, then the right side, then the writers run left to right.
Before 3.1 the right side ran first, which changed the result for targets with side effects. The trace below prints each step.
# frozen_string_literal: true
# Logs the order in which targets and values are evaluated.
class Tracer
attr_reader :log
def initialize = @log = []
def target(name)
@log << "target #{name}"
self
end
def value(name)
@log << "value #{name}"
name
end
def a=(value)
@log << "set a=#{value}"
end
def b=(value)
@log << "set b=#{value}"
end
end
t = Tracer.new
t.target(1).a, t.target(2).b = t.value(:x), t.value(:y) # rubocop:disable Style/ParallelAssignment
puts t.log
Output:
target 1
target 2
value x
value y
set a=x
set b=y
Source: NEWS for Ruby 3.1.0
Since Ruby 3.2 the same left-to-right order also applies to constant assignment on explicit objects (obj.attr::CONST = value
and multiple assignments to constants).
Attribute and index assignment
Assigning to obj.name calls the method name=; assigning to obj[key] calls []=. They are ordinary methods, so
they can do anything, and their return value is discarded: the whole assignment expression still evaluates to the
right-hand value.
# frozen_string_literal: true
# Setters record the calls they receive.
class Reader
attr_reader :log
def initialize = @log = []
def one=(value)
@log << [:one, value]
:ignored
end
def two=(value)
@log << [:two, value]
end
end
r = Reader.new
r.one, r.two = 1, 2 # rubocop:disable Style/ParallelAssignment
p r.log
x = (r.one = 10)
p x
p r.send(:one=, 11)
Output:
[[:one, 1], [:two, 2]]
10
:ignored
Source: Assignment Methods
x = (r.one = 10) stores 10 even though one= returns :ignored, while r.send(:one=, 11) is a plain method call
and does return :ignored. Remember the following about setters:
-
A setter is defined with
def name=(value); it cannot be written as an endless method (def a=(v) = ...is a syntax error:a setter method cannot be defined in an endless method definition). -
attr_writerandattr_accessorgenerate the setters; see Classes and Objects. -
Setters are usually public, but
privatesetters can only be called with the literal receiverself(this is the one exception to the "private methods cannot have an explicit receiver" rule); see Visibility and Encapsulation. -
Multiple assignment and abbreviated assignment call the writers too (
r.one, r.two = 1, 2;c.n += 1). -
An immutable
Dataobject has no setters at all; usewithto create a changed copy (see Struct, Data and Value Objects).
Index assignment and the Ruby 3.4 change
obj[a, b] = value calls obj.[]=(a, b, value): the value is always the last argument. The next class records every
argument it receives, including a splat, and an empty index:
# frozen_string_literal: true
# An index setter that records every argument it receives.
class Recorder
attr_reader :seen
def initialize = @seen = []
def []=(*args)
@seen << args
end
end
rec = Recorder.new
rec[1, 2] = :v
rec[] = :w
pair = [3, 4]
rec[*pair] = :s
p rec.seen
Output:
[[1, 2, :v], [:w], [3, 4, :s]]
Source: Assignment Methods
Ruby 3.4 turned two odd corners into syntax errors: keyword arguments and block arguments inside an index
assignment (a[0, kw: 1] = 2 and a[0, &b] = 1). Both were accepted by earlier versions;
from 3.4 the parser rejects them, as the real messages below show. The portable fix is to call the method by name.
# frozen_string_literal: true
# Ruby 3.4 rejects keyword and block arguments inside an index assignment.
["rec[1, a: 2] = 3", "rec[1, &blk] = 3"].each do |source|
RubyVM::InstructionSequence.compile(source)
rescue SyntaxError => e
puts "#{source.inspect} -> #{e.class}"
puts e.message.lines.grep(/unexpected|not allowed/).first
end
# The portable spelling is an explicit call.
class Recorder
def []=(*args, **opts)
@seen = [args, opts]
end
attr_reader :seen
end
rec = Recorder.new
rec.[]=(1, a: 2)
p rec.seen
Output:
"rec[1, a: 2] = 3" -> SyntaxError
| ^~~~ unexpected keyword arg given in index assignment; keywords are not allowed in index assignment expressions
"rec[1, &blk] = 3" -> SyntaxError
| ^~~~ unexpected block arg given in index assignment; blocks are not allowed in index assignment expressions
[[1], {a: 2}]
Source: NEWS for Ruby 3.4.0
(The message lines are indented because Prism prints a source excerpt; the first message mentions keywords are not
allowed in index assignment expressions and the second blocks are not allowed in index assignment expressions.)
Hash#[]= itself takes exactly one key and one value, so this affects only custom []= methods and code that
forwarded keyword arguments into a subscript.
Rightward assignment: =>
expression => pattern (Ruby 3.0, stable since 3.1) matches the value against a pattern and binds the names the
pattern introduces. It is "assignment from the right" with destructuring: a simple name pattern is just a
rightward assignment (42 => answer), and a hash or array pattern pulls values out of a structure. If the pattern does
not match it raises, which makes it the assertion form of pattern matching (case/in is the branching form and
expression in pattern is the boolean form).
# frozen_string_literal: true
require "json"
config = { db: { host: "localhost", port: 5432 }, tags: %w[a b] }
config => { db: { host:, port: } }
puts "#{host}:#{port}"
config => { tags: [first_tag, *] }
p first_tag
point = Data.define(:x, :y).new(x: 1, y: 2)
point => { x:, y: }
p [x, y]
begin
config => { missing: _ }
rescue NoMatchingPatternKeyError => e
puts "#{e.class}: #{e.message}"
end
begin
1 => String
rescue NoMatchingPatternError => e
puts "#{e.class}: #{e.message}"
end
assigned = 1 in Integer
p assigned
matched = (1 in Integer)
p matched
p("x".then { it in Integer })
parsed = JSON.parse('{"name":"Dune","pages":412}', symbolize_names: true)
parsed => { name:, pages: Integer => pages }
p [name, pages]
Output:
localhost:5432
"a"
[1, 2]
NoMatchingPatternKeyError: {db: {host: "localhost", port: 5432}, tags: ["a", "b"]}: key not found: :missing
NoMatchingPatternError: 1: String === 1 does not return true
1
true
false
["Dune", 412]
Source: Pattern matching
What the example shows:
-
config => { db: { host:, port: } }bindshostandportfrom nested hashes in one statement. A hash pattern matches a subset: keys not in the pattern are ignored.tags: [first_tag, *]mixes in an array pattern with a rest element. -
Datainstances support bothdeconstruct_keysand (on Ruby 4.0, checked)deconstruct, sopoint => { x:, y: }andpoint => [px, py]both work;Structhas the same pair of hooks. Plain objects are matched only if they define them. -
A missing key raises
NoMatchingPatternKeyError(a subclass ofNoMatchingPatternError) with the missing key in the message; any other mismatch raisesNoMatchingPatternError(1: String === 1 does not return true). -
inreturnstrueorfalseand never raises. Mind precedence:assigned = 1 in Integerparses as(assigned = 1) in Integer, soassignedis1, nottrue. Parenthesise:matched = (1 in Integer). -
parsed => { name:, pages: Integer => pages }validates a type while binding, which makes=>a lightweight way to assert the shape of parsed JSON (JSON.parse(..., symbolize_names: true)supplies the symbol keys). -
Pinning (
^variable) and guards are available incase/in; the full grammar is on Pattern Matching.
A shelf example
The shelf scenario puts these pieces together: rightward assignment unpacks a Book and its Author, first, = ...
grabs the head of a collection, and values_at/fetch_values destructure a hash by key. Run it with ruby -Ilib from the
gem directory.
# frozen_string_literal: true
require "shelf"
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
herbert = Shelf::Author.new(name: "Frank Herbert")
book = Shelf::Book.new(isbn:, title: "Dune", author: herbert)
book => { title:, author: { name: } }
puts "#{title} / #{name}"
store = Shelf::Store::Memory.new.add(book)
only, = store.all
puts only
_, *others = store.all
p others
query = { title: "Dune", limit: 5 }
found, limit = query.values_at(:title, :limit)
p [found, limit]
begin
query.fetch_values(:title, :offset)
rescue KeyError => e
puts "#{e.class}: #{e.message}"
end
Output:
Dune / Frank Herbert
Dune by Frank Herbert
[]
["Dune", 5]
KeyError: key not found: :offset
Source: Hash#fetch_values
values_at returns nil for missing keys, whereas fetch_values raises KeyError for the first missing one, which is
the safer choice when the keys are required. Hash#values_at and Hash#fetch_values plus a multiple assignment replace
a chain of h[:a]/h[:b] reads.
Where older code and books are outdated
-
Inspect output. Examples written before Ruby 3.4 print hashes as
{:a=>1, :b=>2}(including the Ruby documentation for assignment, whose comments still show the old form). From 3.4Hash#inspectprints{a: 1, b: 2}and string keys as{"k" => 1}; both outputs are valid, only the display changed. -
Rightward assignment.
expr => patternappeared in 3.0 as experimental and became stable in 3.1. Code that predates it usescase/inor manual destructuring withfetch. -
Index assignment with keywords or a block (
a[0, kw: 1] = 2) is a syntax error from 3.4. -
Evaluation order of multiple assignment changed in 3.1, and of constant assignment in 3.2; code that depended on the old order (rare, but it exists in DSLs with side-effecting readers) must be rewritten.
-
_1versusit. Older code destructures pairs in short blocks with_1/_2; Ruby 3.4 added theitparameter for the one-argument case, but destructuring a pair still wants|key, value|or|(a, b), i|. -
Data and Struct. Value objects are created with
Data.defineand updated withwith, not by assigning to attributes;Structstill has setters (andkeyword_init: trueis unnecessary for keyword construction since 3.2). -
Lazy defaults in class bodies.
@instance ||= newis not thread-safe, and||=memoization cannot cachenilorfalse; usedefined?orSingletonas shown above.
Pitfalls and cops
| Pitfall | What happens | Fix |
|---|---|---|
setter without |
creates a local variable, warning |
|
|
|
|
|
both names share one object |
separate literals, or |
|
the default stores a truthy value first, so |
|
|
recomputed every call |
|
|
|
|
|
stores |
|
|
the target |
assign in separate statements when the order matters |
|
syntax error since Ruby 3.4 |
|
RuboCop cops that touch this page, all enabled by default: Lint/UselessAssignment, Lint/AssignmentInCondition,
Lint/SelfAssignment, Lint/RedundantSplatExpansion, Style/ParallelAssignment, Style/OrAssignment
(suggests ||= for x = x || y and for unless x; x = y; end), Style/AndOr and Style/TrailingUnderscoreVariable
(flags trailing _ targets such as a, _ = pair). See
Style, Linting and Static Analysis.