Junio de 2016

Volumen 31, número 6

Plataforma de compilación .NET: Generación de código independiente del lenguaje con Roslyn

Por Alessandro Del Del

El código base de Roslyn ofrece potentes API que puede aprovechar para realizar un completo análisis del código fuente. Por ejemplo, los analizadores y las refactorizaciones de código pueden recorrer un trozo de código fuente y reemplazar uno o más nodos de sintaxis por código nuevo que genere con las API de Roslyn. Una forma habitual de realizar generación de código es mediante la clase SyntaxFactory, que expone métodos del patrón Factory Method para generar nodos de una forma que los compiladores puedan entender. Sin duda, la clase SyntaxFactory es muy potente, ya que permite la generación de cualquier elemento de sintaxis posible, pero existen dos implementaciones de SyntaxFactory distintas: Microsoft.CodeAnalysis.CSharp.SyntaxFactory y Microsoft.Code­Analysis.VisualBasic.SyntaxFactory. Esto tiene una consecuencia importante si quiere escribir un analizador con una corrección de código destinada tanto a C# como a Visual Basic: debe escribir dos analizadores distintos (uno para C# y otro para Visual Basic) con las dos implementaciones de SyntaxFactory, y cada una de ellas debe tener un enfoque distinto debido a las diferencias en la forma en que dichos lenguajes controlan algunos constructores. Esto probablemente implicará una pérdida de tiempo al tener que escribir el analizador dos veces y su mantenimiento se vuelve más complicado. Afortunadamente, las API de Roslyn también proporcionan el elemento Microsoft.CodeAnalysis.Editing.SyntaxGenerator, que permite la generación de código independiente del lenguaje. En otras palabras, con Syntax­Generator puede escribir la lógica de generación de código una vez y destinarla a C# y Visual Basic. En este artículo mostraré cómo realizar generación de código independiente del lenguaje con Syntax­Generator y ofreceré algunas sugerencias sobre las API Workspaces de Roslyn.

Primero el código

Comencemos con algo de código fuente que se generará mediante SyntaxGenerator. Considere la clase sencilla Person que implementa la interfaz ICloneable en C# (Figura 1) y Visual Basic (Figura 2).

Figure 1. Una clase sencilla Person en C#

public abstract class Person : ICloneable
{
  // Not using auto-props is intentional for demo purposes
  private string _lastName;
  public string LastName
  {
    get
    {
      return _lastName;
    }
    set
    {
      _lastName = value;
    }
  }
  private string _firstName;
  public string FirstName
  {
    get
    {
      return _firstName;
    }
    set
    {
      _firstName = value;
    }
  }
  public Person(string LastName, string FirstName)
  {
    _lastName = LastName;
    _firstName = FirstName;
  }
  public virtual object Clone()
  {
    return MemberwiseClone();
  }
}

Figure 2. Una clase sencilla Person en Visual Basic

Public MustInherit Class Person
  Implements ICloneable
  'Not using auto-props is intentional for demo purposes
  Private _lastName As String
  Private _firstName As String
  Public Property LastName As String
    Get
      Return _lastName
    End Get
    Set(value As String)
      _lastName = value
    End Set
  End Property
  Public Property FirstName As String
    Get
      Return _firstName
    End Get
    Set(value As String)
      _firstName = value
    End Set
  End Property
  Public Sub New(LastName As String, FirstName As String)
    _lastName = LastName
    _firstName = FirstName
  End Sub
  Public Overridable Function Clone() As Object Implements ICloneable.Clone
    Return MemberwiseClone()
  End Function
End Class

Probablemente haya observado que si se declararan propiedades autoimplementadas, se conseguiría el mismo efecto y se mantendría el código mucho más limpio en este caso concreto, pero más tarde descubrirá por qué utilizo la forma extendida.

La implementación de esta clase Person es muy sencilla, pero incluye un número considerable de elementos de sintaxis, lo que permite que sea útil para comprender cómo se realiza la generación de código con Syntax­Generator. Generemos esta clase con Roslyn.

Creación de una herramienta de análisis de código

