Ruby: виклик Proc # проти прибутку


78

Які поведінкові відмінності між двома наступними реалізаціями thriceметоду в Ruby ?

module WithYield
  def self.thrice
    3.times { yield }      # yield to the implicit block argument
  end
end

module WithProcCall
  def self.thrice(&block)  # & converts implicit block to an explicit, named Proc
    3.times { block.call } # invoke Proc#call
  end
end

WithYield::thrice { puts "Hello world" }
WithProcCall::thrice { puts "Hello world" }

До "поведінкових відмінностей" я відношу обробку помилок, продуктивність, підтримку інструментів тощо.


Різниця в поведінці між різними типами рубінових закриттів була широко задокументована
cldwalker,

8
Примітка: def thrice(&block)це більше самодокументування, особливо порівняно з yieldпохованням десь великим способом.
Натан Лонг

Відповіді:


52

Я думаю, що перший насправді є синтаксичним цукром іншого. Іншими словами, поведінкової різниці немає.

Однак друга форма дозволяє "зберегти" блок у змінній. Тоді блок можна викликати в інший момент часу - зворотний виклик.


Гаразд. Цього разу я пішов і зробив швидкий орієнтир:

require 'benchmark'

class A
  def test
    10.times do
      yield
    end
  end
end

class B
  def test(&block)
    10.times do
      block.call
    end
  end
end

Benchmark.bm do |b|
  b.report do
    a = A.new
    10000.times do
      a.test{ 1 + 1 }
    end
  end

  b.report do
    a = B.new
    10000.times do
      a.test{ 1 + 1 }
    end
  end

  b.report do
    a = A.new
    100000.times do
      a.test{ 1 + 1 }
    end
  end

  b.report do
    a = B.new
    100000.times do
      a.test{ 1 + 1 }
    end
  end

end

Цікаві результати:

      user     system      total        real
  0.090000   0.040000   0.130000 (  0.141529)
  0.180000   0.060000   0.240000 (  0.234289)
  0.950000   0.370000   1.320000 (  1.359902)
  1.810000   0.570000   2.380000 (  2.430991)

Це показує, що використання block.call майже вдвічі повільніше, ніж використання yield .


