Operators and Precedence

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), the baseline of the whole section; features newer than Ruby 3.0 name the version that introduced them. Most of what looks like an operator in Ruby is an ordinary method call with nicer syntax, and the rest is a small set of keyword operators that the parser handles itself. This page explains that split, gives the complete precedence table and the associativity rules, and then walks through every operator family: logical operators and truthiness, arithmetic, comparison and equality, pattern and match operators, bitwise and shift operators, the operators that Array, String and Set give a collection meaning to, safe navigation, the ternary operator, ranges and the flip-flop, defined?, the modifier forms, and the way blocks bind. It ends with the compiler warnings and runtime errors that operators produce and with a table of what changed between Ruby 3.0 and 4.0.

It builds on Literals (numbers, strings, ranges and symbols as values) and Assignment and Multiple Assignment (=, the abbreviated forms such as ||=, and attribute assignment). It is continued by Control Expressions (if, case and loops, which consume the values these operators produce), Equality, Hashing and Comparison (==, eql?, equal?, <=> and Comparable in depth), Conversions and Coercion (coerce and implicit conversion) and Numbers (the numeric tower). For how other languages treat the same ideas, see Rust (operators are trait methods there, a close cousin of Ruby’s operator methods) and Go (no operator overloading).

Every example was checked with ruby -wc, run with Ruby 4.0.7 with warnings enabled (ruby -w), and linted with RuboCop 1.92 using the configuration of the running example gem (Ruby 4.0 target, double-quoted strings); the output shown is the real output. A few examples deliberately trigger RuboCop or interpreter warnings because the warning is the subject; those are shown together with the warning text.

Operators are methods, keywords are not

In Ruby, 7 + 3 is a method call: the receiver is 7, the method is + and the argument is 3. The parser only makes the call look like arithmetic. The three spellings below reach the same method, and the owner of the method is the class of the left operand, which is why 3 * "ab" and "ab" * 3 are different calls.

# frozen_string_literal: true

# Operator syntax, an explicit method call and send all reach the same method.
p 7 + 3 # => 10
p 7.+(3) # => 10
p 7.send(:+, 3) # => 10
p 7.public_send(:-, 3) # => 4
p [1, 2, 3].<<(4) # => [1, 2, 3, 4]
p 5.method(:+).owner # => Integer
p 5.method(:+).arity # => 1
# The left operand decides which method runs.
p "ab" * 3 # => "ababab"
begin
  3 * "ab"
rescue TypeError => e
  puts "#{e.class}: #{e.message}"
end
begin
  nil + 1
rescue NoMethodError => e
  puts "#{e.class}: #{e.message}"
end

Two consequences follow. First, how an operator behaves is decided by the left operand, so operators are not symmetric unless the class makes them so (coerce, covered in Conversions and Coercion, is the protocol numbers use to make 5 + fine work). Second, a class can define any of these operators for its own instances, and that is how Set, Rational, Time and Pathname offer +, -, << and /.

Which operators are methods

Group Operators Notes

Unary

!, ~, +@, -@

-x calls -@, +x calls +@, !x and not x call !. A numeric literal such as -2 is a literal, not a call to -@.

Arithmetic

+, -, *, /, %, **

Integer, Float, Rational and Complex define all of them; String defines +, * and %; Array defines +, -, *.

Comparison

==, !=, <, <=, >, >=, <=>, ===

Comparable derives <, <=, >, >=, == and between? from <=>. != is defined once on BasicObject and calls ==.

Matching

=~, !~

String, Symbol, Regexp and NilClass define =~; !~ is defined on Object (through Kernel) and negates =~.

Bitwise and shift

&, |, ^, <<, >>

&, | and ^ also work on true, false, nil, Array (&, |) and Set. << appends to Array, String, IO and Set.

Element access

[], []=

a[i] = v calls []=; the 3.4 change that forbids keyword and block arguments in an index call is described in Assignment and Multiple Assignment.

Call

.()

f.(x) is shorthand for f.call(x).

Defining them is done with a normal def. The method names for the unary operators carry an @ (-@, +@) to tell them from the binary ones. A small Fine value object from the running example shows the common set:

# frozen_string_literal: true

module Shelf
  # A late fee in cents, with the operators a small money type needs.
  Fine = Data.define(:cents) do
    include Comparable

    def +(other) = with(cents: cents + other.cents)

    def -(other) = with(cents: cents - other.cents)

    def *(other) = with(cents: cents * other)

    def -@ = with(cents: -cents)

    def +@ = self

    def <=>(other) = other.is_a?(Fine) ? cents <=> other.cents : nil

    def !
      cents.zero?
    end

    def coerce(number) = [Fine.new(number), self]

    def to_s = format("$%.2f", cents / 100.0)
  end
end

late = Shelf::Fine.new(150)
lost = Shelf::Fine.new(275)
puts late + lost # prints $4.25
puts lost - late # prints $1.25
puts late * 3 # prints $4.50
puts(-late) # prints $-1.50
puts(+late) # prints $1.50
puts 100 + late # prints $2.50
p late < lost # => true
p late.between?(late, lost) # => true
p [lost, late].min # => #<data Shelf::Fine cents=150>
p(!late) # => false
p(!Shelf::Fine.new(0)) # => true
p late == Shelf::Fine.new(150) # => true
begin
  late < 5
rescue ArgumentError => e
  puts "#{e.class}: #{e.message}"
end

A few details are worth noting in that listing. 100 + late works only because Integer#+ does not know Fine, calls late.coerce(100) and then retries the addition with the two values that coerce returned. late < 5 fails with an ArgumentError because Comparable raises when <=> returns nil. And def ! is legal, but redefining ! to mean something other than "negation" is almost always a mistake: not and ! then change meaning for every conditional that receives the object.

Collection-like classes use the indexing, append, call and match operators. The Tray class below defines nine of them and shows each call form, including tray.(2), &tray (through to_proc) and the === that case and when use.

# frozen_string_literal: true

module Shelf
  Book = Data.define(:title, :year)

  # A tray of books that overloads the collection-style operators.
  class Tray
    def initialize(*books) = @books = books

    def <<(book)
      @books << book
      self
    end

    def [](index) = @books[index]

    def []=(index, book)
      @books[index] = book
    end

    def +(other) = Tray.new(*@books, *other.to_a)

    def ~ = Tray.new(*@books.reverse)

    def ===(other) = @books.include?(other)

    def =~(pattern) = @books.index { |book| pattern.match?(book.title) }

    def call(index) = self[index]

    def to_a = @books.dup

    def to_proc = method(:[]).to_proc

    def size = @books.size
  end
end

