Compiladores

Cómo el proyecto de compilador de última generación de Microsoft puede mejorar su código

Jason Bock

Descargar el ejemplo de código

Estoy convencido de que todos los desarrolladores desean escribir buenos código. Nadie quiere crear sistemas con errores y difíciles de mantener, que requieran incontables horas para agregar características o corregir problemas. He participado en proyectos que parecían un caos eterno y no son nada agradables. Se pierden muchas horas en bases de código que son prácticamente ilegibles debido a los distintos enfoques incongruentes usados. Me gusta participar en proyectos donde las capas están bien definidas, donde existen muchas pruebas unitarias y los servidores de creación están en ejecución constante, lo que garantiza que todo funcione correctamente. Los proyectos de esa clase, por lo general, cuentan con pautas y estándares que los desarrolladores deben seguir.

He visto cómo algunos equipos implementan tales pautas. Es posible que los desarrolladores deban evitar solicitar ciertos métodos en sus códigos porque se sabe que son problemáticos. O tal vez quieren asegurarse de que el código siga los mismos patrones en determinadas situaciones. Por ejemplo, los desarrolladores en los proyectos pueden acordar usar estándares como estos:

  • Nadie debe usar valores DateTime locales. Todos los valores DateTime deben estar en formato de hora universal coordinada (UTC).
  • Se debe evitar usar el método Parse que se encuentra en los tipos de valores (como int.Parse); en su lugar se debe usar int.TryParse.
  • Todas las clases de entidades creadas deben ser compatibles con la igualdad, es decir, deben sustituir Equals y GetHashCode e implementar los operadores == y != y la interfaz IEquatable<T>.

Estoy seguro de que ha visto reglas semejantes en documentos de estándares. La coherencia es algo bueno y si todos siguen las mismas prácticas, el código resulta más sencillo de mantener. El truco consiste en expresar rápidamente dicho conocimiento a todos los desarrolladores en el equipo de forma eficaz y reutilizable.

Las revisiones de código son un método para localizar posibles problemas. Es normal que personas con perspectivas nuevas sobre una implementación dada vean los problemas que el autor original pudo pasar por alto. Pedir a otros que revisen lo que uno hizo puede ser bastante ventajoso, especialmente cuando el revisor no está familiarizado con el trabajo. Sin embargo, aún hay problemas que podrían no ser detectados durante el desarrollo. Es más, las revisiones de código toman tiempo, ya que los desarrolladores deben pasar horas revisando el código y reuniéndose con otros desarrolladores para comunicar los problemas que encuentran. Me gustaría un proceso que fuera más rápido. Quiero saber inmediatamente si algo se está haciendo mal. Cometer errores lo antes posible ahorra tiempo y dinero a largo plazo.

Existen herramientas en Visual Studio, como Code Analysis, que pueden analizar el código e informar los posibles problemas. Code Analysis posee varias reglas predefinidas que pueden descubrir casos donde no se haya eliminado su objeto o cuando se tienen argumentos de métodos sin usar. Desafortunadamente, Code Analysis no ejecuta sus reglas hasta que se complete la compilación y eso no es lo suficientemente rápido. Quiero saber que mi código nuevo tiene un error tan pronto lo estoy escribiendo, de acuerdo con mis estándares. Cometer errores lo antes posible es algo bueno. Se ahorra tiempo (y, por lo tanto, dinero) y no se compromete código que puede conllevar varios problemas en el futuro. Para hacer eso, necesito ser capaz de codificar mis reglas de tal forma que se ejecuten mientras las escribo y es en este punto en el que entra en juego el CTP "Roslyn" de Microsoft.

¿Qué es "Roslyn" de Microsoft?

Una de las mejores herramientas de .NET que los desarrolladores pueden usar para analizar sus códigos es el compilador. Sabe cómo analizar códigos y convertirlos en tokens y luego convertirlos en algo que sea significativo basado en su ubicación en el código. El compilador realiza esto al emitir un ensamblado en el disco como su resultado. Existe mucho conocimiento valioso que se deduce en la canalización de compilación que sería genial poder usar, pero lamentablemente esto no es posible en el mundo de .NET porque los compiladores de C# y Visual Basic no proporcionan una API a la que se pueda tener acceso. Gracias a Roslyn todo esto cambia. Roslyn es un conjunto de API de compilador que le entregan acceso total a todas las etapas del compilador a medida que avanza. La Figura 1 es un diagrama de las diferentes etapas en el proceso del compilador que ahora están disponibles en Roslyn.

