Como o Ruby lê e executa seu código

Artigos - 09/Jan/2023 - por André Kanamura

Introdução

Ruby é uma linguagem de programação que muitos programadores amam. O motivo? Além de possuir muitos métodos que facilitam o trabalho, a linguagem tem um conjunto de características chamadas de "Syntactic Sugar" que tornam a vida de quem escreve código mais agradável. Um exemplo disso é a presença do método for para iterar por elementos em coleções. O for está presente em muitas linguagens de programação, então isso facilita a transição de uma linguagem para outra. No entanto, a própria comunidade desencoraja o uso desse construto de linguagem, dando prioridade para o método each, que é utilizado com o mesmo objetivo, mas considerado mais legível. Além disso, internamente, o for é implementado em termos do próprio each. Para entender melhor o que isso significa, vamos precisar compreender como o Ruby lê e interpreta o seu código.

As três fases de transformação do código Ruby

Antes de executar o código que você escreve, o Ruby precisa ler e transformar essas linhas três vezes. Isso significa que, toda vez que um script Ruby roda, ele passa por um processo de três etapas que transforma seu código nas instruções que podem ser executadas: tokenização, parse e, finalmente, compilação.

Vamos falar resumidamente sobre essas etapas, mas se quiser ver mais detalhes, recomendamos conferir o livro Ruby Under a Microscope, que utilizamos como referência. Você também pode acompanhar executando o código localmente no seu computador, sendo apenas necessário ter a versão do Ruby 1.9 ou superior instalada. Para os exemplos deste artigo, utilizamos a versão 3.0.0.

Vamos começar com um script Ruby simples:

[1, 3].each do |e|
  p e
end

Executando o script acima temos:

1
3
# => [1, 3]

Ou seja, 1 e 3 são impressos na tela e o próprio array é retornado. O mesmo resultado pode ser obtido usando uma implementação com for:

for e in [1, 3] do
  p e
end

Antes de executar esse código, o interpretador Ruby precisa traduzi-lo e, para isso, ele lê os caracteres e vai “tokenizando” cada um deles, ou seja, ele converte as sequências em tokens ou palavras. Vamos considerar a linha abaixo:

Primeiro, o interpretador encontra um colchete. Em seguida, encontra um inteiro, seguido de uma vírgula, seguida de um espaço em branco, o que indica que outro elemento deve ser esperado e que o primeiro elemento deste array é um inteiro de um algarismo. E assim por diante. Passo a passo, o interpretador vai “tokenizando” esses caracteres, identificando o método each, a palavra chave do etc.

Se você quiser ver como o Ruby interpreta códigos diferentes, pode utilizar a classe chamada Ripper com o método lex, que permite visualizar o código tokenizado:

require 'ripper'
require 'pp'

code = <<STR
[1, 3].each do |e|
  p e
end
STR

puts code
pp Ripper.lex(code)

Executando o script acima temos:

[1, 3].each do |e|
  p e
end
[[[1, 0], :on_lbracket, "[", BEG|LABEL],
 [[1, 1], :on_int, "1", END],
 [[1, 2], :on_comma, ",", BEG|LABEL],
 [[1, 3], :on_sp, " ", BEG|LABEL],
 [[1, 4], :on_int, "3", END],
 [[1, 5], :on_comma, ",", BEG|LABEL],
 [[1, 6], :on_sp, " ", BEG|LABEL],
 [[1, 7], :on_int, "5", END],
 [[1, 8], :on_rbracket, "]", END],
 [[1, 9], :on_period, ".", DOT],
 [[1, 10], :on_ident, "each", ARG],
 [[1, 14], :on_sp, " ", ARG],
 [[1, 15], :on_kw, "do", BEG],
 [[1, 17], :on_sp, " ", BEG],
 [[1, 18], :on_op, "|", BEG|LABEL],
 [[1, 19], :on_ident, "e", ARG],
 [[1, 20], :on_op, "|", BEG|LABEL],
 [[1, 21], :on_ignored_nl, "\n", BEG|LABEL],
 [[2, 0], :on_sp, "  ", BEG|LABEL],
 [[2, 2], :on_ident, "p", CMDARG],
 [[2, 3], :on_sp, " ", CMDARG],
 [[2, 4], :on_ident, "e", END|LABEL],
 [[2, 5], :on_nl, "\n", BEG],
 [[3, 0], :on_kw, "end", END],
 [[3, 3], :on_nl, "\n", BEG]]

