Những dòng code ruby được thực thi như nào?

Là một ruby deverloper nhưng có bao giờ bạn tự hỏi bản thân mình rằng, một đoạn Ruby code như bên dưới được thực thi như thế nào không?

puts 2 + 3
# => 5

Đoạn code trên được Ruby đọc và chuyển hóa bao nhiêu lần trước khi nó được thực thi?

Chính xác là ba lần. Dù bạn chạy một đoạn code siêu đơn giản như trên, một Rails app vĩ đại hay một rake task, code của bạn đều được Ruby tách thành những phần nhỏ (tí hon) và chuyển hóa thành những format khác nhau tổng cộng ba lần. Khoảng thời gian từ khi bạn gõ lệnh ruby -e "puts 2 + 3" đến khi bạn thấy số 5 được hiện ra trên console, đó thật sự là quá trình dài mà rất nhiều kĩ thuật, công nghệ và thuật toán được dùng đến.

Đầu tiên Ruby tokenize đoạn code của bạn thành những token. Tiếp theo nó parse những token đó thành Abstract Syntax Tree (AST) Node Sau đó compile (biên dịch) thành bytecodes là một tập các lệnh thực thi cấp thấp (tuy nhiên không phải là mã máy). Đây chính là tập lệnh được chạy trong máy áo Ruby (Ruby Virtual Machine).

Tokenizing

Thuật toán tokenize

Ở chương này mình sẽ nói kĩ về phần tokenizing.

Giả sử bạn có một đoạn Ruby code sau

simple.rb

10.times do |n|
  puts n
end

$ ruby simple.rb

Đầu tiên Ruby sẽ đọc file simple.rb và split nó thành những kí tự.

[1] [0] [.] [t] [i] [m] [e] [s] [ ] [d] [o] [|] [n] [|] Ta có một con trỏ đọc từng kí tự của hàng đầu tiên. Ruby nhận ra rằng số 1 là bắt đầu của một số, nó sẽ tiếp tục đọc cho đến khi trỏ đến một kí tự không phải số.

 *
[1] [0] [.] [t] [i] [m] [e] [s] [ ] [d] [o] [|] [n] [|]

Tiếp theo con trỏ đến số 0, vẫn là một con số, nên nó nhảy đến kí tự tiếp theo

     *
[1] [0] [.] [t] [i] [m] [e] [s] [ ] [d] [o] [|] [n] [|]

Ruby nhận ra rằng . vẫn có thể là một phần của số thực và nhảy đến kí tự tiếp theo.

         *
[1] [0] [.] [t] [i] [m] [e] [s] [ ] [d] [o] [|] [n] [|]

Đến khi gặp kí tự t, đây không phải là một phần của một con số, đồng thời kết luận không còn con số nào sau dấu . lúc nãy nữa, Ruby nhận thấy rằng dấu . đó là thể là một phần của token khác, nên trỏ ngược về thêm một kí tự.

             *
[1] [0] [.] [t] [i] [m] [e] [s] [ ] [d] [o] [|] [n] [|]
         *
[1] [0] [.] [t] [i] [m] [e] [s] [ ] [d] [o] [|] [n] [|]

Lúc này Ruby sẽ convert những kí tự số nó đã đi qua thành (tINTERGER) token.

            *
(tINTEGER) [.] [t] [i] [m] [e] [s] [ ] [d] [o] [|] [n] [|]

Ruby trỏ tiếp đến kí tự tiếp theo và convert kí tự nó vừa đi qua dấu . thành token (.)

                *
(tINTEGER) (.) [t] [i] [m] [e] [s] [ ] [d] [o] [|] [n] [|]

Và như thế Ruby lướt qua times, và convert nó thành token (tIDENTIFIER)

                              *
(tINTEGER) (.) (tIDENTIFIER) [d] [o] [|] [n] [|]

Note: IDENTIFER không phải là reserved keyword (từ khóa) trong Ruby, nó dùng để chỉ một biến (var), một hàm (function), hay một method.