The Roslyn Compiler PipelineFigura 1 La canalización del compilador Roslyn

Aunque Roslyn todavía está en modo CTP (usé la versión de septiembre de 2012 para este artículo), de todas maneras vale la pena investigar las funcionalidades disponibles en sus ensambles y aprender qué se puede hacer con Roslyn. Un buen punto de partida es dirigir su atención a la instalación de secuencias de comandos. Con Roslyn, los códigos de C# y Visual Basic ahora se convierten en secuencias de comandos. Es decir, existe un motor de creación de secuencias de comandos disponible en Roslyn donde puede ingresar fragmentos de código. Esto se administra a través de la clase ScriptEngine. A continuación aparece un ejemplo que ilustra cómo este motor puede devolver el valor DateTime actual:

class Program
{
  static void Main(string[] args)
  {
    var engine = new ScriptEngine();
    engine.ImportNamespace("System");
    var session = engine.CreateSession();
    Console.Out.WriteLine(session.Execute<string>(
      "DateTime.Now.ToString();"));
  }
}

En este código, el motor crea e importa el espacio de nombres System por lo que Roslyn podrá resolver lo que DateTime significa. Cuando se crea una sesión, lo único que hay que hacer es solicitar Execute y luego Roslyn analizará el código dado. Si puede analizarlo correctamente, lo ejecutará y devolverá el resultado.

Convertir C# en un lenguaje de creación de secuencias de comandos es un concepto poderoso. Aunque Roslyn sigue en modo CTP, existen quienes ya han creado increíbles proyectos y marcos mediante el uso de sus versiones, tal como scriptcs (scriptcs.net). Sin embargo, creo que Roslyn realmente se destaca al permitir la creación de extensiones de Visual Studio para advertirle de los problemas a medida que escribe el código. En el fragmento de código anterior, usé DateTime.Now. Si estuviera en un proyecto que obliga la adhesión a la directriz de la primera viñeta que presenté al principio de este artículo, estaría desobedeciendo dicho estándar. Exploraré cómo se puede convertir dicha regla en obligatoria, mediante el uso de Roslyn. Pero antes de hacerlo, voy a analizar la primera etapa de la compilación: analizar el código para obtener tokens.

Árboles de sintaxis

Cuando Roslyn analiza una parte del código, devuelve un árbol de sintaxis inmutable. Este árbol contiene todo lo relacionado con el código dado, incluidos aspectos triviales como espacios y tabulaciones. Incluso si el código tiene errores, seguirá intentando brindarle la mayor cantidad de información posible.

Todo esto está muy bien, pero ¿cómo se puede distinguir dónde está la información pertinente en el árbol? Actualmente, la documentación sobre Roslyn no es muy detallada, lo que resulta comprensible ya que todavía se encuentra en CTP. Puede usar los foros de Roslyn para publicar preguntas (bit.ly/16qNf7w), o usar la etiqueta #RoslynCTP en una publicación en Twitter. También existe un ejemplo llamado SyntaxVisualizerExtension donde puede instalar las versiones, que corresponde a una extensión para Visual Studio. A medida que escribe el código en IDE, el visualizador se actualiza automáticamente con la versión actual del árbol.

Esta herramienta resulta indispensable para determinar lo que se está buscando y cómo navegar a través del árbol. En el caso del uso de .Now en la clase DateTime, descubrí que tenía que buscar Member­AccessExpression (o, para ser más preciso, un objeto basado en MemberAccessExpression­Syntax) donde el último valor IdentifierName equivale a Now. Desde luego, esto es para el caso sencillo donde uno podría escribir “var now = DateTime.Now;”, podría poner“System.” frente a DateTime o usar “using DT = System.DateTime;”; es más, puede existir una propiedad en el sistema en una clase diferente llamada Now. Todos los casos se deben procesar correctamente.

Buscar y resolver problemas de código

