Ликбез по исключениям в Ruby
Механизм исключений в Ruby мало чем отличается от реализаций в других распространенных ООП языках, таких как C++ и Java. Но динамическая природа Ruby вносит свои коррективы расширяя стандартные возможности. Механизм исключений Ruby хорошо описан не только в документации, но и в многочисленных детальных статьях. И, как всегда, здесь найдутся свои тонкости и нюансы, которые могут вас удивить.
Итак, что мы обычно используем на практике?
Оператор rescue
. Именно он нужен для перехвата и обработки исключений.
Синтаксис всем хорошо известен:
begin
# code which raises exception
rescue RuntimeError => e
# code to handle exception
end
Оператор ensure
. Его можно увидеть в коде проектов лишь изредка.
Определенный в этом операторе код всегда вызывается после основного
блока даже если брошенное исключение не перехвачено. Здесь обычно
освобождают ресурсы, закрывают файлы итд
begin
@file = File.open('foo.txt', 'w')
raise
ensure
@file.close
end
Операторы retry
/redo
. Практически ни разу не видел, чтобы их
использовали вместе с rescue
. Они работают только в begin
/end
и
do
/end
блоках соответственно. Вызов такого оператора означает
повторное выполнение всего внешнего блока:
begin
# ...
rescue
# do something that may change the result of the begin block
retry
end
Если думаете, что этим все и ограничивается, то вы не правы. Давайте начнем.
Оператор else
Думаю, мало кто видел оператор else
в связке с rescue
и тем более
использовал его. Тем не менее, в блоке можно задавать else
секцию,
которая выполняется после основного блока только если не было
исключения. Таким образом, операторы rescue
и else
как бы
дополняют друг друга. Следовательно, полный синтаксис begin
/end
(как
и do
/end
) блока выглядит так:
begin
# ...
rescue
# ...
else
# this runs only when no exception was raised
ensure
# ...
end
До Ruby 2.6 можно было использовать else
без секции rescue
. Сейчас
это приводит к ошибке синтаксиса:
begin
else
end
# SyntaxError ((irb):3: else without rescue is useless)
Если определены все секции в блоке (rescue
, else
и ensure
),
то они выполняются в следующем порядке:
begin
/end
блокelse
ensure
Если произошло исключение, то порядок следующий:
begin
/end
блокrescue
ensure
do
/end
блок
Долго время операторы rescue
/ensure
/else
можно было использовать
только внутри метода и begin
/end
блока. Начиная с Ruby 2.5 их можно
использовать также и в do
/end
блоке:
['1', 'a'].map do |s|
Integer(s)
rescue
0
end
Но все еще нельзя использовать в блоке с {}
['1', 'a'].map { |s|
Integer(s)
rescue
}
# SyntaxError (syntax error, unexpected rescue, expecting '}')
Список классов-исключений
Оператор rescue
может перехватывать исключения сразу нескольких
классов:
begin
raise
rescue ArgumentError, RuntimeError
end
Тонкость в том, что такой список классов - это обычное выражение, результатом которого является список:
begin
raise
rescue *[StandardError]
puts $!.class
end
Более того, этот список может быть динамическим и зависеть от контекста:
exception_list = [StandardError]
begin
raise
rescue *exception_list
puts $!.class
end
Есть еще одна особенность. Это выражение вычисляется лениво и только
если действительно было брошено исключение. Например, следующий код не
приводит к исключению, хотя в нем и вызывается метод raise
:
begin
rescue *[(raise), StandardError]
puts $!.class
end
В begin
/end
блоке исключение не бросается, поэтому список классов в
rescue
не вычисляется и метод raise
не вызывается.
Согласно документации можно перехватывать исключение указывая его класс.
Но оказывается, можно указывать любой класс или модуль, а не только
класс-исключение (производный от класса Exception
):
begin
raise
rescue Integer
end
begin
raise
rescue Comparable
end
Если попытаться указать что-то отличное от класса или модуля, то получим
исключение TypeError - class or module required for rescue clause
.
Не имеет особого смысла задавать класс, который не наследует от
Exception
или его подклассы, ведь “бросать” можно только экземпляры
класса Exception
или его подклассов. Иначе получим ошибку TypeError
(exception class/object expected)
.
Но.
На самом деле смысл есть и это можно использовать с пользой.
Под капотом rescue
проверяет соответствует ли объект-исключение классу
из списка rescue
и использует для этого метод ===
. Поэтому можно
использовать класс не производный от класса Exception
и определить
любое правило для сравнения с объектом-исключением:
Rescuer = Class.new do
def self.===(exception)
true
end
end
begin
raise
rescue Rescuer
end
В этом примере класс Rescuer
не наследует класс Exception
, но все же
может перехватывает исключение. В данном случае ===
всегда возвращает
true
, поэтому будут перехватываться все исключения.
Как писали выше, бросать можно только объекты-исключения - т.е. в
метод raise
можно передавать только экземпляры класса-исключения. Но
это не совсем верно. (Правда, можно передать аргументом просто класс
исключения или строчку-сообщение, но мы не рассматриваем эти вырожденные
случаи).
На самом деле Ruby может сконвертировать аргумент в экземпляр
класса-исключения. Для этого Ruby пробует вызвать на аргументе метод
exception
. Возвращаемое значение должно быть экземпляром
класса-исключения:
obj = Object.new
def obj.exception
RuntimeError.new("Internal error")
end
raise obj
# RuntimeError (Internal error)
Из интереса можно заглянуть в реализацию метода raise
в Rubinius
(source)
Возвращаемое значение
Думаю, многие наступали на эти грабли и знают, что rescue
влияет на
возвращаемое из метода/блока значение. Если произошло исключение, то
вернется результат последнего выражения из секции rescue
.
def foo
1/0
rescue
Float::INFINITY
end
foo
# => Infinity
Применение в классах и модулях
Сложно представить когда это может пригодиться, но с помощью rescue
можно перехватывать исключения в декларации класса или модуля:
class A
raise
rescue
puts "from rescue"
end
# from class A
ensure и явный return
Интересно поиграться с явным return
в секции ensure
. Как мы знаем,
секция ensure
не влияет на возвращаемое значение блока/метода. Но
явный return
все меняет.
Во-первых, return
перетирает возвращаемое из метода (или блока)
значение:
def foo
return 'from foo'
ensure
return 'from ensure'
end
foo
# => "from ensure"
Во-вторых, если произошло исключение и оно было перехвачено, то return
перетирает значение, которое возвращалось из секции rescue
:
def foo
raise
rescue
'from rescue'
ensure
return 'from ensure'
end
foo
# => "from ensure"
И напоследок самое интересное. Если было брошено исключение, то явный
return
в ensure
его просто проглатывает.
def foo
raise
ensure
return 'from ensure'
end
foo
# => "from ensure"
То же самое происходит, если исключение было брошено и в секции
rescue
:
def foo
raise
rescue
raise
ensure
return 'from ensure'
end
foo
# => "from ensure"
Заключение
Уверен, что здесь перечислены не все особенности перехвата исключений. С большинством из них я познакомился просматривая тесты в проекте RubySpec. Кстати, настоятельно рекомендую его как хорошую (но не исчерпывающую) спецификацию Ruby.