dune = Shelf::Book.new("Dune", 1965)
emma = Shelf::Book.new("Emma", 1815)
tray = Shelf::Tray.new(dune)
tray << emma << Shelf::Book.new("Ulysses", 1922)
p tray[1].title # => "Emma"
tray[0] = Shelf::Book.new("Dune Messiah", 1969)
p tray[0].title # => "Dune Messiah"
both = tray + Shelf::Tray.new(dune)
p both.size # => 4
p((~tray)[0].title) # => "Ulysses"
p(tray =~ /Ulysses/) # => 2
p tray.call(2).title # => "Ulysses"
p [0, 2].map(&tray).map(&:title) # => ["Dune Messiah", "Ulysses"]
case emma
when tray then puts "emma is on the tray"
end
empty = Shelf::Tray.new
p tray != empty # => true
p !tray # => false

Which operators are keywords

Everything else is syntax and cannot be redefined. The Ruby documentation says it plainly: logical operators are not methods, they are tokenized at a lower level. Trying to define one is a syntax error, not a method that never gets called:

ruby -e 'def &&(o) = o'
-e: -e:1: syntax errors found (SyntaxError)
> 1 | def &&(o) = o
    | ^~~ expected an `end` to close the `def` statement
    |    ^ expected a delimiter to close the parameters
    |     ^~ unexpected '&&', ignoring it
    |     ^~ unexpected '&&'; expected a method name
    |           ^ unexpected '='; target cannot be written
    |             ^ unexpected local variable or method, expecting end-of-input

The keyword operators are:

  • &&, ||, and, or and not (short-circuit logic; not is a keyword that calls ! on its operand, so ! can be redefined but the keyword itself cannot).

  • ? : (the ternary conditional), .. and ... (range construction; the range operators build a Range and a literal range in a condition becomes a flip-flop, see below), and defined?.

  • = and the abbreviated assignments +=, -=, *=, ||=, &&= and so on. An abbreviated assignment is rewritten to a = a op b, so a += b calls + (a method you can define), whereas ||= and &&= are keyword logic and are never methods. The exact expansions are in Assignment and Multiple Assignment.

  • ., &. and :: (method call, safe method call and scope resolution), which are punctuation, not operators with methods of their own.

  • The modifier keywords if, unless, while, until and rescue.