Ahora que sé qué debo buscar, necesito crear una extensión de Visual Studio basada en Roslyn para encontrar el uso de la propiedad DateTime.Now. Para hacer esto, simplemente seleccione la plantilla Code Issue en la opción Roslyn de Visual Studio.

Cuando haya hecho esto, obtendrá un proyecto que contiene una clase llamada CodeIssue­Pro­vider. Esta clase implementa la interfaz ICodeIssue­Provider, aunque usted no tiene que implementar cada uno de sus cuatro miembros. En este caso, solo se usan los miembros que trabajan con los tipos SyntaxNode; los otros pueden arrojar NotImplementedException. Debe implementar la propiedad SyntaxNodeTypes al especificar qué tipos de nodo de sintaxis desea administrar con el método GetIssues correspondiente. Como mencioné en el ejemplo anterior, los tipos MemberAccessExpressionSyntax no son los que importan. Los siguientes fragmentos de código muestran cómo se implementa SyntaxNodeTypes:

public IEnumerable<Type> SyntaxNodeTypes
{
  get
  {
    return new[] { typeof(MemberAccessExpressionSyntax) };
  }
}

Esta es una optimización para Roslyn. Al tener que especificar qué tipos quiere uno examinar con más detalle, Roslyn no tiene que solicitar el método GetIssues para cada tipo de sintaxis. Si Roslyn no tuviera este mecanismo de filtro y enviara solicitudes a su proveedor de código por cada nodo en el árbol, el rendimiento sería desastroso.

Ahora solo queda implementar Get­Issues de manera tal que solo informe el uso de la propiedad Now. Como mencioné en la sección anterior, uno solo quiere buscar casos donde se usó Now para DateTime. Cuando solicita tokens, no posee demasiada información además del texto. Sin embargo, Roslyn proporciona lo que se conoce como modelo semántico, el cual puede revelar mucha más información sobre el código que se examina. El código de la Figura 2 demuestra cómo puede buscar los usos de DateTime.Now.

Figura 2. Búsqueda de los usos de DateTime.Now

public IEnumerable<CodeIssue> GetIssues(
  IDocument document, CommonSyntaxNode node, 
  CancellationToken cancellationToken)
{
  var memberNode = node as MemberAccessExpressionSyntax;
  if (memberNode.OperatorToken.Kind == SyntaxKind.DotToken &&
    memberNode.Name.Identifier.ValueText == "Now")
  {
    var symbol = document.GetSemanticModel()
        .GetSymbolInfo(memberNode.Name).Symbol;
    if (symbol != null &&
      symbol.ContainingType.ToDisplayString() ==
        Values.ExpectedContainingDateTimeTypeDisplayString &&
      symbol.ContainingAssembly.ToDisplayString().Contains(
        Values.ExpectedContainingAssemblyDisplayString))
    {
      return new [] { new CodeIssue(CodeIssueKind.Error,
        memberNode.Name.Span,
        "Do not use DateTime.Now",
        new ChangeNowToUtcNowCodeAction(document, memberNode))};
    }
  }
  return null;
}

Notará que no se usa el argumento cancellationToken, ni tampoco se usa en el proyecto de ejemplo que acompaña este artículo. Esto fue algo intencional, porque colocar código en el ejemplo que comprueba constantemente el token para ver si se debe detener el proceso puede resultar confuso. Pero si va a crear extensiones basadas en Roslyn que están listas para la producción, debe asegurarse de revisar el token a menudo y detenerse si el token tiene el estado de cancelado.

Cuando haya determinado que su expresión de acceso de miembros está tratando de conseguir una propiedad llamada Now, puede obtener información de símbolo para ese token. Esto lo puede hacer al obtener el modelo semántico del árbol y, a continuación, se obtiene una referencia a un objeto basado en ISymbol mediante la propiedad Symbol. Luego, lo único que debe hacer es obtener el tipo que contiene y ver si su nombre es System.DateTime y si el nombre del ensamblado que contiene incluye mscorlib. Si ese es el caso, ahí está el problema que anda buscando y puede marcarlo como un error al devolver un objeto CodeIssue.

