해당 포스트는 아래의 블로그를 읽고 번역한 글입니다.
The Structure and Interpretation of Ruby Programs
루비 프로그램이 실행될 때마다 프로그램은 먼저 토큰으로 변환되고 토큰은 Abstract Syntax Tree로 결합되며 마지막으로 이 AST는 가상머신 명령어로 컴파일 된다. 아래에서 이 세가지 단계에 대해 좀 더 깊게 얘기해볼 예정이다.
먼저 예를 들어보자.
class Math
def add(x, y)
x + y
end
end
Math.new.add(1, 2)
루비 프로그램 돌릴때마다 루비는 문자들을 한 번에 하나씩 훑고 토큰이라 불리는 특변한 단어로 묶는다. 이 토큰들은 유효한 루비라고 보장되지는 않는다. 이 시점에서는 토큰 스트림은 유효하지 않을 수도 있다. 입력된 프로그램이 유효한지 아닌지를 결정하는 것은 파서의 몫이다.
리퍼라는 빌트인툴을 사용해서 루비의 토크나이제이션이 어떻게 프로그램을 다루는지 직접 볼 수 있다. 아래를 보자.
require 'ripper'
require 'pp'
code = <<CODE
def add(x, y)
x + y
end
CODE
pp Ripper.lex(code)
결과는 아래와 같다.
[[[1, 0], :on_kw, "def"],
[[1, 3], :on_sp, " "],
[[1, 4], :on_ident, "add"],
[[1, 7], :on_lparen, "("],
[[1, 8], :on_ident, "x"],
[[1, 9], :on_comma, ","],
[[1, 10], :on_sp, " "],
[[1, 11], :on_ident, "y"],
[[1, 12], :on_rparen, ")"],
[[1, 13], :on_ignored_nl, "\n"],
[[2, 0], :on_sp, " "],
[[2, 2], :on_ident, "x"],
[[2, 3], :on_sp, " "],
[[2, 4], :on_op, "+"],
[[2, 5], :on_sp, " "],
[[2, 6], :on_ident, "y"],
[[2, 7], :on_nl, "\n"],
[[3, 0], :on_kw, "end"],
[[3, 3], :on_nl, "\n"]]
require 'ripper'
require 'pp'
code = <<CODE
def add(x, y)
x + y
end
CODE
pp Ripper.sexp(code)
각각의 네스트된 배열의 결과는 싱글 토큰을 나타낸다. 배열의 구조는 아래와 같다.
[[line number, text column], token name, characters]
토큰네임이라는 심볼은 토큰타입으로 맵핑되는데 이 토큰 타입은 루비 소스코드의 "parse.y"라는 파일 안에 정의되어있다.
리퍼라는 툴은 사실 완벽하게 각 토큰을 맵핑해주지는 못하지만 적어도 루비가 어떻게 토크나이제이션을 하는지에 대한 인사이트를 제공하기엔 충분하다.
제일 중요한 것은 토크나이제이션은 각 문자별로 스트리밍 방식으로 작동한다는 점이다.
Parsing
루비가 텍스트를 토큰의 스트림으로 변경한 후 토큰은 그제서야 루비가 이해할 수 있는 논리 단위로 그룹화된다. 이것이 파싱 단계이며 이때 프로그램은 유효한 코드인지 아닌지를 판단한다. 루비는 토큰의 스트림이 parse.y라는 파일에서 정의한 문법 규칙을 준수하고 있는지 판단함으로써 유효코드 여부를 가린다. 만약 유효하다면, 토큰 스트림은 해당하는 추상 구문 트리(Abstract Syntax Tree)로 변환된다.
AST는 루비 프로그램의 구문적 의미를 나타내는 데이터 구조이다. 위에서 사용한 리퍼툴로 AST가 무엇인지 간단히 살펴볼 수 있다.
require 'ripper'
require 'pp'
code = <<CODE
def add(x, y)
x + y
end
CODE
pp Ripper.sexp(code)
이에 대한 결과는
[:program,
[[:def,
[:@ident, "add", [1, 4]],
[:paren,
[:params,
[[:@ident, "x", [1, 8]], [:@ident, "y", [1, 11]]],
nil,
nil,
nil,
nil,
nil,
nil]],
[:bodystmt,
[[:binary,
[:var_ref, [:@ident, "x", [2, 2]]],
:+,
[:var_ref, [:@ident, "y", [2, 6]]]]],
nil,
nil,
nil]]]]
이와 같다. 이를 다시 시각화해보면
이렇게 트리구조로 나타낼 수 있다. 트리의 각 노드는 프로그램의 구성을 나타낸다. 예를 들어 두 식별자 x, y의 합은 이진 노드로 표현된다. 이 이진 노드는 세 번째 값을 도출하기 위해 두 식별자에 대해 수행되는 작업을 인코딩한다.
이 AST는 당신의 프로그램을 이해하기 위해 필요한 모든 것을 캡슐화하며 이를 통해 세번째와 최종 단계까지 넘어갈 수 있다.
Compilation
루비 역시 자바와 거의 같은 방식으로 컴파일된 언어이다. 루비는 네이티브 기계코드로 컴파일되는 것은 아니지만 일련의 바이트코드 명령어로 컴파일 되고 이는 가상머신에 의해 해석된다. 자바의 경우는 JVM이고 루비의 경우는 YARV("Yet Another Ruby Virtual-machine")이다.
당신의 프로그램을 컴파일 하기 위해 루비는 재귀적으로 AST에 있는 노드들을 위에서 아래로 돌며(iterate) 해당하는 YARV명령어로 컴파일한다. 다시 한 번 위의 툴을 사용하여 루비가 어떻게 AST를 YARV명령어로 컴파일 하는지 보자.
code = <<CODE
def add(x, y)
x + y
end
CODE
puts RubyVM::InstructionSequence.compile(code).disasm
아래는 그 결과다.
== disasm: #@>================================
0000 trace 1 ( 1)
0002 putspecialobject 1
0004 putobject :add
0006 putiseq add
0008 opt_send_without_block ,
0011 leave
== disasm: #>=======================================
local table (size: 2, argc: 2 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 2] x [ 1] y
0000 trace 8 ( 1)
0002 trace 1 ( 2)
0004 getlocal_OP__WC__0 4
0006 getlocal_OP__WC__0 3
0008 opt_plus ,
0011 trace 16 ( 3)
0013 leave ( 2)
YARV는 스택지향 가상머신이기 때문에 대부분의 명령어는 객체를 스택에 올린 다음 스택의 값에 대한 작업을 실행하는 것을 포함한다. 명령어의 최상단 블록은 add 메소드를 선언하기 위해 쓰인다. 기본적으로 명령어는 메소드 이름을 스택에 올린 후 새로운 루비 메소드를 만들기 위해 YARV에서 사용하는 "define_method"라는 C 함수를 호출한다. 두번째 블록에서는 로컬 테이블이 정의되며 이 테이블은 우리 함수가 받아들일 수 있는 argument를 나타낸다.
Conclusion
이 포스트를 통해 우리는 루비가 어떻게 프로그램의 텍스트를 번역하는지 알아봤다. 우리의 프로그램은 아래의 세 단계를 거치며 최종적으로 가상머신에서 사용가능한 명령어로 컴파일된다.
토큰 -> AST형태로 파싱 -> 가상머신, 즉 YARV에서 사용가능한 명령어로 컴파일
'TIL > RUBY' 카테고리의 다른 글
exception error handling (0) | 2021.01.24 |
---|---|
Encapsulation and Information Hiding (0) | 2021.01.23 |
Argument (0) | 2020.12.10 |
class method (0) | 2020.12.09 |
Symbol (0) | 2020.12.05 |