本文章是由機器翻譯。

編譯器

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 當前在編譯器進程中提供的不同階段的圖。


圖 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)