Càng hiểu rõ tool của bạn, bạn càng đưa ra quyết định tốt hơn như developer. Thường hữu ích — nhất là khi debug performance issues — để hiểu Ruby thực sự làm gì khi chạy chương trình.
Trong bài này, chúng ta sẽ theo chân hành trình của một chương trình đơn giản khi nó được lexed, parsed và compile thành bytecode. Chúng ta sẽ dùng tool Ruby cung cấp để mồi interpreter mỗi bước đường đi.
Đừng lo — ngay cả bạn không phải expert, bài này cũng dễ theo dõi. Nó giống guided tour hơn technical manual.
Gặp gỡ chương trình mẫu
Làm ví dụ, tôi dùng một if/else statement đơn lẻ. Để tiết kiệm không gian, tôi viết dùng ternary operator. Đừng bị lừa, nó chỉ là if/else.
x > 100 ? 'foo' : 'bar'
Như bạn sẽ thấy, ngay cả chương trình đơn giản này cũng được dịch thành khá nhiều data khi được xử lý.
Lưu ý: Tất cả ví dụ trong bài viết bằng Ruby (MRI) 2.2. Nếu bạn dùng implementation Ruby khác, chúng có thể không chạy.
Tokenizing
Trước khi Ruby interpreter chạy chương trình, nó phải chuyển từ ngôn ngữ lập trình tự do-form thành data có cấu trúc hơn.
Bước đầu có thể là chia chương trình thành chunks. Chunks này gọi là tokens.
# This is a string "x > 1" # These are tokens ["x", ">", "1"]
Ruby standard library cung cấp module Ripper cho phép xử lý Ruby code giống như Ruby interpreter.
Trong ví dụ dưới, chúng ta dùng phương thức tokenize trên Ruby code. Như bạn thấy, nó trả về array của tokens.
require 'ripper' Ripper.tokenize("x > 1 ? 'foo' : 'bar'") # => ["x", " ", ">", " ", "1", " ", "?", " ", "'", "foo", "'", " ", ":", " ", "'", "bar", "'"]
Tokenizer khá ngốc. Bạn có thể feed nó Ruby hoàn toàn invalid và nó vẫn tokenize.
# bad code Ripper.tokenize("1var @= \/foobar`") # => ["1", "var"]
Lexing
Lexing là bước tiến hơn tokenization. String vẫn bị chia thành tokens, nhưng data thêm được thêm vào tokens.
Trong ví dụ dưới, chúng ta dùng Ripper Lex chương trình nhỏ. Như bạn thấy, nó giờ tag mỗi token là identifier :on_ident, operator :on_op, integer :on_int, v.v..
require 'ripper' require 'pp' pp Ripper.lex("x > 100 ? 'foo' : 'bar'") # [[[1, 0], :on_ident, "x"], # [[1, 1], :on_sp, " "], # [[1, 2], :on_op, ">"], # [[1, 3], :on_sp, " "], # [[1, 4], :on_int, "100"], # [[1, 5], :on_sp, " "], # [[1, 6], :on_op, "?"], # [[1, 7], :on_sp, " "], # [[1, 8], :on_tstring_beg, "'"], # [[1, 9], :on_tstring_content, "foo"], # [[1, 12], :on_tstring_end, "'"], # [[1, 13], :on_sp, " "], # [[1, 14], :on_op, ":"], # [[1, 15], :on_sp, " "], # [[1, 16], :on_tstring_beg, "'"], # [[1, 17], :on_tstring_content, "bar"], # [[1, 20], :on_tstring_end, "'"]]
Vẫn chưa có syntax checking thực sự. Lexer sẽ vui vẻ xử lý code invalid.
Parsing
Bây giờ Ruby đã chia code thành chunks manageable, parsing bắt đầu.
Trong giai đoạn parsing, Ruby chuyển text thành cái gọi là abstract syntax tree, hay AST. AST là biểu diễn chương trình của bạn trong memory.
Bạn có thể nói ngôn ngữ lập trình nói chung chỉ là cách user-friendly hơn để mô tả abstract syntax trees.
require 'ripper' require 'pp' pp Ripper.sexp("x > 100 ? 'foo' : 'bar'") # [:program, # [[:ifop, # [:binary, [:vcall, [:@ident, "x", [1, 0]]], :>, [:@int, "100", [1, 4]]], # [:string_literal, [:string_content, [:@tstring_content, "foo", [1, 11]]]], # [:string_literal, [:string_content, [:@tstring_content, "foobar", [1, 19]]]]]]]
Có thể không dễ đọc output này, nhưng nếu nhìn đủ lâu, bạn có thể thấy nó map như thế nào tới chương trình gốc.
# Định nghĩa chương trình [:program, # Làm "if" operation [[:ifop, # Check conditional (x > 100) [:binary, [:vcall, [:@ident, "x", [1, 0]]], :>, [:@int, "100", [1, 4]]], # Nếu true, trả về "foo" [:string_literal, [:string_content, [:@tstring_content, "foo", [1, 11]]]], # Nếu false, trả về "bar" [:string_literal, [:string_content, [:@tstring_content, "foobar", [1, 19]]]]]]]
Lúc này, Ruby interpreter biết chính xác bạn muốn nó làm gì. Nó có thể chạy chương trình ngay bây giờ. Và trước Ruby 1.9, nó sẽ làm vậy. Nhưng bây giờ, còn một bước nữa.
Compile thành bytecode
Thay vì traverse AST trực tiếp, hiện tại Ruby compile AST thành bytecode cấp thấp hơn.
Bytecode này sau đó chạy bởi Ruby virtual machine.
Chúng ta có thể nhìn vào inner workings của virtual machine qua class RubyVM::InstructionSequence. Trong ví dụ dưới, chúng ta compile chương trình mẫu rồi disassemble để human readable.
puts RubyVM::InstructionSequence.compile("x > 100 ? 'foo' : 'bar'").disassemble # == disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>========== # 0000 trace 1 ( 1) # 0002 putself # 0003 opt_send_without_block <callinfo!mid:x, argc:0, FCALL|VCALL|ARGS_SIMPLE> # 0005 putobject 100 # 0007 opt_gt <callinfo!mid:>, argc:1, ARGS_SIMPLE> # 0009 branchunless 15 # 0011 putstring "foo" # 0013 leave # 0014 pop # 0015 putstring "bar" # 0017 leave
Whoa! Điều này trông giống assembly language hơn Ruby. Hãy step through và xem có hiểu được không.
# Gọi method `x` trên self và lưu kết quả trên stack 0002 putself 0003 opt_send_without_block <callinfo!mid:x, argc:0, FCALL|VCALL|ARGS_SIMPLE> # Đặt số 100 lên stack 0005 putobject 100 # Làm so sánh (x > 100) 0007 opt_gt <callinfo!mid:>, argc:1, ARGS_SIMPLE> # Nếu so sánh false, nhảy tới line 15 0009 branchunless 15 # Nếu so sánh true, trả về "foo" 0011 putstring "foo" 0013 leave 0014 pop # Đây line 15. Nhảy về đây nếu so sánh false. Trả về "bar" 0015 putstring "bar" 0017 leave
Ruby virtual machine (YARV) sau đó step qua các instruction này và execute chúng. Đó là tất cả!
Kết luận
Đây kết thúc tour rất đơn giản, cartoon của Ruby interpreter. Với tool tôi show ở đây, có thể loại bỏ nhiều sự đoán mò về cách Ruby interpret chương trình. Tôi nói, không có gì cụ thể hơn AST. Và lần sau bạn băn khoan về performance issue kỳ lạ, thử nhìn bytecode. Nó có thể không giải quyết vấn đề, nhưng có thể làm bạn quên nó. :)