O programador

Ascensão de Roslyn

Joe Hummel
Ted Neward

Ted NewardPor alguns anos, vários profissionais de computação, líderes de pensamentos e especialistas têm defendido a ideia de linguagens específicas de domínio (DSLs) como forma de aproximar soluções para problemas de software. Isso parece particularmente apropriado se a sintaxe DSL é algo que os “usuários casuais” podem usar para adaptar e modificar as regras de negócios em um sistema. Esse é o Santo Graal do software de muitos desenvolvedores - criando um sistema as pessoas podem manter por conta própria quando os seus negócios precisam mudar.

Uma das principais críticas do DSL, no entanto, é o fato de que escrever um compilador é uma “arte perdida”. Não é incomum para os programadores de todos os setores da vida olhar para a criação de um compilador ou intérprete como um tipo de Arte das Trevas.

Na conferência Build 2014 deste ano, a Microsoft anunciou formalmente um dos segredos mais mal guardados no ecossistema de desenvolvimento do Microsoft .NET Framework, o código-fonte do Roslyn. Esse é o sistema do compilador renovado/recompilado que é subjacente às linguagens C# e Visual Basic. Para alguns, essa é a chance da Microsoft de colocar suas linguagens nas comunidades de código-fonte aberto e colher os benefícios - correções de bug, melhorias, revisão pública de novos recursos de linguagem e assim por diante. Para os desenvolvedores, é uma oportunidade de olhar mais profundamente em como os compiladores (e intérpretes - embora Roslyn esteja focado na compilação dada às linguagens em questão) trabalham nos bastidores.

Para obter mais informações (e dicas de instalação), verifique a página de Roslyn CodePlex em roslyn.codeplex.com. Como sempre com os bits ainda não lançados, é altamente recomendado que você fala isso em uma máquina virtual ou uma máquina que você não se importe muito.

Conceitos básicos de Roslyn

Em um alto nível, o objetivo de um compilador é traduzir a entrada do programador (código-fonte) em saída executável, como um arquivo .NET assembly ou arquivo .exe nativo. Enquanto os nomes exatos para os módulos dentro de um compilador varia, geralmente pensamos em um compilador como partido em duas partes fundamentais: um front-end e back-end (ver Figura 1).

Design do Compilador de alto nível
Figura 1 Design do Compilador de alto nível

Uma das principais responsabilidades do front-end é verificar a precisão da formatação do código-fonte de entrada. Da mesma forma como acontece com todas as linguagens de programação, existem programadores de formato específicos que devem seguir para manter as coisas claras e sem ambiguidades para a máquina. Por exemplo, considere a seguinte instrução C#:

if x < 0   <-- syntax error!
  x = 0;

Isso está sintaticamente incorreto, pois se as condições devem estar entre ( ), então:

if (x < 0)
  x = 0;

Uma vez que o código é analisado, o back-end é responsável pela validação mais profunda da fonte, como as violações de segurança do tipo:

string x = "123";
if (x < 0)                   <-- semantic error!
  x = 0;                     <-- semantic error!

Aliás, esses exemplos são decisões de design deliberadas pelo implementador de linguagem. Eles são assunto de longos debates sobre quais são “melhores” do que outros. Para ouvir mais, visite qualquer fórum de programação online e digite, “D00d ur language sux”. Você se encontrará em breve em uma seção “educacional” que certamente será uma para lembrar.

Supondo que não existam erros sintáticos ou semânticos, a compilação continua e o back-end traduz a entrada em um programa equivalente na linguagem-alvo desejada.

Mais fundo nas intensidades

Embora você possa adotar a abordagem de duas partes com as linguagens mais simples, a maioria das vezes o compilador/intérprete da linguagem é dividido em muito mais do que isso. No nível seguinte de complexidade, a maioria dos compiladores se organizam para agir em sem fases maiores, duas no front-end e quatro no back-end (veja a Figura 2). 

As principais fases de um compilador
Figura 2 As principais fases de um compilador

O front-end realiza as duas primeiras fases: análise léxica e análise. O objetivo da análise léxica é ler o programa de entrada e a saída dos tokens - as palavras-chave, a pontuação, os identificadores e assim por diante. A localização de cada token também é mantida, de modo que o formato do programa não é perdido. Suponha que o seguinte fragmento do programa comece no início do arquivo de origem:

// Comment
if (score>100)
  grade = "A++";

A saída da análise léxica deve ser esta sequência de tokens:

