12/08/2018, 13:51

Ruby 2.0 Works Hard So You Can Be Lazy

Tính năng mới Lazy enumerator trong Ruby2.0 có vẻ khá huyền ảo. Nó cho phép bạn duyệt một chuỗi vô hạn các giá trị và lấy ra những giá trị mong muốn. Nó mang những khái niệm lập trình hàm lazy evaluation đến Ruby. Cho ví dụ, ở Ruby1.9 hay các version cũ hơn, vòng lặp sẽ chạy mà không dừng lại ...

work1.jpg

Tính năng mới Lazy enumerator trong Ruby2.0 có vẻ khá huyền ảo. Nó cho phép bạn duyệt một chuỗi vô hạn các giá trị và lấy ra những giá trị mong muốn. Nó mang những khái niệm lập trình hàm lazy evaluation đến Ruby.

Cho ví dụ, ở Ruby1.9 hay các version cũ hơn, vòng lặp sẽ chạy mà không dừng lại nếu bạn cố gắng để duyệt một chuỗi vô hạn các phần tử:

    range = 1..Float::INFINITY
    p range.collect{|x| x*x}.first(10)

    => endless loop!

Ở trên đây thì method .first chưa bao giờ xảy ra. Method collect đã khiến vòng lặp chạy vô hạn. Tuy nhiên, nếu bạn upgrade lên Ruby2.0 và sử dụng method của Enumerable#lazy, bạn có thể tránh được vòng lặp chạy vô hạn và lấy được những giá trị mong muốn.

    range = 1..Float::INFINITY
    p range.lazy.collect{|x| x*x}.first(10)
    => [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

Lazy evaluation(laziness) ở đây đã làm việc thế nào. Trong trường hợp này, tại sao Ruby biết rằng ta chỉ muốn 10 giá trị. Tất cả những gì phải làm là lời gọi đơn giản method lazy và nó làm việc.

Trông có vẻ khá ảo, tất cả những gì xảy ra trong Ruby khi bạn gọi method lazy, để output ra những giá trị mong muốn, Ruby đã tự động tạo và sử dụng nhiều kiểu khác nhau của các đối tượng bên trong Ruby. Giống như một công trường đầy ắp các thiết bị, các đối tượng làm việc cùng nhau để xử lý dữ liệu input từ một chuỗi vô hạn các phần tử. Những đối tượng đó là gì? Chúng đã làm những gì? Chúng cùng nhau làm việc như thế nào?... Hãy cùng nhau tìm hiểu:

I, The Enumerable module: many different ways of calling "each"

Khi chúng ta gọi method collect cho biến range ở trên, Chúng ta đang sử dụng module Enumerable của Ruby. Như các bạn đã quen thuộc, module này chưa một danh sách các method như: select, detect, any, reject...nhằm xử lý một list các giá trị theo những cách khác nhau. Tất cả những method đó đều làm việc bằng lời gọi "each" trên những đối tượng.

collect1.png

select-any.png

II, Enumerable is eager

Nhiều method của Enumerable như select, collect trả về 1 mảng các giá trị. Từ khi lớp Array include vào trong module Enumerable và respond to each, bạn có thể handle những method của Enumerable một cách dễ dàng:

collect-first.png

Ở ví dụ bên trên, method .first gọi .each ở trên một array, là kết quả của Enumerable#collect được sinh bằng lời gọi each khác trên đối tượng range.

Một chi tiết quan trọng chú ý ở đây là cả Enumerable#collect và Enumerable#first đều là eager: có nghĩa là chúng xử lý tất cả những giá trị được return bởi each trước khi return ra một mảng giá trị mới. Vì vậy, ở ví dụ trên, đầu tiên method collect sẽ xử lý tất cả giá trị từ range và lưu trữ kết quả vào một array1 nào đó. Sau đó, method first sẽ xử lý tất cả giá trị từ array1 và đặt chúng vào array2:

two-steps.png

Đây là cái sẽ dẫn đến một vòng lặp vô hạn, theo đó Range#each sẽ không bao giờ dừng việc return các giá trị, Enumerable#collect sẽ không bao giờ kết thúc và Enumerable#first sẽ không chạy.

endless-loop.png

II, The Enumerator object: deferred enumeration

Một trick thú vị bạn có thể sử dụng với những method của module Enumerable là gọi chúng không trong một block. Cho ví dụ, giả sử tôi gọi method collect cho biến range và không kèm theo một block:

    range = 1..10
    enum = range.collect
    p enum
    => #<Enumerator: ...>

Ở đây Ruby đã chuẩn bị sẵn một đối tượng mà bạn có thể sử dụng về sau để thực sự liệt kê cho biến range, được gọi là một "Enumerator".

enumerator-collect.png

Về sau, khi chúng ta muốn duyệt ranger và xử lý những giá trị cho mảng này, ta chỉ cần lời gọi each trên đối tượng Enumerator này:

    p enum.each{|x| x*x}
    => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Có một vài cách khác để handle với Enumerator, như .next. Chi tiết hơn, tôi sẽ viết ở bài sau.

III, Enumerator::Generator – generating new values for enumeration

Ở các ví dụ trước, chúng ta đã sử dụng đối tượng range để sinh ra một chuỗi các giá trị. Tuy nhiên, lớp Enumerator cung cấp cách khác linh hoạt hơn để sinh ra một chuỗi các giá trị sử dụng block. Ví dụ:

    enum = Enumerator.new do |y|
      y.yield 1
      y.yield 2
    end

    => #<Enumerator: ...>

Như bạn thấy, Ruby tạo ra một đối tượng Enumerator mới, cái mà chứa một tham chiếu đến một đối tượng bên trong có tên Enumerator::Generator, và setup để gọi method each trên đối tượng generator đó, bên trong đối tượng generator sẽ convert block bên trên vào trong một đối tượng Proc và lưu chúng.

enum-generator.png

Từ giờ, mỗi khi ta sử dụng đối tượng Enumerator trên, Ruby sẽ gọi Proc được lưu trữ bên trong generator này để có thể duyệt các giá trị.

    p enum.collect{|x| x*x}
    => [1, 4]

IV, Enumerator::Yielder – allowing one block to yield to another

Nếu để ý kĩ, bạn sẽ thấy có chút khác lạ. Đầu tiên, ta tạo một đối tượng Enumerator bằng một block:

    enum = Enumerator.new do |y|
      y.yield 1
      y.yield 2
    end

Cái mà yield những giá trị đến một block thứ 2 khi ta gọi each:

    p enum.collect{|x| x*x}

Nói cách khác, enumerator cho phép bạn yield những giá trị trực tiếp từ block này đến một block khác bằng cách:

two-blocks.png

Nhưng tất nhiên, việc này không phải của Ruby. Với Ruby, block không thể truyền giá trị trực tiếp đến một block khác. Trick này được thực hiện bởi một đối tượng bên trong khác là Enumerator::Yielder, đã truyền vào trong block với params block y

    enum = Enumerator.new do |y|
      y.yield 1
      y.yield 2
    end

Nếu bạn đọc lại đoạn code trên, sẽ thấy rằng ta không thực sự yield tất cả giá trị. Ta đơn giản chỉ gọi method yield trên đối tượng y, là một instance được tạo bằng lớp Enumerator::Yielder. Bạn có thể thấy lớp này qua ví dụ:

    y = Enumerator::Yielder.new{|str| puts str}
    #<Enumerator::Yielder:0x0000000f255058>
    y.yield "test"
    test
    => nil

Yielder sẽ bắt những giá trị mà ta gọi để generate, sử dụng method yield, và rùi thực sự chúng đã được truyền đến block khác. Là một dev, ngoài việc gọi method yield, ta không cần tương tác với generator hay yielder. Chúng được sử dụng bên trong enumerator. Khi ta gọi each trên enumerator, nó sử dụng hai đối tượng đó để generate và yield những giá trị ta mong muốn:

enumerator-yields.png

V, Enumerators generate data; Enumerable methods consume it

Luồng xử lý với kiểu liệt kê trong Ruby là:

  • Đối tượng Enumerator làm nhiệm vụ sinh dữ liệu
  • Các method cửa Enumerable làm nhiệm vụ xử lý dữ liệu

each-and-yield.png

Từ phải qua trái: các method của Enumerable gọi each để request dữ liệu. Sau đó, từ trái qua phải, đối tượng Enumerator cung cấp dữ liệu bằng việc yielding nó đến một block

VI, Enumerator::Lazy – putting it all together

Ruby2.0 thực hiện việc lazy evaluation bằng một đối tượng có tên Enumerator::Lazy. Nó đóng 2 vai trò: Nó là một enumerator và cũng chứa một list các method của enumerable. Nó gọi each để get dữ liệu từ một danh sách, nó cũng yield dữ liệu khỏi một danh sách.

left-and-right.png

Với Enumerator::Lazy, đây là cái sẽ xảy ra với một chuỗi vô hạn các phần tử:

    range = 1..Float::INFINITY
    p range.lazy.collect{|x| x*x}.first(10)
    => [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

Việc gọi lazy sẽ sinh ra đối tượng Enumerator::Lazy đầu tiên. Sau đó khi gọi collect trên đối tượng đầu tiên này method Enumerator::Lazy#collect sinh ra đối tượng Enumerator::Lazy thứ hai:

lazy-chain.png

Ta có thể thấy rằng, với Enumerator::Lazy thứ hai, được tạo bằng lời gọi Enumerator::Lazy#collect cũng gọi my block x*x. Enumerator::Lazy đã sử dụng hai đối tượng Generator và Yielder, Generator gọi each để get dữ liệu và ngay lập tức dữ liệu được truyền vào trong một block đặc biệt:

lazy-details.png

Hãy quan sát kĩ hơn lược đồ bên trên, block này đã thực hiện method Enumerator::Lazy#collect. Ruby thực thi nó bên trong sử dụng code C, nhưng block này tương đương với Ruby code:

    do |y, value|
      result = yield value
      y.yield result
    end

Ở đoạn code trên, block này mang một yielder và một value. Sau đó, nó yield giá trị đến một block khác(x * x). Sau đó block Enumerator::Lazy#collect gọi yielder truyền đi kết quả của my block.

Đây chính là điểm mấu chốt của lazy evaluation trong Ruby. Mỗi giá trị từ dữ liệu nguồn được yield đến my block. Sau đó, kết quả được truyền ngay lập tức tới Enumeration. Method Enumerator::Lazy#collect không collect giá trị vào trong một mảng. Thay vào đó, mỗi giá trị được truyền một lần, tại một thời điểm mà đối tượng Enumerator::Lazy tóm thông qua việc gọi yield.

lazy-chain2.png

VII, Lazy evaluation: executing code backwards

Tại sao chuỗi này là một lazy evaluation. Tại sao Ruby tránh được lặp vô hạn và output được những giá trị mà ta cần. Chính là method .first(10) sẽ quyết định vòng lặp của ta sẽ chạy đến khi nào.

    range = 1..Float::INFINITY
    p range.lazy.collect{|x| x*x}.first(10)
    => [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

lazy-chain-end.png

Sau khi method Enumerable#first nhận đủ 10 giá trị. Nó sẽ dừng vòng lặp bằng việc raise một exception. Nói cách khác, method cuối(ở bên phải) thực sự điều khiển luồng xử lý. Enumerable#first vừa bắt đầu vòng lặp bằng lời gọi each trên lazy enumerator và cũng kết thúc vòng lặp bằng việc raise ra một exception khi nó đủ giá trị.

VIII, Lời kết

Trên đây là những điều bên trong làm việc khi ta gọi Enumerator::Lazy làm việc. Cách khai báo, sử dụng trong những project cụ thể sẽ được viết trong bài viết tiếp theo. Thanks for read!

Nguồn: http://patshaughnessy.net/2013/4/3/ruby-2-0-works-hard-so-you-can-be-lazy

0