Tiếp theo Ruby đọc qua do và nhận ra đây là reserved keyword (keyword_do)

                                           *
(tINTEGER) (.) (tIDENTIFIER) (keyword_do) [|] [n] [|]

Và rồi, Ruby cũng token xong hàng thứ nhất của đoạn code Ruby của chúng ta.

                                           *
(tINTEGER) (.) (tIDENTIFIER) (keyword_do) (|) (tIDENTIFIER) (|)

Ruby parsing

Sau khi tokenize đoạn code Ruby của bạn thành một đống token rồi, Ruby sẽ tiến hành parse các token đó thành các câu, cụm có nghĩa với nó.

Như mọi ngôn ngữ khác, Ruby sử dụng một parser generator tên là Bison .

Ở hình trên, Bison sẽ sinh ra parser parse.c thông qua bộ luật được định nghĩa ở parse.y, sau đó parse.c sẽ thực hiện parse code Ruby thành các AST Nodes và biên dịch nó thành byte code, để máy ảo Ruby có thể thực thi.

LALR Algorithm

Ta sẽ tìm hiểu kĩ hơn về thuật toán parsing được sử dụng trong ngôn ngữ Ruby LALR (Look Ahead Left-reserved Right-most deviration, thông qua ví dụ nho nhỏ sau đây.

Giả sử bạn có câu.

Tôi ăn nhưng tôi không làm. và một bộ luật sau để kiểm tra độ đúng đắn của nó.

(1) Sentence -> Phrase Conjunction Phrase
(2) Phrase -> PositivePhrase | NegativePhrase 
(3) PositivePhrase -> Subject Verb
(4) NegativePhrase -> Subject không Verb
(5) Subject -> "Tôi"
(6) Verb -> "ăn" | "làm"
(7) Conjunction -> "không"

Cách parse câu "Tôi ăn nhưng tôi không làm" theo LALR là như sau:

Cho [] bên trái là một cái grammar stack, ta có

[] | Tôi ăn nhưng tôi không làm Đầu tiên "Tôi" được cho vào stack

[Tôi] | ăn nhưng tôi không làm Match "Tôi" với luật (5) ta có

[Subject] | ăn nhưng tôi không làm Sau đó ta không match với với luật nào tiếp cả, ta đọc token tiếp theo, lúc này token ngay top của stack là "ăn".

[Subject ăn] | nhưng tôi không làm "ăn sẽ được match với luật (6), và Subject Verb chính là luật (3) PositivePhrase, mà PositivePhrase thì có thể sinh được Phrase

[Subject Verb] | nhưng tôi không làm

[PositivePhrase] | nhưng tôi không làm

[Phrase] | nhưng tôi không làm Sau đó thì ta không triệt tiêu (derive) được luật nào nữa cả, nên đọc tiếp ta match được "but" với luật (7) Conjunction

[Phrase nhưng] | tôi không làm

[Phrase Conjunction] | tôi không làm Sau đó là ta tiếp tục match nhanh được "tôi không làm"

[Phrase Conjunction NegativePhrase] |

[Phrase Conjunction Phrase] | Và ta có được production mong muốn là luật (1) Sentence, câu "Tôi ăn nhưng không không làm" được chấp thuận với bộ luật trên.

Ruby Compilation

Như mình đã giới thiệu ở phần đầu, Ruby compile code Ruby thành bytecode, còn gọi là YARV (Yet Another Ruby VM) instructions, và được thực thi ở YARV. Ở phần này chúng ta sẽ tìm hiểu quá trình compile đó diễn ra như thế nào?

Cấu trúc của YARV instruction

YARV bản chất là một Stack-oriented VM, nói một cách khác YARV nó sử dụng một value stack (chứa các args và giá trị trả về) để thực thi "YARV instruction" (từ đây mình sẽ gọi là instruction cho nhanh), nên các instruction được xây dựng tận dụng stack này (push giá trị tính được vào stack hoặc pop giá trị cần tìm ra khỏi stack).

Ruby là một ngôn ngữ OOP hoàn toàn, tất cả các lệnh gọi đều có dạng receiver.method(arguments), vì thế instructions được sinh ra luôn đi theo nguyên tắc sau.

  • Đẩy receiver vào stack.
  • Đẩy arguments vào stack.
  • Đẩy method vào stack. Ví dụ với lệnh 5 + 4, YARV instructions được sinh ra như sau:
putobject   5
putobject   4
opt_plus    <callinfo!mid:+, argc:1, ARGS_SKIP>

ARGS_SKIP ở đây nhằm giúp YARV biết được tham số được truyền vào là những giá trị đơn giản (không phải là block hay một array các tham số).

Và với lệnh puts 9, YARV instructions được sinh ra như sau:

putself
putobject           9
opt_send_simple     <callinfo!mid:puts, argc:1, FCALL|ARGS_SKIP>

NOTE: Như ta đã biết puts là một lệnh từ module Kernel mà bất kì một Ruby class nào cũng include, nên receiver ở đây được hiểu là self, đó là lý đó vì sao lệnh putself được đưa vào vị trí receiver ở trên. Để xuất YARV instruction như trên bạn có thể dùng lệnh

RubyVM::InstructionSequence.compile("your-ruby-code-goes-here").diasam

Local Table

Khi compiler chạy, thông tin về các biến, tham số được Ruby lưu ở một nơi khác gọi là Local Table.

Giá sử ta có một đoạn code Ruby sau:

a = "Viet"
b = "Nam"
puts a + b

Để sử dụng local table Ruby dùng hai lệnh setlocal (dùng để gán) và getlocal (dùng để tham chiếu)

Với đoạn code trên đầu tiên, áp dụng kiến thức ở phần trên và phần này, ta có instruction như sau.

# YARV Instructions                                 # Local Table

putstring   "Viet"
setlocal    3                                                  [3] a
# Tương tự Với b
putstring   "Nam"
setlocal    2                                                  [2] b
putself
getlocal    3
getlocal    2
send        :+
send        :puts
leave

Scoping

Ở 2 mục trên ta đã tìm hiểu các YARV instruction đơn giản và local table, ở mục này ta sẽ tìm hiểu compiler sẽ làm gì với các lệnh Ruby bao gồm scope (như block với tham số).

10.times do |n|
  puts n
end

Cách Ruby làm là nó sẽ chia đoạn code trên thành 2 block khác nhau (với 2 local table khác nhau), mình tạm gọi là outer block gồm 10.times { ... } và inner block gồm { |n| puts n }

# YARV Instructions                                                     # Local Table
putobject   10
send        <callinfo!mid:times, argc:0, block:block in <compiled>>
# Send đến method :times, không tham số với block (inner block bên dưới)
# YARV Instructions                             # Local Table
#                                               [2] n<Arg>
# putself
# getlocal  2
# send      :puts

<Arg> ở trên để đánh dấu cho YARV biết rằng n là một tham số thông thường.

Ngoài ra còn một số kí hiệu cho các loại tham số khác như:

<Arg>: như trên, dành cho tham số thông thường. Ví dụ: def foo(a)
<Rest>: dành cho các tham số dạng argument (splat *). Ví dụdef foo(*args)
<Post>: dành cho các tham số sau splat arguments. Ví dụ: def foo(*args, a)
<Block>: dành cho các tham số block được truyền vào bằng kí hiệu &. Ví dụ def foo(&block)
<Opt=i>: dành cho các tham số có giá trị mặc định. Ví dụ def foo(a=1)

Kết luận

Còn khá nhiều điều khá thú vị về các lệnh bytecode của Ruby mà một bài viết như thế này không đủ để cover hết, chúng ta sẽ tìm hiểu trong phần tiếp theo của bài viết.