Truyền block vào trong Ruby mà không dùng &block
Bài đăng này đã không được cập nhật trong 7 năm
Như chúng ta biết thì có 2 cách để nhận vào block trong một hàm của Ruby. Cách đầu tiên là sử dụng từ khoá yield như sau:
def hello_world
puts yield
end
hello_world { "Hello world" }
# Hello world
# => nil
Cách khác là 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 hello_world(&block)
puts block.call
end
hello_world { "Hello world" }
# 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 độ. Chúng ta có thể kiểm tra thông qua benchmark, blockbenchmark.rb:
require "benchmark"
def hello_world_with_block(&block)
block.call
end
def hello_world_with_yield
yield
end
n = 1_000_000
Benchmark.bmbm do |x|
x.report("&block") do
n.times { hello_world_with_block { "ook" } }
end
x.report("yield") do
n.times { hello_world_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)
Điều đó chứng tỏ là chúng ta nên chọn yield thay vì &block, nhưng nếu chúng ta cần truyền một block qua một hàm khác thì sao? Ví dụ, ở đây là một class với một hàm tellape giao việc cho một hàm khác có tên tell. Kiểu pattern thường được xử lý bằng methodmissing nhưng tôi sẽ giữ và khai báo toàn bộ các hàm để dễ dàng 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
Đấy là một điều không thể làm với từ khoá 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ũng không thể chạy với cách &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
Nhưng có một cách để tạo một đối tượng 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
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ó kèm block được đưa cho hàm ở ngoài.
def hello_world
puts Proc.new.call
end
hello_world { "Hello world" }
# Hello world
# => nil
Điều này có nghĩa là có thể truyền vào một block giữa các hàm với nhau mà không cần phải sử dụng &block nữa:
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 bạn dùng Proc.new, bạn sẽ bị mất tốc độ của cách 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 bạn không cần đến chúng. Bạn có thể kiểm chứng điều tôi vừa nói thông qua benchmark procnewbenchmark.rb giống phía trên tôi đã làm.
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
Kết quả là khác biệt rất lớn về tốc độ:
$ 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 đối tượng Proc mới, nay cả khi chúng ta không cần dùng đến. Bằng cách sử dụng *Proc.new *khi chúng ta cần đến, chúng ta có thể tránh trả giá về tốc độ của việc khởi tạo toàn bộ các đối tượng.
Tuy thế, có thể bạn sẽ gặp một số vấn đề về phần code dễ đọc và tốc độ, điều đó được thấy rõ từ hàm somtimesblock chỉ nhận block và do đó được mặc định hiểu sẽ phải làm cái gì đó với cái block đó, nhưng không thể đưa ra cùng nhận định vào hàm tối ưu sometimesprocnew.
Kết luận là nó tuỳ thuộc vào yêu cầu, nhưng cũng khá thú vị khi biết được chiêu hữu dụng này của Ruby.
All rights reserved