Skip to content
academia.sh

Lesson 11 / 12

Exceptions

The rescue, ensure and retry constructs are measured as the language's own syntax, raise as a message, and the match in a rescue branch as being made with a message to a class.

Published Updated

In this lesson

  • Catches an exception with rescue and reads its type with e.class
  • Derives its own exception class from StandardError and raises it with raise
  • Writes resource cleanup with ensure and retrying with retry
  • Measures that the rescue construct is syntax and raise is a message
Contents

At the end of the previous lesson, a lambda called with the wrong number of arguments raised ArgumentError, and that exception was caught with rescue. This lesson makes that construct its subject. An exception is an event that interrupts the normal flow; raising produces it and rescuing receives it. M12/K01 measured the error channels in PHP; the question of an error channel is not asked here, only who answers the construct is measured.

This lesson’s responder is syntax: rescue is not a method but a construct of the language itself.

rescue Is an Expression

The core observation rests on the defined? question: what does defined? say about a begin ... rescue ... end block? If it were a method call, "method" would come back.

# reply.rb — helpers that classify who answers a message

# Classification asks in order: is the expression a message at all (defined?),
# then do two different blocks give two different results, then who owns the method.
def responder(definition, owner: nil, result: nil, with_blocks: nil)
  return ["syntax", "defined? #{definition}"] unless definition == "method"

  if with_blocks
    first, second = with_blocks.call
    return ["block", "#{first.inspect} / #{second.inspect}"] unless first == second
  end

  kind = owner.instance_of?(Class) ? "class" : "module"
  [kind, "#{owner}, result #{result.inspect}"]
end
# rescue.rb — who answers the rescue construct
require_relative "reply"

definition = defined?(begin; Integer("x"); rescue ArgumentError; 0; end)
kind, evidence = responder(definition)
puts "#{kind}  #{evidence}"
value = Integer("x") rescue 0
puts value
puts Integer("x", exception: false).inspect
syntax  defined? expression
0
nil

The first line is the core’s rescue observation: the responder is syntax, and defined? gave "expression". The second line shows the statement modifier form: Integer("x") rescue 0 caught the exception and gave 0. The third line did the same job with the exception: false keyword argument, without raising an exception, and returned nil. CB15. This lesson measures the modifier rescue only with ArgumentError; whether it catches other exception classes is not measured.

On the second line, Integer("x") rescue 0 was first assigned to a variable and then printed; the modifier form was not written directly inside a method argument or inside defined?. How it resolves in those positions is not measured in this lesson.

Exception Classes

Every exception is an instance of a class. e.class gives its type; the class name is used instead of the message (e.message), because the message text can change between versions. The lesson’s own raise text is outside this rule.

# classes.rb — exception classes
def attempt
  yield
rescue => e
  e.class
end

puts attempt { 1 / 0 }
puts attempt { raise "x" }
puts attempt { Integer("x") }
puts 1.0 / 0

class OutOfStock < StandardError; end

begin
  raise OutOfStock, "out of stock"
rescue OutOfStock => e
  puts "#{e.class} #{e.message}"
end
ZeroDivisionError
RuntimeError
ArgumentError
Infinity
OutOfStock out of stock

Integer division by zero raised ZeroDivisionError; float division gave Infinity instead of an exception. A plain raise "x" produced a RuntimeError. Our own class OutOfStock derived from StandardError, and rescue OutOfStock => e caught only it. The message could be printed here because it is the lesson’s own raise text.

ensure and retry

The ensure branch runs whether or not there is an exception; it is used for resource cleanup. retry runs the begin block again from the start.

# retry.rb — ensure and retry
attempts = 0
begin
  attempts += 1
  raise "not yet" if attempts < 3
  puts "succeeded #{attempts}"
rescue
  retry
end

events = []
begin
  events << 1
  raise "error"
rescue
  events << :rescued
ensure
  events << 2
end
puts events.inspect
succeeded 3
[1, :rescued, 2]

On every failed attempt, retry restarted the block, and the job finished on the third attempt. In the second block, the ensure branch ran even after the exception was caught. CB16. The retry count is the lesson’s own counter; in a real retry scenario an upper bound is needed to prevent an endless loop, and that is not measured here.

Branches and else

A begin block can carry several rescue branches; the exception goes to the first branch that matches. The else branch runs only when no exception occurred. A class’s parent is asked with superclass.

# branches.rb — rescue branches and else
def classify(input)
  Integer(input)
rescue ArgumentError
  "invalid"
rescue TypeError
  "type"
else
  "ok"
end

puts classify("5")
puts classify("x")
puts classify(nil)
puts ZeroDivisionError.superclass
puts StandardError.superclass
ok
invalid
type
StandardError
Exception

