도구에 대해 더 많이 알수록 개발자로서 더 나은 결정을 내릴 수 있습니다. Ruby가 프로그램을 실행할 때 실제로 수행하는 작업을 이해하는 것은 특히 성능 문제를 디버깅할 때 종종 유용합니다.
이 포스트에서 우리는 바이트코드로 렉싱되고, 파싱되고, 컴파일되는 간단한 프로그램의 여정을 따를 것입니다. 우리는 Ruby가 제공하는 도구를 사용하여 모든 단계에서 인터프리터를 감시할 것입니다.
걱정하지 마십시오. 전문가가 아니더라도 이 게시물은 따라하기가 매우 쉽습니다. 기술 설명서라기보다 가이드 둘러보기에 가깝습니다.
샘플 프로그램을 만나보세요
예를 들어 단일 if/else 문을 사용하겠습니다. 공간을 절약하기 위해 삼항 연산자를 사용하여 작성합니다. 그러나 속지 마십시오. 단지 if/else일 뿐입니다.
x > 100 ? 'foo' : 'bar'
보시다시피, 이와 같은 간단한 프로그램도 처리되는 동안 상당히 많은 데이터로 변환됩니다.
참고:이 게시물의 모든 예제는 Ruby(MRI) 2.2로 작성되었습니다. Ruby의 다른 구현을 사용하는 경우 작동하지 않을 수 있습니다.
토큰화
Ruby 인터프리터는 프로그램을 실행하기 전에 프로그램을 다소 자유로운 형식의 프로그래밍 언어에서 보다 구조화된 데이터로 변환해야 합니다.
첫 번째 단계는 프로그램을 청크로 나누는 것일 수 있습니다. 이러한 청크를 토큰이라고 합니다.
# This is a string
"x > 1"
# These are tokens
["x", ">", "1"]
Ruby 표준 라이브러리는 Ruby 인터프리터와 거의 동일한 방식으로 Ruby 코드를 처리할 수 있는 Ripper라는 모듈을 제공합니다.
아래 예제에서는 Ruby 코드에서 tokenize 메서드를 사용하고 있습니다. 보시다시피 토큰 배열을 반환합니다.
require 'ripper'
Ripper.tokenize("x > 1 ? 'foo' : 'bar'")
# => ["x", " ", ">", " ", "1", " ", "?", " ", "'", "foo", "'", " ", ":", " ", "'", "bar", "'"]
토크나이저는 꽤 멍청합니다. 완전히 잘못된 Ruby를 제공할 수 있으며 여전히 토큰화됩니다.
# bad code
Ripper.tokenize("1var @= \/foobar`")
# => ["1", "var"]
렉싱
렉싱은 토큰화를 넘어선 한 단계입니다. 문자열은 여전히 토큰으로 분할되지만 토큰에 추가 데이터가 추가됩니다.
아래 예에서 우리는 작은 프로그램을 Lex하기 위해 Ripper를 사용하고 있습니다. 보시다시피 이제 각 토큰에 :on_ident
식별자로 태그를 지정합니다. , 연산자 :on_op
, 정수 :on_int
등
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, "'"]]
이 시점에서 진행 중인 실제 구문 검사는 아직 없습니다. 렉서는 유효하지 않은 코드를 기꺼이 처리합니다.
파싱
이제 Ruby가 코드를 관리하기 쉬운 덩어리로 나누었으므로 구문 분석을 시작할 때입니다.
구문 분석 단계에서 Ruby는 텍스트를 추상 구문 트리 또는 AST로 변환합니다. 추상 구문 트리는 메모리에 있는 프로그램의 표현입니다.
일반적으로 프로그래밍 언어는 추상 구문 트리를 설명하는 보다 사용자 친화적인 방법이라고 말할 수 있습니다.
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]]]]]]]
이 출력을 읽기가 쉽지 않을 수도 있지만 충분히 오래 응시하면 원본 프로그램에 어떻게 매핑되는지 알 수 있습니다.
# Define a progam
[:program,
# Do an "if" operation
[[:ifop,
# Check the conditional (x > 100)
[:binary, [:vcall, [:@ident, "x", [1, 0]]], :>, [:@int, "100", [1, 4]]],
# If true, return "foo"
[:string_literal, [:string_content, [:@tstring_content, "foo", [1, 11]]]],
# If false, return "bar"
[:string_literal, [:string_content, [:@tstring_content, "foobar", [1, 19]]]]]]]
이 시점에서 Ruby 인터프리터는 원하는 작업이 무엇인지 정확히 알고 있습니다. 지금 바로 프로그램을 실행할 수 있습니다. Ruby 1.9 이전에는 그랬을 것입니다. 하지만 이제 한 단계 더 남았습니다.
바이트코드로 컴파일
추상 구문 트리를 직접 탐색하는 대신 오늘날 Ruby는 추상 구문 트리를 하위 수준 바이트 코드로 컴파일합니다.
이 바이트 코드는 Ruby 가상 머신에 의해 실행됩니다.
RubyVM::InstructionSequence
를 통해 가상 머신의 내부 작동을 엿볼 수 있습니다. 수업. 아래 예에서는 샘플 프로그램을 컴파일한 다음 사람이 읽을 수 있도록 디스어셈블합니다.
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
와! 이것은 갑자기 Ruby보다 어셈블리 언어처럼 보입니다. 단계별로 살펴보고 이해할 수 있는지 살펴보겠습니다.
# Call the method `x` on self and save the result on the stack
0002 putself
0003 opt_send_without_block <callinfo!mid:x, argc:0, FCALL|VCALL|ARGS_SIMPLE>
# Put the number 100 on the stack
0005 putobject 100
# Do the comparison (x > 100)
0007 opt_gt <callinfo!mid:>, argc:1, ARGS_SIMPLE>
# If the comparison was false, go to line 15
0009 branchunless 15
# If the comparison was true, return "foo"
0011 putstring "foo"
0013 leave
0014 pop
# Here's line 15. We jumped here if comparison was false. Return "bar"
0015 putstring "bar"
0017 leave
그런 다음 루비 가상 머신(YARV)은 이러한 지침을 단계별로 실행하고 실행합니다. 그게 다야!
결론
이것으로 Ruby 인터프리터에 대한 매우 간단하고 만화 같은 여행을 마칩니다. 여기에서 보여드린 도구를 사용하면 Ruby가 프로그램을 해석하는 방법에 대해 많은 추측을 할 수 있습니다. 내 말은, AST보다 더 구체적이지 않습니다. 그리고 다음에 이상한 성능 문제가 발생하면 바이트코드를 살펴보십시오. 그것은 아마도 당신의 문제를 해결하지 못할 것이지만, 당신의 마음을 빼앗을 수 있습니다. :)