Наверняка вы сталкивались с ситуацией, когда есть достаточно жирный метод, и вам приходится вынести часть его кода в отдельный метод и ваш класс/модуль переполняется методами, которые относятся к одному единственному методу и нигде более не используется. Ужасный каламбур, правда?
Если вы просто хотите ознакомиться с реализацией класса, то эти самые вспомогательные методы очень сильно мозолят глаза, приходится прыгать по коду туда-сюда. Да, конечно, можно разнести их по отдельным модулям, но я считаю, что зачастую это слишком избыточно (я, например, не хочу создавать модуль, который, по сути, определяет только один метод, декомпозированный на n частей). Особенно неприятно, когда эти вспомогательные функции состоят из одной строки (например, метод, который выдергивает определенный элемент из распарсенного JSON).
И раз уж я заговорил о парсинге, то давайте приведу несколько синтетический пример.
Несколько синтетический пример
Задача
Сгенерировать Hash с курсом разных валют по отношению к рублю. Примерно такой:
{ 'USD' => 30.0,
'EUR' => 50.0,
... }
Решение
На сайте Центробанка есть такая страница: http://www.cbr.ru/scripts/XML_daily.asp
Собственно, все можно сделать вот так:
require 'open-uri'
require 'active_support/core_ext/hash' # for Hash#from_xml
def rate_hash
uri = URI.parse('http://www.cbr.ru/scripts/XML_daily.asp')
xml_with_currencies = uri.read
rates = Hash.from_xml(xml_with_currencies)['ValCurs']['Valute']
rates.map(&method(:rate_hash_element)).to_h
end
def rate_hash_element(rate)
[rate['CharCode'], rubles_per_unit(rate)]
end
def rubles_per_unit(rate)
rate['Value'].to_f / rate['Nominal'].to_f
end
Либо классом:
require 'open-uri'
require 'active_support/core_ext/hash' # for Hash#from_xml
class CentralBankExchangeRate
def rubles_per(char_code)
rate_hash_from_cbr[char_code] || fail('I dunno :C')
end
#
# other public methods for other currencies
#
private
# Gets daily rates from Central Bank of Russia
def rate_hash_from_cbr
uri = URI.parse('http://www.cbr.ru/scripts/XML_daily.asp')
xml_with_currencies = uri.read
rates = Hash.from_xml(xml_with_currencies)['ValCurs']['Valute']
rates.map(&method(:rate_hash_element)).to_h
end
# helper method for #rate_hash_from_cbr
def rate_hash_element(rate)
[rate['CharCode'], rubles_per_unit[rate]]
end
# helper method for #rate_hash_element
def rubles_per_unit(rate)
rate['Value'].to_f / rate['Nominal'].to_f
end
#
# other private methods
#
end
Не будем рассуждать о том, какие библиотеки стоило использовать, будем считать, что у нас есть рельсы и поэтому воспользуемся Hash#from_xml
оттуда.
Собственно, нашу задачу решает метод #rate_hash
, в то время как оставшиеся два метода являются вспомогательными для него. Согласитесь, что их присутствие очень сильно отвлекает.
Обратите внимание на переменную xml_with_currencies
: ее значение используется всего-лишь один раз, а это значит, что ее наличие совсем необязательно и можно было написать Hash.from_xml(uri.read)['ValCurs']['Valute']
, однако, как мне кажется, ее использование чуть-чуть улучшает читаемость кода. Собственно, появление вспомогательных методов — это тот же самый прием, но для кусков кода.
Как вы уже, наверное, догадались по заголовку, я предлагаю использовать для таких вспомогательных методов лямбы.
Решение с lambda
require 'open-uri'
require 'active_support/core_ext/hash' # for Hash#from_xml
def rate_hash
uri = URI.parse('http://www.cbr.ru/scripts/XML_daily.asp')
xml_with_currencies = uri.read
rates = Hash.from_xml(xml_with_currencies)['ValCurs']['Valute']
rubles_per_unit = -> (r) { r['Value'].to_f / r['Nominal'].to_f }
rate_hash_element = -> (r) { [r['CharCode'], rubles_per_unit[r]] }
rates.map(&rate_hash_element).to_h
end
Либо классом:
require 'open-uri'
require 'active_support/core_ext/hash' # for Hash#from_xml
class CentralBankExchangeRate
def rubles_per(char_code)
rate_hash_from_cbr[char_code] || fail('I dunno :C')
end
#
# other public methods for other currencies
#
private
# Gets daily rates from Central Bank of Russia
def rate_hash_from_cbr
uri = URI.parse('http://www.cbr.ru/scripts/XML_daily.asp')
xml_with_currencies = uri.read
rates = Hash.from_xml(xml_with_currencies)['ValCurs']['Valute']
rubles_per_unit = ->(r) { r['Value'].to_f / r['Nominal'].to_f }
rate_hash_element = ->(r) { [r['CharCode'], rubles_per_unit[r]] }
rates.map(&rate_hash_element).to_h
end
#
# other private methods
#
end
Теперь визуально сразу видно, что у нас есть один метод, пригодный для использования. А если мы захотим погрузиться в его реализацию, то проблем с чтением также возникнуть не должно, поскольку объявления лямбд достаточно броские и понятные (спасибо синтаксическому сахару).
Но ведь так нельзя!
Насколько я знаю, в JavaScript справедливо является плохой практикой вкладывание функций друг в друга:
function foo() {
return bar();
function bar() {
return 'bar';
}
}
Справедливо, потому что каждый раз при вызове foo()
мы создаем функцию bar, а затем уничтожаем ее. Более того, параллельное выполнение нескольких foo()
создаст 3 одинаковых функции, что еще и тратит память.
Но насколько критичен вопрос потребления лишних долей секунды для нашего метода? Лично я не вижу смысла ради выигрыша в полсекунды отказываться от разнообразных удобных конструкций. Например:
some_list.each(&:method)
Медлительнее, чем
some_list.each { |e| e.method }
Потому что в первом случае используется неявное приведение к Proc
.
К тому же, Ruby все-таки работает на сервере, а не клиенте, так что скорости там намного выше (хотя тут тоже можно поспорить, ведь сервер обслуживает множество людей, и потеря даже доли секунды в глобальном масштабе увеличивается до минут/часов/дней)
И все же, что со скоростью?
Давайте проведем отдаленный от реальности эксперимент.
using_lambda.rb:
N = 10_000_000
def method(x)
sqr = ->(x) { x * x }
sqr[x]
end
t = Time.now
N.times { |i| method(i) }
puts "Lambda: #{Time.now - t}"
using_method.rb:
N = 10_000_000
def method(x)
sqr(x)
end
def sqr(x)
x * x
end
t = Time.now
N.times { |i| method(i) }
puts "Method: #{Time.now - t}"
Запуск:
~/ruby-test $ alias test-speed='ruby using_lambda.rb; ruby using_method.rb'
~/ruby-test $ rvm use 2.1.2; test-speed; rvm use 2.2.1; test-speed; rvm use 2.3.0; test-speed
Using /Users/nondv/.rvm/gems/ruby-2.1.2
Lambda: 11.564349
Method: 1.523036
Using /Users/nondv/.rvm/gems/ruby-2.2.1
Lambda: 9.270079
Method: 1.523763
Using /Users/nondv/.rvm/gems/ruby-2.3.0
Lambda: 9.254366
Method: 1.333142
Т.е. использование лямбы примерно в 7 раз медленнее аналогичного кода, использующего метод.
Заключение
Данный пост был написан в надежде узнать, что думает хабросообщество по поводу использования данного "приема".
Если, например, скорость не является настолько критичной, что нужно бороться за каждые милисекунды, а сам метод не вызывается по миллиону раз в секунду, можно ли пожертвовать скоростью в данном случае? А может, вы вообще считаете, что это не улучшает читаемость?
К сожалению, приведенный пример не иллюстрирует наглядно смысл такого странного использования лямбд. Смысл появляется, когда есть класс с достаточно большим количеством приватных методов, большая часть которых используется в других приватных методах, причем, только единожды. Это по задумке должно облегчить понимание реализации работы отдельных методов класса, т. к. нет кучи def
и end
, а есть достаточно простые однострочные функции (-> (x) { ... }
)
Спасибо, за уделенное время!
UPD.
Некоторые люди, с которыми я общался по этому поводу, не совсем правильно поняли идею.
- Я не предлагаю заменять все приватные методы на лямбды. Я предлагаю заменять только очень простые однострочники, которые нигде более не используются, кроме как в нужном методе (причем сам метод, скорее всего, будет приватным).
- Более того, даже для простых однострочников нужно исходить из ситуации и использовать этот "прием" только если читаемость кода действительно улучшится и при этом проседание по скорости не будет сколько-нибудь значительным.
- Основной профит использования лямбд — сокращение кол-ва строк кода и визуальное выделение наиболее значимых частей кода (текстовый редактор одинаково подсвечивает главные и вспомогательные методы, а тут мы воспользуемся лямбдой).
- Выносить в лямбды желательно чистые функции
UPD2.
Кстати, в первом примере два вспомогательных метода можно объединить в один:
def rate_hash_element(rate)
rubles_per_unit = rate['Value'].to_f / rate['Nominal'].to_f
[rate['CharCode'], rubles_per_unit]
end
Автор: Sna1L