编译器

Microsoft 的下一代编译器项目可如何改进您的代码

Jason Bock

下载代码示例

我相信,每个开发人员都希望写出优质的代码。 不会有人希望所创建的系统错误百出、不可维护、需要没完没了地添加功能或解决问题。 我曾经参与过一些项目,感觉如同总是处于混乱状态,毫无乐趣可言。 因方法不一致而导致难以理解基本代码,从而浪费了很多时间。 我希望在所从事的项目中,层次经过良好的定义、单元测试丰富充足并且生成服务器持续运行以确保所有情况正常。 此类项目通常会制订由开发人员严格遵守的一组准则和标准。

我已见过有团队制订了此类准则。 可能由于已将某些方法视为有疑问,因此开发人员应避免在其代码中调用这些方法。 或者,他们可能要确保代码在某些情况下遵循相同的模式。 例如,项目中的开发人员可能会同意如下准则:

  • 任何人都不应使用当地 DateTime 值。 所有 DateTime 值都应采用协调世界时 (UTC)。
  • 应避免使用在值类型上找到的 Parse 方法(如 int.Parse);应改用 int.TryParse。
  • 所创建的所有实体类都应支持等同性,即都应重写 Equals 和 GetHashCode 并实现 == 和 != 运算符以及 IEquatable<T> 接口。

我确信您已在某个标准文档中看到过类似的规则。 达成一致是件好事情,如果每个人都遵循同一做法,那么维护代码就会变得更轻松。 其中的窍门在于用一种可重用、有效的方式向团队中的所有开发人员快速地传达这些知识。

代码审阅是一种发现潜在问题的方式。 旁观者清,对于给定实现视角新颖的人员经常能发现原作者意识不到的问题。 让另一方审阅您的开发工作可能大有裨益,在审阅者不熟悉此项工作时尤为如此。 但是,仍然很容易在开发过程中忽视一些问题。 此外,代码审阅耗时漫长 - 开发人员不得不花费数小时审阅代码并与其他开发人员开会,交流双方发现的问题。 我需要这个过程更加快捷。 我希望在出错后尽可能快地得知。 尽快发现故障从长远来看可节省时间和资金。

Visual Studio 中有多种工具(如代码分析)可分析您的代码并向您通知潜在的问题。 代码分析有许多预定义规则,可揭示未销毁对象或未使用方法参数等情况。 遗憾的是,直到编译完毕后,代码分析才运行其规则,而这可不够快! 我希望根据我的标准在键入的新代码中出错时尽快了解这一情况。 尽可能快地发现故障是件好事情。 既可节省时间(并因此节省资金),又可避免交付将来可能会导致无数问题的代码。 为此,我需要能够将我的规则编为代码,以使这些规则在我键入时得以执行,而这正是 Microsoft“Roslyn”CTP 发挥的作用。

Microsoft“Roslyn”是什么?

.NET 开发人员可用于分析其代码的最佳工具之一就是编译器。 它了解如何从语法上将代码分析成各种标记,然后根据这些标记在代码中的位置将其变为有意义的内容。 为此,编译器以其输出的形式将一个程序集发送到磁盘。 可在编译管道中搜集到许多来之不易的知识,而您乐于能够使用这些知识,但是,唉,在 .NET 环境中做不到这一点,因为 C# 和 Visual Basic 编译器不提供 API 供您访问。 Roslyn 使这一情况得到改观。 Roslyn 是一组编译器 API,通过它可靠完整地访问编译器经历的每个阶段。 图 1 是 Roslyn 当前在编译器进程中提供的不同阶段的图。

The Roslyn Compiler Pipeline
图 1:Roslyn 编译器管道

尽管 Roslyn 仍为 CTP 模式(本文中使用的是 2012 年 9 月版),但还是值得花时间研究其程序集中提供的功能以及了解通过 Roslyn 可做到的事情。 首先最好着眼于其脚本功能。 通过 Roslyn,现在可为 C# 和 Visual Basic 代码编写脚本。 即 Roslyn 中提供一个脚本引擎,可向该引擎中输入代码段。 通过 ScriptEngine 类处理此功能。 以下是一个示例,其中演示此引擎可怎样返回当前的 DateTime 值:

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();"));
  }
}

在这段代码中,引擎创建并导入 System 命名空间,因此 Roslyn 将可分析出 DateTime 的含义。 创建会话后,它只需调用 Execute,然后 Roslyn 将分析给定的代码。 如果它可正确地分析这段代码,则它将运行这段代码并返回结果。

