вторник, 4 декабря 2012 г.

Блоки и управление потоком в Ruby 1.9

Захотелось сделать некоторое обобщение по теме управления потоком выполнения в 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» останутся именно такими. Что же происходит?

  1. Метод call у нас введен для примера последовательной обработки. С ним все просто — он выполняет свой блок до изменения значения переменной.
  2. Поток же, заснув после старта на секунду, благополучно дожидается всех изменений и пропускает контрольный вывод. После чего, поскольку блок — это замыкание, берет текущее значение переменной.
  3. Дочерний процесс, созданный вызовом 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 ...” — во-первых, существует, а во-вторых, не является следствием плохого проектирования?

2 комментария:

  1. goto в Ruby такие есть =) http://patshaughnessy.net/2012/2/29/the-joke-is-on-us-how-ruby-1-9-supports-the-goto-statement

    ОтветитьУдалить