Захотелось сделать некоторое обобщение по теме управления потоком выполнения в Ruby. Вся необходимая информация есть в документации, но несколько размазана по описаниям различных конструкций. А я попробую кратенько и плотно изложить именно этот аспект.
Дело в том, что Ruby — очень гибкий язык, а где гибкость, там и неочевидные тонкости...
Содержание
Структура кода
Блоки
Блок в Ruby — это конструкция вида:
‹метод› do |‹аргументы›| ‹некие действия› end
или:
‹метод› { |‹аргументы›| ‹действия› }
Заметим, что блок может и не иметь аргументов, в этом случае вертикальные черты также опускаются.
Оба варианта синтаксиса полностью идентичны и их использование определяется
единственно соображениями удобства. Конструкция эта всегда употребляется
с неким методом, внутри которого может быть вызвана посредством
“yield
” или обработана как объект класса
Proc
. Кстати, «чистый» такой объект можно получить
просто:
prc = Proc.new do |‹аргументы›| ‹некие действия› end
После чего мы можем по всякому манипулировать переменной
prc
: вызвать запомненный блок —
“prc.call(‹аргументы›)
” (здесь
‹аргументы›
— уже не формальный список
параметров, а реальные значения, которые будут подставлены); передавать ее
куда-то в качестве аргумента, или же передать какому-то другому методу
в качестве блока — для этого нам придется написать
“‹метод›(&prc)
” — без
амперсенда будет передан именно объект, а не блок. Что еще с ней можно делать
см. в документации по классу Proc
.
Чаще всего блоки используются совместно с итераторами.
Например, вот так
можно вывести элементы массива, находящегося в переменной
list
:
list.each do |item| p item end
И это плавно подводит нас к следующей теме...
Циклы
Собственно циклов в Ruby имеется три разновидности, если смотреть по ключевым словам языка. Правда, у первых двух имеется еще и постфиксная форма с очередными нюансами...
while ‹выражение› ‹действия› end until ‹выражение› ‹действия› end
Принципиальной разницы между ними нет —
“while ‹выражение›
” полностью эквивалентно
“until ‹отрицание выражения›
”.
Третий же тип циклов — по итератору:
for ‹переменные› in ‹коллекция› ‹действия› end
в свою очередь эквивалентен вызову итератора с блоком. Причем
‹коллекция›
может быть совершенно любым объектом,
у которого определен должным образом метод each
.
Т.о. вышеприведенный пример с массивом можно записать
и так:
for item in list p item end
Въедливый читатель может поинтересоваться: а как же бесконечный цикл:
loop do ‹действия› end
Отвечаем: а никак, потому что это на самом деле не цикл языка, а такой
итератор — метод объекта main
, что и призвано
демонстрировать ключевое слово “do
”. Правда,
чтобы сей факт замаскировать, создатель языка разрешил использовать это
ключевое слово и в настоящих циклах...
И снова блоки
Я обещал тонкости и нюансы. Вот и они, для начала — терминологические:
оказывается, кроме блоков кода («code blocks») —
“do ... end
”, есть еще блочные
выражения («block expressions») —
“begin ... end
” — можно сказать,
операторные скобки, поскольку операторы («statements») в Ruby всегда
выражения. В самом простом виде:
begin ‹действия› end
выполняет ‹действия›
последовательно, как
они записаны и возвращает результат последнего. Вот только «простой вид» никому
нафиг не нужен, а дальше пошли нюансы...
Вспомним циклы по условию. Как уже говорилось, у них есть постфиксная запись — следующий оператор:
‹действие› while ‹условие›
полностью эквивалентен
while ‹условие› ‹действие› end
Т.е. запись постфиксная, но цикл с предусловием. Но все меняется, когда приходят они — блочные выражения. Цикл:
begin ‹действия› end while ‹условие›
будет выполнен как минимум однажды, поскольку волшебным образом превратился в цикл с постусловием. «Панять эта нэвазможна, эта нада запомныт!» ©
Впрочем, проблем с пониманием бы не возникло, если бы конструкция
“begin ... end
” сразу позиционировалась
не как операторные скобки, а как специальное средство языка для постусловий...
Но этому мешает еще одна ее семантика, причем куда более используемая. Так,
с шутками и прибаутками мы подошли к обработке исключений.
Обработка исключений
И снова два варианта... Во-первых, критическая секция внутри массива кода:
begin ‹действия, которые могут вызвать исключение› rescue ‹класс исключения› => ‹переменная› ‹действия при исключении› else ‹действия, если исключения не произошло› ensure ‹действия, которые должны быть выполнены всегда› end
Во-вторых, все то же самое, только для определения метода целиком, чтоб лишнего не писать:
def ‹имя метода› ‹аргументы› ‹действия, которые могут вызвать исключение› rescue ‹класс исключения› => ‹переменная› ‹действия при исключении› else ‹действия, если исключения не произошло› ensure ‹действия, которые должны быть выполнены всегда› end
В целом, привычная для современных языков схема, но на всякий случай поясню:
- Все ветки (“
rescue
”, “else
” и “ensure
”) опциональны. Правда, если нет “rescue
”, то и “else
” вставлять нельзя, ибо бессмысленно. - Веток “
rescue
” может быть несколько — для разных классов исключений, если в последней класс не указан, то она ловит все, что не поймано до нее. Вообще обработка идет по порядку, поэтому более частное исключение после более общего ловить бессмысленно. - Указание “
=> ‹переменная›
” тоже необязательно, да и вообще редко используется — только когда объект исключения несет какую-то дополнительную полезную информацию.
Напомню, что механизм исключительных ситуаций резко нарушает обычный порядок выполнения. Обычные границы: блоков, циклов, методов — ему как бы прозрачны... Поэтому вызов исключения мы будем рассматривать уже в третьей части, а пока обратим внимание на еще одну важную тему.
Многозадачность
Многозадачность в Ruby может быть реализована двумя принципиально разными путями: через потоки и через дочерние процессы. При этом везде будут присутствовать наши любимые блоки кода.
В подробности вдаваться не буду, поскольку они скорее системные, чем языковые, а ключевое различие продемонстрирую на примере:
def call &block yield end v = :start call do sleep 1 puts "Proc => #{v}" end v = :proc t = Thread.start do sleep 1 puts "Thread => #{v}" end v = :thread fork do sleep 1 puts "Process => #{v}" end v = :process puts "Program => final" t.join Process.wait
Какой результат мы получим на выходе? Довольно интересный:
Proc => start Program => final Thread => process Process => thread
Последние две строки могут и поменяться местами, но содержание и порядок относительно контрольной строчки с «Program» останутся именно такими. Что же происходит?
- Метод
call
у нас введен для примера последовательной обработки. С ним все просто — он выполняет свой блок до изменения значения переменной. - Поток же, заснув после старта на секунду, благополучно дожидается всех изменений и пропускает контрольный вывод. После чего, поскольку блок — это замыкание, берет текущее значение переменной.
- Дочерний процесс, созданный вызовом
fork
, на первый взгляд ведет себя так же, как и последовательный метод — берет то значение, которое было установлено до его вызова, но мы прекрасно видим, что отрабатывает-то он позднее, чем произошли дальнейшие изменения. Все дело в том, что, в отличие от потока, он получает не контекст основной программы, а его копию на момент своего создания. Дочерние процессы полностью изолированы и общаться с «родителем» могут только средствами системы: через сигналы, именованные каналы, общие файлы и т.д.
Синхронизация
Как явствует из вышесказанного, потоки действуют в одном адресном
пространстве параллельно, поэтому для них актуальна проблема конфликтов, когда
разные потоки обращаются к одним и тем же данным. Для того, чтобы это
не приводило к сбоям, используется специальный метод класса
Thread
:
Thread.exclusive do ‹общение с внешним миром› end
Такой блок в каждый момент времени может выполняться только один. Очевидно, что долгие вычисления в него помещать не стоит...
Параллелизм в одном потоке
Есть еще один странный класс объектов, реализующий концепцию сопрограмм —
Fiber
. Честно говоря, не очень представляю, зачем это
может понадобиться, но не упомянуть в данной теме их нельзя.
Нить — это такая штука, которая создается в «спящем» состоянии, а будучи
вызванной, просыпается, производит какие-то действия, возвращает значение и
засыпает. При следующей побудке начинает работать не сначала, а с того места,
где заснула прошлый раз. При этом, если нить заканчивает свою работу, очередной
вызов возвращает “nil
”, а последующие
генерируют исключение.
Простой пример:
f = Fiber.new do p :start loop do Fiber.yield :odd Fiber.yield :even end end 10.times do p f.resume end
Изменение порядка выполнения
“goto
” в Ruby нет
И это радует.
Операторы управления циклами
Таких операторов три: “break
”,
“redo
” и
“next
” — прерывание цикла, повтор текущей
итерации и переход к следующей итерации соотвественно.
Естественно, эти методы работают не только с циклами, организованными средствами языка, но и с теми, которые создаются методами-итераторами... Которые, в свою очередь, ничем принципиально от других методов не отличаются. Как же себя поведут управляющие операторы в произвольном блоке?
- “
redo
” - Переход к началу блока.
- “
next
” - Переход в конец блока. Т.е. на самом деле, никакого перехода к следующей итерации сам этот оператор не выполняет — он вообще не знает ни про какие такие итерации, а работает в пределах своего непосредственного блока, как и предыдущий.
- “
break
” - Производит выход из метода, вызвавшего блок.
Рассмотрим на простом примере:
def call &block puts '=> call' yield puts '<= call' end puts '=> [break]' call do puts '=> do' break puts '<= do' end puts '<= [break]' puts puts '=> [next]' call do puts '=> do' next puts '<= do' end puts '<= [next]' puts puts '=> [redo]' c = 0 call do puts '=> do' c += 1 redo if c < 10 puts '<= do' end puts '<= [redo]'
И в результате имеем:
=> [break] => call => do <= [break] => [next] => call => do <= call <= [next] => [redo] => call => do => do => do => do => do => do => do => do => do => do <= do <= call <= [redo]
Теперь вспомним, что блоки могут использоваться не только таким образом,
но и преобразовываться в объект класса Proc
,
запускаться в отдельном потоке или дочернем процессе. Во всех этих случаях
операторы “redo
” и
“next
” будут работать, как и предполагалось —
передавая управление на начало и конец блока соотвественно, тогда как
“break
” вызовет исключение.
λ-функции и “return
”
Однако есть особый случай объектов класса Proc
—
лямбда-функции. Создаются они посредством глобального метода
lambda
и по сути являются анонимными методами, в
которые уже завернут блок. Благодаря этой двойственности, оператор
“break
” в них срабатывает как корректный
выход.
Кроме того, в них тем же самым образом срабатывает и оператор
“return
”, тогда как в обычном блоке он
выполняет выход из окружающего метода. Продемонстрируем:
def ltest puts '=> ltest' lmb = lambda do puts '=> lambda' return puts '<= lambda' end lmb.call puts '<= ltest' end def ptest puts '=> ptest' prc = proc do puts '=> proc' return puts '<= proc' end prc.call puts '<= ptest' end ltest ptest
выведет:
=> ltest => lambda <= ltest => ptest => proc
Заметим следующее: “break
” производит выход
из метода, в который блок был передан, тогда как
“return
” — из метода, в определении которого
он указан. Одинаково они себя ведут только в «лямбдах», поскольку те
одновременно являются и блоками и методами.
Генерация исключений
В любом месте программы мы можем создать исключение методом
raise
:
raise raise ‹сообщение› raise ‹класс исключения›, ‹сообщение› raise ‹класс исключения›, ‹сообщение›, ‹место›
Управление пойдет по стеку вызовов, пока не найдется соответствующий обработчик (см. выше). Если же такового нет, выполнение программы будет прервано, а сообщение выведено в стандартный поток ошибок.
При последовательной обработке тут все понятно. Случай, когда необработанное исключение возникает в дочернем процессе, тоже очевиден — из родительского процесса мы ничего с ним сделать не сможем. Интерес представляет случай, когда необработанное исключение возникает в потоке. Здесь возможны два варианта:
- Поведение по-умолчанию: с точки зрения вызывающей программы исключение
выглядит, как сгенерированное в методе
join
(илиvalue
), и может быть обработано соотвественно. - Если свойство
abort_on_exception
классаThread
установлено в “true
”, то необработанное исключение в любом из потоков дает тот же результат, что и в главном — т.е. немедленное прекращение работы программы.
Пример:
# Thread.abort_on_exception = true t = Thread.start do raise end sleep 1 begin t.join rescue puts 'Error in thread' end
Чтобы пустить обработку по второму варианту, раскомментируйте первую строчку.
Особая магия
Вызов исключения, как было сказано, позволяет перейти к обработчику, не обращая внимания на границы блоков, методов, и т.д. и т.п. Поэтому некоторые хитровыделанные программисты пользуются им не только в исключительных ситуациях, но и, скажем, когда надо быстро выйти из сложной обработки. Это очень плохая практика — мне однажды довелось отлаживать такой код... Сами понимаете, как себя вел отладчик.
Ruby предоставляет отдельный механизм срочного, но при этом штатного, выхода отовсюду:
catch ‹метка› do ‹всякий разный код, в котором нет-нет да встретится...› throw ‹метка›, ‹результат› end
В качестве метки может выступать любой объект, но общепринято использовать
Symbol
.
Ну и закончу вопросом к многоуважаемой публике: а не приведет ли кто-нибудь
пример, где необходимость в последней функциональности —
“catch ... throw ...
” — во-первых, существует, а
во-вторых, не является следствием плохого проектирования?
goto в Ruby такие есть =) http://patshaughnessy.net/2012/2/29/the-joke-is-on-us-how-ruby-1-9-supports-the-goto-statement
ОтветитьУдалитьОх же ж блин пичяль-то какая :)
Удалить