-
Передаем методу вызывающему блок proc и lambd-у вместо блока
-
Разбираемся, как работает преобразование proc-а и lambd-ы в блок
-
Передаем методу proc-и и лямбды в качестве обычных аргументов
-
Преобразуем блок переданный методу в proc-объект
-
Когда нужно преобразовывать блок переданный методу в proc-объект
-
Создание proc-а с помощью метода Symbol#to_proc
-
Разбираемся, как устроен метод Symbol#to_proc
-
Заключение
Пусть мы имеем метод, который вызывает блок переданный ему при вызове:
def met
yield
end
При вызове метода met
он сразу же вызывает переданный ему блок и возвращает то, что вернул этот блок.
Вызовем этот метод и передадим ему блок, который просто возвращает строку "block"
:
met { 'block' } # => "block"
Как видим, метод met
возвращает то, что получил от блока — строку "block"
.
Теперь определим одну лямбду и один proc
:
lam = lambda { 'lam' }
pr = proc { 'pr' }
Попытаемся вызвать метод met
и передать ему поочередно эти лямбду и proc:
met lam # => `met': wrong number of arguments (given 1, expected 0) (ArgumentError)
met pr # => `met': wrong number of arguments (given 1, expected 0) (ArgumentError)
met(lam) # => `met': wrong number of arguments (given 1, expected 0) (ArgumentError)
met(pr) # => `met': wrong number of arguments (given 1, expected 0) (ArgumentError)
Как видим, наш метод отказывается воспринимать лямбду и proc вместо блока. Он считает их обычными аргументами. А т.к. он объявлен как метод без аргументов (def met
), то поэтому он и выбрасывает соответствующую ошибку.
Нам нужен какой-то способ конвертировать лямбду и proc в блок.
И в Ruby есть такой способ — унарный &
-оператор. Ставя этот оператор перед лямбдой или proc-ом при вызове метода, мы сообщаем Ruby о своем желании преобразовать их в блок и передать его вызываемому методу. Есть только одно ограничение — если метод принимает какие-либо аргументы, то при вызове метода &
-аргумент должен идти последним аргументом. Но в нашем случае, когда метод вообще не принимает никаких аргументов, мы можем не заботиться об этом.
Давайте посмотрим, как это работает:
met(&lam) # => "lam"
met(&pr) # => "pr"
Как видим трюк с преобразованием лямбды и proc-а с помощью &
-оператора сработал. Ruby видит, что мы вызываем метод
met
и начинает вычислять аргументы передаваемые методу при его вызове. Встречая в списке аргументов аргумент с префиксом в виде символа &, Ruby преобразует его в блок. И только после этого он переходит к выполнению тела самого метода. А тело нашего метода met
очень простое — вызов блока. После того как выполняется тело блока метод возвращает то, что он получил от этого блока — в нашем случае это просто обычные строки, соответственно "lam"
и "pr"
. Метод обращается с блоком преобразованным с помощью &
-оператора из proc-а или лямбды, так же как и с обычным блоком.
Обратимся к книге "The Ruby Programming Language"
написанной создателем языка Ruby Юкихиро Матцумото
в соавторстве со своим другом Дэвидом Фланагеном. В разделе 6.4.5.1 Using & in method invocation
читаем:
In a method invocation an & typically appears before a Proc object. But it is actually allowed before any object with a to_proc method.
При вызове метода символ
&
обычно встречается перед объектом классаProc
. Но в действительности разрешается, чтобы он находился и перед любым другим объектом, который отвечает на методto_proc
.
Посмотрим, как Ruby преобразует в блок объект нашего кастомного класса:
class Klass
def to_proc
end
end
В соответствии с книгой мы определяем в нашем классе метод с именем to_proc
. Хотя в книге и не говорится, что должен возвращать этот метод, но судя по его имени, он должен возвращать экземпляр класса Proc
. Пусть этот proc возвращает строку, по которой мы сможем однозначно определить, какой proc-объект был вызван:
class Klass
def to_proc
proc { 'Klass#to_proc' }
end
end
На текущий момент вот как выглядит весь наш код:
def met
yield
end
lam = lambda { 'lam' }
pr = proc { 'pr' }
class Klass
def to_proc
proc { 'Klass#to_proc' }
end
end
Теперь все готово для проведения эксперимента. Создадим объект нашего класса и вызовем метод met
, передавая ему этот объект, не забыв предварить его &
-оператором:
obj = Klass.new
met(&obj) # => "Klass#to_proc"
Как видим, метод обращается с блоком полученным преобразованием кастомного объекта, так же как и с обычным блоком.
Ещё раз пройдемся по тому, что здесь произошло. Мы вызываем метод и передаем ему объект нашего класса с префиксом &
. Ruby видит, что он должен вызвать метод met
, и он начинает вычислять аргументы переданные методу. Встречая среди аргументов аргумент с префиксом &
, он понимает, что ему нужно конвертировать его в блок. Для этого он проверяет класс этого &
-аргумента. Если это класс Proc
, то он просто преобразует этот proc в блок, а если это объект какого-то кастомного класса, то Ruby вызывает на этом объекте метод to_proc
, а уже затем полученный proc-объект преобразует в блок. Только теперь Ruby готов вызвать наш метод met
, что он и делает. Метод же met
просто вызывает переданный ему блок. А блок, в свою очередь, возвращает строку "Klass#to_proc"
. Снова вступает в работу метод met
— он возвращает наружу то, что получил от блока — строку "Klass#to_proc"
.
Давайте пронаблюдаем всю эту последовательность вызовов и возвратов с помощью трассировщика идущего в комплекте с Ruby — TracePoint.
def met
yield
end
lam = lambda { 'lam' }
pr = proc { 'pr' }
class Klass
def to_proc
proc { 'Klass#to_proc' }
end
end
obj = Klass.new
# Настраиваем трассировщик
trace = TracePoint.new(:call, :return, :c_call, :c_return, :b_call, :b_return) do |tp|
p [tp.lineno, tp.defined_class, tp.method_id, tp.event]
end
trace.enable # Включаем отслеживание
met(&obj)
Наблюдаем вывод информации в консоли:
[9, Klass, :to_proc, :call] # Вызывается метод Klass#to_proc
[10, Kernel, :proc, :c_call] # Создается proc-объект proc { 'Klass#to_proc' }
[10, Kernel, :proc, :c_return] # Завершается создание proc-объекта proc { 'Klass#to_proc' }
[11, Klass, :to_proc, :return] # Выход из метода Klass#to_proc
[1, Object, :met, :call] # Передача управления нашему методу met
[10, Klass, :to_proc, :b_call] # Передача управления блоку
[10, Klass, :to_proc, :b_return] # Выход из блока
[3, Object, :met, :return] # Выход из метода met
Теперь пронаблюдаем за ситуацией при вызове метода met
с передачей ему лямбды вместо блока:
trace.enable # Включаем отслеживание
met(&lam)
[1, Object, :met, :call] # Передача управления нашему методу met
[5, nil, nil, :b_call] # Передача управления блоку, преобразованному из лямбды
[5, nil, nil, :b_return] # Выход из блока
[3, Object, :met, :return] # Выход из метода met
Аналогично для proc-а:
trace.enable # Включаем отслеживание
met(&pr)
[1, Object, :met, :call] # Передача управления нашему методу met
[6, nil, nil, :b_call] # Передача управления блоку, преобразованному из proc-а
[6, nil, nil, :b_return] # Выход из блока
[3, Object, :met, :return] # Выход из метода met
Как видим, если при вызове метода унарному &
-оператору передавать лямбду или proc, то он просто преобразует их в блок. Если же присмотреться более внимательно, то можно заметить, что фактически никакого преобразования не происходит — используется тот же самый блок, который был создан при определении лямбды или proc-а — в нашем листинге они определяются на строках 5 и 6 соответственно. Говоря простыми словами — при вызове метода &
-оператор как бы отбрасывает "слова" lambda
и proc
, т.е. met(&lambda { 'lam' })
плавно превращается в met { 'lam' }
. По завершению "преобразования" происходит вызов метода с передачей ему этого блока. В случае же когда мы передаем &-оператору объект какого-либо кастомного класса, Ruby сначала вызывает на этом объекте метод to_proc
, а затем "преобразует" полученный proc в блок и только после этого передает управление самому методу.
Как известно всё (на самом деле почти всё) в Ruby является объектами. Не являются исключением и лямбды с proc-ами, которые представляют собой экземпляры класса Proc. Чтобы передать методу при вызове какой-либо объект, необходимо при объявлении этого метода с помощью ключевого слова def
задать соответствующий параметр.
Рассмотрим метод принимающий один параметр:
def met_with_arg(x)
# Тело метода
end
Пусть этот метод умеет работать только с proc-ами и лямбдами:
def met_with_arg(prc)
# Тело метода
end
Что такой метод может сделать с полученным proc
-ом или лямбдой?
- Он может просто его/её проигнорировать (просто не обращаться к своему аргументу):
def met_with_arg(prc)
# Метод делает свою работу, никак не обращаясь к prc
end
- Он может его/её вызвать:
def met_with_arg(prc)
prc.call # Без передачи аргументов
prc.()
prc.call(0) # или с передачей каких-либо аргументов
prc.(0)
end
- Он может вызвать какой-то другой метод и передать этот
Proc
-объект вызываемому методу в качестве обычного аргумента (так же как сам был вызван):
def met_with_arg(prc)
# Здесь метод делает свою работу (опционально)
# А вот здесь метод вызывает другой метод
another_met(prc)
# И здесь метод тоже что-то делает (опционально)
end
def another_met(prc)
# Тело метода
end
- Он может вызвать другой метод преобразовав
Proc
-объект в блок:
def met_with_arg(prc)
# Здесь метод делает свою работу (опционально)
# Метод вызывает другой метод передавая ему блок, преобразованный из proc-а или лямбды
another_met(&prc)
# И здесь метод тоже что-то делает (опционально)
end
def another_met
# Тело метода
end
- И наконец, метод может быть какой-либо комбинацией вышеприведенных паттернов.
Как видите, есть несколько вариантов того, что метод может сделать с proc-ом и лямбдой, который он принял при вызове в качестве своего аргумента. А вот какой из этих вариантов использовать зависит от конкретной задачи. Однако, стоит признать, что задача при решении которой оказывается недостаточно передать методу блок и требуется дополнительно передавать лямбду является очень редкой. Мы же её здесь рассмотрели для полноты картины и чтобы показать, что такой вариант тоже возможен.
#4. Преобразуем блок переданный методу в proc-объект
Метод может преобразовать блок, переданный ему при вызове в объект класса Proc, если при объявлении этого метода последним идет аргумент с префиксом в виде символа &. Вот как это выглядит:
def met(&prc)
end
Вызов такого метода с блоком может выглядеть следующим образом:
met { 'Some string' } met { |x| x } met { |x, y| x + y }
Т.е. ничего нового — обычный вызов метода с блоком. Если же вызываемый метод не вызывает блок с помощью yield и не вызывает на своем аргументе prc метод call, то можно вообще не передавать никакого блока при вызове такого метода:
met
Некоторых людей может смутить то, что при объявлении метода с помощью def мы указываем параметр &prc, а при вызове этого метода не передаем ему никаких аргументов. Все дело в операторе &. Давайте рассмотрим более подробно, как он работает и что происходит при вызове метода.
Когда Ruby видит, что он должен вызвать метод, то сначала он начинает вычислять аргументы переданные методу. Затем он парсит (считывает и анализирует) блок, который передается методу. Блок не является аргументом, т.к. он не вычисляется при вызове метода, а только парсится. В нашем случае мы не передаем методу никаких аргументов, но зато передаем блок. Далее Ruby находит определение метода (то место в программе, где мы определили метод с помощью def) и создает в памяти т.н. контекст (ещё говорят "область видимости" или "скоуп" — он англ. "scope"). Потом Ruby начинает наполнять этот скоуп, т.е. для каждого параметра метода он создает локальную переменную с таким же именем и присваивает этой переменной соответствующее значение аргумента, которое он вычислил, заметив вызов этого метода. Дойдя до параметра, начинающегося с символа & (в нашем случае — &prc) Ruby создает локальную переменную prc, преобразует блок переданный методу в Proc-объект и присваивает его переменной prc. И только сделав все это, Ruby начинает выполнять тело самого метода.
Теперь давайте посмотрим, что происходит при выполнении тела вызванного метода. Взглянем на конкретный код:
def met(&prc)
end
met { 'Some string' }
Мы вызываем метод и передаем ему блок.
Обратите внимание, что в теле метода мы имеем доступ к двум сущностям:
к блоку (т.е. при преобразовании в Proc-объект блок никуда не исчезает)
мы можем вызвать его с помощью ключевого слова yield с аргументами или без
def met(&prc)
yield
yield 1, 2
end
к Proc-объекту полученному преобразованием блока
мы можем его вызвать с аргументами или без
def met(&prc)
prc.call
prc.call(1, 2)
prc.(1, 2)
end
мы можем вызвать другой метод и передать ему этот Proc-объект в качестве обычного аргумента или в качестве блока
def met(&prc)
another_met(prc) # Передаем как обычный аргумент
another_met(&prc) # Передаем как блок
end
Как видите мы можем преобразовать блок переданный методу в proc-объект. Причем при таком преобразовании блок никуда не исчезает.
Давайте с помощью трассировщика заглянем на уровень ниже и посмотрим, как на самом деле происходит преобразование блока в proc.
def met(&blk) blk.call # Вызываем proc-объект полученный преобразованием из блока end
trace = TracePoint.new(:call, :return, :c_call, :c_return, :b_call, :b_return) do |tp| p [tp.lineno, tp.defined_class, tp.method_id, tp.event] end
trace.enable # Включаем отслеживание
met { 'blk' }
[1, Object, :met, :call] # Вход в метод met [11, nil, nil, :b_call] # Вызов блока определенного на строке 11 [11, nil, nil, :b_return] # Выход из блока [3, Object, :met, :return] # Выход из метода met
Обратите внимание, что хотя метод met обращаемся не к блоку, а к proc-объекту, в который преобразуется блок, в реальности вызывается именно блок, определенный на 11-й строке.
А вот что происходит с лямбдой, которую передают в метод в качестве блока, а в методе обратно преобразуют в proc-объект:
def met(&blk) blk.call # Вызываем proc-объект полученный преобразованием из блока end
lam = lambda { 'lam' }
met(&lam) # Преобразуем лямбду в блок и вызываем метод met
[1, Object, :met, :call] # Вход в метод met [5, nil, nil, :b_call] # Вызов блока определенного на строке 5 (это блок лямбды) [5, nil, nil, :b_return] # Выход из блока [3, Object, :met, :return] # Выход из метода met
В реальности никакого двойного преобразования не происходит. Просто вызывается блок принадлежащий лямбде.
Посмотрим на "преобразования" proc-а:
def met(&blk) blk.call # Вызываем proc-объект полученный преобразованием из блока end
pr = proc { 'pr' }
met(&pr) # Преобразуем proc в блок и вызываем метод met
[1, Object, :met, :call] # Вход в метод met [5, nil, nil, :b_call] # Вызов блока определенного на строке 5 (это блок принадлежащий proc-у с 5-й строки) [5, nil, nil, :b_return] # Выход из блока [3, Object, :met, :return] # Выход из метода met
И с proc-ом нет никакого двойного преобразования. Просто вызывается блок принадлежащий proc-у.
Концепции блока, proc-а и лямбды являются концепциями, определенными на уровне Ruby, т.е. высокоуровневыми. Однозначно можно сказать, что преобразования между блоками, proc-ами и лямбдами являются бесплатными — они выполняются практически мгновенно. А с практической точки зрения можно считать, что &-оператор то "отбрасывает" "слова" proc и lambda от блока, то "приклеивает" их обратно. #5. Когда нужно преобразовывать блок переданный методу в proc-объект
В языке программирования Ruby, где как известно всё является объектом, блок объектом не является!
Мы не можем, например, создать массив, содержащий хотя бы один блок или вернуть блок из метода в качестве возвращаемого значения.
С блоком мы можем сделать только две вещи:
передать методу при вызове (причем метод вовсе не обязан что-то делать с переданным ему блоком)
вызвать из метода с помощью ключевого слова yield
Когда мы не можем сделать с какой-то сущностью то, что мы можем сделать с другими, говорят, что эта сущность не являестя First-class citizen (не являются полноценной сущностью).
Давайте рассмотрим конкретную задачу — создать метод-обертку (wrapper method) для какого-то другого метода.
def met
end
def wrapper
met
end
Наш враппер прекрасно работает...
def met 'Hello!' end
def wrapper
met
end
wrapper # => "Hello!"
Но только до тех пор пока тот метод вокруг которого мы обернули наш враппер не вызовет блок:
def met name = yield "Hello, #{name}!" end
def wrapper
met
end
wrapper # => `met': no block given (yield) (LocalJumpError)
Такая ошибка происходит тогда, когда вызывается метод вызывающий блок (метод met в нашем примере) без блока.
Эту проблему можно решить, если мы передадим блок нашему врапперу и попросим его передать этот блок методу met. Вот как это делается:
def met name = yield "Hello, #{name}!" end
def wrapper(&blk) # 1-е преобразование: Блок преобразуется в proc-объект
met(&blk) # 2-е преобразование: proc преобразуется в блок. А потом вызывается метод met
end
wrapper { 'John' } # => "Hello, John!"
Как видите, нашему блоку приходится нелегко. Его преобразовывают два раза. Это является следствием того, что блоки не являются First-class citizens (полноценными сущностями). В Ruby блоки не являются объектами. #6. Создание proc-а с помощью метода Symbol#to_proc
Давайте посмотрим на следующий код:
['a', 'b', 'c'].map { |letter| letter.upcase } # => ["A", "B", "C"] ['a', 'b', 'c'].map(&:upcase) # => ["A", "B", "C"]
Первая строка должна быть понятна для любого человека знакомого с методами-итераторами и с блоками — мы создаем новый массив заглавных букв на основе массива прописных.
Вторая строка во многом похожа на первую. Так же совпадают и результаты выполнения обеих строчек кода. Однако, есть и существенные различия:
первая строчка ощутимо длиннее;
в первой строчке методу map передается блок { |letter| letter.upcase } в то время как во второй ему передается некий странно выглядящий аргумент &:upcase.
Можно предположить, что вторая строчка просто является синтаксическим сахаром для первой. Т.е. праще говоря, что это некий трюк позволяющий писать более краткий код. Именно такое мнение и доминирует в интернете.
Давайте попробуем разобраться, действительно ли это какай-то трюк или всё же здесь присутствует определенная логика, которую мы пока не понимаем.
Взглянем ещё раз на строчку ['a', 'b', 'c'].map(&:upcase) и проговорим вслух то что там происходит:
мы вызываем на массиве ['a', 'b', 'c'] метод map с неким аргументом &:upcase
мы вызываем метод map c аргументом &:upcase
мы вызываем метод c аргументом &:upcase
мы вызываем некий метод с аргументом &:upcase
Стоп! Отбросим пока несущественные подробности и запишем ту фразу, к которой мы только что пришли (мы вызываем некий метод с аргументом &:upcase) на языке Ruby:
met(&:upcase)
Что-то похожее мы уже видели во 2-й части этой статьи, когда разбирались с тем как передать методу лямбду и proc вместо блока. Давайте вспомним, как мы это делали:
def met yield end
lam = lambda { 'lam' } pr = proc { 'pr' }
met(&lam) # => "lam" met(&pr) # => "pr"
Потом мы выяснили, что при вызове метода унарный оператор & преобразует лямбду и proc в блок. После этого мы пошли дальше и установили, что &-оператор может преобразовывать в блок не только лямбды и proc-и, но даже объекты кастомных классов имеющих метод to_proc.
Давайте сделаем предположение, что странно выглядящий аргумент &:upcase вызова метода — это на самом деле случай использования унарного &-оператора, которому передается символ :upcase (экземпляр класса Symbol). Обратившись к документации на класс Symbol мы можем заметить, что он имеет метод экземпляра to_proc. Здесь очень соблазнительно сделать поспешный вывод о том, что &-оператор вызывает на этом символе метод to_proc и преобразует полученный proc-объект в блок — так же как он это делает с экземплярами кастомных классов. Однако истина заключается в том, что класс Symbol не является ни кастомным ни даже классом стандартной библиотеки — он входит в ядро языка Ruby. А ядро Ruby написано на Си. Язык Си — это не объектно-ориентированный язык программирования — там нет ни объектов, ни методов. Даже если мы запустим трассировщик для того чтобы увидеть вызов метода Symbol#to_proc или создание блока, то мы этого там не увидим, т.к. объекты, методы, блоки, лямбды и proc-и — это более высокоуровневые сущности чем те с которыми имеет дело язык Си. Поэтому чтобы разобраться в том, что же такое &:symbol мы не должны покидать "королевство" Ruby.
Воспроизведем тот код с которого мы начали эту часть статьи в упрощенной форме:
def met yield 'a' end
met { |letter| letter.upcase } # => "A" met(&:upcase) # => "A"
Как мы видим наш метод met работает с сущностью созданной с помощью &:symbol так же как и с обычным блоком. А сама эта сущность возвращает такой же результат, как и обычный блок.
Давайте познакомимся с этой сущностью поближе. И для этого мы создадим специальный метод blk_to_prc, который нам поможет добыть эту сущность:
def blk_to_prc(&blk) blk end
prc_from_blk = blk_to_prc { |letter| letter.upcase } prc_from_sym = blk_to_prc(&:upcase)
У нашего вспомогательного метода blk_to_prc только одна задача — преобразовать переданный ему блок в Proc-объект и вернуть его наружу.
Давайте посмотрим, что добыл для нас blk_to_prc:
prc_from_blk = blk_to_prc { |letter| letter.upcase } prc_from_blk.class # => Proc prc_from_blk.lambda? # => false prc_from_blk.parameters # => [[:opt, :letter]] prc_from_blk.arity # => 1 prc_from_blk.() # =>
': undefined method `upcase' for nil:NilClass (NoMethodError) prc_from_blk.('a') # => "A" prc_from_blk.('a', 'b') # => "A"prc_from_sym = blk_to_prc(&:upcase)
prc_from_sym.class # => Proc
prc_from_sym.lambda? # => false
prc_from_sym.parameters # => [[:rest]]
prc_from_sym.arity # => -1
prc_from_sym.() # => <main>': no receiver given (ArgumentError) prc_from_sym.('a') # => "A" prc_from_sym.('a', 'b') # =>
upcase': invalid option (ArgumentError)
prc_from_blk == prc_from_sym # => false prc_from_blk == :upcase.to_proc # => false
prc_from_sym == :upcase.to_proc # => true
Можно заметить, что наиболее существенная разница между обычным блоком и сущностью созданной с помощью &:symbol заключается в том, что блок требует для своей работы как минимум тот объект, на котором он должен вызвать метод upcase и не возражает, если ему передать ещё какие-то параметры. В то же время сущность, созданная с помощью &:symbol более строго относится к количеству аргументов передаваемых ей.
Из последней строчки листинга следует, что сущность, создаваемая с помощью &:symbol эквивалентна proc-объекту, возвращаемому методом Symbol#to_proc.
Теперь давайте рассмотрим более сложный пример — вызов метода с одним аргументом. Вспомним, что в Ruby оператор + реализован, как вызов метода +. Т.е. выражение 'a' + 'b' на самом деле вычисляется как 'a'.+('b') — вызов на строке 'a' метода + с аргументом 'b' (так происходит вызов метода String#+). Это сделано для того, чтобы кастомные классы могли переопределять этот метод и реализовывать нужное им поведение. Давайте посмотрим, как будет себя вести "нормальный" блок в теле которого используется + и та сущность, которая генерируется с помощью конструкции &:+ :
def met yield 'a', 'b' end
met { |x, y| x + y } # => "ab" <- вычисляется как 'a'.+('b') met(&:+) # => "ab"
Ситуация полностью аналогична вызову met(&:upcase) — сущность созданная с помощью &:symbol возвращает такой же результат, как и обычный блок.
Сравним блок с сущностью, созданной с помощью &:symbol:
prc_from_blk = blk_to_prc { |x, y| x + y } prc_from_blk.class # => Proc prc_from_blk.lambda? # => false prc_from_blk.parameters # => [[:opt, :x], [:opt, :y]] prc_from_blk.arity # => 2 prc_from_blk.() # =>
': undefined method+' for nil:NilClass (NoMethodError) prc_from_blk.('a') # =>
+': no implicit conversion of nil into String (TypeError)
prc_from_blk.('a', 'b') # => "ab"
prc_from_blk.('a', 'b', 'c') # => "ab"
prc_from_sym = blk_to_prc(&:+)
prc_from_sym.class # => Proc
prc_from_sym.lambda? # => false
prc_from_sym.parameters # => [[:rest]]
prc_from_sym.arity # => -1
prc_from_sym.() # => <main>': no receiver given (ArgumentError) prc_from_sym.('a') # =>
+': wrong number of arguments (given 0, expected 1) (ArgumentError)
prc_from_sym.('a', 'b') # => "ab"
prc_from_sym.('a', 'b', 'c') # => `+': wrong number of arguments (given 2, expected 1) (ArgumentError)
prc_from_sym == :+.to_proc # => true
Можно предположить, что сущность, создаваемая с помощью &:symbol требует чтобы ей передавалось ровно столько аргументов сколько способен принять метод имя которого задается символом в конструкции &:symbol плюс один аргумент — тот объект на котором будет вызываться заданный метод.
Для того чтобы подтвердить наше предположение давайте с помощью конструкции &:symbol сгенерируем блок с методом, который принимает более чем два аргумента:
def met0 yield [] end
def met1 yield [], 'a' end
def met2 yield [], 'a', 'b' end
def met3 yield [], 'a', 'b', 'c' end
met0 { |arr| arr } # => [] met0(&:append) # => []
met1 { |arr, x1| arr.append(x1) } # => ["a"] met1(&:append) # => ["a"]
met2 { |arr, x1, x2| arr.append(x1, x2) } # => ["a", "b"] met2(&:append) # => ["a", "b"]
met3 { |arr, x1, x2, x3| arr.append(x1, x2, x3) } # => ["a", "b", "c"] met3(&:append) # => ["a", "b", "c"]
prc_from_sym = blk_to_prc(&:append) prc_from_sym == :append.to_proc # => true
Мы можем сделать вывод, что сущность создаваемая с помощью конструкции &:symbol эквивалентна блоку, принимающему в качестве своего первого аргумента объект на котором он вызывает метод с именем задаваемым символом :symbol, передавая все остальные аргументы этому методу в качестве аргументов. Метод же Symbol#to_proc просто позволяет получить эту сущность в виде proc-объекта, не создавая для этого сервисный метод — метод blk_to_prc в нашем примере. #7. Разбираемся, как устроен метод Symbol#to_proc
Давайте попробуем реконструировать метод Symbol#to_proc. Т.е. переписать на Ruby ту словесную формулировку, к которой мы пришли в предыдущей части статьи. Вот эта формулировка:
Сущность создаваемая при вызове метода с помощью конструкции &:symbol эквивалентна блоку, принимающему в качестве своего первого аргумента объект на котором он вызывает метод с именем задаваемым символом :symbol, передавая все остальные аргументы этому методу в качестве аргументов. Метод же Symbol#to_proc просто позволяет получить эту сущность в виде proc-объекта.
Начинаем с заготовки:
class Symbol def to_proc proc { |obj, *args| }
end end
Давайте представим себе как будет выглядеть proc-объект, возвращаемый методом Symbol#to_proc для символа :upcase:
proc { |obj, *args| obj.upcase } proc { |obj, *args| obj.upcase() } # скобки только ради единообразия со последующими примерами
А вот так для символа :+:
proc { |obj, *args| obj.+(args[0]) }
Для символа :append:
proc { |obj, *args| obj.append(*args) } # сплэт-оператор * "вытряхивает" объекты из массива args
Все вышеприведенные proc-и отличаются только именем метода, который они вызывают на своем первом аргументе. Но ведь этот proc будет создаваться внутри метода экземпляра класса Symbol. А что является экземплярами этого класса? Например, вот такие символы: :upcase, :+, :append. Как можно обратиться к самому экземпляру класса из метода экземпляра этого класса? Это делается с помощью переменной self. И наконец, как вызвать на каком-то объекте метод зная имя этого метода? Мы можем это сделать с помощью метода send или с помощью метода public_send. Выберем последний для того чтобы случайно не вызвать приватный метод. Вот что у нас получилось:
class Symbol def to_proc proc { |obj, *args| obj.public_send(self, *args) } end end
Здесь у нас возникает соблазн проверить написанный нами метод Symbol#to_proc в действии. Но если мы запустим на выполнение код в таком виде, в каком он есть сейчас, то мы заменим оригинальный метод Symbol#to_proc нашим (для этого есть специальный термин — monkey patching). Этого не стоит делать, по крайней мере, по двум причинам:
такая практика может войти в привычку и т.о. классы из ядра Ruby и из его стандартной библиотеки засоряются методами, о которых никто кроме нас не знает. И если в проект придет новый человек, он не сможет быстро разобраться в коде, видя какие-то странные методы вызываемые на экземплярах стандартных классов;
как мы уже указывали ранее, класс Symbol входит в состав ядра Ruby и т.о. он написан на языке Си. Нельзя заранее предсказать к каким последствиям может примести замена метода реализованного на Си методом реализованном на Ruby.
Первую причину мы можем проигнорировать, т.к. мы просто хотим проверить нашу реализацию метода Symbol#to_proc. Мы не собираемся её использовать в наших проектах.
А для решения второй в Ruby есть метод Module#prepend. Он очень похож на метод Module#include, но в отличие от последнего он помещает модуль в цепочку наследования класса не после самого класса, а перед ним. Т.е. методы содержащиеся в модуле, который мы prepend-им в какой-то класс перекрывают одноименные методы, определенные в самом классе. Эта техника считается более безопасной, чем обычный monkey patching. Если это пока звучит для вас непонятно, потерпите — мы сейчас покажем, как это делается на практике.
module Procable # Создаем новый модуль def to_proc # Переносим наш метод to_proc из класса Symbol в модуль puts "My Symbol#to_proc is running for the #{inspect} symbol" # чтобы убедиться, что работает именно наш метод proc { |obj, *args| obj.public_send(self, *args) } end end
class Symbol prepend Procable # Включаем модуль с нашим методом to_proc в цепочку наследования класса Symbol ПЕРЕД самим этим классом end
Symbol.ancestors # => [Procable, Symbol, Comparable, Object, Kernel, BasicObject]
words = 'one two three four five six seven'
words.split.map(&:reverse).map(&:capitalize).map(&:reverse).join(' ') # => "onE twO threE fouR fivE siX seveN" words.gsub(/\w\b/, &:upcase) # => "onE twO threE fouR fivE siX seveN"
Вывод в консоли:
My Symbol#to_proc is running for the :reverse symbol My Symbol#to_proc is running for the :capitalize symbol My Symbol#to_proc is running for the :reverse symbol My Symbol#to_proc is running for the :upcase symbol
Наш метод Symbol#to_proc вызывается четыре раза (дла раза для символа :reverse, один раз для :capitalize и один раз для :upcase) о чем и свидетельствует вывод в консоли.
Для особо внимательных людей мы хотим пояснить, что сам факт манки патчинга метода Symbo#to_proc приводит к тому, что этот метод начинает вызываться при вызове методов с аргументом вида &:symbol. Если же мы пользуемся стандартной реализацией метода Symbol#to_proc, то при вызове методов с аргументом вида &:symbol метод Symbol#to_proc не вызывается, а просто создается эквивалентный блок. Сам же стандартный метод Symbol#to_proc предназначен исключительно для того, чтобы получить этот блок в виде proc-объекта. Вы сами можете в этом убедиться с помощью трассировщика, техника использования которого подробно показана в предыдущих частях этой статьи.
Итак, мы выяснили эквивалентную Ruby-реализацию метода Symbol#to_proc и то как он участвует в генерации блока при вызове методов с аргументом вида&:symbol. И теперь мы можем сказать, что он является синтаксическим сахаром не в большей мере, чем сам язык Ruby. Он органично и логично встроен в Ruby. #8. Заключение
В этот статье мы разобрали как передать методу лямбду и proc вместо блока и когда это нужно на практике. Мы научились преобразовывать блок в proc и наоборот, а так же узнали, когда это может пригодиться. Кроме того мы затронули смежную тему — создание блока с помощью конструкции &:symbol и убедились, что в этом нет никакой магии — она органично и логично встроена в Ruby.
Что предпочесть — создание блока с помощью конструкции &:symbol или использование обычных блоков? Мы считаем, что в простых случаях (например, когда блок состоит только из вызова одного метода либо вообще не принимающего аргументов, либо с одним аргументом — такого как методы upcase и +) предпочтительнее использовать генерацию блока с помощью &:symbol. Это повышает читаемость кода, а как дополнительный бонус вы получаете увеличенную производительность. Во всех же остальных случаях стоит выбирать обычные блоки, т.к. создание эквивалентной конструкции с использованием &:symbol потребует больших усилий при создании и, что самое главное, она плохо читается — приходится подолгу всматриваться, чтобы понять, что же происходит в таком коде. Если же вы склоняетесь к использованию сложных конструкций с &:symbol чтобы улучшить производительность, то вполне может оказаться, что либо выигрыш будет слишком мал, либо его вообще может не быть. В любом случае выбор за вами.