0

Truyền block vào method không sử dụng &block

Có 2 cách để truyền vào một block cho một hàm trong Ruby.

Cách 1

Sử dụng từ khoá yield:

def speak
  puts yield
end
speak { "Hello" }
# Hello
#  => nil

Cách 2

Chèn vào trước argument cuối của một hàm với một dấu & (ampersand) để sau đó tạo ra một đối tượng Proc từ bất kể block nào được truyền vào. Đối tượng này có thể được thực thi với hàm call như sau:

def speak(&block)
  puts block.call
end
speak { "Hello" }
# Hello
#  => nil

Vấn đề của cách thứ 2 là khi khởi tạo đối tượng Proc mới sẽ làm ảnh hưởng đến tốc độ, anh Aaron Patterson có giải thích chi tiết trong bài nói “ZOMG WHY IS THIS CODE SO SLOW?" tại RubyConf X, (đoạn 30 phút ở trang 181).

Ta có thể kiểm tra thông qua benchmark, block_benchmark.rb:

require "benchmark"

def speak_with_block(&block)
  block.call
end

def speak_with_yield
  yield
end

n = 1_000_000
Benchmark.bmbm do |x|
  x.report("&block") do
    n.times { speak_with_block { "ook" } }
  end
  x.report("yield") do
    n.times { speak_with_yield { "ook" } }
  end
end

Kết quả cho thấy sự khác biệt rõ rệt giữa 2 cách:

$ ruby block_benchmark.rb
Rehearsal ------------------------------------------
&block   1.410000   0.020000   1.430000 (  1.430050)
yield    0.290000   0.000000   0.290000 (  0.291750)
--------------------------------- total: 1.720000sec

             user     system      total        real
&block   1.420000   0.030000   1.450000 (  1.452686)
yield    0.290000   0.000000   0.290000 (  0.292179)

Như vậy ta có thể nhận thấy việc sử dụng yield sẽ nhanh hơn &block. Nhưng khi ta cần truyền 1 block cho 1 method thì sao?

Ví dụ, ta có 1 class với hàm tell_ape gọi đến hàm tell. Kiểu pattern thường được xử lý bằng method_missing nhưng ta sẽ giữ và khai báo toàn bộ các hàm để tiện giải thích:

class Monkey

  # Monkey.tell_ape { "ook!" }
  # ape: ook!
  #  => nil
  def self.tell_ape(&block)
    tell("ape", &block)
  end

  def self.tell(name, &block)
    puts "#{name}: #{block.call}"
  end
end

Cách làm trên là không thể nếu sử dụng yield

class Monkey

  # Monkey.tell_ape { "ook!" }
  # ArgumentError: wrong number of arguments (2 for 1)
  def self.tell_ape
    tell("ape", yield)
  end

  def self.tell(name)
    puts "#{name}: #{yield}"
  end
end

Và cách trên cũng không thể chạy nếu sử dụng &block:

class Monkey

  # Monkey.tell_ape { "ook!" }
  # TypeError: wrong argument type String (expected Proc)
  def self.tell_ape
    tell("ape", &yield)
  end

  def self.tell(name)
    puts "#{name}: #{yield}"
  end
end

Tuy nhiên có 1 cách để chỉ tạo ra 1 object Proc khi cần thiết, đó là cách sử dụng một đặc tính ít được biết đến của hàm Proc.new, anh Aaron có giải thích trong bài nói được nhắc ở trên

Nếu Proc.new được gọi từ bên trong một hàm mà không có bất kỳ argument nào thì nó sẽ trả về một Proc có kèm block được đưa cho hàm ở ngoài.

Nếu Proc.new được gọi từ bên trong một hàm với không có argument nào của chính nó, nó sẽ trả về một Proc có chứa block cho method chứa nó.

def speak
  puts Proc.new.call
end
speak { "Hello" }
# Hello

Điều này có nghĩa là ta có thể truyền vào một blockgiữa các methods mà không cần phải sử dụng &block

class Monkey

  # Monkey.tell_ape { "ook!" }
  # ape: ook!
  #  => nil
  def self.tell_ape
    tell("ape", &Proc.new)
  end

  def self.tell(name)
    puts "#{name}: #{yield}"
  end
end

Dĩ nhiên là nếu dùng Proc.new, thì sẽ mất thời gian hơn so với yield (khi các đối tượng Proc được khởi tạo với &block) nhưng nó sẽ tránh được các khởi tạo không cần thiết của các đối tượng Proc khi ta không cần đến chúng. Ta có thể sử dụng benmark để làm rõ điều trên: proc_new_benchmark.rb:

require "benchmark"

def sometimes_block(flag, &block)
  if flag && block
    block.call
  end
end

def sometimes_proc_new(flag)
  if flag && block_given?
    Proc.new.call
  end
end
n = 1_000_000
Benchmark.bmbm do |x|
  x.report("&block") do
    n.times do
      sometimes_block(false) { "won't get used" }
    end
  end
  x.report("Proc.new") do
    n.times do
      sometimes_proc_new(false) { "won't get used" }
    end
  end
end

Ta có thể thấy sự khác biệt khá lớn:

$ ruby code/proc_new_benchmark.rb
Rehearsal --------------------------------------------
&block     1.080000   0.160000   1.240000 (  1.237644)
Proc.new   0.160000   0.000000   0.160000 (  0.156077)
----------------------------------- total: 1.400000sec

               user     system      total        real
&block     1.090000   0.080000   1.170000 (  1.178771)
Proc.new   0.160000   0.000000   0.160000 (  0.155053)

Mấu chốt ở đây là khi sử dụng &block thì sẽ luôn tạo ra object Proc mới, ngay cả khi ta không cần dùng đến. Bằng cách sử dụng Proc.new khi cần, ta có thể tránh việc khởi tạo toàn bộ các object.

Tuy nhiên, có thể bạn sẽ phải cân nhắc về code dễ đọc hay tốc độ. Như với method somtimes_block cần truyền vào block. Và nghiễm nhiên ta sẽ hiểu rằng block đó được sử dụng để làm gì trong method đó. Nhưng với method sometimes_proc_new thì rất dễ ngây hiểu nhầm 😄

Kết luận

Vậy dùng cách nào tuỳ thuộc vào yêu cầu và mục đích của chúng ta. Đó cũng là 1 tính năng khá hữu ích.

Nguồn tham khảo http://mudge.name/2011/01/26/passing-blocks-in-ruby-without-block.html


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí