EL programador ocupado

El auge de Roslyn

Joe Hummel
Ted Neward

Ted NewardDurante años, distintos profesionales de la informática, considerados líderes y eruditos, han defendido la idea de los lenguajes específicos de dominio (DSL) como una forma de abordar soluciones para problemas de software. Esto parece especialmente apropiado si la sintaxis DSL es algo que los "lectores ocasionales" pueden usar para adaptar y modificar las reglas de negocio en un sistema. Este es el santo grial del software para muchos desarrolladores: la construcción de un sistema que la gente pueda mantener por sí mismos cuando las necesidades de negocio cambien.

Sin embargo, una de las principales críticas a los DSL es el hecho de que la escritura de un compilador es un "arte perdida". No es poco común que programadores de toda índole consideren la creación de un compilador o un intérprete como un tipo de arte oscura.

En la conferencia Build 2014 de este año, Microsoft anunció formalmente uno de los secretos peor guardados del ecosistema de desarrollo en .NET: la liberación del código fuente de Roslyn. Se trata del sistema de compilación renovado y recompilado que forma la base de los lenguajes C# y VisualBasic. Para algunos, esta es una oportunidad para Microsoft de poner sus lenguajes en una comunidad de código abierto y obtener beneficios: corrección de bugs, mejoras, revisiones públicas de las nuevas características del lenguaje y más. Para los desarrolladores, es una oportunidad para comprobar con mayor profundidad la forma en que los compiladores (y los intérpretes, aunque obviamente Roslyn está enfocado en la compilación, por los lenguajes en cuestión) trabajan de forma interna.

Para obtener más información (y sugerencias de instalación), consulte la página de Roslyn en CodePlex en roslyn.codeplex.com. Como siempre sucede con lo que aún no se ha publicado, se recomienda encarecidamente que utilice una máquina virtual o una máquina que no le importe demasiado.

Aspectos básicos de Roslyn

A alto nivel, el objetivo de un compilador es traducir la entrada del programador (el código fuente) en una salida ejecutable, como un ensamblador de .NET o un archivo .exe nativo. Aunque los nombres exactos de los módulos de un compilador varían, normalmente al pensar en un compilador consideramos que está dividido en dos partes fundamentales: un front end y un back end (consulte la Figura 1).

Diseño de un compilador de alto nivel
Figura 1. Diseño de un compilador de alto nivel

Una de las responsabilidades clave del front end es la de verificar la precisión del formato del código fuente de entrada. Como con todos los lenguajes de programación, hay un formato específico que los programadores deben seguir para que la máquina vea las cosas claras y sin ambigüedades. Por ejemplo, considere la siguiente instrucción en C#:

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

Es sintácticamente incorrecta, porque las condiciones if deben estar rodeadas de ( ), como se muestra a continuación:

if (x < 0)
  x = 0;

Cuando el código se analiza, el back end es el responsable de una validación más profunda del código fuente, como las infracciones de seguridad de tipos:

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

Casualmente, esos ejemplos son decisiones de diseño deliberadas del implementador del lenguaje. Son sujeto de largos debates sobre si son "mejores" que otras. Para obtener más información, visite cualquier foro de programación en línea y teclee "D00d ur language sux". Pronto se verá envuelto en una sesión "educativa" que con seguridad no olvidará.

Si suponemos que no hay errores sintácticos o semánticos, la compilación continúa y el back end traduce la entrada en un programa equivalente en el lenguaje de destino deseado.

Más allá de las profundidades

Aunque podría seguir el enfoque de dos partes con los lenguajes más simples, a menudo el compilador o el intérprete de un lenguaje suele dividirse mucho más. En un siguiente nivel de complejidad, la mayoría de compiladores se disponen para actuar en seis fases principales, dos en el front end y cuatro en el back end (consulte la Figura 2). 

Las fases principales de un compilador
Figura 2. Las fases principales de un compilador

El front end lleva a cabo las dos primeras fases: el análisis léxico y el análisis (parsing). El objetivo del análisis léxico es la lectura del programa de entrada y proporcionar como salida los tokens (las palabras clave, la puntuación, los identificadores, etc.). La ubicación de cada token también se mantiene, de forma que el formato del programa no se pierda. Suponga que el siguiente fragmento de programa comienza en el principio del archivo de código fuente:

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