Because "5" raised no exception, the else branch ran. "x" went to the ArgumentError branch and nil to the TypeError branch: the first matching branch won. The parent of ZeroDivisionError turned out to be StandardError, and the parent of that is Exception; a bare rescue therefore catches ZeroDivisionError. CB18. The order of the branches matters: if a broad class is written first, the branches after it never run, but that behavior is not measured here.

StandardError and Exception

A rescue with no parameter and rescue => e catch only StandardError and its subclasses. Exceptions that stand outside StandardError in the hierarchy pass through this catcher. The measurement below shows that an ArgumentError is caught, that a NotImplementedError is not caught, and the relevant parent classes.

# hierarchy.rb — which exceptions does rescue catch
def guarded
  yield
  "none"
rescue => e
  "caught #{e.class}"
end

puts guarded { raise ArgumentError }
begin
  guarded { raise NotImplementedError }
rescue NotImplementedError => e
  puts "outside #{e.class}"
end
puts NotImplementedError.superclass
puts ScriptError.superclass
puts KeyError.superclass
puts IndexError.superclass
caught ArgumentError
outside NotImplementedError
ScriptError
Exception
IndexError
StandardError

ArgumentError was caught and caught ArgumentError was printed. NotImplementedError passed through the rescue of guarded and was caught in the outer begin block. The parent chain explains this: NotImplementedError is under ScriptError, which is under Exception; it does not reach StandardError. KeyError, on the other hand, connects to StandardError through IndexError. CB34. Only the parent chain of these four classes was printed.

ensure Does Not Change the Return Value

The ensure branch runs however the method ends. However, the last expression of this branch does not become the method’s return value. ensure runs on an early exit with return too.

# ensure_value.rb — the ensure branch and the return value
def value
  1
ensure
  2
end

def early
  return 1
ensure
  puts "cleanup"
end

puts value
puts early
1
cleanup
1

value returned 1; the 2 inside ensure did not change the return value. early exited with return 1, but ensure first printed the cleanup line and then 1 was returned. CB35. Only these two forms were measured; writing a return inside ensure was not measured.

Re-raising

A bare raise inside rescue re-raises the caught exception as it is. This way a layer records the event but leaves the decision to the layer above.

# reraise.rb — re-raising with raise
def outer
  yield
rescue ArgumentError => e
  puts "logged #{e.class}"
  raise
end

begin
  outer { Integer("x") }
rescue => e
  puts "again #{e.class}"
end
logged ArgumentError
again ArgumentError

The inner rescue first printed the logged ArgumentError line and raised the same exception with a bare raise. The outer rescue saw the same class: again ArgumentError. CB36. The exception message was not printed; only the class was measured.

The retry Limit and the Cause Chain

retry runs the block from the start; left unbounded, it falls into an endless loop. That is why it is always written together with a counter. If a new exception is raised inside a rescue, the earlier exception remains on the new one through cause.

# limit.rb — a retry counter and cause
attempts = 0
begin
  attempts += 1
  raise "x" if attempts < 10
rescue
  retry if attempts < 3
  puts "gave up #{attempts}"
end

begin
  begin
    Integer("x")
  rescue => e
    raise RuntimeError, "upper"
  end
rescue => upper
  puts upper.cause.class
end
gave up 3
ArgumentError

In the first two rounds retry ran the block again; in the third round the condition was not met, so gave up 3 was printed. In the second part, Integer("x") raised an ArgumentError, a new RuntimeError was raised inside the rescue, and the outer upper.cause.class gave ArgumentError. CB37. retry was measured only in the begin and rescue form.

Multiple Classes and Branch Order

A rescue can list several classes separated by commas. If there are several rescue branches, the first matching branch runs and the later ones are not looked at.

# order.rb — a rescue list and branch order
def classify
  yield
rescue ZeroDivisionError, TypeError
  "both"
rescue StandardError
  "general"
end
puts classify { 1 / 0 }
puts classify { 1 + nil }
puts classify { Integer("x") }
def reversed
  yield
rescue StandardError
  "general"
rescue ZeroDivisionError
  "specific"
end
puts reversed { 1 / 0 }
both
both
general
general

rescue ZeroDivisionError, TypeError caught both 1 / 0 and 1 + nil: both printed both. Integer("x") was not in this list and fell to the general branch. In the reversed method, StandardError was written first; 1 / 0 got caught by that branch and the specific branch was never reached. The specific class must be written before the general one. CB48. The measurement was made with these three exception classes.

The Order of begin, else and ensure

The branches in a begin block run in a particular order. The order in which the branches run is read from the symbols appended to an array.

# sequence.rb — the running order of the branches
steps = []
begin
  steps << :body
rescue
  steps << :rescue
else
  steps << :else
ensure
  steps << :ensure
end
puts steps.inspect
steps = []
begin
  steps << :body
  raise "x"