Lo primero que debe hacer es crear un proyecto nuevo en Visual Studio 2015 con referencias a las bibliotecas Roslyn. Debido al propósito general de este artículo, en lugar de crear un analizador o refactorizar, elegiré otra plantilla de proyecto disponible en el SDK de la plataforma del compilador de .NET, la herramienta de análisis de código independiente, disponible en el nodo Extensibilidad del diálogo Nuevo proyecto (vea la Figura 3).

La plantilla de proyecto de la herramienta de análisis de código independiente
Figura 3. La plantilla de proyecto de la herramienta de análisis de código independiente

Esta plantilla de proyecto en realidad genera una aplicación de consola y agrega automáticamente los paquetes NuGet adecuados para las API de Roslyn, tomando como destino los lenguajes que elija. Como la idea es tener como destino tanto C# como Visual Studio, lo primero que debe hacer es agregar los paquetes NuGet para el segundo lenguaje. Por ejemplo, si inicialmente creó un proyecto de C#, deberá descargar e instalar las siguientes bibliotecas de Visual Basic desde NuGet:

  • Microsoft.CodeAnalysis.VisualBasic.dll
  • Microsoft.CodeAnalysis.VisualBasic.Workspaces.dll
  • Microsoft.CodeAnalysis.VisualBasic.Workspaces.Common.dll

Puede simplemente instalar la última de ellas desde NuGet y automáticamente se resolverán las dependencias de las otras bibliotecas necesarias. La resolución de dependencias es importante siempre que pretenda usar la clase SyntaxGenerator, sin importar la plantilla de proyecto que utilice. Si olvida hacerlo, se producirán excepciones en tiempo de ejecución.

SyntaxGenerator y las API Workspaces

La clase SyntaxGenerator expone un método estático llamado GetGenerator, que devuelve una instancia de SyntaxGenerator. La instancia devuelta se utiliza para llevar a cabo generación de código. GetGenerator tiene las tres siguientes sobrecargas:

public static SyntaxGenerator GetGenerator(Document document)
public static SyntaxGenerator GetGenerator(Project project)
public static SyntaxGenerator GetGenerator(Workspace workspace, string language)