La salida del análisis léxico sería esta secuencia 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 incorpora información adicional, como las posiciones de inicio y fin (Span) medidas desde el comienzo del archivo de código fuente. Tenga en cuenta que IfKeyword comienza en la posición 12. Esto se debe a que el comentario que se extiende en [0..10) y los caracteres de fin de línea que se extienden en [10..12). Aunque técnicamente no se consideran tokens, la salida del analizador léxico normalmente incluye información sobre los espacios en blanco, incluidos los comentarios. En el compilador de .NET, los espacios en blanco se transfieren como insignificancias de sintaxis. 

La segunda fase del compilador es el parsing (análisis). El analizador trabaja mano a mano con el analizador léxico para realizar el análisis de la sintaxis. El analizador hace la mayoría del trabajo, solicitando tokens al analizador léxico mientras comprueba el programa de entrada contra las distintas reglas gramaticales del lenguaje del código fuente. Por ejemplo, todos los programadores en C# conocen la sintaxis de una instrucción if:

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

El [ ... ] simboliza que la parte del else es opcional. El analizador refuerza esta regla mediante la coincidencia de tokens y aplica reglas adicionales para los elementos sintácticos complejos, como la condición y la parte then:

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

La función match(T) llama al analizador léxico para obtener el siguiente token y comprueba si ese token coincide con T. La compilación continúa con normalidad si coincide. De lo contrario, notifica un error de sintaxis. El analizador más simple usa una función match para lanzar una excepción tras un error de sintaxis. Así se detiene de forma efectiva la compilación. Aquí se muestra una implementación de ese tipo:

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

Afortunadamente, el compilador de .NET contiene un analizador mucho más sofisticado. Es capaz de continuar aunque haya errores de sintaxis importantes.

Suponiendo que no haya errores de sintaxis, esencialmente se ha terminado la parte del front end. Solo queda una tarea pendiente: transmitir sus esfuerzos al back end. La forma en que se almacena internamente se conoce como su representación intermedia o IR (pese a la similitud en la terminología, una IR no tiene nada que ver con el Lenguaje intermedio común de .NET). El analizador en el compilador de .NET crea un Árbol de sintaxis abstracta (AST) como la IR y le pasa este árbol al back end.

Los árboles son una IR natural, dada la naturaleza jerárquica de los programas en C# y Visual Basic. Un programa contendrá una o más clases. Una clase contiene propiedades y métodos, las propiedades y los métodos contienen instrucciones, las instrucciones a menudo contienen bloques y los bloques contienen instrucciones adicionales. El objetivo de un AST es representar el programa basándose en su estructura sintáctica. El "abstract" en AST denota la ausencia de azúcar sintáctica como ; y ( ). Como ejemplo, considere la siguiente secuencia de instrucciones de C# (asuma que compilan sin errores):

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

A alto nivel, el AST de este fragmento de código tendría un aspecto como el de la Figura 3.

Árbol de sintaxis abstracta de alto nivel para un fragmento de código en C#
Figura 3. Árbol de sintaxis abstracta de alto nivel para un fragmento de código en C# (se omiten algunos detalles para una mayor simplicidad)

El AST captura la información necesaria sobre el programa: las instrucciones, el orden de las instrucciones, las partes de cada instrucción, etc. La sintaxis innecesaria se descarta, como todos los punto y coma. La característica clave que se debe entender sobre el AST de la Figura 3 es que captura la estructura sintáctica del programa.

En otras palabras, es la forma en que se escribe el programa, no de la forma que se ejecuta. Considere la instrucción foreach, que entra en bucle cero o más veces conforme itera a través de una colección. El AST captura los componentes de la instrucción foreach: la variable loop, la colección y el cuerpo. Lo que el AST no transmite es que foreach puede repetirse una y otra vez. De hecho, si observa el árbol, no hay ninguna flecha en el árbol que indique cómo se ejecuta foreach. La única forma de saberlo es conocer el bucle == de la palabra clave foreach.