!= and !~ sit in between: they are methods, but the default implementations are built from == and =~, so you normally define == (or <=> with Comparable) and get != for free. Defining != yourself is rare. Since Ruby 3.2 Object no longer defines =~ at all (Kernel#=~ was removed), so Object.new =~ /x/ is a NoMethodError, as the match-operator example below shows.

The precedence table

Precedence decides how an expression with several operators is grouped; associativity decides how operators of the same level group. The official table runs from highest to lowest:

Level Operators Associativity Notes

0 (not in the table)

., &., ::, []

left

Method calls, safe calls, scope lookups and indexing bind tighter than every operator below: -n.abs is -(n.abs), !a.nil? is !(a.nil?).

1

!, ~, unary +

right

2

**

right

2**3**2 is 2**(3**2). -2**2 is -(2**2) because a literal minus before ** is treated as unary minus.

3

unary -

right

Lower than **, higher than *.

4

*, /, %

left

5

+, -

left

6

<<, >>

left

Lower than +, so 1 << 2 + 1 is 1 << 3.

7

&

left

8

|, ^

left

| and ^ share a level; & is above both.

9

>, >=, <, <=

left

Chaining is allowed by the grammar but useless (1 < 2 < 3 calls < on true).

10

<=>, ==, ===, !=, =~, !~

none

Non-associative: 1 == 2 == 3 is a syntax error.

11

&&

left

12

||

left

13

.., ...

none

1..2..3 is a syntax error. Ranges are lower than && and ||, so a || b..c is (a || b)..c.

14

? :

right

a ? b : c ? d : e is a ? b : (c ? d : e).

15

modifier rescue

left

x = f rescue 0 assigns the rescued value (see below).

16

=, +=, -=, *=, ||= and the other abbreviated assignments

right

a = b = 3 assigns 3 to both.

17

defined?

Lower than assignment: defined? a = 1 asks about the assignment.

18

not

right

Lower than ==: not 1 == 2 is not (1 == 2).

19

or, and

left

Same level for both, unlike || and &&.

20

modifier if, unless, while, until

left

The lowest of the statement-level forms.

21

{ } blocks, then do ... end blocks

The official table ends with brace blocks and adds that do ... end blocks bind even more loosely. A brace block binds to the nearest call, a do ... end block to the whole command (see below).

The official page lists levels 1 to 21 only; the "level 0" row is the usual reading of the grammar and is verified by the examples below (-negative.abs is -4 for negative = -4, so the call happened before the minus). The documentation also notes that (a if b rescue c) parses as ((a if b) rescue c) for reasons unrelated to precedence, and that all the words in the table that are not symbols are keywords. The grouping of each line below is explained by the rows of the table:

# frozen_string_literal: true

stock = 5
# unary minus on a variable is lower than **, a minus sign glued to a literal is not
p(-2**2) # => -4
p((-2)**2) # => 4
base = 2
p(-base**2) # => -4
# a minus glued to a numeric literal is part of the literal, a minus before a name is an operator
negative = -4
p(-negative.abs) # => -4
p(-4.abs) # => 4
# ** is right associative; the other binary operators are left associative
p(2**3**2) # => 512
p((2**3)**2) # => 64
p(10 - 4 - 3) # => 3
p(2 + (3 * 4)) # => 14
# + and - bind tighter than <<, and & tighter than ==
p(1 << (2 + 1)) # => 8
p(4 & 5 == 4) # => true
p((4 & 5) == 4) # => true
p(1 | 2 ^ 3 & 4) # => 3
# ! is higher than &&, ranges are lower than +, ?: is lower than ranges
closed = true
p(!closed && false) # => false
p(1..stock + 1) # => 1..6
label = stock > 3 ? "big" : "small"
p label # => "big"
p(defined?(stock) && stock) # => 5
total = subtotal = 3
p [total, subtotal] # => [3, 3]

Because the table is long and a few levels are surprising (<< lower than +, & higher than ==, .. lower than ||), the practical rules are:

  • Add parentheses whenever two operators from different families meet and the result is not obvious. RuboCop’s Lint/AmbiguousOperatorPrecedence flags exactly these places, and Lint/AmbiguousRange flags complex range ends.

  • Never rely on the relative order of &&/|| and and/or; use one family per expression.

  • Use == for comparison and = for assignment; mixing them in a condition produces a warning (see the warnings section).

flowchart TB A["Calls and lookups: . &. :: []"] --> B["Unary: ! ~ +x"] B --> C["**"] C --> D["Unary minus"] D --> E["* / %"] E --> F["+ -"] F --> G["Shifts"] G --> H["&"] H --> I["Bitwise or, xor"] I --> J["Relational < <= > >="] J --> K["Equality and match"] K --> L["&&"] L --> M["||"] M --> N["Ranges"] N --> O["Ternary"] O --> P["Modifier rescue"] P --> Q["Assignment"] Q --> R["defined?"] R --> S["not"] S --> T["and, or"] T --> U["Modifier if, unless, while, until"]

The figure reads from the tightest binding at the top to the loosest at the bottom: an operator lower in the figure is applied after the ones above it.

Truthiness

Every Ruby value is either falsy or truthy in a condition, and exactly two objects are falsy: nil and false. Everything else is truthy, including 0, 0.0, the empty string, the empty array and the empty hash. This differs from C, JavaScript and Python, where zero and empty containers are false; Ruby code that needs "is it empty" must ask empty? or zero? explicitly.

# frozen_string_literal: true

[nil, false, true, 0, "", [], {}, 0.0, :a].each do |value|
  puts "#{value.inspect.ljust(5)} is #{value ? 'truthy' : 'falsy'}"
end
value = 0
p !!value # => true
p !value # => false
p nil.to_a # => []
p nil & true # => false
p nil | 1 # => true
p nil.to_s # => ""
p NilClass.instance_method(:&).owner # => NilClass

The first block prints the truth table; the rest shows what the operators do with the two falsy objects. !!value (a double negation) converts any value to true or false and is the usual way to coerce for a predicate that must return a real boolean; RuboCop’s Style/DoubleNegation discourages it in favour of an explicit nil?/empty? test or a comparison, which is why the example is the one line RuboCop reports in that file. nil also defines &, | and ^ (nil & true is false, nil | 1 is true), to_a ([]), to_s ("") and inspect ("nil"), which is why nil often flows through code without an error until a method it does not define is called.

true, false and nil are the only instances of TrueClass, FalseClass and NilClass. Because they are singletons, nil.equal?(nil) is always true, and comparisons against them are cheap.

Logical operators: &&, || and !

&& and || do not return true or false; they return one of their operands. a && b evaluates a, returns it if it is falsy, and otherwise evaluates and returns b. a || b returns a if it is truthy and otherwise evaluates and returns b. The right operand is evaluated only when the left one does not decide the result, which is called short-circuit evaluation. The documentation puts it as "the last evaluated expression defines the result".

# frozen_string_literal: true

title = "Dune"
subtitle = nil
p(title && title.length) # => 4
p(subtitle && subtitle.length) # => nil
p(subtitle || "no subtitle") # => "no subtitle"
p(subtitle || false) # => false
# the result is the last operand evaluated, not true or false
total = 1 + 2
p(total || :never) # => 3
# the right side is not evaluated once the left side decides
calls = []
note = lambda do |tag, value|
  calls << tag
  value
end
first = note.call(:first, nil)
second = first && note.call(:second, 1)
p [first, second, calls] # => [nil, nil, [:first]]
used = note.call(:third, 5) || note.call(:fourth, 6)
p [used, calls] # => [5, [:first, :third]]
# & and | on booleans are ordinary methods: both operands are always evaluated
no = false
both = no & note.call(:fifth, true)
p [both, calls.last] # => [false, :fifth]

The same behaviour as a picture, for both operators:

flowchart LR subgraph AND["a && b"] A1["evaluate a"] --> A2{"a truthy?"} A2 -- "no" --> A3["result is a"] A2 -- "yes" --> A4["evaluate b"] --> A5["result is b"] end subgraph OR["a || b"] O1["evaluate a"] --> O2{"a truthy?"} O2 -- "yes" --> O3["result is a"] O2 -- "no" --> O4["evaluate b"] --> O5["result is b"] end

This is what makes the common idioms work: title = cached || "Untitled" (default), user && user.name (guard, though &. below is usually better), and File.exist?(path) or raise ... (control flow, below). Two warnings come with it:

  • Never rely on && returning a boolean. late.fine_cents in the Loan example below returns false for an on-time loan and an Integer for a late one, which is surprising for a method that reads like a number. A predicate method (name ending in ?) should return true or false; a method that computes a value should always return a value of the same kind.

  • & and | on booleans are ordinary methods, so they always evaluate both operands. Use them only when the side effect of the right operand is wanted, as the last lines of the example show.

The ! operator is a method too (BasicObject#!), so !x is x.!. It is higher than every binary operator, so !a == b is (!a) == b; write a != b or !(a == b) when you mean the other reading. not is its low-precedence twin and is covered next.

and, or and not

and, or and not mean the same as &&, || and ! but sit at the bottom of the precedence table, below assignment. They are not stylistic variants: they exist to chain statements, in the style of Perl’s open(...) or die, and they change how an assignment is grouped.

# frozen_string_literal: true

cached = nil
yes = true
no = false
# && and || bind tighter than =, so the assignment receives the whole expression.
title = cached || "Untitled"
p title # => "Untitled"
# `or` is lower than =, so the assignment happens first and the `or` is discarded.
other = cached or "Untitled"
p other # => nil
found = yes && no
p found # => false
found = yes and no
p found # => true
# `not` is lower than ==, ! is higher.
one = 1
p(not one == 2) # => true
p((not one) == 2) # => false
p(!one == 2) # => false

Read the output line by line:

  • title = cached || "Untitled" assigns "Untitled", because || is above =.

  • other = cached or "Untitled" is (other = cached) or "Untitled": other gets nil, and the or branch is evaluated and thrown away. This is the classic bug, and the reason most style guides forbid and/or in conditions and assignments.

  • found = yes and no stores true for the same reason.

  • not one == 2 is not (one == 2), so it is true; (not one) == 2 is false == 2; !one == 2 is (!one) == 2.

The grouping of the first assignment pair is easier to see as a tree:

flowchart TB subgraph S1["found = yes && no"] direction TB E1["="] --> V1["found"] E1 --> AN1["&&"] AN1 --> Y1["yes"] AN1 --> N1["no"] end subgraph S2["found = yes and no"] direction TB AN2["and"] --> E2["="] AN2 --> N2["no"] E2 --> V2["found"] E2 --> Y2["yes"] end

The legitimate use of and, or and not is control flow, where the left side is a whole statement and the right side is another statement that should run only when the left one succeeded (and) or failed (or):

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir do |dir|
  path = File.join(dir, "title.txt")
  File.write(path, "Dune\n")

  # the idiomatic use of the low-precedence operators: control flow
  title = File.read(path).strip or raise "no title"
  puts title

  File.exist?(File.join(dir, "missing")) or puts "no such file, carrying on"
  File.exist?(path) and puts "file found"
  puts "not found" unless File.exist?(path)
end

Even there, an explicit if/unless or a guard clause (raise ... unless title) is usually clearer, and RuboCop’s Style/AndOr flags and and or inside conditions by default. A workable rule: use &&, || and ! everywhere; accept or raise/or return as an idiom only in code that already uses it; and never mix the two families in one expression.

Predicates and explaining variables

Long boolean expressions read better when the pieces have names. A predicate method (a method whose name ends in ? and that returns true or false) names a condition and can be reused and tested; an explaining variable names a sub-condition inside one method. Both let the operators read like the rule they implement:

# frozen_string_literal: true

module Shelf
  # Predicates built from named conditions instead of one long expression.
  Loan = Data.define(:days_late, :renewals, :reserved) do
    def overdue? = days_late.positive?

    def renewable?
      under_limit = renewals < 2
      under_limit && !overdue? && !reserved
    end

    # && returns its last evaluated operand, so this is false or an Integer
    def fine_cents = overdue? && (days_late * 25)

    def status = (overdue? && "overdue") || (renewable? && "renewable") || "on loan"
  end
end

fresh = Shelf::Loan.new(days_late: 0, renewals: 0, reserved: false)
late = Shelf::Loan.new(days_late: 4, renewals: 1, reserved: false)
held = Shelf::Loan.new(days_late: 0, renewals: 2, reserved: true)
p fresh.renewable? # => true
p late.renewable? # => false
p fresh.fine_cents # => false
p late.fine_cents # => 100
p [fresh, late, held].map(&:status) # => ["renewable", "overdue", "on loan"]

status chains || over && terms to pick the first matching label, which works only because each term is either a string or false. When a chain has more than two or three terms, a case expression (see Control Expressions) is clearer. The parentheses around (days_late * 25) and around each && term are not required by the grammar; they are there so that neither the reader nor Lint/AmbiguousOperatorPrecedence has to remember the table.

|| and ||= for defaults

value || default is the idiom for "use the default when the value is missing", and value ||= default is value || (value = default). Both treat false as missing, which is wrong when false is a legitimate stored value:

# frozen_string_literal: true

options = { archived: false }
# || cannot tell false from "missing", so a stored false is replaced by the default
p(options[:archived] || true) # => true
# fetch distinguishes a missing key from a false value
p options.fetch(:archived, true) # => false
p options.fetch(:limit, 10) # => 10
# ||= has the same trap, because it is a || b = c in disguise
options[:archived] ||= true
p options # => {archived: true}

Use Hash#fetch (or key?) whenever a hash value can legitimately be false or nil; the same applies to ENV.fetch. Pattern matching with in (see Pattern Matching) is the other tool for "take the value if the key is present".

Arithmetic operators

The arithmetic operators are +, -, *, /, % and **, plus unary -. Integers behave exactly: they have arbitrary size (2**200 is an exact Integer), / is floor division, and the result of an operation between two integers is an integer. Mixing in a Float makes the result a Float; mixing in a Rational or Complex keeps the more exact type.

# frozen_string_literal: true

pages = 7
# integer division floors toward negative infinity
p pages / 2 # => 3
p(-pages / 2) # => -4
# the sign of % follows the divisor, the sign of remainder follows the receiver
p pages % 3 # => 1
p(-pages % 3) # => 2
p pages % -3 # => -2
p((-pages).remainder(3)) # => -1
p pages.divmod(2) # => [3, 1]
p((-pages).divmod(2)) # => [-4, 1]
p pages.fdiv(2) # => 3.5
p pages / 2.0 # => 3.5
p pages.ceildiv(2) # => 4
p pages.div(2) # => 3
p 7.0.div(2) # => 3
p 2**10 # => 1024
# a negative exponent gives a Rational
p 2**-2 # => (1/4)
p 2**0.5 # => 1.4142135623730951
# modular exponentiation without building the huge intermediate number
p 10.pow(3, 7) # => 6
p Integer.sqrt(17) # => 4
p 2**200 # => 1606938044258990275541962092341162602522202993782792835301376
p Rational(1, 3) + Rational(1, 6) # => (1/2)

The points to remember:

  • Integer division floors. 7 / 2 is 3 and -7 / 2 is -4, not -3. Integer#div is the same, fdiv always returns a Float, divmod returns quotient and remainder together, and ceildiv (added in Ruby 3.2) rounds up, which is what pagination needs: total_pages = book_count.ceildiv(page_size).

  • The sign of % follows the divisor, so -7 % 3 is 2 and 7 % -3 is -2. remainder follows the dividend instead (-7.remainder(3) is -1). Code ported from C or Java, where % takes the dividend’s sign, needs attention here. % and modulo are the same method.

  • ** is right associative and binds tighter than unary minus. 2**3**2 is 512 and -base**2 is -(base**2). A negative integer exponent returns a Rational (2**-2 is (1/4)) and a fractional one a Float. pow(exponent, modulus) does modular exponentiation without building the huge intermediate number.

  • Since Ruby 3.4, Integer#** and Rational#** no longer return Infinity or NaN for results that do not fit: they return the exact value, and raise ArgumentError (exponent is too large) when the result would be absurdly large.

  • Integer.sqrt(n) returns the exact integer square root; Math.sqrt returns a Float and loses precision for large integers.

Zero, infinity and floating-point surprises

# frozen_string_literal: true

def attempt
  yield
rescue ZeroDivisionError, ArgumentError => e
  puts "#{e.class}: #{e.message}"
end

zero = 0
attempt { 1 / zero }
attempt { 1 % zero }
attempt { 1.0 % 0.0 }
attempt { 10**10_000_000_000 }
p 1 / 0.0 # => Infinity
p(-1 / 0.0) # => -Infinity
p (0 / 0.0).nan? # => true
p 1.fdiv(zero) # => Infinity
sum = 0.1 + 0.2
p sum # => 0.30000000000000004
p sum.eql?(0.3) # => false
p((sum - 0.3).abs < Float::EPSILON) # => true
p 0.1r + 0.2r == 0.3r # => true

Integer division and modulo by zero raise ZeroDivisionError (divided by 0). Float division by zero does not: 1 / 0.0 is Infinity, -1 / 0.0 is -Infinity and 0 / 0.0 is NaN. Note, however, that in the Ruby version used here the modulo operator raises ZeroDivisionError even for floats (1.0 % 0.0), so do not assume NaN there. Floating-point numbers are binary approximations, so 0.1 + 0.2 is 0.30000000000000004 and == on computed floats is almost never what you want; compare with a tolerance (Float::EPSILON for numbers near 1, a relative tolerance otherwise), round first, or use Rational (the r suffix makes a literal) or BigDecimal when exactness matters, for example for money. The Numbers page covers the numeric tower, Integer/Float/Rational/Complex conversions and rounding in depth.

Operators on strings and arrays reuse the same symbols with different meanings, which is why the left operand matters: "ab" * 3 repeats, [1, 2] * "," joins and [1, 2] * 2 repeats; "%d" % 5 formats; Array#- removes elements and Array#& intersects. The next sections list them.

Comparison and equality operators

==, !=, <, <=, >, >= and <=> compare values. They return true or false, except <=>, the spaceship operator, which returns -1, 0 or 1 for "less", "equal" and "greater", and nil when the two values cannot be compared. <=> is the foundation: sort, min, max and Comparable all use it.

# frozen_string_literal: true

one = 1
two = 2
p one <=> two # => -1
p two <=> 2 # => 0
p two <=> one # => 1
p one <=> "a" # => nil
p "apple" <=> "banana" # => -1
p [1, 2] <=> [1, 3] # => -1
p [1, 2] <=> [1] # => 1
p :a <=> :b # => -1
p nil <=> 1 # => nil
p one == 1.0 # => true
p one.eql?(1.0) # => false
p one.equal?(1) # => true
p one != two # => true
p 5.clamp(1, 3) # => 3
p 5.between?(1, 10) # => true

Things to keep in mind:

  • <=> returns nil for incomparable operands (1 <=> "a"), and then <, > and sort raise ArgumentError: comparison of Integer with String failed while == quietly says false.

  • Strings compare byte-wise by default, so "Zebra" <=> "apple" is -1; a case-insensitive or locale-aware order needs casecmp, casecmp? or an explicit sort_by. Arrays compare element by element, and a shorter array that is a prefix of a longer one sorts first.

  • 1 == 1.0 is true (numeric value equality), but 1.eql?(1.0) is false (same class and value, the test hash keys use) and equal? tests object identity. Never override equal?.

  • Defining <=> and including Comparable gives <, <=, >, >=, ==, between? and clamp, as Fine did above. Comparable#== is derived from <=>, so a <=> that returns nil or 0 for different objects also changes equality.

  • A class that defines == should define eql? and hash together if its instances are used as hash keys or in sets. The full contract, and the special case of Float::NAN (which is not equal to itself), is in Equality, Hashing and Comparison.

Case equality: ===

=== is the operator behind case/when. Despite its name it is not "stricter equality": it means "does this pattern cover that value?". when pattern calls pattern === value, so the pattern is always the receiver. Each class defines the sense that makes sense for it: Module#=== is is_a?, Range#=== is membership, Regexp#=== is a match, Proc#=== calls the proc, and for most other objects it is ==.

# frozen_string_literal: true

big = ->(n) { n > 2 }
# === is what `case`/`when` calls; read it as "does this pattern cover that value?"
p Integer.===(3) # => true
p (1..5).===(3) # => true
p(/ab/.match?("cab")) # => true
p(/ab/.===("cab")) # => true
sym = :a
p sym.===(:a) # => true
p big.===(3) # => true
p String.===(String) # => false
p Class.===(String) # => true
count = 3
case count
when big then puts "matched the lambda"
end
word = "cab"
case word
when /ab/ then puts "matched the regexp"
end

Calling === by hand is rare and RuboCop’s Style/CaseEquality discourages it; prefer is_a?, match? and cover? outside case. Note the asymmetry: String === String is false (the class String is not itself a string) whereas Class === String is true. case/when and its use of === with splat lists and lambdas are covered in Control Expressions.

Match operators: =~ and !~

string =~ regexp returns the index of the first match or nil, and sets the special variables $~, $1, $2 and so on; !~ returns true when there is no match. The regexp may be on either side. When a regexp literal with named captures is on the left, the captures become local variables, which is why the example below can use first and second without declaring them. Regexp.last_match is the readable spelling of $~.

# frozen_string_literal: true

title = "Dune Messiah"
p title =~ /Mes/ # => 5
p title !~ /xyz/ # => true
p title.match?(/dune/i) # => true
# a regexp literal on the left creates local variables for named captures
if /(?<first>\w+) (?<second>\w+)/ =~ title
  p first
  p second
end
p Regexp.last_match(0) # => "Dune Messiah"
p nil =~ /x/ # => nil
p :Dune =~ /un/ # => 1
begin
  Object.new =~ /x/
rescue NoMethodError => e
  puts "#{e.class}: #{e.message}"
end

When the position is not needed, prefer match?: it returns a boolean, does not allocate a MatchData and does not touch $~. Object#=~ was removed in Ruby 3.2; nil =~ /x/ still works (it returns nil) because NilClass defines it, but an arbitrary object raises NoMethodError. Regular expressions themselves are the subject of Regular Expressions.

Bitwise and shift operators

&, |, ^ and ~ operate on the bits of integers; << and >> shift. Integers are conceptually infinite two’s complement numbers, so ~5 is -6 and there is no overflow: 1 << 70 is an exact large integer. Integer#[] reads a bit or a bit range, and anybits?, allbits? and nobits? are the readable forms of the usual masks (RuboCop’s Style/BitwisePredicate suggests them).

# frozen_string_literal: true

flags = 0b1100
mask = 0b1010
p flags & mask # => 8
p flags | mask # => 14
p flags ^ mask # => 6
p ~flags # => -13
p 1 << 4 # => 16
p(-16 >> 2) # => -4
p 1 << 70 # => 1180591620717411303424
p flags[2] # => 1
p flags[0, 3] # => 4
p flags.anybits?(mask) # => true
p flags.allbits?(0b0100) # => true
p flags.nobits?(0b0011) # => true
p flags.bit_length # => 4

A right shift of a negative number keeps the sign (-16 >> 2 is -4), and a negative shift count shifts the other way. Precedence trap for readers coming from C and Java: there flags & mask == mask groups as flags & (mask == mask), while in Ruby & binds tighter than ==, so it is (flags & mask) == mask. Write the parentheses anyway; the code then means the same in every language.

Collection and string operators

The same symbols are reused by the core collections. They are ordinary methods, so the left operand decides.

# frozen_string_literal: true

a = [1, 2]
b = [2, 3]
p a & b # => [2]
p a | b # => [1, 2, 3]
p a - b # => [1]
p a + b # => [1, 2, 2, 3]
p a * "," # => "1,2"
p a * 2 # => [1, 2, 1, 2]
p a <=> b # => -1
p Set[1, 2] & Set[2, 3] # => Set[2]
p Set[1, 2] ^ Set[2, 3] # => Set[1, 3]
p Set[1, 2] <= Set[1, 2, 3] # => true
p({ a: 1 } <= { a: 1, b: 2 }) # => true
p [1] << 2 << 3 # => [1, 2, 3]
yes = true
no = false
p yes & no # => false
p yes ^ yes # => false
p no | yes # => true

Source: Array#&, Array#-, Set and Hash#⇐

  • Array#&, Array#| and Array#- compare elements with hash and eql? and remove duplicates from the result; Array#+ concatenates and keeps them. Array#<=> is lexicographic.

  • Set is available without require "set" since Ruby 3.2 and uses &, |, -, ^ and <=/>= for the set operations; Hash#<= and Hash#>= test hash containment (Ruby 2.3 and later).

  • << on an array or set returns the receiver, so appends can be chained; on a string it appends a string or an integer code point and mutates the receiver, which makes it the main source of FrozenError below.

  • << is also the start of a here-document. After a method name, puts <<TEXT starts a heredoc, whereas after a local variable buffer <<"x" is an append; keep a space on both sides of the append operator so that the reader (and the parser after a method call) cannot confuse the two.

Strings

# frozen_string_literal: true

title = "Dune"
begin
  title << " Messiah"
rescue FrozenError => e
  puts "#{e.class}: #{e.message}"
end
# += builds a new string, << changes the receiver
title += " Messiah"
p title # => "Dune Messiah"
buffer = +"Title: "
buffer << title << "!"
p buffer # => "Title: Dune Messiah!"
p buffer.frozen? # => false
p (-buffer).frozen? # => true
p "ab" "cd" # => "abcd"
p((+"ab") << 99) # => "abc"
price = 3.14159
p format("%.2f", price) # => "3.14"
p "%05.1f|%-5s|%+d" % [3.14159, "ab", 5] # => "003.1|ab   |+5"
p "%<name>s has %<count>03d copies" % { name: "Dune", count: 2 } # => "Dune has 002 copies"
p "-" * 3 # => "---"
  • With # frozen_string_literal: true (the default in the examples of this section) every literal is frozen, and title << " Messiah" raises FrozenError: can't modify frozen String: "Dune". Without the magic comment, Ruby 3.4 treats literals as chilled: they can still be modified, but ruby -w (or enabling the :deprecated warning category) prints literal string will be frozen in the future at the first modification. The default is expected to become frozen in a future major release.

  • += creates a new string (title += x is title = title + x), so it works on frozen strings. << mutates, so use it on a mutable buffer created with unary plus, +"" or String.new.

  • Unary +string returns a mutable copy when the receiver is frozen (otherwise itself), and unary -string returns a frozen, deduplicated string; the example shows both flags.

  • "ab" "cd" (adjacent literals) is a compile-time concatenation, not an operator; RuboCop flags it as Lint/ImplicitStringConcatenation when it is probably a missing comma.

  • % formats with the same directives as Kernel#format: positional arrays, %<name>s and %{name} for hashes, and %05.1f-style flags. RuboCop prefers format/sprintf over String#% and annotated tokens over %s when more than one placeholder is used.

The full list of String methods and encodings is in Strings.

Safe navigation: &.

receiver&.method(args) calls the method unless the receiver is nil, in which case the whole call (including the evaluation of its arguments) is skipped and the result is nil. It replaces the user && user.address && user.address.city ladder. Only nil is special: false&.to_s calls to_s.

# frozen_string_literal: true

Owner = Struct.new(:name, :address)
Address = Struct.new(:city)

owner = Owner.new("Ana", nil)
p owner.address&.city # => nil
owner.address = Address.new("Lyon")
p owner.address&.city # => "Lyon"
nothing = nil
p nothing&.length # => nil
p false&.to_s # => "false"
# the arguments are not evaluated when the receiver is nil
calls = []
nothing&.push(calls << :argument_evaluated)
p calls # => []
# it works with operator-assignment and with operators written as methods
count = nil
p(count&.+(1)) # => nil
text = nil
text &&= text.upcase
p text # => nil
owner.address&.city = "Paris"
p owner.address.city # => "Paris"
owner.address&.city ||= "Rome"
p owner.address.city # => "Paris"
# the safe call guards only one link of the chain
begin
  nothing&.foo.bar
rescue NoMethodError => e
  puts "#{e.class}: #{e.message}"
end
p nothing&.foo&.bar # => nil
names = [nil, "x"]
p(names.map { it&.upcase }) # => [nil, "X"]
p names.compact.map(&:upcase) # => ["X"]

Details that matter in practice:

  • The arguments are not evaluated for a nil receiver (calls stays empty), which is useful but also a side-effect trap.

  • Operator calls are legal in method-call form (count&.+(1)), and attribute and abbreviated assignment work: owner.address&.city = "Paris" assigns only when address is not nil, and owner.address&.city ||= "Rome" assigns only when the receiver exists and city is falsy.

  • The guard covers one link. In nothing&.foo.bar, bar is still sent to the result of nothing&.foo, which is nil, so you get a NoMethodError for nil unless every link uses &.. RuboCop’s Lint/SafeNavigationChain detects this, and Lint/RedundantSafeNavigation flags &. on receivers that cannot be nil.

  • &. hides nil rather than handling it. A chain of four &. calls often means a missing value object or a missing fetch; it is a good fit at the boundary of optional data and a poor one for values that should never be absent.

  • With map { it&.upcase } the safe call keeps nil elements in the result ([nil, "X"]); compact.map(&:upcase) drops them. The it implicit parameter is new in Ruby 3.4.

calling-methods-and-argument-forwarding covers receivers and argument syntax in full: Calling Methods and Argument Forwarding.

The ternary operator

condition ? then_value : else_value is an expression with the same meaning as the if expression, with lower precedence than ranges and higher than assignment and the modifiers. It is right associative.

# frozen_string_literal: true

stock = 0
p(stock.zero? ? "sold out" : "in stock") # => "sold out"
label = stock > 5 ? "plenty" : (stock.positive? ? "few" : "none")
p label # => "none"
# for more than two outcomes, an if-expression reads better than a nested ternary
level =
  if stock > 5 then "plenty"
  elsif stock.positive? then "few"
  else "none"
  end
p level # => "none"
kind = stock.zero? ? :empty : :stocked
p kind # => :empty
flag = stock.zero?
p(flag ? [1] : [2]) # => [1]
# spacing matters next to symbols and ? methods
p(flag ?"yes":"no") # => "yes"
p(flag ? "yes": "no") # => "yes"
  • Use it for a single, short choice between two values (stock.zero? ? "sold out" : "in stock"). Nested ternaries are legal but RuboCop’s Style/NestedTernaryOperator flags them even when parenthesized; an if expression assigned to a variable (shown above) is clearer.

  • if is also an expression in Ruby: the last expression of the chosen branch is the value, so level = if ... end needs no ternary at all. See Control Expressions.

  • Spacing matters next to symbols and predicate methods. flag ?"yes":"no" and flag ? "yes": "no" happen to parse, but they are fragile and RuboCop reports the missing spaces (Layout/SpaceAroundOperators); x ? :a : :b needs the spaces.

  • Both branches are evaluated lazily: only the chosen branch runs.

Ranges in conditions and the flip-flop

.. builds an inclusive range and ... an exclusive one. Both are operators in the precedence table and are lower than arithmetic, so 1..n + 1 is 1..(n + 1). Beginless and endless ranges ((..5), (1..)) are first-class values and work in case/when and with cover?. The details of Range are in Ranges.

# frozen_string_literal: true

n = 3
# .. has lower precedence than +, so the end is n + 1
p(1..n + 1) # => 1..4
p (1..n).sum # => 6
p (1..).first(2) # => [1, 2]
p (..5).cover?(3) # => true
p 1.step(10, 4).to_a # => [1, 5, 9]
p ("a".."e").to_a # => ["a", "b", "c", "d", "e"]
p (1...4).size # => 3
age = 30
p age.between?(18, 65) # => true
p (18..65).cover?(age) # => true
case age
when ..17 then puts "minor"
when 18.. then puts "adult"
end

Range#cover? and Range#=== test the bounds only, which is what a "between" test needs. include? and member? are equivalent for numeric ranges, but for other element types (a range of strings) they walk the elements, so prefer cover? when the range is already at hand; Comparable#between? is the choice when it is not.

The flip-flop

A range of two conditions inside a condition (if, unless, ?:, while, a modifier) is not a Range at all. It is the flip-flop operator, borrowed from awk and Perl: it becomes true when the first condition is true, stays true on later evaluations, and turns false after the second condition is true. It keeps state between evaluations, so it is only meaningful inside a loop or iterator.

# frozen_string_literal: true

lines = %w[intro BEGIN one two END outro BEGIN three END]
# a range of two conditions inside a condition is a flip-flop: it stays "on" between them
inside = lines.select { |line| true if (line == "BEGIN")..(line == "END") }
p inside # => ["BEGIN", "one", "two", "END", "BEGIN", "three", "END"]
body = lines.select do |line|
  next false unless (line == "BEGIN")..(line == "END")

  !%w[BEGIN END].include?(line)
end
p body # => ["one", "two", "three"]
# the same thing without a flip-flop, with an explicit state variable
collecting = false
explicit = lines.select do |line|
  collecting = true if line == "BEGIN"
  keep = collecting
  collecting = false if line == "END"
  keep
end
p explicit # => ["BEGIN", "one", "two", "END", "BEGIN", "three", "END"]
# .. checks the end condition on the line that turned it on, ... does not
numbers = [1, 2, 3, 4, 5, 6]
p(numbers.select { |i| true if (i == 2)..i.even? }) # => [2]
p(numbers.select { |i| true if (i == 2)...i.even? }) # => [2, 3, 4]
  • .. checks the second condition on the same evaluation that turned the flip-flop on; ... does not. With [1, 2, 3, 4, 5, 6] and the pair (i == 2) and i.even?, .. yields only [2] while ... yields [2, 3, 4].

  • A range of two integer literals in a condition (if 1..2) is also a flip-flop, compared against the line number $.; it is almost always a mistake and Ruby warns (see the warnings section).

  • Flip-flops are rare, state hidden in the operator is easy to misread, and RuboCop’s Lint/FlipFlop flags every use. An explicit state variable, as in the last block of the example, is the maintainable alternative.

defined?

defined? is a keyword operator that tests whether an expression could be evaluated, without evaluating the expression itself; it returns a description string or nil. It has very low precedence, below assignment, so defined? a = 1 asks about the assignment and defined?(a) && a needs the parentheses.

# frozen_string_literal: true

stock = 3
p defined?(stock) # => "local-variable"
p defined?(@missing) # => nil
p defined?(puts) # => "method"
p defined?(String) # => "constant"
p defined?(1 + 1) # => "method"
p defined?(yield) # => nil
p defined?(@loans = 2) # => "assignment"
p defined?(@missing || 1) # => "expression"
p defined?(Shelf::Missing) # => nil
p defined?(super) # => nil
p defined?(zzz) # => nil
p(defined?(zzz) ? 1 : 2) # => 2

The strings include "local-variable", "method", "constant", "instance-variable", "global-variable", "assignment", "expression", "yield", "super", "self", "nil", "true" and "false". Idiomatic uses are defined?(@memo) (telling a variable that was never set from one set to nil, which @memo.nil? cannot do), defined?(yield) and defined?(super) inside methods, and defined?(SomeConstant) to test for an optional dependency without raising NameError. block_given? and respond_to? are usually clearer for the first and last of those cases.

Modifiers and block binding

The statement modifiers if, unless, while, until and rescue are the loosest operators of all. A modifier applies to the whole statement on its left, including an assignment:

# frozen_string_literal: true

def parse(text) = Integer(text)

# a rescue modifier is lower than =, so it guards the whole right-hand side
count = begin
  parse("x")
rescue ArgumentError
  0
end
p count # => 0
value = parse("x") rescue 0
p value # => 0
p((raise "boom" rescue "rescued")) # => "rescued"
items = [3, 1, 2]
# a brace block binds to the nearest call, a do block to the whole command
p(items.reject { it > 1 }) # => [1]
rejected = items.reject do |n|
  n > 1
end
p rejected # => [1]
puts items.map do |n| # prints #<Enumerator:0x000000012493d3f0>
  n * 2
end
puts(items.map { |n| n * 2 })
  • value = parse("x") rescue 0 is value = (parse("x") rescue 0); the rescue modifier rescues only StandardError, hides which error happened and is flagged by Style/RescueModifier. A begin/rescue block (first assignment of the example) or rescue ArgumentError in a method body is the precise alternative; see Exceptions and Error Handling.

  • x = 5 if cond assigns only when cond is truthy; if x was not assigned before, it is nil afterwards, because the parser still creates the variable.

  • A { } block binds to the nearest call, a do ... end block to the whole command. puts items.map do |n| ... end therefore passes the block to puts, which ignores it, and map without a block returns an enumerator (the output #<Enumerator:0x...> has an address that differs on every run). Use braces for chained calls whose result is used and do ... end for blocks that perform actions, or parenthesize (puts(items.map { ... })). RuboCop reports the mistake as Lint/AmbiguousBlockAssociation. Blocks and procs are covered in Blocks, Procs and Lambdas.

Line continuation and leading logical operators

A newline ends a statement unless the expression is obviously incomplete: after a binary operator, a comma or an opening bracket, or when the next line starts with a method-call dot. Starting with Ruby 4.0, the logical operators &&, ||, and and or at the beginning of a line also continue the previous line, like a leading dot:

# frozen_string_literal: true

stock = 2
overdue = false
available = stock.positive?
  && !overdue
p available # => true
cached = nil
label = cached
  || "fallback"
p label # => "fallback"
ready = available
  and stock > 5
p ready # => true
if stock.positive?
   && stock < 5
  puts "few left"
end

Ruby 3.4 and older do not accept such a line, so code that has to run on them must keep the operator at the end of the previous line. The feature allows layouts such as an if whose conditions each begin with &&. Note that ready = available followed by and stock > 5 still assigns first: the low precedence of and is unchanged, and ready is true even though the whole expression was falsy.

Warnings and errors

The interpreter warns about operator mistakes when run with -w; these are the ones you are most likely to meet, with the text produced by Ruby 4.0.7.

# frozen_string_literal: true

count = 3
if count = 4
  puts "assigned"
end
warn_assign.rb:4: warning: found '= literal' in conditional, should be ==
assigned

A literal on the right of = in a condition is almost certainly a typo for ==. A non-literal assignment such as if (m = re.match(s)) is idiomatic and silent when it is parenthesized.

# frozen_string_literal: true

def show(*args) = args

p show -1 # => [-1]
n = 5
p n -1 # => 4
p n - 1 # => 4
p show *[1, 2] # => [1, 2]
warn_ambiguous.rb:5: warning: ambiguous first argument; put parentheses or a space even after `-` operator
warn_ambiguous.rb:7: warning: '-' after local variable or literal is interpreted as binary operator even though it seems like unary operator
warn_ambiguous.rb:9: warning: ambiguous `*` has been interpreted as an argument prefix
[-1]
4
4
[1, 2]

Whitespace changes the meaning of -, *, & and [ after a method name: show -1 passes -1, n -1 (with n a local variable) subtracts. Put parentheses on calls, or spaces on both sides of a binary operator; RuboCop’s Lint/AmbiguousOperator reports the first and the third call.

# frozen_string_literal: true

value = 5
puts value +1 # prints 6
puts (1 + 2) * 3 # prints 9
puts((1 + 2) * 3) # prints 9
warn_spacing.rb:4: warning: '+' after local variable or literal is interpreted as binary operator even though it seems like unary operator
6
9
9

puts (1 + 2) * 3 prints 9 because the parenthesized group is only the first part of the argument, which is why the parenthesized call puts((1 + 2) * 3) is the form to write; puts value +1 is parsed as a subtraction (6) with a warning.

# frozen_string_literal: true

x = 1
p x == 1 # => true
x == 2
x + 1
puts "ok" # prints ok
warn_void.rb:5: warning: possibly useless use of == in void context
warn_void.rb:6: warning: possibly useless use of + in void context
true
ok

A comparison or arithmetic result that is thrown away is almost always a bug (x == 2 was meant to be x = 2).

# frozen_string_literal: true

if 1..2
  puts "range literal in condition"
end
puts "done" if "text" # prints done
puts "x" if /re/
warn_literal.rb:3: warning: integer literal in flip-flop
warn_literal.rb:3: warning: integer literal in flip-flop
warn_literal.rb:6: warning: string literal in condition
warn_literal.rb:7: warning: regex literal in condition
done

A literal in a condition is constant, and an integer range literal is silently turned into a flip-flop on $..

The other errors this page’s operators raise are summarized below.

Message Raised by Typical cause

TypeError: String can't be coerced into Integer

Integer#+, *, etc.

The left operand is a number and the right one cannot be coerced; 3 * "ab", 1 + "2".

TypeError: no implicit conversion of Integer into String

String#+

"a" + 1; use interpolation or to_s.

NoMethodError: undefined method '+' for nil

any operator

The left operand is nil; trace where the nil came from, or use &./fetch/a default.

ArgumentError: comparison of Shelf::Fine with 5 failed

Comparable, sort, min, max

<=> returned nil for the pair.

ZeroDivisionError: divided by 0

/, %, divmod, div

Integer or float modulo by zero; guard or rescue it.

FrozenError: can't modify frozen String: "Dune"

<<, concat, upcase!

Mutating a frozen literal; build a +"" buffer or use +=.

ArgumentError: exponent is too large

Integer#**

An exponent so large that the exact result cannot be built (Ruby 3.4 and later).

What changed between Ruby 3.0 and 4.0

Version Change affecting operators

3.2

Integer#ceildiv added; Set available without require "set"; Kernel#=~ (and therefore Object#=~) removed.

3.4

Integer#** and Rational#** return exact results instead of Infinity/NaN, and raise for absurd exponents; index assignment no longer accepts keyword or block arguments; the it block parameter; chilled string literals; error messages use 'method' quotes and the class-qualified 'Integer#%' form.

4.0

&&, ||, and and or at the start of a line continue the previous line; *nil no longer calls nil.to_a (so f(*nil) passes no arguments, consistent with **nil).

Where older books and tutorials are outdated

Much written about operators in older Ruby books and blog posts still holds, because the operator set has barely changed in 25 years. A few points have moved, and a reader of an older book should check them:

  • Fixnum/Bignum are gone (unified into Integer in 2.4, constants removed in 3.2), so "overflow into Bignum" discussions are only history.

  • Object#=~ used to exist and returned nil; it was removed in 3.2.

  • Integer#** with a huge exponent used to give Infinity and now gives the exact integer or an error (3.4).

  • Leading &&/|| on the next line used to be a syntax error and is now accepted (4.0). Style guides that say "always put the operator at the end of the line" describe the older constraint rather than a rule of the language today.

  • and/or guidance has not changed: the common advice to avoid them in conditions and assignments is still correct, and the control-flow use (or raise) is still idiomatic.

  • Set no longer needs require "set", and error messages in 3.4 and later use ' instead of the backtick-quote pair (undefined method '+' for nil), so an exact message copied from an older book will not match what you see.

  • it in blocks (3.4) and endless methods (3.0) make operator-heavy one-liners shorter than older texts show.

  • The books mostly show puts output; modern documentation and this section use the 3.4+ inspect format ({name: "Dune"} instead of {:name=>"Dune"}).

Summary

  • Operators are methods unless they are keywords; define +, -, <=>, ==, [] and friends on your own classes, and include Comparable after defining <=>.

  • Only nil and false are falsy; && and || return an operand, not a boolean, and short-circuit.

  • Use &&, || and ! in expressions and conditions; keep and, or and not for control flow, if at all.

  • Parenthesize mixed operator families; remember that & is above ==, << is below +, .. is below ||, and ** is above unary minus.

  • / floors for integers, % follows the divisor, fdiv and Rational avoid surprises, and floats are never compared with == after arithmetic.

  • &. guards one link only, || and ||= confuse false with missing, rescue modifiers hide errors, and do ... end versus { } changes which call receives the block.

References