IfKeyword                       @ Span=[12..14)
OpenParenToken              @ Span=[15..16)
IdentifierToken                  @ Span=[16..21), Value=score
GreaterThanToken             @ Span=[21..22)
NumericLiteralToken           @ Span=[22..25), Value=100
CloseParenToken              @ Span=[25..26)
IdentifierToken                  @ Span=[30..35), Value=grade
EqualsToken                    @ Span=[36..37)
StringLiteralToken             @ Span=[38..43), Value=A++
SemicolonToken               @ Span=[43..44)

Cada token carrega informações adicionais, como posição inicial e final (Span), conforme medida no início do arquivo de origem. Observe que o IfKeyword começa na posição 12. Isso é devido ao comentário que alcança [0..10) e os caracteres de fim de linha que alcançam [10..12). Embora tecnicamente sem tokens, a saída do analisador léxico inclui informações sobre espaço em branco, incluindo comentários. No compilador .NET, o espaço em branco é transmitido como desafio de sintaxe. 

A segunda fase do compilador é a análise. O analisador funciona corpo a corpo com o analisador léxico para realizar a análise sintática. O analisador faz a grande maioria do trabalho, solicitando tokens do analisador léxico conforme ele verifica o programa de entrada em relação às várias regras gramaticais do idioma de origem. Por exemplo, todos os programadores de C# conhecem a sintaxe de uma instrução IF:

if  (  condition  )  then-part  [ else-part ]

O [ … ] que simboliza a parte ELSE é opcional. O analisador aplica essa regra por correspondência de tokens e aplica as regras adicionais para elementos sintáticos mais complexos, como condição e parte THEN:

void if( )
{
  match(IfKeyword);
  match(OpenParenToken);
  condition();
  match(CloseParenToken);
  then_part();
  if (lookahead(ElseKeyword))
  else_part();
}

A função match(T) chama o analisador léxico para obter o próximo token e verifica para ver se este token corresponde ao T. A compilação continua normalmente se ela corresponde. Caso contrário, ela relata um erro de sintaxe. Os analisadores mais simples usam uma função correspondente para lançar uma exceção em cima de um erro de sintaxe. Isso efetivamente interrompe a compilação. Aqui está tal implementação:

void match(SyntaxToken T)
{
  var next = lexer.NextToken();
  if (next == T)
  ;  // Keep going, all is well:
  else
  throw new SyntaxError(...);
}

Para nossa sorte, o compilador .NET contém um analisador muito mais sofisticado. Ele é capaz de continuar diante de erros de sintaxe grosseiros.

Supondo que não existem erros de sintaxe, o front-end está essencialmente feito. Ele tem apenas uma tarefa restante - transmitir seus esforços para o back-end. A forma em que ele é armazenado internamente é conhecida como sua representação intermediária ou IR. (Apesar da semelhança na terminologia, um IR não tem nada a ver com a Linguagem intermediária Comum do .NET.) O analisador no compilador .NET compila uma árvore Abstract Syntax (AST) como o IR e transmite essa árvore para o back-end.

As árvores são um IR natural, dado a natureza hierárquica dos programas de C# e Visual Basic. Um programa que contém um ou mais classes. Uma classe contém propriedades e métodos, propriedades e métodos contém instruções, instruções geralmente contém blocos, e blocos contém instruções adicionais. A meta de um AST é representar o programa com base em sua estrutura sintática. O “abstrato” no AST denota a ausência de açúcar sintático, tais como ; e ( ). Por exemplo, considere a seguinte sequência das instruções C# (assuma estas compilações sem erro):

sum = 0;
foreach (var x in A)   // A is an array:
  sum += x;
avg = sum / A.Length;

Em um alto nível, o AST para este fragmento de código deve parecer como a Figura 3.

Alto nível da Árvore de Abstract Syntax para fragmento de código C#
Figura 3 Alto nível da Árvore de Abstract Syntax para fragmento de código C# (detalhes em falta para simplicidade)

O AST captura as informações necessárias sobre o programa: as instruções, a ordem das instruções, as partes de cada instrução, e assim por diante. A sintaxe desnecessária é descartada, como todos os pontos e vírgulas. O recurso principal para compreender sobre o AST na Figura 3 é que ele captura a estrutura sintática do programa.