Los AST son una IR perfectamente buena, con una principal ventaja: son fáciles de crear y comprender. La desventaja es que los análisis más sofisticados, como los que se usan en el back end del compilador, son más difíciles de llevar a cabo en un AST. Por este motivo, los compiladores mantienen a menudo varios IR, incluyendo una alternativa común al AST. Esta alternativa es el gráfico de flujos de control (CFG), que representa un programa según su flujo de control: bucles, instrucciones if-then-else, excepciones, etc. (Trataremos más este tema en la próxima columna).

La mejor forma de aprender cómo se usa el AST en el compilador de .NET es a través del Visualizador de sintaxis de Roslyn. Se instala como parte del SDK de Roslyn. Cuando esté instalado, abra un programa de C# o de Visual Studio 2013, sitúe el cursor en la línea de interés del código fuente y abra el visualizador. Aparecerá el menú Ver, Otras ventanas y Visualizador de sintaxis de Roslyn (consulte la Figura 4).

El Visualizador de sintaxis de Roslyn en Visual Studio 2013
Figura 4. El Visualizador de sintaxis de Roslyn en Visual Studio 2013

Como un ejemplo concreto, considere la instrucción if que analizamos anteriormente:

 

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

En la Figura 5 se muestra el fragmento del AST correspondiente, creado con el compilador de .NET.

Árbol de sintaxis abstracta creado con el compilador de .NET para IfStatement
Figura 5. Árbol de sintaxis abstracta creado con el compilador de .NET para IfStatement

Como pasa con muchas otras cosas, el árbol puede parecer un poco abrumador al principio. Sin embargo, recuerde dos cosas. Una, que el árbol es simplemente una expansión de las instrucciones del código fuente anterior, por lo que en realidad es bastante fácil recorrer el árbol y ver como apunta de vuelta al código fuente original. Y dos, el AST está pensado para que una máquina lo consuma, no para humanos. Generalmente, el único momento en que un humano mira el AST es para depurar un analizador. Recuerde también que ofrecer un curso más completo de léxico y análisis está fuera del alcance del espacio de que disponemos aquí. Hay una gran cantidad de recursos disponibles para aquellos que quieran profundizar en este ejercicio. El objetivo es ofrecer una ligera introducción, no profundizar.

Resumen

Aún no hemos terminado Roslyn, ni por asomo, así que permanezca atento. Si está interesado en profundizar más en Roslyn, le sugerimos que instale Roslyn. A continuación, consulte alguna de la documentación, comenzando por la página en CodePlex de Roslyn.

Si quiere profundizar más en el análisis y el léxico, hay una gran cantidad de libros disponibles. Está el formidable "Dragon Book", también conocido como "Compilers: Principles, Techniques & Tools" (Addison Wesley, 2006). Si le interesa un enfoque más centrado en .NET, consulte "Compiling for the .NET Common Language Runtime (CLR)" de John Gough (Prentice Hall, 2001) o "Writing Compilers and Interpeters: A Software Engineering Approach" de Ronald Mak (Wiley, 2009). ¡Que disfrute programando!


Joe Hummel es profesor asociado de investigación en la University of Illinois, Chicago, creador de contenido para Pluralsight.com, MVP de Visual C++ y consultor privado. Tiene un doctorado de UC Irvine en el campo de informática de alto rendimiento y se interesa en todo lo que se le relacione. Vive en el área de Chicago y cuando no está navegando, puede ponerse en contacto con él en joe@joehummel.net.

Ted Neward es el Director de Tecnología de iTrellis, una empresa de servicios de consultoría. Ha escrito más de 100 artículos y es autor de una docena de libros, entre los que figura "Professional F# 2.0" (Wrox, 2010). Es un MVP de F# y orador en congresos en todo el mundo. Ejerce de consultor y mentor con regularidad. Si está interesado, puede ponerse en contacto con él en la dirección de correo ted@tedneward.com o ted@itrellis.com.

Gracias al siguiente experto técnico de Microsoft por revisar este artículo: Dustin Campbell