9
Я думаю, що Рубі була б більш послідовною, якби це було правдою (тобто якби це yieldбув просто синтаксичний цукор для Proc#call), але я не думаю, що це правда. наприклад, існує інша поведінка щодо обробки помилок (див. мою відповідь нижче). Я також бачив, як він пропонує (наприклад, stackoverflow.com/questions/764134/… ), що yieldє більш ефективним, оскільки йому не потрібно спочатку створювати Procоб'єкт, а потім викликати його callметод.
Сем Стокс,

Re оновлення з критеріями: так, я теж зробив кілька тестів і одержали Proc#callбути більш ніж 2 рази , як повільно , як yield, по даним МРТ 1.8.6p114. На JRuby (1.3.0, JVM 1.6.0_16 Server VM) різниця була ще більш вражаючою: Proc#callбула приблизно в 8 разів повільнішою, ніж yield. Тим не менш, yieldна JRuby було вдвічі швидше, ніж yieldна МРТ.
Сем Стокс,

Я зробив це на МРТ 1.8.7p174 x86_64-linux.
jpastuszek

3
ви також пропускаєте третій випадок:, def test(&block) ; 10.times(&block) ; endякий повинен перевірити те саме, що і випадок прибутковості.
чемпіон

1
Тести вище приблизного Ruby v2.1.2 також; block.callв ~ 1,7 рази повільніше, ніж yield.
Gav

9

Ось оновлення для Ruby 2.x

ruby 2.0.0p247 (редакція 27.06.2013 41674) [x86_64-darwin12.3.0]

Мені набридло писати тести вручну, тому я створив маленький модуль бігуна, який називається benchable

require 'benchable' # https://gist.github.com/naomik/6012505

class YieldCallProc
  include Benchable

  def initialize
    @count = 10000000    
  end

  def bench_yield
    @count.times { yield }
  end

  def bench_call &block
    @count.times { block.call }
  end

  def bench_proc &block
    @count.times &block
  end

end

YieldCallProc.new.benchmark

Вихідні дані

                      user     system      total        real
bench_yield       0.930000   0.000000   0.930000 (  0.928682)
bench_call        1.650000   0.000000   1.650000 (  1.652934)
bench_proc        0.570000   0.010000   0.580000 (  0.578605)

Я думаю, що тут найдивовижніше те, що bench_yieldце повільніше, ніж bench_proc. Я хотів би трохи більше зрозуміти, чому це відбувається.


2
Я вважаю, що це тому, що в bench_procодинарному операторі насправді перетворюється процес в блок timesвиклику, пропускаючи накладні витрати на створення блоку для timesв bench_yieldі bench_call. Це дивний різновид особливого випадку, схоже, yieldдля більшості випадків це все одно швидше. Більше інформації про процес блокування призначення: ablogaboutcode.com/2012/01/04/the-ampersand-operator-in-ruby (розділ: The Unary &)
Метт Сандерс

Integer#timesвиклики yield(версія c, rb_yield, яка приймає VALUE, що представляє блок). Ось чому bench_proc настільки швидкий.
Nate Symer

7

Вони видають різні повідомлення про помилки, якщо ви забудете передати блок:

> WithYield::thrice
LocalJumpError: no block given
        from (irb):3:in `thrice'
        from (irb):3:in `times'
        from (irb):3:in `thrice'

> WithProcCall::thrice
NoMethodError: undefined method `call' for nil:NilClass
        from (irb):9:in `thrice'
        from (irb):9:in `times'
        from (irb):9:in `thrice'

Але вони поводяться однаково, якщо ви намагаєтесь передати "нормальний" (неблоковий) аргумент:

> WithYield::thrice(42)
ArgumentError: wrong number of arguments (1 for 0)
        from (irb):19:in `thrice'

> WithProcCall::thrice(42)
ArgumentError: wrong number of arguments (1 for 0)
        from (irb):20:in `thrice'

6

Інші відповіді досить ґрунтовні, і « Закриття в Рубі» широко висвітлює функціональні відмінності. Мені було цікаво, який метод буде найкращим для методів, які необов'язково приймають блок, тому я написав деякі еталони (виходячи з цього допису Пола Мукура ). Я порівняв три методи:

  • & заблокувати підпис методу
  • Використовуючи &Proc.new
  • Загортання yieldв інший блок

Ось код:

require "benchmark"

def always_yield
  yield
end

def sometimes_block(flag, &block)
  if flag && block
    always_yield &block
  end
end

def sometimes_proc_new(flag)
  if flag && block_given?
    always_yield &Proc.new
  end
end

def sometimes_yield(flag)
  if flag && block_given?
    always_yield { yield }
  end
end

a = b = c = 0
n = 1_000_000
Benchmark.bmbm do |x|
  x.report("no &block") do
    n.times do
      sometimes_block(false) { "won't get used" }
    end
  end
  x.report("no Proc.new") do
    n.times do
      sometimes_proc_new(false) { "won't get used" }
    end
  end
  x.report("no yield") do
    n.times do
      sometimes_yield(false) { "won't get used" }
    end
  end

  x.report("&block") do
    n.times do
      sometimes_block(true) { a += 1 }
    end
  end
  x.report("Proc.new") do
    n.times do
      sometimes_proc_new(true) { b += 1 }
    end
  end
  x.report("yield") do
    n.times do
      sometimes_yield(true) { c += 1 }
    end
  end
end

Продуктивність була подібною між Ruby 2.0.0p247 та 1.9.3p392. Ось результати для 1.9.3:

                  user     system      total        real
no &block     0.580000   0.030000   0.610000 (  0.609523)
no Proc.new   0.080000   0.000000   0.080000 (  0.076817)
no yield      0.070000   0.000000   0.070000 (  0.077191)
&block        0.660000   0.030000   0.690000 (  0.689446)
Proc.new      0.820000   0.030000   0.850000 (  0.849887)
yield         0.250000   0.000000   0.250000 (  0.249116)

Додавання явного &blockпараметра, коли він використовується не завжди, дійсно сповільнює метод. Якщо блок необов’язковий, не додайте його до підпису методу. І для передачі блоків довкола загортання yieldв інший блок є найшвидшим.

Тим не менш, це результати для мільйона ітерацій, тому не турбуйтеся про це занадто. Якщо один метод робить ваш код зрозумілішим за рахунок мільйонної частки секунди, скористайтеся ним у будь-якому випадку.


2

Я виявив, що результати різняться залежно від того, змушуєте Ви Ruby будувати блок чи ні (наприклад, вже існуючий процес).

require 'benchmark/ips'

puts "Ruby #{RUBY_VERSION} at #{Time.now}"
puts

firstname = 'soundarapandian'
middlename = 'rathinasamy'
lastname = 'arumugam'

def do_call(&block)
    block.call
end

def do_yield(&block)
    yield
end

def do_yield_without_block
    yield
end

existing_block = proc{}

Benchmark.ips do |x|
    x.report("block.call") do |i|
        buffer = String.new

        while (i -= 1) > 0
            do_call(&existing_block)
        end
    end

    x.report("yield with block") do |i|
        buffer = String.new

        while (i -= 1) > 0
            do_yield(&existing_block)
        end
    end

    x.report("yield") do |i|
        buffer = String.new

        while (i -= 1) > 0
            do_yield_without_block(&existing_block)
        end
    end

    x.compare!
end

Дає результати:

Ruby 2.3.1 at 2016-11-15 23:55:38 +1300

Warming up --------------------------------------
          block.call   266.502k i/100ms
    yield with block   269.487k i/100ms
               yield   262.597k i/100ms
Calculating -------------------------------------
          block.call      8.271M (± 5.4%) i/s -     41.308M in   5.009898s
    yield with block     11.754M (± 4.8%) i/s -     58.748M in   5.011017s
               yield     16.206M (± 5.6%) i/s -     80.880M in   5.008679s

Comparison:
               yield: 16206091.2 i/s
    yield with block: 11753521.0 i/s - 1.38x  slower
          block.call:  8271283.9 i/s - 1.96x  slower

Якщо ви міняєте do_call(&existing_block)на do_call{}ви знайдете це близько 5x повільніше в обох випадках. Я думаю, що причина цього повинна бути очевидною (оскільки Ruby змушена будувати Proc для кожного виклику).


0

До речі, просто оновити це до поточного дня, використовуючи:

ruby 1.9.2p180 (2011-02-18 revision 30909) [x86_64-linux]

На Intel i7 (1,5 років).

user     system      total        real
0.010000   0.000000   0.010000 (  0.015555)
0.030000   0.000000   0.030000 (  0.024416)
0.120000   0.000000   0.120000 (  0.121450)
0.240000   0.000000   0.240000 (  0.239760)

Все-таки вдвічі повільніше. Цікаво.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.