Roslyn CTP:三个入门项目
Roslyn CTP 简介
引言
自《Roslyn 项目》的第一个公开版本发布以来,已经过去了两个月。虽然理解本文内容并非必需,但如果您尚未听说过它,我强烈建议观看 Anders Hejlsberg 的演示文稿(关于 Roslyn 的部分从 35:40 开始),阅读 Eric Lippert 的帖子,或者查看 白皮书中的介绍。
一句话来说,它的主要目标是开放编译器,并提供对编译器在编译过程的不同阶段所收集信息的轻松访问。
结果
在开始之前,我想让您先看一眼本文介绍的项目的最终形态。
第一个是重构扩展。它将方法内的变量声明提取出来。
第二个项目是一个代码生成器。它为 Enumerable 扩展方法创建重载,这些重载基于编译器推断显式类型函数参数的能力,从而允许用户在过滤到特定类型时省略 Enumerable.OfType
调用。下面是生成代码的测试方法的一瞥。
第三个向 C# 添加了一个简单的面向属性的功能,该功能与源代码完美集成。
背景
对于不熟悉 System.Linq 命名空间中的 Enumerable 扩展方法的读者,相应的 MSDN 页面可能会派上用场。其中一些方法在项目中得到了广泛使用。
请注意,一些屏幕截图仅在宽分辨率下显示效果良好。对于那些在较小分辨率下浏览(或计划打印本文)的人,我深感抱歉,但以两种不同的样式显示代码会很混乱,而且我认为没有一种尺寸能完美适应所有人。如果您无法阅读图片中的源代码,请单击它,它会在新标签页中打开。对于仍不满意的人来说,重新格式化代码以更好地符合他们的口味应该不难。所有源代码都附加在文章中。
我还要指出,API 目前远未得到充分记录,这主要是因为它可能会根据开发者的反馈而发生变化。我花费了大量时间探索 CTP,并正尽我所知来展示它。由于缺乏文档(以及其他因素造成的错误),不可避免地会产生错误。我决定写这篇文章的原因是,我认为我可以从与 CTP 提供的示例项目略有不同的角度来展示 Roslyn。
请记住,这是一个早期介绍。
代码重构
问题
重构是人们最想使用代码分析工具来完成的事情之一,而 Roslyn 已经为此领域提供了出色的支持。在等待 CTP 的过程中,我曾构思过一些我打算用它来构建的工具。然而,某些语言功能的暂时缺乏支持以及 API 的陌生外观让我有些害怕,促使我决定先用一个更“盒子内部”的项目来实验 Roslyn。
在 CTP 发布前的几天,有人在开发者论坛上询问是否有可以
- 将方法内的所有变量声明移到方法开头
- 保持声明顺序
- 将 var 关键字替换为相应的类型
- 一次处理任意数量的类,而无需手动点击每个方法。
他说这样可以让同事的代码更容易理解。
我本人很幸运,不必经常处理我认为写得不好的代码,但这听起来是一个完美的 Roslyn 任务,所以我选择它作为我的第一个实验项目。
构建过程
我发现处理单个文件是开始编写代码重构工具的最佳方式。解析文件内容非常简单快捷,无需为每次调试会话加载新的 Visual Studio 实例,也无需处理整个解决方案的特殊性,同时我们仍然可以使用整个 IDE 来验证生成代码。
我们可以通过以下方式获取给定代码片段的语法树
语法树包含了代码的所有语法信息。解析是无损的。我们可以在任何时候取回创建树时传入的精确文本。
由于我们要处理的最高层语法元素是方法,因此第一个目标是从语法树中提取方法节点。
我希望这个调用是不言自明的。唯一可能值得注意的是,我们正在过滤 BaseMethodDeclarationSyntax 而不是 MethodDeclarationSyntax,因为后者不包括我们想要处理的所有类型的方法(例如构造函数)。
现在我们可以遍历树中的每个方法节点。我们要用它们做什么?语法树(当然,节点也是)是不可变的。这意味着无法就地“更新”节点,如果我们想要某些不同之处,就必须创建一个新的节点。
最终,我们希望创建一个新的语法树,将旧树中的方法节点替换为我们期望的节点,而其他方面与旧树相同。
Syntax.MethodDeclaration
返回一个新的 MethodDeclarationSyntax 实例。在 Roslyn 中,使用 Syntax 类的工厂方法而不是使用 new 运算符是创建新的语法节点(以及标记和琐碎项)的标准方法。
SyntaxTree.Create
方法以文件名(对我们来说不重要)和根节点作为参数,并返回一个新树。
SyntaxNode.ReplaceNodes
接受一个枚举,其中包含作为调用该方法节点的后代节点的节点,以及一个接收旧节点作为参数并返回我们要替换它的新节点的函数。实际上,该函数接受 2 个参数,第二个参数是“用替换的后代重写的同一节点”,我在我的项目中找不到需要它的地方。但出于某种原因,没有只接受一个参数的预定义重载,所以我自作主张创建了一个扩展重载。
使用这个,可以这样重写调用
这一行的返回值正是我们需要的,它是一个新树的代码,在方法级别上与上面的旧树相同。
现在我们只需要创建一个新的方法节点,该节点基于满足问题规格的现有节点,我们就准备好了。
CTP 附带的 Syntax Visualizer Extension 提供了一种极佳的方式来检查方法节点的外观。
这是一个简单方法的有向语法图
void MethodName() {
var firstVarName = 1;
var secondVarName = "stringvalue";
}
如您从图中可以推断的,包含方法体的子节点可以通过 BaseMethodDeclarationSyntax.BodyOpt 属性访问。
SyntaxNode.ReplaceNode
返回一个新节点,其中只有一个原始后代被替换。
要更改方法或方法体,我们只需替换节点。但是,更改块内的语句列表有所不同。直接包含语句的不是语法节点,而是 SyntaxList。我们不能像替换节点那样替换它,我们需要更新整个块并为其提供一个满足我们需求的新列表。
因此,我们需要从头开始构建一个新的语句列表。
正如您可能也从语法图中推断出的,变量声明语句的类型是 LocalDeclarationStatementSyntax。这些是我们需要的处理对象,其他语句可以不经修改地进入新列表。
当然,完成列表处理后,我们必须将声明和语句连接起来,并将它们组合成新方法。
现在我们可以处理声明了。
如果我们尝试从局部声明语句中提取声明并查看其属性,我们会发现它包含一个变量声明列表而不是单个声明。这是因为支持多重声明。如果我们还想支持此功能,我们就需要处理其中的每一个。让我们看一下多重声明语句的语法图。
int firstVarName = 2, secondVarName = 3;
为了将变量声明分组,我创建了一个局部声明语句列表,命名为 declarations
。对于每个变量声明,我们应该将一个独立的声明添加到 declarations
中,并将一个独立的初始化添加到语句列表中。
我们先来处理前一部分。
如果我们像这样提取声明,它实际上在某些情况下会运行良好。
但对于隐式类型变量则不然,它们的类型将保持为 var。这不仅违反了问题的规范,而且生成的代码甚至无法编译。
这两个声明
将被转换为
我们需要找到隐式类型声明的实际类型。为此,在很多情况下,我们可以使用现有的语法树,并进行大量工作(只要类型未在其他程序集中定义),但幸运的是 Roslyn 为我们完成了语义分析。
如果我们能够访问语法树的语义模型,它就可以直接告诉我们其中表达式的类型。
要获取语义模型,我们需要一个提供相应程序集引用的编译。
var semanticModel = Compilation.Create("test").AddSyntaxTrees(tree)
.AddReferences(new AssemblyFileReference(typeof(object).Assembly.Location))
.GetSemanticModel(tree);
之后,以下调用将返回声明的类型,无论它们是显式类型还是隐式类型。
semanticModel.GetSemanticInfo(declaration.Declaration.Type).Type;
在功能上,这样使用效果很好。但是,TypeSymbol 的 Name 属性保存了相应类型的全名。这意味着上述声明将被解析为
这通常不是人们希望看到的类型显示方式。
因此,有一个方便的扩展方法 ISymbol.ToMinimalDisplayString
,它可以在语法树的某个位置找到可用符号的最短名称。
.ToMinimalDisplayString(tree.GetLocation(declaration), semanticModel)
如果我们用此调用替换 Name 属性,声明的类型现在将显示为
您可能已经注意到,将 var 关键字替换为显式类型也带来了另一个问题。
注释被存储在树中的 SyntaxTrivia。根据白皮书:“通常,一个标记会拥有同一行上它之后直到下一个标记的任何琐碎项。该行之后的任何琐碎项都与随后的标记相关联。”
声明之前的注释与它们的类型语法子项相关联。当这些被全新的内容替换时,注释就会丢失。
我们可以使用 Syntaxnode.WithLeadingTrivia
方法将前导琐碎项重新添加到我们新构建的节点中。
现在声明将被正确提取。
这段代码在方法主块内没有单独的块时运行良好。但它无法提取内部块中的变量。这不应令人惊讶,因为任何不是 LocalDeclarationStatementSyntax 类型的语句都将不被处理。以下代码
var var1 = 0;
if (var1 == 1) {
var var2 = 2;
var var3 = "3";
}
else {
var var4 = 4;
}
var var5 = 5;
将被转换为
int var1;
int var5;
//End of declarations
为了解决这个问题,我们需要以与处理方法主块完全相同的方式处理内部块。为此,我将块处理代码提取到一个委托中,并将其用于每个常规语句中的每个 BlockSyntax。
第一个问题是,内部块中的块将被多次处理,因为调用向 ReplaceNodes
提供了每个语句的每个后代。例如
var var0 = 0;
if (var0 == 0) {
var var1 = 1;
while (true) {
var var2 = 2;
}
}
int var0;
int var2;
int var1;
int var2;
//End of declarations
这很容易解决,因为 SyntaxNode.DescendentNodes
方法接受一个可选的函数参数,该参数可以指定我们不想进一步下降的节点。这不仅修复了错误,还通过消除对同一节点的多次访问来提高了性能。
我们还没有准备好,因为在处理空块时会发生异常。需要过滤掉它们。
现在这工作正常,但上一步引入了一些明显的冗余。
由于这是我们第二次使用 DescendentNodes
函数,我认为现在是时候向您展示我添加的另一个小扩展重载,以帮助其使用。
有了这个作用域,我们可以这样使用该方法:
我们也可以更新开头使用的调用,因为它实际上容易出现同样的错误(如果遇到空方法则抛出异常),并且效率较低,因为它会枚举方法中的每个节点。
好了,关于 DescendentNodes 就说这么多。
下一个问题是由同一方法内声明的具有相同名称的不同变量引起的。它们将导致多次声明同一个变量。
if (true) {
int q = 2;
} else {
double q = 2.0;
}
int q;
double q;
//End of declarations
嗯,我想到的最合理的解决方案是忽略具有这些名称的变量。
为此,首先创建一个不应处理的变量名查找表。
然后,在处理局部声明语句之前检查此集合。
好的,关于声明还有最后一件事要做,主要是因为我们正在使用 CTP,并且语义分析器不支持整个 C# 语言。由于缺少语言功能,它无法解析以下调用的类型,例如:
SyntaxTree tree = null;
var descendents = tree.Root.DescendentNodes();
SyntaxTree st;
var descendents;
//End of declarations
为了不让这个临时限制导致重构的代码无法编译,我们应该始终检查类型是否已成功解析。如果未解析,则将声明保留在原位,像处理其他语句一样处理它。
(type = semanticModel.GetSemanticInfo(localDeclaration.Declaration.Type).Type).Kind != SymbolKind.ErrorType
现在上面的提取的声明看起来像这样:
SyntaxTree tree;
//End of declarations
关于声明部分就到这里。让我们向前迈出一小步,开始处理初始化。别担心,这些需要的微调比声明少得多。
首先,这是简单变量初始化的语法树。
varName = 3;
我们需要提取的是类型为 BinaryExpressionSyntax 的表达式语句,以及 SyntaxKind AssignExpression.
。
效果很好,只要表达式的右侧没有语句块。这些将不会被处理。
Action a = () => {
int k = 1;
};
Action a;
//End of declarations
a = () => {
int k = 1;
};
为了解决这个问题,我们可以对每个变量的初始化器运行与我们已经对每个非声明性语句运行的相同的函数。
这实际上解决了有效代码的处理。唯一剩下的是格式化。这是方法的最终形态。
我希望在所有声明末尾添加注释的部分不会引起太多麻烦。
新根末尾的 SyntaxNode.Format
调用将必需的空格和其他必需的琐碎项插入其后代节点中。如果没有这个,我们就必须逐个插入这些琐碎项,这将导致代码更加冗长。
不幸的是,它目前无法自定义,并且只能根据默认设置进行格式化。因此,如果您有自定义需求,您可能会想在由它处理的每个文档上运行 IDE 中的Edit.FormatDocument 命令。如果这让您非常困扰,您可以编写自己的格式化方法,或者等待 Roslyn 的未来版本。我确信将来我们可以自定义它,因为目前在运行启用 Roslyn 的 Visual Studio 时打开Tools -> Options -> Text Editor -> CSharp -> Formatting 会导致异常,它也无法自定义。
除了缺乏自定义之外,我注意到的最大问题是它不喜欢 #region 块。具体来说,它会擦除关键字与其区域名称之间的现有空格,如下所示:
#region name
#regionname
从而导致代码无法编译。
作为一种快速修复,我只是将相应的字符串替换为正确的变体。这可能会意外地修改字符串 “#region”,需要手动修复,但这种情况的可能性非常小,而且我们无法让 CTP 完美运行。
所以方法就绪了。我们应该如何使用它?
如果我们想处理比文件更大的实体,Workspace 应该为我们提供一个简单的解决方案。
static void ProcessSolution(string fileName) {
var workspace = Workspace.LoadSolution(fileName);
foreach (var d in workspace.CurrentSolution.Projects.SelectMany(p => p.Documents))
workspace.UpdateDocumentAsync(d.Id, new StringText(ProcessCode(d.GetText())));
}
它存在的问题是,目前一些项目在将文档 ID 传递给 Workspace.UpdateDocument
时会抛出 InternalErrorException。这是一个非常糟糕的异常,我甚至无法在应用程序中处理它。经过快速搜索,我找到的唯一相关信息是 这个。我决定不推迟发布本文,以找到一个变通方法,直到它被修复(至少变成一个可捕获的异常)。如果您确实需要处理一个有故障项目的解决方案,并且无法等待 CTP 的未来版本,我建议您逐个处理文件。
然而,大多数项目不会引起任何问题。单独处理项目很简单。
static void ProcessProject(string fileName) {
var workspace = Workspace.LoadStandAloneProject(fileName);
foreach (var d in workspace.CurrentSolution.Projects.First().Documents)
workspace.UpdateDocumentAsync(d.Id, new StringText(ProcessCode(d.GetText())));
}
将方法放入扩展也很简单,并且能很好地协同工作。唯一的问题是,只有在 Roslyn 启用编辑器时才会生效,这在目前大大限制了它的可用性。
只需创建一个新的 Roslyn Code Refactoring Extension 项目。该模板非常直观易用。
结果
代码生成
当我开始使用 Roslyn 时,我写的第一行代码包含了许多
.DescendentNodes().OfType<TNode>().Where(node => /*<arbitrary condition>*/) 格式的调用。这是我第一次如此严重地依赖 OfType
扩展。我减少代码复杂性的第一个尝试导致了 Where
方法的一个重载。
它看起来像这样:
由于编译器可以从带有显式类型输入参数的函数推断类型参数,因此这允许我每次少调用一次就能获得相同的结果。正如我在上一节中所展示的,最终重载 DescendentNodes
取得了更好的结果,但这个重载让我思考,如果我为每个可行的扩展方法都有类似的东西会怎么样。这可以使代码在几种不同情况下更简单一些。
一个值得注意的问题是,仅 Enumerable 类就有 86 个不同的扩展(即这样的重载是有意义的)。构建、调试和(对于框架的未来版本)更新它们需要大量的工作。
当然,我一开始从未打算这样做。
长话短说,这是生成扩展方法的代码。
目前它只生成 66 个,因为 CTP 目前不支持可空类型。
我不想像解释第一个项目那样解释这个项目的构建过程,因为我大部分时间都在重复。现在几乎所有的调用都应该很熟悉了,其余调用的性质可以通过与第一节中描述的类似方式进行检查。
这是生成代码的片段。
以及演示这些扩展如何使代码更短的测试方法(当然,您也可以生成测试方法……让您的代码生成器生成测试其是否正常工作的代码)。
面向属性的编程
我选择了一个非常简单的例子来演示 Roslyn 如何用于为 C# 添加面向属性的功能。我制作的工具提供了两个可以应用于属性的属性。一个从属性的 setter 触发事件,另一个从其 getter 触发事件。不幸的是,它目前并非全自动。在应用属性后,它需要开发人员采取三个额外的步骤才能使其工作。它目前还依赖于 Reactive Extensions,因为 CTP 缺乏对标准 C# 样式的事件的支持。
实际使用效果如下:
当将适当的属性应用于没有实现相应事件的属性时,CodeIssueProvider 会指示错误。
当按下 Ctrl+. 键在错误上时,它会显示建议的快速修复。
应用快速修复后,代码应该会格式化。
最后,需要折叠由 SyntaxOutliner 放置的相应大纲。
这样做的主要目的是在开发项目时隐藏冗余代码。这是一个不太简单的例子。
用于 CodeIssueProvider 的代码。
以及用于 SyntaxOutliner 的代码。
当然,还有很多改进空间。它可以正确处理错误,在属性被移除时“清理”自己,接受参数进行自定义,处理非自动实现的属性,处理给定类的所有属性,构建新的完成列表以便私有成员不会污染类内智能感知等……
这应该是一个基本的概念验证。它依赖于 Roslyn 扩展的事实限制了它今天的可用性。
结论
Roslyn CTP 是一个非常令人兴奋的玩具。早在 PDC ’08,Anders Hejlsberg 就说过他们已经为此工作了一年,这意味着 C# 和 VB 团队现在也为此付出了极大的努力。虽然它确实存在一些问题——包括功能性和结构性问题——让消费者不断提醒它还不是一个完全成熟的工具,但总体而言,我发现它的设计非常令人满意。除了其公共 API 的潜力之外,设计团队还在考虑最终发布其源代码。
无论 Roslyn 的未来如何,它当前的发布已经打开了无数新的可能性。我希望本文能让您品尝到其中的一点滋味。
关于源代码
附带的非扩展项目将在 Visual Studio 11 或 Visual Studio 2010 SP1 中打开,但除非提供 CTP 的相应 DLL,否则它们将无法编译。重构扩展需要 Visual Studio 2010 SP1 并安装 Roslyn CTP,而第三个项目还需要在此之上引用 Reactive Extensions。
历史
- 2012-05-08:进行了一些微小的修改。
- 2011-12-26:小幅修订。
- 添加了索引、源代码和历史部分。
- 添加了生成代码的屏幕截图。
- 更改了一些句子的措辞。
- 2011-12-22:首次发布版本。