Lazy Enumerable trong ruby
Bài đăng này đã không được cập nhật trong 6 năm
Hôm nay mình muốn chia sẽ một chút về Enumerator::Lazy. Tính năng này đã tồn tại trong ruby kể từ phiên bản 2.0, nhưng mình rất ít khi thấy nó được áp dụng trong codebase mà mình hay đụng tới (cũng có thể tại mình còn gà chưa đụng vào codebase nào phức tạp ). Và mình search trên này không ra nên mạo phép share đại, hi vọng sẽ không thành múa rìu qua mắt thợ.
Vấn đề mà Enumerator::Lazy giải quyết
Trước khi nói về tính năng gì chúng ta cũng phải nói về vấn đề mà nó giải quyết. Chứ không thì tính năng làm ra cũng chỉ để làm cảnh cho vui. Lấy ví dụ một bài toán như sau:
Đầu vào: một range vô hạn như thế này range = 1..Float::INFINITY
Đầu ra: bình phương 10 phần tử đầu của range này
Đáp án đầu tiên mà nhiều người sẽ nghĩ đến sẽ là dùng collect
rồi take
hoặc first
như sau:
range = 1..Float::INFINITY
range.collect{|x| x*2}.take 5
nhưng nếu bạn thử chạy lệnh này trong irb
nó sẽ chạy mãi mãi. Đó là bởi vì lệnh collect sẽ nay lập tức lặp qua tất cả phần tử trong range
để tạo ra mảng bình phương mới. Và vì mảng này chạy tới vô hạn nên take
sẽ không bao giờ được gọi.
LAZY TO THE RESCUE
Để giải quyết vấn đề trên tất cả những gì chúng ta cần làm là chants the magic word LAZY (và đổi take thành first, lý do mình sẽ giải thích sau)
range = 1..Float::INFINITY
range.lazy.collect{|x| x*2}.first 5
# [2, 4, 6, 8, 10]
Enumerator::Lazy hoạt động như thế nào
Liếc qua 2 đoạn code phía trên mình nghĩ nhiều người cũng có thể đoán ra những điều sau:
- Đối với một non-lazy Enumerable, các hàm như
collect
,inject
,sum
,all
, sẽ được thực hiện ngay lập tức lúc được gọi. Nếu có 300 phần tử mỗi lần chúng ta gọi các hàm trên hàmeach
sẽ được gọi đúng 300 lần và trả về mảng 300 phần tử cho dù không cần dùng đến 300 giá trị. Điều này cũng đồng nghĩa với việc nếu chúng ta gọicollect
3 lần chúng ta sẽ phải lặp qua các phần tử 900 lần. - Còn đối với lazy Enumerable, vì nó rất là lười nên nó sẽ chỉ gọi những hàm trên khi nó bắt buộc phải thực hiện. Như ví dụ trên là dùng
first 5
ruby sẽ lặp qua duy nhất 5 phần tử đầu tiên 1 lần mặc kệ có bao nhiêucollect
được chain vào. QUÁ BÁ ĐẠO
Vậy lazy
thực sự hoạt động như thế nào:
range = 1..Float::INFINITY
range.lazy
# => #<Enumerator::Lazy: 1..Infinity>
range.lazy.collect{|x| x*2}
# => #<Enumerator::Lazy: #<Enumerator::Lazy: 1..Infinity>:collect>
range.lazy.collect{|x| x*2}.take 5
# => #<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator::Lazy: 1..Infinity>:collect>:take(5)>
Từ output của irb
chúng ta có thể thấy khi chúng ta gọi hàm lazy trên Enumerable ruby
sẽ ngay lập tức trả về một instance của Enumerator::Lazy
chứa Enumerable ban đầu. Sau đó khi chúng ta gọi tiếp các hàm hỗ trợ lazy
như collect
, take
, drop
,
ruby sẽ không lập tức lặp qua các phần tử và trả về mảng như bình thường mà sẽ ngay lập tức trả về một instance Enumerator::Lazy
mới chứa Enumerator:::Lazy
phía trước.
irb(main):016:0> enum = range.lazy.collect{|x| x+1; p x+1}.collect{|x| x*2; p x*2}
# => #<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator::Lazy: 1..Infinity>:collect>:collect>
irb(main):017:0> enum.first 4
2
4
3
6
4
8
5
10
=> [4, 6, 8, 10]
Như có thể thấy ở trên, khi chúng ta gọi một hàm không lazy
(Những hàm không được define trong class Enumerator::Lazy
, nhưng nằm trong Enumerable
) ruby
sẽ lặp qua số phần tử cần thiết và gọi từng block
gắn liền với từng Enumerator
trong chuỗi enumerators với mỗi phần tử đó (truyền kết quả của block 1 vào block 2, 2 -> 3, 3 -> 4 rồi v.v).
P/S: Ai từng đọc qua về lazy trong ActiveRecord của Rails thì chắc cũng không xa la với kiểu lazy
này.
Tham khảo: http://patshaughnessy.net/2013/4/3/ruby-2-0-works-hard-so-you-can-be-lazy
All rights reserved