rescue
  steps << :rescue
else
  steps << :else
ensure
  steps << :ensure
end
puts steps.inspect
[:body, :else, :ensure]
[:body, :rescue, :ensure]

With no error, else ran after the body and ensure ran last. When an error was raised, rescue came into play, else was skipped and ensure ran again. ensure took the last place in both cases. CB49.

An Exception Class with a Field

Your own exception class can carry extra information. You store the field in initialize and pass the message with super.

# with_field.rb — an exception class with its own field
class StockError < StandardError
  attr_reader :quantity

  def initialize(quantity)
    @quantity = quantity
    super("insufficient stock")
  end
end

begin
  raise StockError.new(3)
rescue StockError => e
  puts "#{e.class} quantity=#{e.quantity} message=#{e.message}"
end
puts StockError.superclass
puts StockError.instance_method(:quantity).owner
StockError quantity=3 message=insufficient stock
StandardError
StockError

StockError derived from StandardError, carried the quantity field and gave its own message text with super. Because rescue reached the field with e.quantity, quantity=3 was printed; message=insufficient stock is text the lesson wrote itself. The owner of the quantity reader is StockError: the class answers this method. CB50.

catch and throw

catch and throw provide a labeled exit. throw climbs out to the nearest catch block opened with the same label and carries a value to it.

# tags.rb — catch and throw
r = catch(:done) do
  [1, 2, 3].each { |n| throw :done, n * 10 if n == 2 }
  :nothing
end
puts r
puts "owner=#{method(:throw).owner}"
20
owner=Kernel

throw :done, 20 ended the inner each block and the outer catch block; catch returned 20 instead of :nothing. The owner of throw was printed as Kernel. CB51. Only this single-label example was measured.

Nested ensure and the rescue Suffix

If an inner begin block has an ensure and the exception is not caught there, ensure runs before the exception goes out. As a short form, expression rescue value uses the value if the expression fails; this form can be assigned to a variable in parentheses.

# nested.rb — nested ensure and the rescue suffix
log = []
begin
  begin
    log << :inner
    raise ArgumentError
  ensure
    log << :cleanup
  end
rescue ArgumentError
  log << :outer
end
puts log.inspect
recovered = (Integer("x") rescue -1)
puts recovered
[:inner, :cleanup, :outer]
-1

The exception was not caught in the inner block, but ensure ran before it went out, and the order became [:inner, :cleanup, :outer]. So ensure runs before the outer rescue. When Integer("x") failed, the -1 in the suffix form was used. CB55. That the suffix form catches only the StandardError family was not measured; only this single example was tried in this lesson.

The Bounding Measurement

raise is a message, that is, a method call; rescue is syntax. The two have different responders. One can also ask whether the rescue ArgumentError => e branch makes a match of its own: does the branch ask the ArgumentError class with a message?

# matching.rb — the match of a rescue branch
puts "raise owner=#{method(:raise).owner}"

begin
  Integer("x")
rescue ArgumentError => e
  puts ArgumentError === e
  puts "=== owner=#{ArgumentError.method(:===).owner}"
end
raise owner=Kernel
true
=== owner=Module

raise is a method and its owner is the Kernel module. The branch’s match is made with the === message: ArgumentError === e gave true, and the owner of === is Module, not Class; that is, the class takes its own === from a higher structure. This sharpens the distinction: the language resolves the rescue keyword, but which branch receives the exception is decided by the class’s === answer. The language builds the construct and the class decides the match. The class written on the same rescue line is therefore an ordinary class object and answers its own match message. CB17. This measurement was made only for the ArgumentError branch; that different exception classes get the same message from the same owner is not assumed.

The result agrees with the core’s classification: defined? named the expression "expression", that is, the rescue keyword is not a message; the branch match, as seen above, is made with the === message. The raising side is different; raise is an ordinary method, it comes from the Kernel module and takes an exception class or text as an argument. So in an exception event two responders stand side by side: a module answers the raising and the language itself answers the rescuing. This is also the reason syntax was chosen as the lesson’s responder: the question measured is who builds the rescuing construct.

Summary

  • An exception is an event that interrupts the normal flow; rescue catches, raise raises, ensure runs in every case, and retry restarts the block.
  • This lesson’s responder is syntax: defined? returns "expression" for begin ... rescue ... end.
  • The statement modifier rescue and exception: false do the same job in two ways: a value instead of an exception.
  • raise is a method and its owner is the Kernel module; the match of a rescue branch is made with the === message.
  • The bounding measurement: rescue is syntax, but the match inside its branch is a message to a class.

Next Step

In this lesson, all the operations that raise exceptions ran in memory. The last lesson brings files and input/output: whether the thing that opens a file is a block or a method, and which construct ensure cleans up in this job.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close