Pull to refresh

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

Reading time6 min
Views9.5K

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


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


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


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

UPD3. от 10.08.2016


Оказывается, в ruby-style-guide упоминается этот прием. (Ссылка)[https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#no-nested-methods]

Only registered users can participate in poll. Log in, please.
Как вам идея?
26.98% Нравится, возьму на заметку17
15.87% Не нравится, но ничего ужасного не вижу10
25.4% Выглядит уродливо даже без учета падения скорости16
15.87% Потери скорости слишком велики10
15.87% Затрудняюсь. Интересен результат10
63 users voted. 29 users abstained.
Tags:
Hubs:
Total votes 14: ↑9 and ↓5+4
Comments51

Articles