Esto representa un buen progreso hasta ahora, porque verá una línea de error roja y serpenteante debajo del texto Now en el IDE. Pero no abarca todo lo necesario. Es agradable cuando el compilador le informa que faltan dos puntos o una llave de cierre en el código. Obtener información de error es mejor que nada y, por lo general, resulta más fácil corregir errores sencillos a partir del mensaje de error. Sin embargo, ¿no sería mejor si las herramientas pudieran descubrir los errores por sí mismas? Me gusta que me digan cuando me equivoco y soy mucho más feliz cuando el mensaje de error me entrega información detallada que me explique cómo puedo corregir el problema. Y si esa información se puede automatizar de manera tal que la herramienta pueda resolver los problemas por mí, finalmente se traduce en menos tiempo que le tengo que dedicar al problema. Mientras más tiempo se ahorra, mejor.

Por esa razón es que puede ver en el fragmento de código anterior una referencia a la clase llamada ChangeNowToUtcNowCodeAction. Esta clase implementa la interfaz ICodeAction y su trabajo consiste en cambiar Now a UtcNow. El método principal que tiene que implementar se llama GetEdit. En este caso, el token Name en el objeto MemberAccessExpressionSyntax se debe cambiar a un nuevo token. Tal como se muestra en el siguiente código, resulta bastante sencillo realizar este reemplazo:

public CodeActionEdit GetEdit(CancellationToken cancellationToken)
{
  var nameNode = this.nowNode.Name;
  var utcNowNode =
    Syntax.IdentifierName("UtcNow");
  var rootNode = this.document.
    GetSyntaxRoot(cancellationToken);
  var newRootNode =
    rootNode.ReplaceNode(nameNode, utcNowNode);
  return new CodeActionEdit(
    document.UpdateSyntaxRoot(newRootNode));
}

Lo único que necesita hacer es crear un nuevo identificador con el texto UtcNow y reemplazar el toke Now con este nuevo identificador a través de ReplaceNode. Recuerde que los árboles de sintaxis son inmutables, por lo que no cambia el árbol de documento actual. Lo que hace es crear un nuevo árbol y devolver dicho árbol a partir de la solicitud de método.

Con todo este código en su lugar, puede comenzar a probarlo en Visual Studio, simplemente al presionar F5. Esto inicia una nueva instancia de Visual Studio con la extensión instalada automáticamente.

Análisis de los constructores DateTime

Este es un buen comienzo, pero existen más casos que se deben trabajar. La clase DateTime posee varios constructores definidos que pueden causar problemas. En concreto, hay que estar atento por dos casos:

  1. Puede que el constructor no acepte un tipo de enumeración DateTimeKind como uno de sus parámetros, lo que significa que el DateTime resultante tendrá un estado Unspecified (sin especificación).
  2. El constructor puede aceptar un valor DateTimeKind con uno de sus parámetros, lo que significa que puede especificar un valor de enumeración distinto de Utc.

Puede escribir código para buscar ambas condiciones. Sin embargo, solo crearé una acción de código para el segundo.

La Figura 3 enumera el código para el método GetIssues en la clase basada en ICodeIssue que buscará solicitudes incorrectas del constructor DateTime.

Figura 3. Búsqueda de solicitudes incorrectas del constructor DateTime

public IEnumerable<CodeIssue> GetIssues(
  IDocument document, CommonSyntaxNode node, 
  CancellationToken cancellationToken)
{
  var creationNode = node as ObjectCreationExpressionSyntax;
  var creationNameNode = creationNode.Type as IdentifierNameSyntax;
  if (creationNameNode != null && 
    creationNameNode.Identifier.ValueText == "DateTime")
  {
    var model = document.GetSemanticModel();
    var creationSymbol = model.GetSymbolInfo(creationNode).Symbol;
    if (creationSymbol != null &&
      creationSymbol.ContainingType.ToDisplayString() ==
        Values.ExpectedContainingDateTimeTypeDisplayString &&
      creationSymbol.ContainingAssembly.ToDisplayString().Contains(
        Values.ExpectedContainingAssemblyDisplayString))
    {
      var argument = FindingNewDateTimeCodeIssueProvider
        .GetInvalidArgument(creationNode, model);
      if (argument != null)
      {
        if (argument.Item2.Name == "Local" ||
          argument.Item2.Name == "Unspecified")
        {
          return new [] { new CodeIssue(CodeIssueKind.Error,
            argument.Item1.Span,
            "Do not use DateTimeKind.Local or DateTimeKind.Unspecified",
            new ChangeDateTimeKindToUtcCodeAction(document, 
              argument.Item1)) };
        }
      }
      else
      {
        return new [] { new CodeIssue(CodeIssueKind.Error,
          creationNode.Span,
          "You must use a DateTime constuctor that takes a DateTimeKind") };
      }
    }
  }
  return null;
}

