Lazy Enumerable trong ruby

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àm each 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ọi collect 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êu collect đượ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