Block, Proc và Lambda trong Ruby
Bài đăng này đã không được cập nhật trong 6 năm
I. Block, Lambda và Proc là gì?
Ruby là một ngôn ngữ với một tập hợp các tính năng mạnh mẽ - các tính năng mạnh mẽ nhất được cho là Blocks, Procs, và Lambdas. Nói tóm lại, các tính năng này cho phép bạn chuyển mã lệnh vào một method và thực thi mã đó tại một thời gian sau đó (khi method chứa mã lệnh được gọi). Mặc dù thường xuyên sử dụng các tính năng này, nhưng không phải developer nào cũng hoàn toàn hiểu được sự khác biệt tinh tế giữa chúng.
Blocks, Procs và Lambdas (gọi chung là các bao đóng (closure) trong khoa học máy tính) là một trong những khía cạnh mạnh nhất của Ruby đồng thời cũng là một trong những mặt gây sự hiểu lầm nhất. Lý do có lẽ là bởi Ruby xử lý các bao đóng này theo cách khá độc đáo và riêng mình có. Phức tạp hơn là Ruby có 4 cách khác nhau để sử dụng các bao đóng, mỗi một cách trong số đó có một chút khác biệt so với số còn lại.
closure : theo ngôn từ của người lập trình thì nó là 1 chức năng hoặc 1 tham chiếu đến 1 chức năng cùng với 1 môi trường tham chiếu. Không giống như 1 chức năng đơn giản, closure cho phép 1 chức năng truy cập đến các biến không phải địa phận của mình (non-local) thậm chí khi gọi bên ngoài phạm vi trực tiếp của mình.
1. Block
- Block đơn giản chỉ là tập hợp các lệnh thành một khối (method) được đặt trong dấu
{...}
hoặcdo...end
- Có 1 quy ước chung là sử dụng
{...}
cho các block đơn (1 dòng lệnh) vàdo...end
cho các block bội (multi-line). Ví dụ về 2 cách viết này cho khối block có cùng chức năng :
array = [1,2,3,4]
array.map! do |n|
n * n
end
=> [1, 4, 9, 16]
array = [1,2,3,4]
array.map! { |n| n * n } #cách viết này ngắn gọn hơn cách dùng do...end
=> [1, 4, 9, 16]
- Sự kỳ diệu đằng sau 1 khối block là từ khóa
yield
; nó trì hoãn việc thực thi gọi method để tính toán giá trị cho khối block. Kết quả của block - nếu có, sau đó được đánh giá bởi các mã còn lại trong method (kết quả của khối block thường được sử dụng để thực thi các nhiệm vụ khác bên trong method bởi mã lệnh nào đó trong method). Chỉ lệnhyield
cũng có thể chấp nhận các tham số (params)-những thông số này được truyền vào và đánh giá trong khối block (ví dụ như loop 1 tập hợp phần tử nào đó với tham số nhất định như attr, value, index...). Để hiểu rõ hơn chúng ta có 1 ví dụ đơn giản củamap!
:
class Array
def map!
self.each_with_index do |value, index|
self[index] = yield(value) #self là 1 tập hợp các phần tử nào đó; value và index là 2 params sử dụng cho chỉ lệnh yield
end
end
[1,2,3].map! {|i| i * 2}
end
Trong ví dụ này, thể hiện của map! (1 method) gọi đến method ```each_with_index``` và thay thế giá trị của các item trong tập self bằng value tương ứng với index của item (value được lặp tương ứng với index thành từng cặp tương ứng). Đây chỉ là ví dụ rất nhỏ về sự hữu ích của việc sử dụng block và thể hiện sức mạnh của yield. Việc sử dụng block này là vô tận, thường xuyên xuất hiện trong các dòng code của chúng ta.
Ở dòng ```[1,2,3].map! {|i| i * 2}``` thì ```{|i| i * 2}``` là đoạn block truyền cho hàm **map!**, và **yield** sẽ gọi ra và thực thi block đó.
Như vậy định nghĩa dễ hiểu hơn về yield là gọi ra và thực thi block truyền vào hàm
- Trong Ruby thì block phổ biến và dễ dùng hơn so với Lambda và Proc.
- Khi sử dụng block thì có hạn chế đó là khi muốn thay đổi đầu vào thì chúng ta phải viết lại toàn bộ block để hiển thị giá trị cho input mới.
2. Proc
- Như đã nói ở trên cấu trúc của block có ưu điểm là đơn giản, tiện dụng, dễ dùng; nhưng khi thay đổi input thì lại phải viết block mới (dùng 1 lần, chúng ta phải nhập lại các khối mỗi khi sử dụng lại chúng trên các mảng khác nhau) dẫn tới trùng lặp code. Vì vậy Proc được tạo ra để giải quyết vấn đề này. Chúng ta có thể lưu trữ 1 Proc trong 1 biến và sau đó truyền nó 1 cách rõ ràng tới bất kỳ method nào gọi nó. Ví dụ trên được viết lại bằng cách sử dụng Proc sẽ như sau :
number_squared = Proc.new { |n| n * n }
Như trên ta thấy 1 proc thực chất là 1block được đặt tên.
- Chỉnh sửa method
map!
trong phần block thành proc :
class Array
def map!(proc_object)
self.each_with_index do |value, index|
self[index] = proc_object.call(value)
end
end
end
array = [1,2,3,4]
array.map!(number_squared) #gọi method map! cho mảng array input
# number_squared - 1 biến được khởi tạo trước đó, đóng vai trò là proc_object trong method map!, giá trị của từng item trong array được tính toán trong khối proc number_squared để trả về kết quả cho khối proc bởi chỉ lệnh n*n
=> [1, 4, 9, 16]
- Lưu ý là ở đây không còn sử dụng từ khóa
yield
thay vào đó sử dụng trực tiếp các phương pháp gọi trên đối tượng Proc, truyền cho nó các giá trị của mảng. Kết quả nhận được tương tự như khối block nhưng chỉ khác là chúng ta lưu trữ khối block này trong 1 biến để sử dụng lại sau này. - Ví dụ về cách gọi proc:
proc = Proc.new {|x| puts x + 1}
Đối với input truyền vào là 1 mảng
[1, 2, 3].each(&proc)
Với 1 giá trị có thể gọi
proc.call(2)
Khi muốn thay đổi input thì gọi lại với giá trị mới như sau
[2, 5, 6].each(&proc)
Ký hiệu ```&``` để hiểu tham số truyền vào là 1proc, bản chất nó là chuyển symbol thành proc object, và truyền vào cho hàm như 1 block. Ví dụ:
[1,2,3].map(&:to_s)
sẽ tương đương với [1,2,3].map {|i| i.to_s}
- 1 proc là một object
3. Lambda
- Lambda là một function và không có tên cụ thể
- Nó có thể được sử dụng để gán 1 đoạn code
- Là 1 object
- Trả về(return) về 1 giá trị như các function khác
- Cú pháp (syntax):
- Ví dụ:
result = lambda { |x| x + 1 }
puts result.call(10)
result = ->(x) { x + 1 }
puts result.call(10)
- Ruby dùng
{ }
để viết lambda với 1 dòng code (như trên) và dùngdo...end
để viết một lambda với nhiều dòng code
result = lambda do |ten_phuongthuc|
if ten_phuongthuc == "cong"
return 1 + 2
elseif ten_phuongthuc == "tru"
return 2 - 1
elseif ten_phuongthuc == "nhan"
return 2*1
else
2/1
end
end
puts result.call("cong")
# => kết quả trả về = 1 + 2 = 3
# tương tự có thể gọi cho phương thức "tru" hoac "nhan" thay cho "cong"
# Hoặc có thể gọi result.call() nếu muốn lấy kết quả trả về thuộc trường hợp else : 2/1
- Có nhiều cách gọi lambda :
# cách viết đầy đủ
result.call(10)
result.call("cong")
# viết tắt
result[10]
result.(10)
II. Sự khác nhau giữa Block, Proc và Lambda
1. Sự khác nhau giữa Block và Proc
- Proc là objects, còn block thì không
- 1 proc là thể hiện của 1 lớp Proc
p = Proc.new { puts "helo world!" }
- Điều này cho phép gọi phương thức cũng như gán vào biến và trả lại giá trị của chính proc đó, còn block thì không thể lưu trữ trong một biến và không phải là object
p.call # prints 'helo world!'
p.class # returns 'Proc'
x = p # x tương đương với p, x Proc instance
p # trả lại 1 object proc '#<Proc:0x00000002e2a840@(irb):1>'
- Block chỉ là một phần trong hàm (từng dòng lệnh một), không có ý nghĩa gì nếu đứng độc lập. Ví dụ:
{ puts "helo"} //SyntaxError: (irb):6: syntax error, unexpected tSTRING_BEG
x = { puts "helo"} //SyntaxError: (irb):6: syntax error, unexpected tSTRING_BEG
[1,2,3].each {|x| puts x+1} // pass
- Chỉ truyền được 1 block vào trong danh sách đối số của hàm, còn với proc thì có thể truyền n proc vào hàm
```Ruby
def call_procs(p1, p2)
p1.call
p2.call
end
a = Proc.new { puts "First proc" }
b = Proc.new { puts "Second proc" }
call_procs(a,b)
2. Sự khác nhau giữa Proc và Lambda
Thực chất Lambda chính là một đối tượng của Proc
p = Proc.new { puts "Hello" } //#<Proc:0x00000002e2cb68@(irb):16>
lam = lambda { puts "Hello" } //#<Proc:0x00000002e0b8c8@(irb):17 (lambda)>
p.class //proc
lam.class //proc
# Sự khác nhau giữa lambda và proc là đối tượng trả về, và cách gọi
Về cơ bản thì chức năng của Proc và Lambda gần như giống hệt nhau (thực chất Lambda chính là một đối tượng của Proc), nhưng chúng có 2 điểm khác nhau cơ bản.
- Thứ nhất : lambda kiểm tra số lượng các tham số của nó nhận và trả về một
ArgumentError
nếu số lượng đó không phù hợp với số lượng đối số trong method của nó; còn Proc thì không, nếu không truyền tham số thì proc mặc định tham số đó bằng nil. Ví dụ :
l = lambda { "I'm a lambda" }
l.call
=> "I'm a lambda"
l.call('arg')
ArgumentError: wrong number of arguments (1 for 0)
Ví dụ sau đây chỉ ra sự khác nhau giữa Proc và Lambda :
p = Proc.new { |x| puts x +1 }
p.call(1, 2)
# return 2
l = lambda { |x| puts x +1 }
l.call(1, 2)
# return Argument Error
- Thứ 2 : Đối với hàm dùng return trong lambda và proc thì với proc thì sẽ return ngay sau khi thực hiện xong proc, còn với lambda thì vẫn tiếp tục chạy hết hàm sau khi gọi xong lambda. Ví dụ:
# 1. return trong lambda
def method_lambda
lam = lambda { return puts "xin chao" }
lam.call
puts "cac ban"
end
# khi gọi lambda trên
method_lambda
# kết quả in ra là
xin chao
cac ban
# 2. return trong proc
def method_proc
proc = Proc.new { return puts "xin chao" }
proc.call
puts "cac ban"
end
# gọi proc trên
method_proc
# kết quả in ra là
xin chao
#lệnh return được gọi trước khi thực hiện lệnh puts "cac ban", ở lambda thì vẫn chạy hết tới cuối hàm, còn proc thì dừng chương trình sau khi chạy xong lệnh return.
Ví dụ khác chỉ ra sự khác nhau khi có return ở 2 thằng proc và lambda :
def proc_math
Proc.new { return 1 + 1 }.call
return 2 + 2
end
def lambda_math
lambda { return 1 + 1 }.call
return 2 + 2
end
proc_math # => 2
lambda_math # => 4
Như trên, proc_math
ngay khi gặp chỉ lệnh return
trong Proc đã trả về giá trị 2; ngược lại lambda_math
bỏ qua return
và thay thế giá trị là "2 + 2".
Vì vậy dùng proc hay lambda phụ thuộc việc có dùng return hay không.
Qua so sánh 3 loại trên, rút ra được các đặc trưng của từng loại :
- Procs là object còn block thì không
- Hầu hết block xuất hiện trong một danh sách các đối số (argument)
- Lambda kiểm tra số lượng đối số còn proc thì không
- Lambda và proc đối xử với
return
không giống nhau.
III. Truyền một Block, Lambda vào function trong Ruby
1. Truyền Block vào function
- Có 2 cách để nhận vào block trong một hàm của Ruby.
- Dùng yield:
def test_yield
puts yield
end
test_yield { "xin chao cac ban" }
- Dùng
&
trước đối số(argument)
def test_block(&block)
puts block.call
end
test_block { "xin chao cac ban" }
- Ở cách thứ 2 thì nó tạo ra một đối tượng Proc từ bất kể block nào được truyền vào, sau đó đối tượng này thực hiện hàm call. So sánh tốc độ thì cách này sẽ lâu hơn bởi vì phải sinh ra đối tượng proc để gọi hàm.
- Kiểm tra benchmark sẽ thấy:
require "benchmark"
def test_block(&block)
block.call
end
def test_yield
yield
end
n = 10000
Benchmark.bmbm do |x|
x.report("&block") do
n.times { test_block { "xin chao cac ban" } }
end
x.report("yield") do
n.times { test_yield { "xin chao cac ban" } }
end
end
Rehearsal ------------------------------------------
&block 0.030000 0.000000 0.030000 ( 0.022620)
yield 0.000000 0.000000 0.000000 ( 0.004504)
--------------------------------- total: 0.030000sec
user system total real
&block 0.010000 0.000000 0.010000 ( 0.011766)
yield 0.000000 0.000000 0.000000 ( 0.003615)
2. Truyền Lambda vào function
- Như đã biết chúng ta có thể sử dụng lambda để gán 1 đoạn code dưới dạng 1 variable thì trong code sẽ ngắn gọn và sáng sủa hơn
- yield và & là gì
- yield: có thể tự động gọi đoạn code mà nó thấy có liên quan
- &: đứng trước 1 đối số thì mình có thể nhận biết được đó là 1 lambda, đối số này nên ở vị trí cuối cùng trong 1 dãy đối số truyền vào.
- Ví dụ: ta có 1 lambda sau
result = lambda do |phuongthuc|
if phuongthuc == 'cong'
return 1 + 2
elseif phuongthuc == 'tru'
return 2 - 1
elseif phuongthuc == 'nhan'
return 2*1
else
2/1
end
end
sau đó define một method tính, trong đó mình truyền vào tham số phuongthuc(phuongthuc này để chọn tính công trừ hay nhân chia trong lambda result)
def tinh(phuongthuc)
yield(phuongthuc)
end
Cách để truyền lambda để sử dụng
puts tinh('cong', $result)
puts tinh('tru', $result)
IV. Lời kết
Block, proc và lambda là những khái niệm và khía cạnh rất hay của Ruby. Bài viết trên đây còn sơ sài chưa thể nói rõ hết về từng loại. Nếu bạn quan tâm có thể tham khảo các bài viết sau :
http://code.tutsplus.com/tutorials/ruby-on-rails-study-guide-blocks-procs-and-lambdas--net-29811
http://www.tutorialspoint.com/ruby/ruby_blocks.htm
http://awaxman11.github.io/blog/2013/08/05/what-is-the-difference-between-a-block/
http://mixandgo.com/blog/mastering-ruby-blocks-in-less-than-5-minutes
http://www.reactive.io/tips/2008/12/21/understanding-ruby-blocks-procs-and-lambdas/
All rights reserved