Замыкания в Ruby: блоки, процедуры, лямбды
Блоки, процедуры и лямбды — замыкания (closures) — являются важным аспектом языка Ruby, с одной стороны, и малопонятным с другой. Малопонятным в основном из-за того, что Ruby подходит к замыканиям в значительной степени уникальным образом, к тому же в Ruby существуют три разных пути использования замыканий.
Хорошо описанных руководств по замыканиям в Ruby не так много, одно из них послужило материалом для данного перевода — Understanding Ruby Blocks, Procs and Lambdas, написанное Робертом Сосински (Robert Sosinski), также он подготовил репозиторий c примерами на GitHub.
Путь Первый - блоки
Самый распространённый, простой и вероятно самый «Ruby like» путь использования замыканий в Ruby - это блоки (blocks). У них очень простой синтаксис:
[1, 2, 3, 4].map { |i| i**3 }Блоком является выражение внутри {}. В фигурных скобочках записываются однострочные блоки, тоже самое можно записать иначе:
[1, 2, 3, 4].map do |i|i**3end
Так записывают блоки состоящие из нескольких строк. Здесь тот же блок только заключённый между do и end. Конечно, можно использовать {} и do-end там и там, но между ними есть небольшая разница [todo: описать разницу]. И обычно поэтому пишут именно так как указано в примерах.
Что же такое блок? Фактически блок — это просто выражение-процедура. В примере метод map для каждого элемента массива [1, 2, 3, 4] выполняет выражение в блоке, где на каждой итерации в блок будет передаваться значение элемента массива, причём это значение будет в переменной n. Имя переменной - произвольное, интерпретатор Ruby использует для этого значение записанное между двумя |.
Как же метод map взаимодействует с блоком? Рассмотрим следующий пример:
class Arraydef map# создадим новый массивarray = []# в цикле обходим наш массивfor x in self do# в новый массив добавляем результат выполнения выражения описанного в блокеarray << yield(x)end# возвращаем результирующий массивarrayendend
Здесь используется специальная конструкция yiеld, которая выполняет выражение с заданными параметрами и возвращает результат. В примере в блок передаётся переменная x, в которой содержится элемент массива на текущей итерации.
Итак, с одной стороны, yiеld(x) с другой блок { |i| i**3 }, таким образом, на каждой итерации обхода массива yiеld(x) выполняет блок { |i| i**3 } в который будут передаваться значения массива. В примере это числа от 1 до 4, соответственно результатом будет массив кубов этих чисел.
[[Схожим образом дело обстоит в ДССП где процедура в цикле выполняет операции над вершиной сетка.]]
Благодаря блокам Ruby позволяет писать простой и надёжный код, например, нам надо открыть файл, выполнить операции и закрыть, с блоками это очень просто!
File.open('filename') { |f| … }Внутри, как нетрудно догадаться, будет что-то подобное:
def open(file)fd = read_file(file)yield(fd)fd.closeend
Таким образом, файл после выполнения операций будет закрыт автоматически.
Следует отметить, что количество аргументов неограниченно: yiеld(x, y, z) -> |a, b, c|, также аргументов может не быть вовсе.
Но это только начало. Использование yield лишь один из возможных вариантов использования блока, однако есть и другие. Вызов его как Proc.
class Arraydef map(&our_block)array = []for x in self doarray << our_block.call(x)endarrayendend
Пример, практически, аналогичен предыдущему, однако всё же есть пара различий. Первое, мы передаём аргумент our_block в метод map, который к тому же начинается со знака & (это способ отметить аргумент, который будет соответствовать передаваемому блоку). Второе отличие - это способ вызова блока, теперь вместо yield в нужном месте, вызываем метод call у нашего блока c требуемыми параметрами. Результат будет идентичен предыдущему варианту с yield. Зачем два варианта одного и того же? Давайте узнаем, чем же на самом деле в последнем примере является наш блок.
def some_method(&block)block.classendputs some_method {}# => Proc
Блок это просто объект Proc! А что есть Proc?
Путь второй - процедуры, они же Proc
Блоки очень удобны и имеют простой синтаксис, однако нам может понадобиться много различных блоков и возможность их многократного использования. Таким образом, передавая один и тот же блок снова и снова в разных местах получим дублирование кода. Однако, так как Ruby полностью объектно-ориентированный язык, он позволяет избежать такой ситуации, сохранив повторяющейся код как класс объектов. Таким классом является Proc (сокращение от Procedure — процедура). Разница между блоком и Proc в том, что блок, хоть и является экземпляром Proc, его нельзя запомнить, блок это решение для однократного использования. Работая же с Procs, мы можем делать следующие:
class Arraydef map(block)array = []for x in self doarray << block.call(x)endarrayendendsquare = Proc.new do |i|i ** 2end[1, 2, 3, 4].map(square) # => [1, 4, 9, 16][5, 6, 7, 8].map(square) # => [25, 36, 49, 64]
Обратите внимание, что в методе map аргумент записывается без &. Это потому, что передача Procs ничем не отличается от передачи любых других данных. Так как экземпляры Proc - это обычные объекты, можно проделывать и такой трюк:
[1, 2, 3, 4].map(Proc.new { |i| i**2 }) # => [1, 4, 9, 16]Согласитесь, эквивалентная ей следующая запись куда проще:
[1, 2, 3, 4].map { |i| i**2 } # => [1, 4, 9, 16]Но не надо сбрасывать со счетов Proc. Например, как передать два блока? С простыми блоками — никак. Но создавая Proc очень просто!
def some_method(procs)procs[:starting].callputs 'Some operations'procs[:finishing].callendsome_method(:starting => Proc.new { puts 'Starting' },:finishing => Proc.new { puts 'Finishing' })# => Starting# => Still going# => Finishing
Итак, когда использовать Procs? Procs:
- требуется использовать повторно блок
- требуется использовать несколько блоков сразу
- требуется обернуть код в блок
Для других задач вполне достаточно блоков.
Путь третий - Лямбды (Lambdas)
До этого мы использовали Procs двумя способами, передавая их напрямую как атрибут и сохраняя их в переменной. Это похоже на то, что в других языках называется анонимными функциями или лямбдами (lambdas). Что ещё интереснее, лямбды присутствуют и в Ruby.
class Arraydef map(block)array = []for x in self doarray << block.call(x)endarrayendend[1, 2, 3, 4].map(lambda { |i| i ** 2 }) # => [1, 4, 9, 16]
На первый взгляд, лямбды ничем не отличаются от Procs. Однако, есть два важных отличия. Первое в том, что в отличие от Procs, лямбды проверяют количество передаваемых аргументов.
def arguments(code)one, two = 1, 2code.call(one, two)endarguments(Proc.new { |a, b, c| puts "Give me a #{a} and a #{b} and a #{c.class}" })arguments(lambda { |a, b, c| puts "Give me a #{a} and a #{b} and a #{c.class}" })# => Give me a 1 and a 2 and a NilClass# *.rb:10: ArgumentError: wrong number of arguments (2 for 3) (ArgumentError)
Видно, что в примере с Proc, дополнительная переменная просто равна nil. Однако, в случае с лямбдами, Ruby выбрасывает исключение.
Второе различие в том как обрабатывается return. В то время как return в Proc прерывает поток исполнения метода в котором он был вызван, лямбда просто возвращает значение в метод, который продолжает поток исполнения.
def proc_returnProc.new { return "Proc.new"}.callreturn "proc_return method finished"enddef lambda_returnlambda { return "lambda" }.callreturn "lambda_return method finished"endputs proc_returnputs lambda_return# => Proc.new# => lambda_return method finished
В proc_return, наш метод выполняет return, останавливая процесс дальнейшего выполнения метода, возвращая строку Proc.new. С другой стороны, lambda_return выполняет код в замыкании, которое возвращает строку lambda, продолжая выполнение основного метода, который возвращает строку lambda_return method finished.
Ответ кроется в концептуальном различии между процедурами и методами. Процедуры (Proc) в Ruby - это фрагмент кода, не метод. Поэтому return в proc_return по сути является return для самого метада, в котором тот был выполнен. Лямбды (lambda) ведут себя как методы, они проверяют количество аргументов и не переопределяют вызов return в методах. Поэтому проще считать лямбды безымянными методами.