Las dos primeras sobrecargas funcionan con tipos Document y Project, respectivamente. La clase Document representa un archivo de código en un proyecto, mientras que la clase Project representa un proyecto de Visual Studio completo. Estas sobrecargas detectan automáticamente el lenguaje (C# o Visual Basic) que Document o Project tienen como destino. Document, Project y Solution (una clase adicional que representa una solución .sln de Visual Studio) forman parte de un elemento Workspace, que proporciona una forma administrada de interactuar con todo lo que conforma una solución MSBuild con proyectos, archivos de código, metadatos y objetos. Las API Workspaces exponen varias clases que puede usar para administrar áreas de trabajo, como la clase MSBuildWorkspace, que permite trabajar con una solución .sln, o la clase AdhocWorkspace, que resulta muy útil cuando no trabaja con una solución MSBuild existente, pero quiere un área de trabajo en memoria que represente una. En el caso de los analizadores y las refactorizaciones de código, ya dispone de un área de trabajo MSBuild que le permite trabajar con archivos de código mediante instancias de las clases Document, Project y Solution. En el proyecto de ejemplo actual, no hay ningún área de trabajo, por lo que se creará una mediante la tercera sobrecarga de SyntaxGenerator. Para obtener una nueva área de trabajo, puede usar la clase AdhocWorkspace:

// Get a workspace
var workspace = new AdhocWorkspace();

Ahora puede obtener una instancia de SyntaxGenerator, si pasa la instancia del área de trabajo y el lenguaje deseado como argumentos:

// Get the SyntaxGenerator for the specified language
var generator = SyntaxGenerator.GetGenerator(workspace, LanguageNames.CSharp);

El nombre del lenguaje puede ser CSharp o VisualBasic, que son constantes de la clase LanguageNames. Comencemos con C#; más tarde verá cómo se puede cambiar el nombre del idioma a VisualBasic. Ya dispone de todas las herramientas que necesita y está en disposición de generar nodos de sintaxis.

Generación de nodos de sintaxis

La clase SyntaxGenerator expone métodos de instancia del patrón Factory Method que generan nodos de sintaxis adecuados de forma que sean conformes con las gramáticas y semánticas de C# y Visual Basic. Por ejemplo, los métodos cuyos nombres terminan con el sufijo Expression generan expresiones; los que terminan con el sufijo Statement generan instrucciones; y los que terminan con el sufijo Declaration generan declaraciones. Para cada categoría, existen métodos especializados que generan nodos de sintaxis concretos. Por ejemplo, MethodDeclaration genera un bloque de método, PropertyDeclaration genera una propiedad, FieldDeclaration genera un campo, etc. (y, como es habitual, IntelliSense será su mejor amigo). La peculiaridad de estos métodos es que cada uno devuelve SyntaxNode, en lugar de un tipo especializado que derive de SyntaxNode, como sucede con la clase SyntaxFactory. Esto ofrece una gran flexibilidad, especialmente cuando se generan nodos complejos.

Basándonos en la clase Person del ejemplo, lo primero que se debe generar es una directiva using/Imports para el espacio de nombres System, que expone la interfaz ICloneable. Esto se puede conseguir con el método NamespaceImportDeclaration como se indica a continuación:

// Create using/Imports directives
var usingDirectives = generator.NamespaceImportDeclaration("System");

Este método toma un argumento de cadena que representa el espacio de nombres que quiere importar. Continuaremos con la declaración de dos campos, que se realiza con el método FieldDeclaration:

// Generate two private fields
var lastNameField = generator.FieldDeclaration("_lastName",
  generator.TypeExpression(SpecialType.System_String),
  Accessibility.Private);
var firstNameField = generator.FieldDeclaration("_firstName",
  generator.TypeExpression(SpecialType.System_String),
  Accessibility.Private);

FieldDeclaration toma como argumentos el nombre del campo, el tipo de campo y el nivel de accesibilidad. Para facilitar el tipo adecuado, invoque el método TypeExpression, que toma un valor de la enumeración SpecialType, en este caso System_String (no olvide usar IntelliSense para detectar otros valores). El nivel de accesibilidad se establece con un valor de la enumeración Accessibility. Cuando se invocan métodos desde SyntaxGenerator, es muy habitual anidar invocaciones a otros métodos de la misma clase, como en el caso de TypeExpression. El paso siguiente es generar dos propiedades, lo que se puede realizar mediante la invocación del método PropertyDeclaration, como se muestra en la Figura 4.

Figura 4. Generación de dos propiedades mediante el método PropertyDeclaration

// Generate two properties with explicit get/set
var lastNameProperty = generator.PropertyDeclaration("LastName",
  generator.TypeExpression(SpecialType.System_String), Accessibility.Public,
  getAccessorStatements:new SyntaxNode[]
  { generator.ReturnStatement(generator.IdentifierName("_lastName")) },
  setAccessorStatements:new SyntaxNode[]
  { generator.AssignmentStatement(generator.IdentifierName("_lastName"),
  generator.IdentifierName("value"))});
var firstNameProperty = generator.PropertyDeclaration("FirstName",
  generator.TypeExpression(SpecialType.System_String),
  Accessibility.Public,
  getAccessorStatements: new SyntaxNode[]
  { generator.ReturnStatement(generator.IdentifierName("_firstName")) },
  setAccessorStatements: new SyntaxNode[]
  { generator.AssignmentStatement(generator.IdentifierName("_firstName"),
  generator.IdentifierName("value")) });

Como puede observar, la generación de un nodo de sintaxis para una propiedad es más compleja. Aquí sigue pudiendo pasar una cadena con el nombre de la propiedad, después un elemento TypeExpression para el tipo de propiedad y después el nivel de accesibilidad. Normalmente, con una propiedad también será necesario proporcionar los descriptores de acceso Get y Set, especialmente en aquellas situaciones en las que necesite ejecutar código que no sea de establecimiento o devolución del valor de propiedad (como generar el evento OnPropertyChanged cuando se implementa la interfaz INotifyPropertyChanged). Los descriptores de acceso Get y Set se representan mediante una matriz de objetos SyntaxNode. En Get, normalmente se devuelve el valor de propiedad, por lo que aquí el código invoca el método ReturnStatement, que representa la instrucción return junto con el valor o el objeto que devuelve. En este caso, el valor devuelto es un identificador de campo. Un nodo de sintaxis para un identificador se obtiene mediante la invocación del método IdentifierName, que toma un argumento de tipo cadena y, aun así, devuelve SyntaxNode. Los descriptores de acceso Set, en cambio, almacenan el valor de propiedad en un campo mediante una asignación. Las asignaciones se representan mediante el método AssignmentStatement, que toma dos argumentos: los lados izquierdo y derecho de la asignación. En el caso actual, la asignación se encuentra entre dos identificadores, de forma que el código invoca dos veces a IdentifierName, una para el lado izquierdo de la asignación (el nombre del campo) y otra para el lado derecho (el valor de propiedad). Como el valor de propiedad se representa mediante el identificador de valor tanto en C# como en Visual Basic, se puede codificar de forma rígida.

El paso siguiente es la generación de código para el método Clone, que es un requisito de la implementación de la interfaz ICloneable. En términos generales, un método consta de la declaración, que incluye los delimitadores de bloque y la signatura, y un número de instrucciones, que conforman el cuerpo del método. En el ejemplo actual, Clone también debe implementar el método ICloneable.Clone. Por este motivo, un enfoque práctico es la división de la generación de código para el método en tres nodos de sintaxis más pequeños. El primer nodo de sintaxis es el cuerpo del método, que tiene el siguiente aspecto:

// Generate the method body for the Clone method
var cloneMethodBody = generator.ReturnStatement(generator.
  InvocationExpression(generator.IdentifierName("MemberwiseClone")));

En este caso, el método Clone devuelve el resultado de la invocación del método MemberwiseClone que hereda de System.Object. Por este motivo, el cuerpo del método es simplemente una invocación a ReturnStatement, que se mostró con anterioridad. Aquí, el argumento del elemento ReturnStatement es una invocación del método InvocationExpression, que representa la invocación de un método y cuyo parámetro es un identificador que representa el nombre del método invocado. Como el argumento InvocationExpression es de tipo SyntaxNode, una forma práctica de facilitar el identificador es usar el método IdentifierName y pasar la cadena que representa el identificador al método que se invocará. Si tuviera un método con un cuerpo de método más complejo, debería generar una matriz de tipo SyntaxNode, en la que cada nodo representase una parte del código del cuerpo del método.

El paso siguiente es la generación del método Clone, que se realiza de la siguiente manera:

// Generate the Clone method declaration
var cloneMethoDeclaration = generator.MethodDeclaration("Clone", null,
  null,null,
  Accessibility.Public,
  DeclarationModifiers.Virtual,
  new SyntaxNode[] { cloneMethodBody } );

Se genera un método con el método MethodDeclaration. Este toma una serie de argumentos, como por ejemplo:

  • el nombre del método, de tipo String;
  • los parámetros del método de tipo IEnumerable<SyntaxNode> (null en este caso);
  • los parámetros de tipo de los métodos genéricos, de tipo IEnumerable<SyntaxNode> (null en este caso);
  • el tipo de valor devuelto, de tipo SyntaxNode (null en este caso);
  • el nivel de accesibilidad, con un valor de la enumeración Accessibility;
  • los modificadores de declaración, con uno o varios valores de la enumeración DeclarationModifiers; en este caso, el modificador es virtual (reemplazable en Visual Basic);
  • las instrucciones del cuerpo del método, de tipo SyntaxNode; en este caso, la matriz contiene un elemento, que es la instrucción return que se definió anteriormente.

Más adelante verá un ejemplo de cómo se agregan parámetros de método con el método más especializado ConstructorDeclaration. El método Clone debe implementar su equivalente de la interfaz ICloneable, para que se pueda controlar. Lo que ahora necesita es un nodo de sintaxis que represente el nombre de la interfaz, que también será útil cuando se agregue la implementación de la interfaz a la clase Person. Esto se puede conseguir mediante la invocación del método IdentifierName, que devuelve un nombre adecuado a partir de la cadena especificada:

// Generate a SyntaxNode for the interface's name you want to implement
var ICloneableInterfaceType = generator.IdentifierName("ICloneable");

Si quisiera importar el nombre completo, System.ICloneable, usaría DottedName en lugar de IdentifierName para generar un nombre completo adecuado; pero en el ejemplo actual ya se agregó un elemento NamespaceImportDeclaration para System. En este momento, ya puede juntar todo. SyntaxGenerator tiene los métodos AsPublicInterfaceImplementation y AsPrivateInterfaceImplementation que puede usar para indicar al compilador que la definición de un método implementa una interfaz, como se muestra a continuación:

// Explicit ICloneable.Clone implemenation
var cloneMethodWithInterfaceType = generator.
  AsPublicInterfaceImplementation(cloneMethoDeclaration,
  ICloneableInterfaceType);

Esto es especialmente importante con Visual Basic, que requiere explícitamente la cláusula Implements. AsPublicInterfaceImplementation es el equivalente de la implementación de interfaz implícita en C#, mientras que AsPrivateInterfaceImplementation es el equivalente de la implementación de interfaz explícita. Los dos funcionan con métodos, propiedades e indexadores.

El paso siguiente consiste en la generación del constructor, que se realiza mediante el método ConstructorDeclaration. Como en el caso del método Clone, la definición del constructor debe dividirse en partes más pequeñas para que se pueda entender con más facilidad y el código quede más limpio. Como recordará de la Figura 1 y la Figura 2, el constructor toma dos parámetros de tipo cadena, que son necesarios para la inicialización de propiedades. Por lo tanto, es una buena idea generar primero el nodo de sintaxis de los dos parámetros:

// Generate parameters for the class' constructor
var constructorParameters = new SyntaxNode[] {
  generator.ParameterDeclaration("LastName",
  generator.TypeExpression(SpecialType.System_String)),
  generator.ParameterDeclaration("FirstName",
  generator.TypeExpression(SpecialType.System_String)) };

Cada parámetro se genera con el método ParameterDeclaration, que toma una cadena que representa el nombre del parámetro y una expresión que representa el tipo de parámetro. Los dos parámetros son de tipo String, por lo que el código simplemente usa el método TypeExpression, como ya se indicó. El motivo para empaquetar los dos parámetros en un elemento SyntaxNode es que el elemento ConstructorDeclaration quiere un objeto de este tipo para representar parámetros.

Ahora necesita construir el cuerpo del método, que aprovecha el método AssignmentStatement que vio previamente, como se indica a continuación:

// Generate the constructor's method body
var constructorBody = new SyntaxNode[] {
  generator.AssignmentStatement(generator.IdentifierName("_lastName"),
  generator.IdentifierName("LastName")),
  generator.AssignmentStatement(generator.IdentifierName("_firstName"),
  generator.IdentifierName("FirstName"))};

En este caso, existen dos instrucciones agrupadas en un objeto Syntax­Node. Por último, puede generar el constructor, juntando los parámetros y el cuerpo del método:

// Generate the class' constructor
var constructor = generator.ConstructorDeclaration("Person",
  constructorParameters, Accessibility.Public,
  statements:constructorBody);

ConstructorDeclaration es similar a MethodDeclaration, pero está diseñado específicamente para generar un método .ctor en C# y un método Sub New en Visual Basic.

Generación de un elemento CompilationUnit

Hasta ahora se ha visto cómo generar código para todos los miembros de la clase Person. Ahora debe poner dichos miembros juntos y generar un objeto SyntaxNode para la clase. Es necesario facilitar miembros de clase en la forma de un elemento SyntaxNode; a continuación se muestra cómo juntar todos los miembros creados anteriormente:

// An array of SyntaxNode as the class members
var members = new SyntaxNode[] { lastNameField,
  firstNameField, lastNameProperty, firstNameProperty,
  cloneMethodWithInterfaceType, constructor };

Ahora por fin puede generar la clase Person, aprovechando el método ClassDeclaration como se indica a continuación:

// Generate the class
var classDefinition = generator.ClassDeclaration(
  "Person", typeParameters: null,
  accessibility: Accessibility.Public,
  modifiers: DeclarationModifiers.Abstract,
  baseType: null,
  interfaceTypes: new SyntaxNode[] { ICloneableInterfaceType },
  members: members);

Como sucede con otros tipos de declaraciones, este método requiere que se especifique el nombre, el tipo genérico (null en este caso), el nivel de accesibilidad, los modificadores (Abstract en este caso, MustInherit en Visual Basic), los tipos base (null en este caso) y las interfaces implementadas (en este caso un elemento SyntaxNode que contiene el nombre de la interfaz que se creó anteriormente como un nodo de sintaxis). También puede interesarle encapsular la clase en un espacio de nombres. SyntaxGenerator incluye el método NamespaceDeclaration, que acepta el nombre del espacio de nombres y el elemento SyntaxNode que contiene. Se usa del siguiente modo:

// Declare a namespace
var namespaceDeclaration = generator.NamespaceDeclaration("MyTypes", classDefinition);

Los compiladores ya saben cómo controlar el nodo de sintaxis generado para el espacio de nombres completo y los miembros anidados, y cómo realizar análisis de código en sintaxis; pero, en ocasiones, es necesario devolver este resultado en forma de un elemento CompilationUnit, un tipo que representa un archivo de código. Esto es habitual con refactorizaciones de código y analizadores. Este el código que se debe escribir para devolver un elemento CompilationUnit:

// Get a CompilationUnit (code file) for the generated code
var newNode = generator.CompilationUnit(usingDirectives, namespaceDeclaration).
  NormalizeWhitespace();

Este método acepta una o varias instancias de SyntaxNode como argumento.

La salida en C# y Visual Basic

Después de todo este trabajo, todo está listo para ver el resultado. En la Figura 5 se muestra el código C# generado para la clase Person.

El código en C# generado mediante Roslyn para la clase Person
Figura 5. El código en C# generado mediante Roslyn para la clase Person

Ahora, sencillamente cambie el lenguaje a VisualBasic en la línea de código que crea un nuevo elemento AdhocWorkspace:

generator = SyntaxGenerator.GetGenerator(workspace, LanguageNames.VisualBasic);

Si ejecuta de nuevo el código, obtendrá una definición de clase de Visual Basic, como se muestra en la Figura 6.

El código en Visual Basic generado mediante Roslyn para la clase Person
Figura 6. El código en Visual Basic generado mediante Roslyn para la clase Person

El aspecto clave aquí es que, con SyntaxGenerator, escribió código una sola vez y pudo generar tanto código en C# como en Visual Basic con el que las API de análisis de Roslyn pueden trabajar. Cuando haya terminado, no olvide invocar el método Dispose para la instancia AdhocWorkspace; también puede sencillamente incluir el código dentro de una instrucción using. Como nadie es perfecto y el código generado puede contener errores, también puede consultar la propiedad ContainsDiagnostics para ver si existe información de diagnóstico en el código y obtener información detallada sobre problemas de código mediante el método GetDiagnostics.

Refactorizaciones y analizadores independientes del lenguaje

Puede usar las API de Roslyn y la clase SyntaxGenerator cuando necesite realizar un análisis detallado sobre el código fuente, pero este enfoque también resulta muy útil con analizadores y refactorizaciones de código. En efecto, los analizadores, las correcciones de código y las refactorizaciones tienen los atributos DiagnosticAnalyzer, ExportCodeFixProvider y ExportCodeRefactoringProvider, respectivamente, y cada uno de ellos acepta los lenguajes principales y secundarios admitidos. Mediante el uso de SyntaxGenerator en lugar de SyntaxFactory, puede tener como destinos C# y Visual Basic de forma simultánea.

Resumen

La clase SyntaxGenerator del espacio de nombres Microsoft.CodeAnalysis.Editing ofrece una manera de generar nodos de sintaxis independiente del lenguaje, que permite tener como destinos C# y Visual Basic con un código base. Con esta potente clase, puede generar cualquier elemento de sintaxis posible de manera conforme con los dos compiladores, lo que ahorra tiempo y mejora el mantenimiento del código.


Alessandro Del Soleha sido MVP de Microsoft desde el año 2008. Alessandro ha sido proclamado MVP del año en cinco ocasiones y es el autor de numerosos libros, eBooks, vídeos didácticos y artículos sobre desarrollo .NET con Visual Studio. Del Sole trabaja como experto en desarrollo de soluciones en Brain-Sys su trabajo se centra principalmente en las áreas de desarrollo en .NET, formación y consultoría. Puede seguirlo en Twitter: @progalex.

Gracias a los siguientes expertos técnicos de Microsoft por revisar este artículo: Anthony D. Green y Matt Warren
Anthony D. Green es el Jefe de programa de Visual Basic. También trabajó en Roslyn durante 5 años. Es de Chicago y puede encontrarlo en su cuenta de Twitter @ThatVBGuy.