Cada linha é lida e traduzida. Os caracteres são identificados com sua posição e símbolo, por exemplo, [1, 10] indica linha 1, posição 10, e :on_ident o símbolo da sequência each. Nesta etapa, mesmo que o código apresente algum erro de sintaxe, ele ainda pode ser tokenizado. Usando o Ripper para ler o script que utiliza o for no lugar do each, temos:

for e in [1, 3] do
  p e
end
[[[1, 0], :on_kw, "for", BEG],
 [[1, 3], :on_sp, " ", BEG],
 [[1, 4], :on_ident, "e", CMDARG],
 [[1, 5], :on_sp, " ", CMDARG],
 [[1, 6], :on_kw, "in", BEG],
 [[1, 8], :on_sp, " ", BEG],
 [[1, 9], :on_lbracket, "[", BEG|LABEL],
 [[1, 10], :on_int, "1", END],
 [[1, 11], :on_comma, ",", BEG|LABEL],
 [[1, 12], :on_sp, " ", BEG|LABEL],
 [[1, 13], :on_int, "3", END],
 [[1, 14], :on_rbracket, "]", END],
 [[1, 15], :on_sp, " ", END],
 [[1, 16], :on_kw, "do", BEG],
 [[1, 18], :on_ignored_nl, "\n", BEG],
 [[2, 0], :on_sp, "  ", BEG],
 [[2, 2], :on_ident, "p", CMDARG],
 [[2, 3], :on_sp, " ", CMDARG],
 [[2, 4], :on_ident, "e", END|LABEL],
 [[2, 5], :on_nl, "\n", BEG],
 [[3, 0], :on_kw, "end", END],
 [[3, 3], :on_nl, "\n", BEG]]

Como já era esperado, os resultados são distintos, já que nessa etapa a única coisa que o Ruby faz é ler os caracteres e transformá-los em tokens.

Depois de “tokenizar” todo o script, o Ruby ainda precisa entender exatamente o que é necessário executar e, para isso, não basta apenas ler cada token em ordem. Como muitas linguagens de programação, o “parser” do Ruby contém uma série de regras gramaticais que são utilizadas para analisar e interpretar seu código. Da mesma maneira que o processo de tokenização, podemos usar a classe Ripper para ver como o “parser” faz isso. Podemos usar o mesmo script anterior, mas utilizamos o método sexp no lugar do lex:

require 'ripper'
require 'pp'

code = <<STR
[1, 3].each do |e|
  p e
end
STR

puts code
pp Ripper.sexp(code)

Rodando o script acima temos o seguinte resultado:

[1, 3].each do |e|
  p e
end
[:program,
 [[:method_add_block,
   [:call,
    [:array, [[:@int, "1", [1, 1]], [:@int, "3", [1, 4]]]],
    [:@period, ".", [1, 6]],
    [:@ident, "each", [1, 7]]],
   [:do_block,
    [:block_var,
     [:params, [[:@ident, "e", [1, 16]]], nil, nil, nil, nil, nil, nil],
     false],
    [:bodystmt,
     [[:command,
       [:@ident, "p", [2, 2]],
       [:args_add_block, [[:var_ref, [:@ident, "e", [2, 4]]]], false]]],
     nil,
     nil,
     nil]]]]]

Agora, já é possível começar a encontrar mais elementos que representam componentes de um script, como, por exemplo a seguinte linha, que identifica claramente um array com dois números inteiros, 1 e 3, sendo que 1 está na linha 1, na posição 1 e o 3 está na linha 1, posição 4:

[:array, [[:@int, "1", [1, 1]], [:@int, "3", [1, 4]]]]

Note que o símbolo :method_add_block indica que é feita uma chamada de método com um bloco de código. A título de comparação, podemos ver como fica a leitura do seguinte código:

code = <<STR
arr = []                                                                                                                                                                                                                             
arr << "Pedro"
STR

puts code
pp Ripper.sexp(code)
[:program,
 [[:assign, [:var_field, [:@ident, "arr", [1, 0]]], [:array, nil]],
  [:binary,
   [:var_ref, [:@ident, "arr", [2, 0]]],
   :<<,
   [:string_literal,
    [:string_content, [:@tstring_content, "Pedro", [2, 8]]]]]]]

