Статьи

Затворы в рубине

Некоторое время назад, будучи заядлым программистом на C ++, который изучал загадочный мир Ruby, я был в восторге от концепции замыканий и способа обработки языка. Я уже был знаком с концепцией замыканий из своего опыта JavaScript , но манера, в которой Ruby обрабатывает замыкания, и озадачивала, и очаровывала меня. Спустя годы я решил написать о них статью и вот она.

Что такое закрытие?

Закрытие — это в основном функция, которая:

  1. Может рассматриваться как переменная, т. Е. Присваиваться другой переменной, передаваться как аргумент метода и т. Д.
  2. Запоминает значения всех переменных, которые были в области видимости, когда функция была определена, и может получить доступ к этим переменным, даже если она выполняется в другой области видимости.

Иными словами, замыкание — это первоклассная функция с лексической областью действия .

Кодовые блоки

По сути, блок — это анонимная функция, которая также предлагает функциональность, подобную замыканию. Рассмотрим следующее,

outer = 1 def m inner = 99 puts "inner var = #{inner}" end 

В отличие от некоторых других языков, Ruby не вкладывает области видимости, поэтому внутренние и внешние переменные полностью экранированы друг от друга. Что, если мы хотим получить доступ к внутренней переменной из внешней (иначе основной ) области? Один из способов сделать это — использовать блок:

 outer = 1 def m inner = 99 yield inner puts "inner var = #{inner}" end m {|inner| outer += inner} puts "outer var = #{outer}" 

Выход:

 #=> inner var = 99 #=> outer var = 100 

Внутри метода мы передаем внутреннюю переменную блоку, который добавляется к нашему вызову метода. Затем мы используем значение внутренней переменной для выполнения некоторой арифметики в нашей основной области видимости. Несмотря на то, что кодовый блок, по сути, является анонимной функцией, он все еще может обращаться к переменным в окружающей его области, такой как ‘external’, при доступе к переменным в методе, который его выдает. Другими словами, использование подобного блока позволяет нам пересекать область видимости .

«Почему я не могу использовать вместо этого возвращаемое значение метода?»

В этом простом примере вы могли бы. Однако, если вы хотите вернуть более одной переменной, у вас ничего не получится, в то время как вы можете легко yield несколько переменных в блок. Что еще более важно, метод не будет иметь доступа к окружающим переменным в точке его определения, поэтому вы не сможете делать классные вещи, которые мы увидим в оставшейся части этой статьи ?

Procs

Возможно, вы заметили, что наш пример блока кода не соответствует критериям, которые мы определили для замыкания. Хотя блок запоминает переменные в окружающей его области и может получать доступ к переменным, полученным из другой области, мы не можем передать его в качестве аргумента метода или назначить его другому объекту. Другими словами, блоки, используемые с оператором yield, не являются истинными замыканиями . Блоки могут быть настоящими замыканиями, но они должны рассматриваться как Procs . Давайте посмотрим, как мы можем сделать это:

 outer = 1 def m &a_block inner = 99 a_block.call(inner) puts "inner var = #{inner}" puts "argument is a #{a_block.class}" end m {|inner| outer += inner} puts "outer var = #{outer}" 

Выход:

 #=> inner var = 99 #=> argument is a Proc #=> outer var = 100 

Первое различие между этим и нашим предыдущим примером заключается в том, что мы теперь определяем параметр для нашего метода: &a_block . Это говорит методу, чтобы он ожидал блок в качестве аргумента, а также рассматривал его как объект Proc (оператор & неявно преобразует блок в Proc ). Proc — это просто именованный блок кода, то есть фактический объект. Поскольку это объект, блок можно обойти и вызвать для него методы. Интересующий метод здесь #call , который вызывает Proc. Зная все это, мы можем переписать наш код следующим образом:

 outer = 1 def m a_proc inner = 99 a_proc.call(inner) puts "inner var = #{inner}" puts "argument is a #{a_proc.class}" end m proc {|inner| outer += inner} # we can also use Proc.new instead of proc, with the same effect: # m Proc.new {|inner| outer += inner} puts "outer var = #{outer}" 

Выход:

 #=> inner var = 99 #=> argument is a Proc #=> outer var = 100 

Здесь мы создаем объект Proc на лету и передаем его в качестве аргумента метода. Чтобы полностью использовать замыкание, мы должны иметь возможность назначить его другой переменной и вызывать его, когда нам это нужно (отложенное выполнение). Давайте изменим наш метод для достижения именно этого:

 outer = 1 def m a_var inner = 99 puts "inner var = #{inner}" proc {inner + a_var} end p = m(outer) puts "p is a #{p.class}" outer = 0 puts "changed outer to #{outer}" puts "result of proc call: #{p.call}" 

Выход:

 #=> inner var = 99 #=> p is a Proc #=> changed outer to 0 #=> result of proc call: 100 

Наш метод теперь получает внешнюю переменную в качестве аргумента и возвращает Proc который выполняет добавление inner и outer . Затем мы назначаем Proc переменной ( p ), вызываемой на досуге ниже в коде. Обратите внимание, что даже когда мы изменяем значение outer перед вызовом proc и устанавливаем его в 0, наш результат не изменяется. Proc запоминает значение outer когда оно было определено, а не когда оно было вызвано. Теперь у нас есть настоящее прирожденное закрытие!

Лямбда