使 C# 成为一种脚本语言是一个强大的概念。 虽然 Roslyn 仍处于 CTP 模式,但人们使用其少量功能即创造出令人惊叹的项目和框架,如 scriptcs (scriptcs.net)。 不过,我认为 Roslyn 真正的亮点在于可创建 Visual Studio 扩展以在编写代码时告知问题。 在前一代码段中,我使用了 DateTime.Now。 如果我所从事的项目实施了我在本文开头以项目符号列出的第一点,那么我将违反该标准。 以后我将探讨可怎样使用 Roslyn 实施这项规则。 但在我这样做之前,我将介绍编译的第一个阶段: 分析代码以获得标记。

语法树

当 Roslyn 分析一行代码后,它返回一个不可变的语法树。 此树包含有关给定代码的任何信息,包括空格和制表符等细枝末节。 即使这段代码有错,代码树仍将尽可能尝试向您提供尽可能多的信息。

这固然很好,但您是否明白相关信息在树中何处? 当前,有关 Roslyn 的文档还很少,由于它仍处于 CTP,这一点可以理解。 可使用 Roslyn 论坛张贴问题 (bit.ly/16qNf7w),或在 Twitter 上的推文中使用 #RoslynCTP 标签。 在安装文件时,还有一个名为 SyntaxVisualizerExtension 的示例,它是 Visual Studio 的一个扩展。 在 IDE 中键入代码时,可视化工具自动随树的当前版本一起更新。

要搞清您在寻找什么以及如何在树中导航,此工具不可或缺。 在对 DateTime 类使用 .Now 时,我搞清了我需要找到 Member­AccessExpression(或者更精确地说,需要找到基于 MemberAccessExpression­Syntax 的对象),其中最后一个 IdentifierName 值等于 Now。 当然,这适用于输入“var now = DateTime.Now;”的简单情况,而您可能会在 DateTime 前面放置“System.”,或使用“using DT = System.DateTime;”;此外,系统中的其他类中可能有一个名为 Now 的属性。 必须正确处理所有这些情况。

查找并解决代码问题

既然知道要查找什么,那么需要创建一个基于 Roslyn 的 Visual Studio 扩展以查寻 DateTime.Now 属性的使用情况。 为此,只需在 Visual Studio 中的“Roslyn”选项下选择“代码问题”模板。

这样做后,将得到一个项目,其中包含一个名为 CodeIssue­Pro­vider 的类。 此类实现 ICodeIssue­Provider 接口,但您不必实现其四个成员中的每个。 在这种情况下,仅使用处理 SyntaxNode 类型的成员;而其他成员可能会引发 NotImplementedException。 通过指定要用相应 GetIssues 方法处理的语法节点类型,实现 SyntaxNodeTypes 属性。 如上一个示例提到的那样,MemberAccessExpressionSyntax 类型才是重要的类型。 以下代码段演示如何实现 SyntaxNodeTypes:

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

这是 Roslyn 的一项优化。 通过让您更详细地指定您要检查的类型,Roslyn 不必对每种语法类型都调用 GetIssues 方法。 如果 Roslyn 未配备此筛选机制并对树中的每个节点调用了您的代码提供程序,则性能将令人震惊。

现在只剩下实现 Get­Issues,以使其将仅报告 Now 属性的使用情况。 如同我在前一节提到的那样,您只想查找已对 DateTime 使用 Now 的情况。 使用标记时,除了文本之外没有多少信息。 但是,Roslyn 提供一个所谓的语义模型,后者可提供有关被检查代码的更多信息。 图 2 中的代码演示可怎样查找 DateTime.Now 的使用情况。

图 2:查找 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;
}

您将注意到未使用 cancellationToken 参数,并且本文附带的示例项目中也未使用它。 这是一个慎重的选择,因为向示例中放置不断检查标记以了解处理是否应停止的代码可能会分散精力。 但是,如果将创建适合生产环境的基于 Roslyn 的扩展,则应确保经常检查标记,如果标记处于已取消状态,则停止。

一旦判断成员访问表达式正在尝试获得一个名为 Now 的属性,即可获取该标记的符号信息。 为此,请获得树的语义模型,然后通过 Symbol 属性获得对基于 ISymbol 的对象的引用。 然后,只需获得包含类型,然后查看其名称是否为 System.DateTime,以及其包含程序集名称是否包括 mscorlib。 如果是这样,则这就是所寻找的问题,可通过返回一个 CodeIssue 对象,将其标为错误。