Es muy semejante al otro problema. Cuando uno sabe que el constructor procede de un DateTime, necesita evaluar los argumentos. (Explicaré qué hace GetInvalidArgument en un momento). Si descubre un argumento del tipo DateTimeKind y no especifica Utc, entonces tiene un problema. De lo contrario, sabe que está usando un constructor que no tendrá el DateTime en Utc, por lo que ese es otro problema que hay que informar. La Figura 4 muestra cómo se ve GetInvalidArgument.

Figura 4. El método GetInvalidArgument

private static Tuple<ArgumentSyntax, ISymbol> GetInvalidArgument(
  ObjectCreationExpressionSyntax creationToken, ISemanticModel model)
{
  foreach (var argument in creationToken.ArgumentList.Arguments)
  {
    if (argument.Expression is MemberAccessExpressionSyntax)
    {
      var argumentSymbolNode = model
        .GetSymbolInfo(argument.Expression).Symbol;
      if (argumentSymbolNode.ContainingType.ToDisplayString() ==
        Values.ExpectedContainingDateTimeKindTypeDisplayString)
      {
        return new Tuple<ArgumentSyntax,ISymbol>(argument, 
            argumentSymbolNode);
      }
    }
  }
  return null;
}

Esta búsqueda es muy parecida a las otras. Si el tipo de argumento es DateTimeKind, entonces sabe que posiblemente tiene un valor de argumento no válido. Para corregir el argumento, el código es virtualmente idéntico a la primera acción de código que vio, por lo que no lo repetiré aquí. Ahora bien, si otros desarrolladores intentan saltarse la restricción de DateTime.Now, puede descubrirlos en el acto y también corregir las solicitudes del constructor.

En el futuro

Resulta maravilloso pensar en todas las herramientas que se crearán con Roslyn, pero todavía hay mucho trabajo por hacer. Una de las mayores frustraciones que creo que tendrá con Roslyn en estos momentos es la falta de documentación. Existen buenos ejemplos en línea y en las versiones de instalación, pero Roslyn es un gran conjunto de API y puede resultar confuso determinar dónde se debe comenzar exactamente y qué se debe usar para realizar una tarea en especial. No es raro tener que investigar un buen rato antes de descubrir qué solicitudes son las que se deben usar. El aspecto alentador es que, por lo general, puedo hacer algo en Roslyn que parece ser complejo en un principio, pero que termina siendo menos que 100 o 200 líneas de código.

Creo que a medida que Roslyn se acerque a su publicación, todo lo que se le relacione mejorará. También estoy convencido de que Roslyn tiene el potencial de respaldar muchos marcos y herramientas en el ecosistema .NET. No creo que todos los desarrolladores de .NET lleguen a usar las API de Roslyn diariamente y de forma directa, pero lo más probable es que terminemos usando versiones que usan Roslyn de una u otra forma. Es por este motivo que lo aliento a sumergirse en Roslyn y descubrir cómo funciona. Poder codificar expresiones idiomáticas para convertirlas en reglas reutilizables que cualquier desarrollador en un equipo pueda aprovechar, contribuye a que todos puedan producir mejores códigos de forma más rápida.

Jason Bock es jefe de actividad de Magenic (magenic.com) y recientemente fue coautor de “Metaprogramming in .NET” (Manning Publications, 2013). Puede ponerse en contacto con él en jasonb@magenic.com.

GRACIAS al siguiente experto técnico por su ayuda en la revisión de este artículo: Kevin Pilch-Bisson (Microsoft), Dustin Campbell, Jason Malinowski (Microsoft), Kirill Osenkov (Microsoft)