Время для точечной игры. Посмотрите на следующий код и посмотрите, чем он отличается от кода в предыдущем разделе:

 outer = 1 def m a_var inner = 99 puts "inner var = #{inner}" lambda {inner + a_var} end p = m(outer) puts "p is a #{p.class}" outer = 0 puts "changed outer to #{outer}" puts "result of proc call: #{p.call}" 

Выход:

 #=> inner var = 99 #=> p is a Proc #=> changed outer to 0 #=> result of proc call: 100 

Да, единственное отличие состоит в том, что наш метод теперь возвращает лямбда вместо proc . Функциональность и вывод нашего кода остаются точно такими же. Но … подождите, мы присвоили лямбду p , но p говорит нам, что это Proc ! Как это возможно? Ответ таков: эта лямбда — замаскированный прок. #lambda — это метод Kernel который создает объект Proc который немного отличается от других объектов Proc .

«Как я могу сказать, является ли объект процедурой или лямбда-выражением?»

Просто спросите, позвонив по номеру #lambda? метод.

 obj = lambda {"hello"}. puts obj.lambda? #=> true 

Кроме того, вызов #inspect для лямбды даст вам его класс как Proc(lambda) .

Procs vs Lambdas

Мы уже видели, что лямбда — это просто Proc с другим поведением. Различия между Proc ( proc ) и лямбда заключают в себе:

  1. возврате
  2. Проверка аргументов

Возвращаясь из Проц

 def method_a lambda { return "return from lambda" }.call return "method a returns" end def method_b proc { return "return from proc" }.call return "method b returns" end puts method_a puts method_b 

Выход:

 #=> method a returns #=> return from proc 

В то время как наш лямбда-метод ( method_a ) ведет себя как ожидалось, наш метод использования proc ( method_b ) никогда не возвращается, возвращая вместо этого возвращаемое значение Proc. Для этого есть простое объяснение:

  1. Блок, созданный с помощью lambda, возвращается в родительскую область, как это делают методы, поэтому никаких сюрпризов нет.
  2. Блок, созданный с помощью proc (или Proc.new ), считает, что он является частью вызывающего метода, поэтому возвращается к родительской области видимости вызывающего метода. Что может быть немного шокирующим, когда вы понимаете, что половина кода вашего метода не была выполнена, потому что Proc вы поместили на полпути, имел оператор return . Итак, можем ли мы вернуться из Proc «нормальным» способом? Да, если мы используем ключевое слово next вместо return . Мы можем переписать] method_b так, чтобы он был функционально таким же, как method_a :

     def method_b proc { next "return from proc" }.call return "method b returns" end puts method_b 

Выход:

 #=> method b returns 

Проверка аргументов

Давайте создадим и вызовем лямбду с двумя параметрами:

 l = lambda {|x, y| "#{x}#{y}"} puts l.call("foo","bar") 

Выход:

 #=> foobar 

Что произойдет, если мы опустим аргумент?

 puts l.call("foo") 

Выход:

 #=> wrong number of arguments (1 for 2) (ArgumentError) 

Правильно, лямбда строго относится к своим аргументам (арность), как метод. Что насчет процов?

 p = proc {|x, y| "#{x}#{y}"} puts p.call("foo","bar") puts p.call("foo") 

Выход:

 #=> foobar #=> foo 

Мы видим, что процесс гораздо более холоден в своих аргументах. Если аргумент отсутствует, процесс просто примет это за nil и продолжит жизнь.

Синтаксический сахар

Одна из многих вещей, которые мне нравятся в Ruby, это то, что он позволяет нам делать одно и то же разными способами. Думаете, что метод #call слишком похож на Фортран на ваш вкус? Нет проблем, назовите ваш процесс с точкой или двойной двоеточием. Тоже не нравится? Всегда есть обозначение в квадратных скобках.

 p = proc {|x, y| "#{x}#{y}"} puts p.call("foo","bar") puts p::("foo","bar") puts p.("foo","bar") puts p["foo","bar"] 

Выход:

 #=> foobar #=> foobar #=> foobar #=> foobar 

Кроме того, помните унарный оператор амперсанда ( & ), который мы использовали ранее для преобразования блока в процесс? Ну, это работает и по-другому. 1

 p = proc {|i| i * 2} l = proc {|i| i * 3} puts [1,2,3].map(&p) puts [1,2,3].map(&l) 

Выход:

 #=> 2 #=> 4 #=> 6 #=> 3 #=> 6 #=> 9 

Здесь метод #map ожидает блок, но вместо этого мы можем легко передать ему proc или lambda. Это означает, что мы можем использовать возможности замыканий для транспонирования переменных из других областей в преимущества методов Ruby с ожиданием блоков, что является довольно мощным средством.

Так в чем же дело?

Ruby предлагает непревзойденную универсальность в реализации замыканий. Блоки, процы и лямбды могут быть использованы взаимозаменяемо для отличного эффекта. Большая часть «магии», созданной многими из наших любимых драгоценных камней, облегчается замыканиями. Замыкания позволяют нам абстрагировать наш код таким образом, чтобы он был меньше, компактнее, пригоднее для повторного использования и элегантным 2 . Ruby предоставляет разработчикам эти замечательные и гибкие конструкции, чтобы мы могли максимально использовать мощность замыкания.

[1] : На самом деле, унарный оператор амперсанда нюансов по-своему

[2]: я чувствую новую статью;)