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;
rescuecatches,raiseraises,ensureruns in every case, andretryrestarts the block. - This lesson’s responder is syntax:
defined?returns"expression"forbegin ... rescue ... end. - The statement modifier
rescueandexception: falsedo the same job in two ways: a value instead of an exception. raiseis a method and its owner is theKernelmodule; the match of arescuebranch is made with the===message.- The bounding measurement:
rescueis 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.