Skip to content

throw and catch are not exceptions

Level: 201 · anyone who has used an exception to break out of two loops

One line: catch(:tag) { … throw :tag, value … } is a labelled non-local jump: throw unwinds to the matching catch, which returns the value, rescue clauses on the way never see it, ensure clauses still run — and only a throw with no matching catch becomes an exception, UncaughtThrowError.

Ruby keeps two mechanisms apart that Python folds into one. raise and rescue are for errors. throw and catch are for leaving early: a search that has found its answer three loops deep, a parser that has seen enough. catch takes a tag — nearly always a symbol — and runs its block; throw with that tag unwinds the stack to the catch, which then returns the thrown value (or nil). No exception object is built for a matched throw, which is why a rescue => e between the two is not consulted, while ensure clauses, which run on any exit, still do. The tag is matched by identity, which is the reason for symbols: two equal-looking strings are two objects. A throw that finds no catch above it has nowhere to go, and that is an exception — UncaughtThrowError, an ArgumentError, so a bare rescue catches it and reports the tag and value.

Verified output of throw_and_catch_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. catch returns the value thrown to it, or the block's value
   with a throw:    20
   without a throw: "block finished without a throw"
2. escaping nested loops
   visiting 1,1
   visiting 1,2
   visiting 1,3
   visiting 2,1
   visiting 2,2
   first cell whose product is 4: [2, 2]
3. a throw with no matching catch is an exception, and a StandardError
   UncaughtThrowError: "uncaught throw :nope"; tag :nope, value 1
   e.is_a?(ArgumentError) true
4. a matched throw is not seen by a rescue in between, but ensure runs
   ensure ran on the way out
   catch returned "thrown past the rescue"
5. catch with no tag makes one and hands it to the block
   catch { |tag| throw tag, 7 }   -> 7
   the tag is a plain Object
6. throw with no value throws nil
   catch(:t) { throw :t }         -> nil
7. tags match by identity, which is why symbols are used
   catch("s") { throw "s".dup, 1 } -> UncaughtThrowError: "uncaught throw \"s\""
   the same String object       -> 1
   :s.equal?(:s)                true
8. throw unwinds through method calls
   catch(:bottom) { descend(5) } -> "reached the bottom"

Reading the output

Row 1 is the basic shape twice: with a throw the catch returns the thrown value, 20; without one it returns the block's last expression. Row 2 is the use case — two nested each loops, a throw as soon as the cell is found, and the visits stop at 2,2 with [2, 2] as the answer; no flag, no helper method, no exception.

Row 3 is the failure mode: throw :nope with no enclosing catch(:nope) raises UncaughtThrowError, whose message names the tag and whose tag and value readers hold both — and it is_a?(ArgumentError), hence a StandardError, hence caught by rescue => e. Row 4 is the difference from an exception: the throw passes a rescue => e without triggering it (no "rescue ran" line), but the ensure clause prints, and catch returns the thrown string — the block's own "block finished" is never reached.

Row 5 is the tagless form: catch with no argument creates a fresh Object as the tag and yields it, so a tag can be private to one block. Row 6: a throw with only a tag delivers nil. Row 7 explains the symbols: catch("s") does not catch throw "s".dup — two String objects, however alike, are two tags — while the same object works, and a symbol is always the same object (:s.equal?(:s)). Row 8 shows the jump crossing method boundaries: five nested calls of descend, one throw at the bottom, one catch at the top.

Compared with Python

Python has no second mechanism: a non-local jump is an exception, so the twin defines class Found(Exception) and a tiny catch(f) helper that calls f and returns the raised value. Rows 1, 2, 6 and 8 then print the same values — the exception carries the payload in args, unwinds through the loops and the recursion, and the helper hands it back. The differences are in what an exception is. Row 3: an unmatched Found is not a special error, just an uncaught Found. Row 4 is the one that matters in practice: an except Exception in between does intercept the jump, because Found is an Exception — the twin shows it swallowed and the function returning normally — and the only way to make a jump pass through such handlers is to derive it from BaseException (Escape), which is what SystemExit, KeyboardInterrupt and GeneratorExit do. finally runs in both cases, like ensure. Row 5: there is no tagless form, the class is the tag. Row 7: matching is by class (isinstance), never by identity, so two Found() instances match the same handler even though they are different objects. Python also has for … else for the commonest single-loop search: else runs only when the loop was not left by break. (Not machine-checked here.)

Verified output of throw_and_catch_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. a control-flow exception carries the value; the helper returns it
   with a raise:    20
   without a raise: 'function finished without a raise'
2. escaping nested loops
   visiting 1,1
   visiting 1,2
   visiting 1,3
   visiting 2,1
   visiting 2,2
   first cell whose product is 4: [2, 2]
3. a raise with no matching except is just an uncaught exception
   Found; args (1,)
   isinstance(e, Exception) True
4. an except Exception in between DOES intercept it; finally runs either way
   except Exception intercepted Found
   finally ran on the way out for Found
   returned 'function finished'
   finally ran on the way out for Escape
   Escape (a BaseException) reached the outer handler
5. no tagless form: the exception class is the tag
   any class works as a tag; there is nothing to generate
6. a raise with no value: args is empty, the helper returns None
   catch(lambda: (_ for _ in ()).throw(Found())) -> None
7. matching is by class, not identity
   isinstance(Found(), Found) True; Found() is Found() False
8. the jump unwinds through function calls
   catch(lambda: descend(5)) -> 'reached the bottom'

Try it

  1. Write find_pair(matrix, target) that returns the [row, column] of the first cell equal to target, printing each cell it visits, using catch and throw to leave both loops at once, and returning nil when the value is absent. Run it on a 3×3 matrix for a present and an absent value.
  2. Wrap the search in begin … rescue => e … ensure … end and show that a matched throw skips the rescue but runs the ensure; then throw a tag nobody catches and show the rescue catching UncaughtThrowError and printing e.tag.
Solution to 1

throw_and_catch_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Exercise 1: find_pair returns the [row, column] of the first cell equal to target,
# leaving both loops the moment it is found, and nil when the value is absent.

def find_pair(matrix, target)
  catch(:found) do
    matrix.each_with_index do |row, r|
      row.each_with_index do |value, c|
        puts "   visiting #{r},#{c} (#{value})"
        throw :found, [r, c] if value == target
      end
    end
    nil
  end
end

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

puts "1. a value that is present"
puts "   -> #{find_pair(matrix, 5).inspect}"

puts "2. a value that is absent"
puts "   -> #{find_pair(matrix, 42).inspect}"

Verified output of throw_and_catch_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. a value that is present
   visiting 0,0 (1)
   visiting 0,1 (2)
   visiting 0,2 (3)
   visiting 1,0 (4)
   visiting 1,1 (5)
   -> [1, 1]
2. a value that is absent
   visiting 0,0 (1)
   visiting 0,1 (2)
   visiting 0,2 (3)
   visiting 1,0 (4)
   visiting 1,1 (5)
   visiting 1,2 (6)
   visiting 2,0 (7)
   visiting 2,1 (8)
   visiting 2,2 (9)
   -> nil

See also