https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md
更新于2022年3月10日
Summary
增量生成器是一种与源生成器并存的新API,它允许用户指定可以由宿主层以高性能方式应用的生成策略。
High Level Design Goals
- 允许使用更细粒度的方法来定义生成器
- 缩放源代码生成器以支持Visual Studio中的“Roslyn/CoreCLR”缩放项目
- 利用细粒度步骤之间的缓存来减少重复工作
- 支持生成更多只包含源文本的项目
-
Simple Example
我们首先定义一个简单的增量生成器,该生成器提取附加文本文件的内容,并使其内容作为编译时常量可用。在下一节中,我们将围绕所显示的概念进行更深入的探讨。
[Generator]
public class Generator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext initContext)
{
// define the execution pipeline here via a series of transformations:
// find all additional files that end with .txt
IncrementalValuesProvider<AdditionalText> textFiles = context.AdditionalTextsProvider.Where(static file => file.Path.EndsWith(".txt"));
// read their contents and save their name
IncrementalValuesProvider<(string name, string content)> namesAndContents = textFiles.Select((text, cancellationToken) => (name: Path.GetFileNameWithoutExtension(text.Path), content: text.GetText(cancellationToken)!.ToString()));
// generate a class that contains their values as const strings
context.RegisterSourceOutput(namesAndContents, (spc, nameAndContent) =>
{
spc.AddSource($"ConstStrings.{nameAndContent.name}", $@"
public static partial class ConstStrings
{{
public const string {nameAndContent.name} = ""{nameAndContent.content}"";
}}");
});
}
}
Implementation
增量生成器是
Microsoft.CodeAnalysis.IIncrementalGenerator
的实现。namespace Microsoft.CodeAnalysis
{
public interface IIncrementalGenerator
{
void Initialize(IncrementalGeneratorInitializationContext initContext);
}
}
与源生成器一样,增量生成器在外部程序集中定义,并通过
-analyzer:
选项传递给编译器。实现需要使用Microsoft.CodeAnalysis.GeneratorAttribute
和一个指示生成器支持的语言的可选参数进行批注:[Generator(LanguageNames.CSharp)]
public class MyGenerator : IIncrementalGenerator { ... }
Pipeline based execution
基于流水线的执行
IIncrementalGenerator
有一个Initialize
方法,该方法由宿主(例如IDE或命令行编译器)仅调用一次,而不考虑可能发生的进一步编译的次数。例如,具有多个加载项目的宿主可以在多个项目之间共享相同的生成器实例,并且在宿主的生命周期内只调用Initialize
一次。
与源码生成器专用的Execute
方法不同,增量生成器是将不可变的执行管道定义为初始化的一部分。Initialize
方法接收IncrementalGeneratorInitializationContext
的实例,生成器使用该实例定义一组转换。public void Initialize(IncrementalGeneratorInitializationContext initContext)
{
// define the execution pipeline here via a series of transformations:
}
定义的转换不会在初始化时直接执行,而是会推迟到它们使用的数据发生更改时执行。从概念上讲,这类似于LINQ,在LINQ中,在实际迭代可枚举数之前,可能不会执行lambda表达式:
IEnumerable:var squares = Enumerable.Range(1, 10).Select(i => i * 2);
// the code inside select is not executed until we iterate the collection
foreach (var square in squares) { ... }
这些转换用于形成操作的有向图,该图可以在以后输入数据更改时按需执行。
Incremental Generators:IncrementalValuesProvider<AdditionalText> textFiles = context.AdditionalTextsProvider.Where(static file => file.Path.EndsWith(".txt"));
// the code in the Where(...) above will not be executed until the value of the additional texts actually changes
在每次转换之间,生成的数据都会被缓存,从而允许在适用的情况下重用先前计算的值。这种缓存减少了后续编译所需的计算。有关详细信息,请参见缓存。
IncrementalValue[s]Provider
管道以不透明数据源的形式提供输入数据,可以是
IncrementalValueProvider<T>
或IncrementalValuesProvider<T>
(请注意复数值),其中T
是提供的输入数据类型。
初始提供程序集由主机创建,可以从初始化期间提供的IncrementalGeneratorInitializationContext
访问。
目前可用的提供商包括: CompilationProvider
- AdditionalTextsProvider
- AnalyzerConfigOptionsProvider
- MetadataReferencesProvider
- ParseOptionsProvider
注意:没有访问语法节点的提供程序。这是以一种略有不同的方式处理的。有关详细信息,请参阅SyntaxValueProvider。
可以将值提供程序视为容纳值本身的“盒子”。执行管道不直接访问value provider中的值。
IValueProvider<TSource>
┌─────────────┐
| |
│ TSource │
| |
└─────────────┘
相反,生成器提供一组转换,这些转换将应用于提供程序中包含的数据,进而创建一个新的值提供程序。
Select
最简单的转换是Select
。这将通过对一个提供程序应用转换将该提供程序中的值映射到新的提供程序。
IValueProvider<TSource> IValueProvider<TResult>
┌─────────────┐ ┌─────────────┐
│ │ Select<TSource,TResult1> │ │
│ TSource ├───────────────────────────►│ TResult │
│ │ │ │
└─────────────┘ └─────────────┘
生成器转换可以认为在概念上有点类似于LINQ,值提供程序取代了IEnumerable<T>
。转换是通过一组扩展方法创建的:
public static partial class IncrementalValueSourceExtensions
{
// 1 => 1 transform
public static IncrementalValueProvider<TResult> Select<TSource, TResult>(this IncrementalValueProvider<TSource> source, Func<TSource, CancellationToken, TResult> selector);
public static IncrementalValuesProvider<TResult> Select<TSource, TResult>(this IncrementalValuesProvider<TSource> source, Func<TSource, CancellationToken, TResult> selector);
}
请注意,这些方法的返回类型也是IncrementalValue[s]Provider
的实例。这允许生成器将多个转换链接在一起:
IValueProvider<TSource> IValueProvider<TResult1> IValueProvider<TResult2>
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ │ Select<TSource,TResult1> │ │ Select<TResult1,TResult2> │ │
│ TSource ├───────────────────────────►│ TResult1 │──────────────────────────►│ TResult2 │
│ │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
请考虑以下简单示例:
initContext.RegisterExecutionPipeline(static context =>
{
// get the additional text provider
IncrementalValuesProvider<AdditionalText> additionalTexts = context.AdditionalTextsProvider;
// apply a 1-to-1 transform on each text, which represents extracting the path
IncrementalValuesProvider<string> transformed = additionalTexts.Select(static (text, _) => text.Path);
// transform each extracted path into something else
IncrementalValuesProvider<string> prefixTransform = transformed.Select(static (path, _) => "prefix_" + path);
});
注意transform
和prefixTransform
本身是如何成为IncrementalValuesProvider
的。它们表示将应用的转换结果,而不是结果数据。
Multi Valued providers
IncrementalValueProvider<T>
将始终提供单个值,而IncrementalValuesProvider<T>
可以提供零个或多个值。例如,CompilationProvider
将始终生成单个编译实例,而AdditionalTextsProvider
将生成数量可变的值,具体取决于传递给编译器的附加文本的数量。
从概念上讲,考虑从IncrementalValueProvider<T>
转换单个项目很简单:单个项目应用了选择器函数,该函数会生成单个TResult
值。
但是,对于IncrementalValuesProvider<T>
,此转换更为微妙。选择器函数被多次应用,值提供程序中的每一项都应用一次。然后使用每个转换的结果为结果值提供程序创建值:
Select<TSource, TResult>
.......................................
. ┌───────────┐ .
. selector(Item1) │ │ .
. ┌────────────────►│ Result1 ├───┐ .
. │ │ │ │ .
IncrementalValuesProvider<TSource> . │ └───────────┘ │ . IncrementalValuesProvider<TResult>
┌───────────┐ . │ ┌───────────┐ │ . ┌────────────┐
│ │ . │ selector(Item2) │ │ │ . │ │
│ TSource ├──────────────┼────────────────►│ Result2 ├───┼─────────►│ TResult │
│ │ . │ │ │ │ . │ │
└───────────┘ . │ └───────────┘ │ . └────────────┘
3 items . │ ┌───────────┐ │ . 3 items
[Item1, Item2, Item3] . │ selector(Item3) │ │ │ . [Result1, Result2, Result3]
. └────────────────►│ Result3 ├───┘ .
. │ │ .
. └───────────┘ .
.......................................
正是这种逐项转换允许缓存在此模型中特别强大。考虑IncrementalValueProvider<TSource>
内的值何时更改。任何给定的更改可能一次只更改一项,而不是整个集合(例如,用户键入附加文本仅更改给定文本,其他附加文本保持不变)。
当发生这种情况时,生成器驱动程序可以将输入项与之前使用的项进行比较。如果它们被认为相等,则可以跳过这些项的转换,而使用以前计算的版本。有关更多详细信息,请参阅比较项目(Comparing Items)。
在上图中,如果Item2
发生变化,我们将对修改后的值执行选择器,为Result2
生成一个新值。由于Item1
和Item3
保持不变,驱动程序可以自由地跳过执行选择器,而只使用上一次执行中缓存的Result1
和Result3
的值。
Select Many
除了上面显示的1对1转换之外,还有一些转换可以生成批量数据。例如,给定的转换可能希望为每个输入生成多个值。有一组SelectMany
方法可以实现一对多或多对多项的转换:
1 to many:
public static partial class IncrementalValueSourceExtensions
{
public static IncrementalValuesProvider<TResult> SelectMany<TSource, TResult>(this IncrementalValueProvider<TSource> source, Func<TSource, CancellationToken, IEnumerable<TResult>> selector);
}
SelectMany<TSource, TResult>
.......................................
. ┌───────────┐ .
. │ │ .
. ┌──►│ Result1 ├───┐ .
. │ │ │ │ .
IncrementalValueProvider<TSource> . │ └───────────┘ │ . IncrementalValuesProvider<TResult>
┌───────────┐ . │ ┌───────────┐ │ . ┌────────────┐
│ │ . selector(Item)│ │ │ │ . │ │
│ TSource ├────────────────────────────┼──►│ Result2 ├───┼─────────►│ TResult │
│ │ . │ │ │ │ . │ │
└───────────┘ . │ └───────────┘ │ . └────────────┘
Item . │ ┌───────────┐ │ . 3 items
. │ │ │ │ . [Result1, Result2, Result3]
. └──►│ Result3 ├───┘ .
. │ │ .
. └───────────┘ .
.......................................
Many to many:
public static partial class IncrementalValueSourceExtensions
{
public static IncrementalValuesProvider<TResult> SelectMany<TSource, TResult>(this IncrementalValuesProvider<TSource> source, Func<TSource, CancellationToken, IEnumerable<TResult>> selector);
}
SelectMany<TSource, TResult>
...............................................
. ┌─────────┐ .
. │ │ .
. ┌────►│ Result1 ├───────┐ .
. │ │ │ │ .
. │ └─────────┘ │ .
. selector(Item1) │ │ .
.┌─────────────────┘ ┌─────────┐ │ .
.│ │ │ │ .
IncrementalValuesProvider<TSource>.│ ┌────►│ Result2 ├───────┤ . IncrementalValuesProvider<TResult>
┌───────────┐ .│ │ │ │ │ . ┌────────────┐
│ │ .│ selector(Item2) │ └─────────┘ │ . │ │
│ TSource ├─────────────┼─────────────────┤ ┌─────────┐ ├──────────────►│ TResult │
│ │ .│ │ │ │ │ . │ │
└───────────┘ .│ └────►│ Result3 ├───────┤ . └────────────┘
3 items .│ │ │ │ . 7 items
[Item1, Item2, Item3] .│ selector(Item3) └─────────┘ │ . [Result1, Result2, Result3, Result4,
.└─────────────────┐ │ . Result5, Result6, Result7 ]
. │ ┌─────────┐ │ .
. │ │ │ │ .
. ├────►│ Result4 ├───────┤ .
. │ │ │ │ .
. │ └─────────┘ │ .
. │ ┌─────────┐ │ .
. │ │ │ │ .
. ├────►│ Result5 ├───────┤ .
. │ │ │ │ .
. │ └─────────┘ │ .
. │ ┌─────────┐ │ .
. │ │ │ │ .
. └────►│ Result6 ├───────┘ .
. │ │ .
. └─────────┘ .
...............................................
例如,考虑一组包含多个相同类型元素的附加XML文件。生成器可能希望将每个元素视为用于生成的不同项,从而有效地将单个附加文件拆分成多个子项。
initContext.RegisterExecutionPipeline(static context =>
{
// get the additional text provider
IncrementalValuesProvider<AdditionalText> additionalTexts = context.AdditionalTextsProvider;
// extract each element from each additional file
IncrementalValuesProvider<MyElementType> elements = additionalTexts.SelectMany(static (text, _) => /*transform text into an array of MyElementType*/);
// now the generator can consider the union of elements in all additional texts, without needing to consider multiple files
IncrementalValuesProvider<string> transformed = elements.Select(static (element, _) => /*transform the individual element*/);
}
Where
WHERE允许作者通过给定谓词对值提供程序中的值进行过滤。WHERE实际上是SELECT MULE的一种特定形式,其中每个输入恰好转换为1或0输出。但是,由于它是如此常见的操作,因此它被直接作为原语转换提供。
public static partial class IncrementalValueSourceExtensions
{
public static IncrementalValuesProvider<TSource> Where<TSource>(this IncrementalValuesProvider<TSource> source, Func<TSource, bool> predicate);
}
Select<TSource, TResult>
.......................................
. ┌───────────┐ .
. predicate(Item1)│ │ .
. ┌────────────────►│ Item1 ├───┐ .
. │ │ │ │ .
IncrementalValuesProvider<TSource> . │ └───────────┘ │ . IncrementalValuesProvider<TResult>
┌───────────┐ . │ │ . ┌────────────┐
│ │ . │ predicate(Item2) │ . │ │
│ TSource ├──────────────┼─────────────────X ├─────────►│ TResult │
│ │ . │ │ . │ │
└───────────┘ . │ │ . └────────────┘
3 Items . │ ┌───────────┐ │ . 2 Items
. │ predicate(Item3)│ │ │ .
. └────────────────►│ Item3 ├───┘ .
. │ │ .
. └───────────┘ .
.......................................
一个明显的用例是过滤输出生成器知道它不感兴趣的输入。例如,生成器可能希望在过滤中添加关于文件扩展名的文本:
initContext.RegisterExecutionPipeline(static context =>
{
// get the additional text provider
IncrementalValuesProvider<AdditionalText> additionalTexts = context.AdditionalTextsProvider;
// filter additional texts by extension
IncrementalValuesProvider<string> xmlFiles = additionalTexts.Where(static (text, _) => text.Path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));
}
Collect
在具有多个项的值提供程序上执行转换时,将项视为单个集合而不是一次查看一个项通常很有用。为此,存在Collect
转换。Collect
将IncrementalValuesProvider<T>
转换为IncrementalValueProvider<ImmutableArray<T>>
。从本质上讲,它将多值源转换为具有所有项数组的单值源。
public static partial class IncrementalValueSourceExtensions
{
IncrementalValueProvider<ImmutableArray<TSource>> Collect<TSource>(this IncrementalValuesProvider<TSource> source);
}
IncrementalValuesProvider<TSource> IncrementalValueProvider<ImmutableArray<TSource>>
┌───────────┐ ┌─────────────────────────┐
│ │ Collect<TSource> │ │
│ TSource ├─────────────────────────────────►│ ImmutableArray<TSource> │
│ │ │ │
└───────────┘ └─────────────────────────┘
3 Items Single Item
Item1 [Item1, Item2, Item3]
Item2
Item3
initContext.RegisterExecutionPipeline(static context =>
{
// get the additional text provider
IncrementalValuesProvider<AdditionalText> additionalTexts = context.AdditionalTextsProvider;
// collect the additional texts into a single item
IncrementalValueProvider<AdditionalText[]> collected = context.AdditionalTexts.Collect();
// perform a transformation where you can access all texts at once
var transform = collected.Select(static (texts, _) => /* ... */);
}
Multi-path pipelines
到目前为止描述的转换都是有效的单路径操作:虽然给定提供程序中可能有多个项,但每个转换都在单个输入值提供程序上操作,并生成单个派生的输出提供程序。
虽然对于简单操作来说已经足够,但通常需要组合来自多个输入提供程序的值或多次使用转换结果。为此,有一组转换将单路径转换拆分并组合成多路径管道。
Split
可以将变换的输出分割成多个并行输入。这可以通过简单地使用与多个转换的输入相同的值提供程序来实现,而不是使用专用的转换。
IncrementalValueProvider<TResult>
┌───────────┐
Select<TSource,TResult> │ │
IncrementalValueProvider<TSource> ┌───────────────────────►│ TResult │
┌───────────┐ │ │ │
│ │ │ └───────────┘
│ TSource ├─────────────┤
│ │ │
└───────────┘ │ IncrementalValuesProvider<TResult2>
│ ┌───────────┐
│ SelectMany<TSource,TResult2> │ │
└─────────────────────────────►│ TResult2 │
│ │
└───────────┘
然后,这些变换可以用作新的单路径变换的输入,彼此独立。
例如:
initContext.RegisterExecutionPipeline(static context =>
{
// get the additional text provider
IncrementalValuesProvider<AdditionalText> additionalTexts = context.AdditionalTextsProvider;
// apply a 1-to-1 transform on each text, extracting the path
IncrementalValuesProvider<string> transformed = additionalTexts.Select(static (text, _) => text.Path);
// split the processing into two paths of derived data
IncrementalValuesProvider<string> nameTransform = transformed.Select(static (path, _) => "prefix_" + path);
IncrementalValuesProvider<string> extensionTransform = transformed.Select(static (path, _) => Path.ChangeExtension(path, ".new"));
}
nameTransform
和extensionTransform
为同一组附加文本输入生成不同的值。例如,如果还有一个名为file.txt
的附加文件,那么nameTransform
将生成字符串prefix_file.txt
,其中extensionTransform
将生成字符串file.new
。
当附加文件的值更改时,生成的后续值可能不同,也可能不同。例如,如果将附加文件的名称更改为file.xml
,那么nameTransform
现在将生成prefix_file.xml
,而extensionTransform
仍然将生成file.new
。带有nameTransform
输入的任何子转换都将使用新值重新运行,但extensionTransform
的任何子转换都将使用以前缓存的版本,因为它的输入没有更改。
Combine
合并是最强大的,但也是最复杂的转换。它允许生成器接受两个输入提供程序并创建单个统一的输出提供程序。
Single-value to single-value:
public static partial class IncrementalValueSourceExtensions
{
IncrementalValueProvider<(TLeft Left, TRight Right)> Combine<TLeft, TRight>(this IncrementalValueProvider<TLeft> provider1, IncrementalValueProvider<TRight> provider2);
}
当组合两个单值提供程序时,结果节点在概念上很容易理解:一个包含两个输入项的元组的新值提供程序。
IncrementalValueProvider<TSource1>
┌───────────┐
│ │
│ TSource1 ├────────────────┐
│ │ │ IncrementalValueProvider<(TSource1, TSource2)>
└───────────┘ │
Single Item │ ┌────────────────────────┐
│ Combine<TSource1, TSource2> │ │
Item1 ├─────────────────────────────────────────►│ (TSource1, TSource2) │
│ │ │
IncrementalValueProvider<TSource2> │ └────────────────────────┘
┌───────────┐ │ Single Item
│ │ │
│ TSource2 ├────────────────┘ (Item1, Item2)
│ │
└───────────┘
Single Item
Item2
Multi-value to single-value:
public static partial class IncrementalValueSourceExtensions
{
IncrementalValuesProvider<(TLeft Left, TRight Right)> Combine<TLeft, TRight>(this IncrementalValuesProvider<TLeft> provider1, IncrementalValueProvider<TRight> provider2);
}
但是,当将多值提供程序组合为单值提供程序时,语义会稍微复杂一些。生成的多值提供程序生成一系列元组:每个元组的左侧是从多值输入生成的值,而右侧始终是来自单值提供程序输入的相同的单值。
IncrementalValuesProvider<TSource1>
┌───────────┐
│ │
│ TSource1 ├────────────────┐
│ │ │
└───────────┘ │
3 Items │ IncrementalValuesProvider<(TSource1, TSource2)>
│
LeftItem1 │ ┌────────────────────────┐
LeftItem2 │ Combine<TSource1, TSource2> │ │
LeftItem3 ├─────────────────────────────────────────►│ (TSource1, TSource2) │
│ │ │
│ └────────────────────────┘
IncrementalValueProvider<TSource2> │ 3 Items
┌───────────┐ │
│ │ │ (LeftItem1, RightItem)
│ TSource2 ├────────────────┘ (LeftItem2, RightItem)
│ │ (LeftItem3, RightItem)
└───────────┘
Single Item
RightItem
Multi-value to multi-value:
如上面的定义所示,不可能将多值源组合到另一多值源。生成的交叉联接可能包含大量值,因此默认情况下不提供该操作。
相反,作者可以在一个输入多值提供程序上调用Collect()
,以生成可以如上所述组合的单值提供程序。
IncrementalValuesProvider<TSource1>
┌───────────┐
│ │
│ TSource1 ├──────────────┐
│ │ │
└───────────┘ │
3 Items │ IncrementalValuesProvider<(TSource1, TSource2[])>
│
LeftItem1 │ ┌────────────────────────┐
LeftItem2 │ Combine<TSource1, TSource2[]> │ │
LeftItem3 ├─────────────────────────────────────────►│ (TSource1, TSource2) │
│ │ │
│ └────────────────────────┘
IncrementalValuesProvider<TSource2> IncrementalValueProvider<TSource2[]> │ 3 Items
┌───────────┐ ┌────────────┐ │
│ │ Collect<TSource2> │ │ │ (LeftItem1, [RightItem1, RightItem2, RightItem3])
│ TSource2 ├───────────────────────────┤ TSource2[] ├──────────────┘ (LeftItem2, [RightItem1, RightItem2, RightItem3])
│ │ │ │ (LeftItem3, [RightItem1, RightItem2, RightItem3])
└───────────┘ └────────────┘
3 Items Single Item
RightItem1 [RightItem1, RightItem2, RightItem3]
RightItem2
RightItem3
通过以上转换,生成器作者现在可以接受一条或多条输入,并将它们组合成单个数据源。例如:
initContext.RegisterExecutionPipeline(static context =>
{
// get the additional text provider
IncrementalValuesProvider<AdditionalText> additionalTexts = context.AdditionalTextsProvider;
// combine each additional text with the parse options
IncrementalValuesProvider<(AdditionalText, ParseOptions)> combined = context.AdditionalTextsProvider.Combine(context.ParseOptionsProvider);
// perform a transform on each text, with access to the options
var transformed = combined.Select(static (pair, _) =>
{
AdditionalText text = pair.Left;
ParseOptions parseOptions = pair.Right;
// do the actual transform ...
});
}
如果组合的任一输入发生更改,则后续转换将重新运行。但是,对于每个输出元组,缓存是以成对的方式考虑的。例如,在上面的示例中,如果只有其他文本更改,则后续转换将仅对更改的文本运行。将跳过其他文本和解析选项对,并使用其先前计算的值。如果单个值发生更改,如示例中的解析选项,则对每个元组执行转换。
SyntaxValueProvider
语法节点不能直接通过值提供程序使用。相反,生成器作者使用特殊的SyntaxValueProvider
(通过IncrementalGeneratorInitializationContext.SyntaxProvider
)提供来创建专用的输入节点,该节点公开他们感兴趣的语法子集。语法提供程序以这种方式进行专门化,以实现所需的性能级别。
CreateSyntaxProvider:
目前,提供程序公开了允许作者构造输入节点的单一方法CreateSyntaxProvider
。
public readonly struct SyntaxValueProvider
{
public IncrementalValuesProvider<T> CreateSyntaxProvider<T>(Func<SyntaxNode, CancellationToken, bool> predicate, Func<GeneratorSyntaxContext, CancellationToken, T> transform);
}
请注意这是如何采用两个lambda参数的:一个单独检查SyntaxNode
,第二个然后可以使用GeneratorSyntaxContext
访问语义模型并转换节点以供下游使用。
正是由于这种分割,才能实现性能:当驱动程序知道选择哪些节点进行检查时,当语法树保持不变时,它可以安全地跳过第一个predicate
lambda。即使对于未更改的文件中的节点,驱动程序仍将重新运行第二个transform
lambda,因为一个文件中的更改可能会影响另一个文件中节点的语义。
请考虑以下语法树:
// file1.cs
public class Class1
{
public int Method1() => 0;
}
// file2.cs
public class Class2
{
public Class1 Method2() => null;
}
// file3.cs
public class Class3 {}
作为作者,我可以创建一个输入节点来提取返回类型信息
initContext.RegisterExecutionPipeline(static context =>
{
// create a syntax provider that extracts the return type kind of method symbols
var returnKinds = context.SyntaxProvider.CreateSyntaxProvider(static (n, _) => n is MethodDeclarationSyntax,
static (n, _) => ((IMethodSymbol)n.SemanticModel.GetDeclaredSymbol(n.Node)).ReturnType.Kind);
}
最初,predicate
将针对所有语法节点运行,并选择两个MethodDeclarationSyntax
节点Method1()
和Method2()
。然后将这些传递给transform
,在转换中使用语义模型来获取方法符号并提取方法的返回类型。returnKinds
将包含两个值,均为NamedType
。
现在假设file3.cs
被编辑:
// file3.cs
public class Class3 {
public int field;
}
predicate
将只对file3.cs
内的语法节点运行,并且不会返回任何东西,因为它仍然不包含任何方法符号。但是,对于Class1
和class2
中的两个方法,transform
仍将再次运行。
要了解为什么需要重新运行transform
,请考虑对file1.cs
进行以下编辑,其中我们更改了类名:
// file1.cs
public class Class4
{
public int Method1() => 0;
}
predicate
将为file1.cs
重新运行,因为它已更改,并将再次选择方法符号Method1()
。接下来,因为对所有方法重新运行transform
,所以Method2()
的返回类型种类被正确地更改为Error
,因为Class1
不再存在。
注意,我们不需要为file2.cs
中的节点运行predicate
,即使它们引用了file1.cs
中的内容。因为第一次检查纯粹是语法检查,所以我们可以确保file2.cs
的结果是相同的。
虽然驱动程序必须为所有选定的语法节点运行transform
,这似乎很不幸,但如果不这样做,则可能会由于跨文件依赖而产生不正确的数据。因为初始语法检查允许驱动程序实质上过滤语义检查必须在其上重新运行的节点数,所以在编辑语法树时仍然观察到显著改进的性能特征。
Outputting values
在管道中的某个点,作者可能希望实际使用转换后的数据来生成输出,例如SourceText
。IncrementalGeneratorInitializationContext
上有一组Register...Output
方法,允许生成器作者从一系列转换构造输出。
这些输出注册是终端,因为它们不返回值提供程序,也不能对其应用进一步的转换。但是,作者可以自由地使用不同的输入转换注册相同类型的多个输出。
这组输出方法包括
- RegisterSourceOutput
- RegisterImplementationSourceOutput
- RegisterPostInitializationOutput
RegisterSourceOutput:RegisterSourceOutput
允许生成器作者生成将包含在用户编译中的源文件和诊断信息。作为输入,它接受一个Value[s]Provider
和一个将为值提供程序中的每个值调用的Action<SourceProductionContext,TSource>
。
public static partial class IncrementalValueSourceExtensions
{
public void RegisterSourceOutput<TSource>(IncrementalValueProvider<TSource> source, Action<SourceProductionContext, TSource> action);
public void RegisterSourceOutput<TSource>(IncrementalValuesProvider<TSource> source, Action<SourceProductionContext, TSource> action);
}
提供的SourceProductionContext
可用于添加源文件和报告诊断:
public readonly struct SourceProductionContext
{
public CancellationToken CancellationToken { get; }
public void AddSource(string hintName, string source);
public void ReportDiagnostic(Diagnostic diagnostic);
}
例如,生成器可以提取附加文件的路径集,并创建打印它们的方法:
initContext.RegisterExecutionPipeline(static context =>
{
// get the additional text provider
IncrementalValuesProvider<AdditionalText> additionalTexts = context.AdditionalTextsProvider;
// apply a 1-to-1 transform on each text, extracting the path
IncrementalValuesProvider<string> transformed = additionalTexts.Select(static (text, _) => text.Path);
// collect the paths into a batch
IncrementalValueProvider<ImmutableArray<string>> collected = transformed.Collect();
// take the file paths from the above batch and make some user visible syntax
initContext.RegisterSourceOutput(collected, static (sourceProductionContext, filePaths) =>
{
sourceProductionContext.AddSource("additionalFiles.cs", @"
namespace Generated
{
public class AdditionalTextList
{
public static void PrintTexts()
{
System.Console.WriteLine(""Additional Texts were: " + string.Join(", ", filePaths) + @" "");
}
}
}");
});
}
RegisterImplementationSourceOutput:RegisterImplementationSourceOutput
的工作方式与RegisterSourceOutput
相同,但声明从代码分析的角度来看,生成的源代码对用户代码没有语义影响。这允许主机(如IDE)选择不运行这些输出作为性能优化。生成可执行代码的主机将始终运行这些输出。
RegisterPostInitializationOutput:RegisterPostInitializationOutput
允许生成器作者在初始化运行后立即提供源代码。它不接受输入,因此不能引用用户编写的任何源代码或任何其他编译器输入。
POST初始化源在运行任何其他转换之前包括在编译中,这意味着它将作为常规执行管道的剩下的一部分可见,并且作者可能会询问有关它的语义问题。
它对于向用户源代码添加属性特别有用。然后,用户可以添加这些代码,生成器可以通过语义模型找到属性代码。
Handling Cancellation
增量生成器设计为在交互式主机(如IDE)中使用。因此,生成器尊重并响应传入的取消令牌至关重要。
通常,每次转换执行的用户计算量可能很低,但通常会调用可能会对性能产生重大影响的Roslyn APIs。因此,作者应该始终将提供的取消令牌转发给任何接受它的Roslyn APIs。
例如,在检索其他文件的内容时,应将令牌传递给GetText(...)
:
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var txtFiles = context.AdditionalTextsProvider.Where(static f => f.Path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
// ensure we forward the cancellation token to GeText
var fileContents = txtFiles.Select(static (file, cancellationToken) => file.GetText(cancellationToken));
}
这将确保增量生成器正确且快速地响应取消请求,并且不会在主机中造成延迟。
如果生成器创建者正在做一些开销很大的事情,比如循环遍历值,他们应该自己定期检查是否取消。建议作者定期使用CancellationToken.ThrowIfCancellationRequested()
,并允许主机重新运行它们,而不是尝试保存部分生成的结果,因为这可能极难正确创作。
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var txtFilesArray = context.AdditionalTextsProvider.Where(static f => f.Path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)).Collect();
var expensive = txtFilesArray.Select(static (files, cancellationToken) =>
{
foreach (var file in files)
{
// check for cancellation so we don't hang the host
cancellationToken.ThrowIfCancellationRequested();
// perform some expensive operation (ideally passing in the token as well)
ExpensiveOperation(file, cancellationToken);
}
});
}
Caching
虽然细粒度的步骤允许通过生成器主机对输出类型进行一些粗略的控制,但是只有当驱动程序可以将输出从一个流水线步骤缓存到下一个流水线步骤时,才能真正看到性能优势。虽然我们通常说ISourceGenerator
中的Execute方法应该是确定性的,但是增量生成器主动要求此属性为真。
在计算要作为步骤的一部分应用的所需转换时,生成器驱动程序可以自由查看它以前看到的输入,并为这些输入使用以前计算和缓存的转换值。
请考虑以下转换:
IValuesProvider<string> transform = context.AdditionalTextsProvider
.Select(static (t, _) => t.Path)
.Select(static (p, _) => "prefix_" + p);
在流水线的第一次执行期间,将针对每个附加文件执行两个λ中的每一个:
AdditionalText | Select1 | Select2 |
---|---|---|
Text{ Path: “abc.txt” } | “abc.txt” | “prefix_abc.txt” |
Text{ Path: “def.txt” } | “def.txt” | “prefix_def.txt” |
Text{ Path: “ghi.txt” } | “ghi.txt” | “prefix_ghi.txt” |
现在考虑这样一种情况:在将来的某个迭代中,第一个附加文件已更改,并且具有不同的路径,而第二个文件已更改,但其路径保持不变。
AdditionalText | Select1 | Select2 |
---|---|---|
Text{ Path: “diff.txt” } | ||
Text{ Path: “def.txt” } | ||
Text{ Path: “ghi.txt” } |
生成器将对第一个和第二个文件运行select1,分别生成“Diff.txt”和“def.txt”。但是,它不需要重新运行第三个文件的SELECT,因为输入没有更改。它只能使用以前缓存的值。
AdditionalText | Select1 | Select2 |
---|---|---|
Text{ Path: “diff.txt” } | “diff.txt” | |
Text{ Path: “def.txt” } | “def.txt” | |
Text{ Path: “ghi.txt” } | “ghi.txt” |
接下来,驱动程序将运行Select2。它将对"Diff.txt"
进行操作,生成"prefix_diff.txt"
,但是当涉及到def.txt
时,它可以观察到生成的项与上一次迭代相同。尽管原始输入(Text{path:"def.txt"})
已更改,但对其执行Select1的结果是相同的。因此,不需要在"def.txt"
上重新运行Select2,因为它只需要使用之前缓存的值。类似地,可以使用"ghi.txt"
的缓存状态。
AdditionalText | Select1 | Select2 |
---|---|---|
Text{ Path: “diff.txt” } | “diff.txt” | “prefix_diff.txt” |
Text{ Path: “def.txt” } | “def.txt” | “prefix_def.txt” |
Text{ Path: “ghi.txt” } | “ghi.txt” | “prefix_ghi.txt” |
这样,只有相应的更改才会流经管道,从而避免重复工作。如果生成器只依赖于AdditionalTexts
,那么驱动程序知道当SyntaxTree
更改时不会有任何工作要做。
Comparing Items
为了让用户提供的结果在迭代之间具有可比性,需要有一些等价的概念。默认情况下,主机将使用EqualityCompeller<T>
来确定等价性。显然,有时这是不够的,并且存在一个扩展方法,它允许作者提供一个比较器,在比较给定转换的值时应该使用该比较器:
public static partial class IncrementalValueProviderExtensions
{
public static IncrementalValueProvider<TSource> WithComparer<TSource>(this IncrementalValueProvider<TSource> source, IEqualityComparer<TSource> comparer);
public static IncrementalValuesProvider<TSource> WithComparer<TSource>(this IncrementalValuesProvider<TSource> source, IEqualityComparer<TSource> comparer);
}
允许生成器作者指定给定的比较器。
var withComparer = context.AdditionalTextsProvider
.Select(static t => t.Path)
.WithComparer(myComparer);
请注意,比较器是基于每个转换的,这意味着作者可以为管道的不同部分指定不同的比较器。
var select = context.AdditionalTextsProvider.Select(static t => t.Path);
var noCompareSelect = select.Select(...);
var compareSelect = select.WithComparer(myComparer).Select(...);
同一选择节点在充当一个转换的输入时可以没有比较器,在充当不同转换的输入时仍然可以提供一个比较器。
仅当派生给定比较器的项已被修改时,宿主才会调用该比较器。当输入值是新的或被删除时,或者输入转换被确定为缓存(可能由提供的比较器缓存)时,不会考虑给定的比较器。
Authoring a cache friendly generator
增量生成器的成功在很大程度上将取决于创建一个可缓存的最佳管道。本节包括实现这一目标的一些一般提示和最佳实践
Extract out information early: 最好在管道中尽早从输入中获取信息。这可确保主机不会缓存大而昂贵的对象(如符号)。
Use value types where possible: 值类型更易于缓存,并且通常具有定义良好且易于理解的比较语义。
Use multiple transformations: 您将操作分解成的转换越多,缓存的机会就越多。将转换视为执行图中的“检查点”。检查点越多,匹配缓存值并跳过任何剩余工作的机会就越大。
Build a data model: 与其试图将每个输入项传递到Register...Output
方法中,不如考虑构建一个数据模型,使其成为传递到输出的最终项。使用转换来操作数据模型,并且具有定义良好的等价性,允许您在模型的修订之间进行正确的比较。这还使测试最终的Register...Output
变得非常简单:您只需使用虚拟数据模型调用该方法并检查生成的代码,而不是试图模拟增量转换。
Consider the order of combines: 确保你只组合了最少量的所需信息(这是为了“提早提取信息”)。
考虑以下(不正确的)组合,其中组合了基本输入,然后用于生成某些源:
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var compilation = context.CompilationProvider;
var texts = context.AdditionalTextsProvider;
// Don't do this!
var combined = texts.Combine(compilation);
context.RegisterSourceOutput(combined, static (spc, pair) =>
{
var assemblyName = pair.Right.AssemblyName;
// produce source ...
});
当用户在IDE中键入时,任何时候编译都会频繁更改,然后RegisterSourceOutput
将重新运行。相反,应首先查找依赖于编译的信息,然后将其与其他文件组合在一起:
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var assemblyName = context.CompilationProvider.Select(static (c, _) => c.AssemblyName);
var texts = context.AdditionalTextsProvider;
var combined = texts.Combine(assemblyName);
context.RegisterSourceOutput(combined, (spc, pair) =>
{
var assemblyName = pair.Right;
// produce source ...
});
}
现在,当用户在IDE中键入内容时,assemyName
转换将重新运行,但是非常便宜,并且每次返回的值都很可能是相同的。这意味着,除非附加文本也已更改,否则主机不需要重新运行合并或重新生成任何源。