Использование lambda в качестве локальных функций

в 22:01, , рубрики: lambda, ruby, ruby on rails, локальная функция, Совершенный код

Наверняка вы сталкивались с ситуацией, когда есть достаточно жирный метод, и вам приходится вынести часть его кода в отдельный метод и ваш класс/модуль переполняется методами, которые относятся к одному единственному методу и нигде более не используется. Ужасный каламбур, правда?

Если вы просто хотите ознакомиться с реализацией класса, то эти самые вспомогательные методы очень сильно мозолят глаза, приходится прыгать по коду туда-сюда. Да, конечно, можно разнести их по отдельным модулям, но я считаю, что зачастую это слишком избыточно (я, например, не хочу создавать модуль, который, по сути, определяет только один метод, декомпозированный на 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.
Некоторые люди, с которыми я общался по этому поводу, не совсем правильно поняли идею.

  1. Я не предлагаю заменять все приватные методы на лямбды. Я предлагаю заменять только очень простые однострочники, которые нигде более не используются, кроме как в нужном методе (причем сам метод, скорее всего, будет приватным).
  2. Более того, даже для простых однострочников нужно исходить из ситуации и использовать этот "прием" только если читаемость кода действительно улучшится и при этом проседание по скорости не будет сколько-нибудь значительным.
  3. Основной профит использования лямбд — сокращение кол-ва строк кода и визуальное выделение наиболее значимых частей кода (текстовый редактор одинаково подсвечивает главные и вспомогательные методы, а тут мы воспользуемся лямбдой).
  4. Выносить в лямбды желательно чистые функции

UPD2.
Кстати, в первом примере два вспомогательных метода можно объединить в один:


def rate_hash_element(rate)
  rubles_per_unit = rate['Value'].to_f / rate['Nominal'].to_f
  [rate['CharCode'], rubles_per_unit]
end

Автор: Sna1L

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js