到现在为止进展顺利,因为您将在 IDE 中 Now 文本的下方看到一个红色弯曲的错误行。 但这还不够深入。 编译器告知代码中缺少冒号或大括号显然不错。 获得错误信息比毫无提示好,而对于简单的错误,通常可比较轻松地根据错误消息纠正这些错误。 但是,如果工具本身即可找出错误岂不更好? 我喜欢在出错时收到通知,而当错误消息给出解释可怎样解决问题的详细信息时,我会更加高兴。 而如果可自动处理该信息,使某个工具可替我解决问题,那么我在问题上所用的时间就会更少。 节省的时间越多越好。

因此,您在上一个代码段中看到对一个名为 ChangeNowToUtcNowCodeAction 类的引用。 此类实现 ICodeAction 接口,而其作用是将 Now 改为 UtcNow。 必须实现的主方法称为 GetEdit。 在本例中,需要将 MemberAccessExpressionSyntax 对象中的 Name 标记改为一个新标记。 如以下代码所示,进行此替换比较轻松:

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));
}

只需创建一个具有 UtcNow 文本的新标识符,然后通过 ReplaceNode 将 Now 标记替换为这个新标识符。 请记住,语法树是不可变的,因此请勿更改当前的文档树。 新建一个树,然后从方法调用中返回该树。

这段代码就位后,在 Visual Studio 中按 F5 即可测试它。 此操作将启动一个新的 Visual Studio 实例,其中自动装有扩展。

分析 DateTime 构造函数

以上是个好的开头,但还有更多情况需要处理。 DateTime 类定义了许多构造函数,而这可能导致问题。 有两种情况尤其要注意:

  1. 构造函数不能采用 DateTimeKind 枚举类型作为其某个参数,这意味着所得的 DateTime 将处于 Unspecified 状态。
  2. 构造函数可对其某个参数采用 DateTimeKind 值,这意味着可指定 Utc 以外的枚举值。

可编写代码以同时查找这两个条件。 但是,我将仅创建用于后者的代码操作。

图 3 列出基于 ICodeIssue 的类中 GetIssues 方法的代码,该方法将查找不正确的 DateTime 构造函数调用。

图 3:查找不正确的 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;
}

它与另一个问题非常类似。 得知构造函数来自 DateTime 后,即需要计算参数值。 (我马上就会介绍 GetInvalidArgument 的作用。) 如果找到 DateTimeKind 类型的参数,并且它未指定 Utc,则有问题。 否则,即得知所用构造函数的 DateTime 将不是 Utc 格式,因此这又是一个要报告的问题。 图 4 展示 GetInvalidArgument 的外观。

图 4: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;
}

此搜索与其他搜索非常类似。 如果参数类型为 DateTimeKind,则得知某个参数值可能无效。 为了纠正该参数,这段代码几乎与您看到的第一个代码操作完全相同,因此我在此不再赘述。 现在,如果其他开发人员尝试规避 DateTime.Now 限制,则您可将其抓个现行,并可纠正构造函数调用!

展望未来

想到将用 Roslyn 创建的所有工具,感觉特棒,但工作还是需要完成。 我认为 Roslyn 现在最大的一个不利因素是缺少文档。 网上和安装文件中有许多好的示例,但 Roslyn 是一个庞大的 API 集,因此难以搞清从何处开始以及使用什么完成特定任务。 必须研究一段时间才能搞清要使用的正确调用,这种现象并不少见。 而令人鼓舞的一面是,我在 Roslyn 中编程时经常能够碰到一种现象,起初看起来比较复杂,但最后代码只有不到 100 或 200 行。

我认为随着 Roslyn 发布日期的临近,它周围的一切都会得到改善。 我还坚信,Roslyn 拥有巩固 .NET 生态系统中许多框架和工具的潜力。 我并未看到每个 .NET 开发人员在日常直接使用 Roslyn API,但您最终很有可能使用在某个级别使用 Roslyn 的文件。 而这正是我鼓励您深入研究 Roslyn 并了解运行原理的原因。 能够将习惯用语编为团队中每个开发人员可利用的可重用规则可帮助每个人快速地产出质量更高的代码。

Jason Bock是 Magenic (magenic.com) 的实务主管,最近与人合著了《Metaprogramming in .NET》(Manning Publications,2013 年)。 可通过 jasonb@magenic.com 与他联系。

衷心感谢以下技术专家对本文的审阅: Kevin Pilch-Bisson (Microsoft)Dustin CampbellJason Malinowski (Microsoft)Kirill Osenkov (Microsoft)