Podemos ver a atribuição (:assign) de valor a uma variável (:var_field), identificada no trecho [:@ident, "arr", [1, 0]]], e é feita uma chamada de método :<< adicionando um elemento ao array.

A principal diferença dessa etapa para a anterior é a necessidade do interpretador conseguir entender a priorização das operações que serão executadas. E nesta fase do processo de leitura do código, erros de sintaxe geram falhas de leitura.

Após essas duas fases, o código está convertido em uma estrutura de dados em árvore sintática abstrata.

Finalmente, o código “tokenizado” e “parseado” vai passar por um compilador, o que significa que ele será traduzido para outra linguagem que é mais fácil para o computador entender. Diferente de outras linguagens, como Java e C, por exemplo, Ruby compila o código automaticamente sem nem percebermos. O compilador vai gerar instruções bytecode, chamadas instruções YARV, que serão interpretadas na máquina virtual Ruby (YARV).

Aproveitando a estrutura de dados em árvore, o compilador consegue entender a priorização de nós e vai adicionando os objetos e chamadas de métodos na pilha de execução.

Da mesma forma que nas etapas anteriores, podemos visualizar o resultado do código compilado usando o Ripper. O que é apresentado aqui difere do que é de fato processado no compilador em C, mas já permite uma visualização interessante. Inicialmente, vamos ver a compilação de um código mais simples:

code = <<STR
2 + 3
STR

puts code
puts RubyVM::InstructionSequence.compile(code).disasm 

E o resultado desse script é o seguinte:

2 + 3
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,5)> (catch: FALSE)
0000 putobject                   2                         (   1)[Li]
0002 putobject                   3
0004 opt_plus                    <calldata!mid:+, argc:1, ARGS_SIMPLE>
0006 leave

Na ordem, a primeira instrução envia um objeto (o inteiro 2) para a pilha de execução, a segunda passa o argumento (outro inteiro 3). Finalmente, a terceira instrução envia o método + (mid:+, method id = +). Apenas para comparação, se trocarmos a operação para uma multiplicação, essa linha ficaria:

opt_mult                       <calldata!mid:*, argc:1, ARGS_SIMPLE>

Agora vamos compilar o script inicial com o bloco each:

[1, 3].each do |e|
  p e
end
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(3,3)> (catch: FALSE)
== catch table
| catch type: break  st: 0000 ed: 0005 sp: 0000 cont: 0005
| == disasm: #<ISeq:block in <compiled>@<compiled>:1 (1,12)-(3,3)> (catch: FALSE)
| == catch table
| | catch type: redo   st: 0001 ed: 0006 sp: 0000 cont: 0001
| | catch type: next   st: 0001 ed: 0006 sp: 0000 cont: 0006
| |------------------------------------------------------------------------
| local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
| [ 1] e@0<Arg>
| 0000 nop                                                       (   1)[Bc]
| 0001 putself                                                   (   2)[Li]
| 0002 getlocal_WC_0                          e@0
| 0004 opt_send_without_block   <calldata!mid:p, argc:1, FCALL|ARGS_SIMPLE>
| 0006 nop
| 0007 leave                                                     (   3)[Br]
|------------------------------------------------------------------------
0000 duparray                               [1, 3]               (   1)[Li]
0002 send                  <calldata!mid:each, argc:0>, block in <compiled>
0005 nop
0006 leave                                                       (   1)

Cada uma das linhas que começa com == disasm: indica um escopo, e cada escopo pode possuir sua própria tabela local (local table), com suas características. Para ficar mais fácil de visualizar, abaixo vamos mostrar apenas o escopo interno do bloco each:

 == disasm: #<ISeq:block in <compiled>@<compiled>:1 (1,12)-(3,3)> (catch: FALSE)
 == catch table
 | catch type: redo   st: 0001 ed: 0006 sp: 0000 cont: 0001
 | catch type: next   st: 0001 ed: 0006 sp: 0000 cont: 0006
 |------------------------------------------------------------------------
 local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
 [ 1] e@0<Arg>
 0000 nop                                                       (   1)[Bc]
 0001 putself                                                   (   2)[Li]
 0002 getlocal_WC_0                          e@0
 0004 opt_send_without_block   <calldata!mid:p, argc:1, FCALL|ARGS_SIMPLE>
 0006 nop
 0007 leave                                                     (   3)[Br]