Em outras palavras, é como o programa é escrito, não como ele executa. Considere a instrução foreach, onde zero loops ou mais vezes ele repete através da coleção. O AST captura os componentes da instrução foreach - a variável de loop, a coleção e o corpo. O que o AST não transmite é que o foreach pode repetir quantas vezes forem necessárias. Na verdade, se você olhar para a árvore, não existe nenhuma seta na árvore para significar o quanto o foreach executa. A única forma de saber é ao conhecer a senha foreach == loop.

Os ATSs são um ótimo IR, com uma vantagem principal: Eles são fáceis de compilar e compreender. A desvantagem é que as análises mais sofisticadas, como aquelas usadas na parte de trás do computador, são mais difíceis de realizar em um AST. Por essa razão, os compiladores geralmente mantém IRs múltiplos, incluindo uma alternativa comum para o AST. Essa alternativa é o gráfico de fluxo de controle (GFC), que representa um programa com base no seu fluxo de controle: loops, instruções se-então-outro, exceções e assim por diante. (abordaremos isso na próxima coluna.)

A melhor forma de aprender como o AST é usado no compilador .NET é através do Visualizador Roslyn Syntax. Ele é instalado como parte do SDK do Roslyn. Uma vez instalado, abra qualquer programa C# ou Visual Basic no Visual Studio 2013, posicione o seu cursor na linha de interesse de fonte e abra o visualizador. Você verá o menu Exibição, Outras Janelas e Visualizador do Roslyn Syntax (veja a Figura 4).

O Visualizador do Roslyn Syntax no Visual Studio 2013
Figura 4 O Visualizador do Roslyn Syntax no Visual Studio 2013

Como um exemplo concreto, considere se a declaração que analisamos anteriormente:

 

// Comment
  if (score>100)
    grade = "A++";

A Figura 5 mostra o fragmento AST correspondente compila através do compilador .NET.

Árvore do Syntax abstrato compilado pelo compilador .NET para IfStatement
Figura 5 Árvore do Syntax abstrato compilado pelo compilador .NET para IfStatement

Como acontece com várias coisas, a árvore parece exagerada em primeiro lugar. No entanto, lembre-se de duas coisas. Primeiro, a árvore é apenas uma expansão das declarações da fonte anteriores, por isso é geralmente muito fácil o passo a passo da árvore e ver como ela mapeia de volta para a fonte original. E segundo, o AST é destinado ao consumo da máquina, não a humanos. Geralmente, a única vez que um humano olha no AST é para deputar um analisador. Tendo em mente, também, que um curso mais completo de léxico e análise está além do espaço que temos aqui. Existem muitos recursos disponíveis para aqueles que desejam mergulhar mais fundo neste exercício. O objetivo aqui é uma introdução superficial, não um mergulho profundo.

Conclusão

Não terminamos com o Roslyn por qualquer esforço de imaginação, então, fique atento. Se estiver interessado em mergulhar mais profundamente no Roslyn, podemos sugerir a instalação do Roslyn. Em seguida, examine algumas documentações, começando com a página do Roslyn CodePlex.

Se quiser mergulhar mais profundamente na análise e no léxico, existem vários livros disponíveis. Existe o venerável “Dragon Book”, também conhecido como “Compilers: Principles, Techniques & Tools” (Addison Wesley, 2006). Se estiver interessado em uma abordagem mais centralizada no .NET, considere “Compiling for the .NET Common Language Runtime (CLR)” de John Gough (Prentice Hall, 2001), ou “Writing Compilers and Interpeters: A Software Engineering Approach”, de Ronald Mak (Wiley, 2009). Boa codificação.


Joe Hummel é um professor associado de pesquisa na Universidade de Illinois, Chicago, um criador de conteúdo para Pluralsight.com, um MVP do Visual C++ e um consultor particular. Ele obteve o grau de Ph.D. na UC Irvine na área de computação de alto desempenho e é interessado em todas as coisas paralelas. Ele reside na área de Chicago e, quando não está velejando, pode ser contatado pelo email joe@joehummel.net.

Ted Neward é o CTO na iTrellis, uma empresa de serviços de consultoria. Ele já escreveu mais de 100 artigos e é autor de dezenas de livros, incluindo “Professional F# 2.0” (Wrox, 2010). Ele é um MVP de F# e participa como palestrante em conferências em todo o mundo. Ele atua como consultor e mentor regularmente. Entre em contato com ele pelo email ted@tedneward.com ou ted@itrellis.com se estiver interessado.

Agradecemos ao seguinte especialista técnico da Microsoft pela revisão deste artigo: Dustin Campbell