Fica clara a chamada da variável e com getlocal_WC_0 e, em seguida, a chamada do método p. Ao final, leave indica o fim do bloco. A linha de “pipes” (|) dá uma indicação visual dos escopos.

Retornando no restante da compilação podemos ver a chamada do método each (send) no array [1, 3] (duparray), que abre o bloco de código anterior.

Agora que já conseguimos entender melhor como o Ruby lê nosso código, vamos ver como fica o script do exemplo inicial com for e compará-lo com o que acabamos de analisar, gerado do script com each:

for e in [1, 3] do
  p e
end
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(3,3)> (catch: FALSE)
== catch table
| catch type: break  st: 0000 ed: 0005 sp: 0000 cont: 0005
| == disasm: #<ISeq:block in <compiled>@<compiled>:1 (1,0)-(3,3)> (catch: FALSE)
| == catch table
| | catch type: redo   st: 0005 ed: 0010 sp: 0000 cont: 0005
| | catch type: next   st: 0005 ed: 0010 sp: 0000 cont: 0010
| |------------------------------------------------------------------------
| local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
| [ 1] ?@0<Arg>
| 0000 getlocal_WC_0                          ?@0                (   1)
| 0002 setlocal_WC_1                          e@0
| 0004 nop                                    [Bc]
| 0005 putself                                                   (   2)[Li]
| 0006 getlocal_WC_1                          e@0
| 0008 opt_send_without_block   <calldata!mid:p, argc:1, FCALL|ARGS_SIMPLE>
| 0010 nop
| 0011 leave                                                     (   3)[Br]
|------------------------------------------------------------------------
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] e@0
0000 duparray                               [1, 3]               (   1)[Li]
0002 send                  <calldata!mid:each, argc:0>, block in <compiled>
0005 nop
0006 leave                                                       (   1)

Para facilitar a comparação, Vamos nos concentrar nas linhas abaixo:

|------------------------------------------------------------------------
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] e@0
0000 duparray                               [1, 3]               (   1)[Li]
0002 send                  <calldata!mid:each, argc:0>, block in <compiled>
0005 nop
0006 leave                                                       (   1)

Note como a definição do array e a chamada do método é exatamente igual ao script com each, ou seja, a chamada do for, apesar de ser “tokenizado” e interpretado como um for, quando compilado, é traduzido como um each! Mas nem tudo ficou igual! Abaixo, vamos colocar o trecho do bloco each compilado para comparação:

|------------------------------------------------------------------------
0000 duparray                               [1, 3]               (   1)[Li]
0002 send                  <calldata!mid:each, argc:0>, block in <compiled>
0005 nop
0006 leave   

Perceba que não há tabela local, nem definição de variável e. Isso significa que, quando usamos o for, ele cria uma variável fora do seu escopo e que será usada internamente no bloco. E isso tem implicações na forma como seu código será executado. Vamos ver um exemplo:

arr = [1, 2, 3]
for elem in arr do
  puts elem
end

# elem está acessível fora do escopo do for
elem # => 3
arr = [1, 2, 3]
arr.each { |elem| puts elem }

# elem não está acessível fora do escopo each
elem # => NameError: undefined local variable or method `elem'

Isso significa que o for tem comportamento diferente do each e pode afetar variáveis que já estejam definidas fora do seu escopo com o mesmo nome. Por exemplo:

i = "a"

for i in [2,3] do
  p i
end
2
3
# => [2, 3] 

i # => 3 
i = "a"

[2, 3].each { |i| p i }
2
3
# => [2, 3] 

i # => "a" 

Se quiser ver um exemplo um pouco mais complexo desse efeito colateral de usar o for em Ruby, recomenda-se a leitura do artigo “The Evils of the For Loop”.

Conclusão

Neste artigo fizemos um resumo das três fases do processo de leitura do código Ruby até chegar na execução das instruções. Isso nos permite compreender melhor como a linguagem funciona e, potencialmente, nos ajuda a evoluir como pessoa programadora de uma forma geral.

Além disso, conseguimos ver internamente como o Ruby processa o for, compreendendo que ele depende do each e que utilizá-lo pode gerar efeitos colaterais inesperados no seu código.

Leitura adicionais:

Referências:

Foto de perfil do autor
André Kanamura

Dev na Campus Code