聊一聊.NET里的Source Generator。很多刚开始用源生成器的人,最大的困惑不是怎么写generator,而是生成的代码怎么跟手写的代码“拼”在一起。答案其实早就写在语言里了:partial。我自己的经验是,只要你把partial的几个使用范式玩顺,源生成器就从“魔法黑盒”变成一个相当顺手的编译期工具。这篇文章会从partial在源生成器里的两种核心角色讲起,带一个可以直接跑的示例,再聊聊怎么给生成器写自动化测试,最后分享几个踩坑点。适合正在写或准备写源生成器的朋友。
这里的“范式”不是数据库三范式,而是指写Source Generator时沉淀下来的几种固定套路:一种是partial class,生成器往用户已有的类型里补成员;另一种是partial method,用户只写方法声明,生成器负责生成实现。两者可以单独用,也能组合用。理解了这两种范式的边界,就能明白什么时候该让用户写partial关键字、什么时候生成器只需静默补文件,整个设计思路会清晰很多。
1. 为什么partial和Source Generator是天生一对
先回到编译器的视角。Roslyn源生成器的执行流程是:先把用户代码解析成语法树,再构建语义模型,然后运行生成器,最后把生成器输出的源码当成额外的语法树,和用户源码一起参与编译。也就是说,生成器本质上是在“编译过程的中途”往程序集里塞新代码,它不能回头去改用户写好的那个文件。
那问题就来了:生成器塞进来的代码,怎么和用户手写的类无缝合并?最直接的语言机制就是partial。一个班级的人分两拨写作业,最后合成一份完整的作业,靠的是“partial”这个约定:编译器知道同一个类型的定义可以分散在多个文件里,合并时把成员汇总到一起。所以从语言设计的角度说,partial就是给代码生成器预留的官方接口。
没有partial之前,想给一个已有类补充成员,只能继承、扩展方法或者改源代码,代价都不小。有了partial之后,生成器可以很自然地生成一个“配套文件”,用户代码里写半个类,生成器补另外半个,编译器负责合并。这也就是为什么所有与Source Generator相关的模板和官方示例,几乎都离不开partial关键字。
1.1 partial在Source Generator里的双重角色
partial在源生成器里有两个角色,很多人一开始会混淆。
第一个角色是partial type,也就是partial class、partial struct、partial interface、partial record。它的作用是让“类型定义可以分散到多个源文件”。用户在自己的文件里写public partial class Foo,生成器在另一个文件里也生成public partial class Foo,两个文件里的成员会被合并成一个类型。通过这种方式,生成器可以往用户类里添加属性、字段、方法、嵌套类型等。
第二个角色是partial method,也就是partial void DoSomething();这种声明。它的特点是:在一个文件中只写方法签名,在另一个文件中写实现。C# 9之前,partial方法有严格限制,必须返回void、不能有访问修饰符、不能有out参数;如果始终没人提供实现,编译器会自动移除方法以及所有调用点。C# 9之后放开了一大截,partial方法可以有返回值、可以带访问修饰符,但一旦带了这些,编译器就要求必须有实现部分。
对源生成器来说,partial type解决的是“代码合并到哪”的问题,partial method解决的是“用户怎么预留钩子、生成器怎么补全逻辑”的问题。两者正好对应了两种典型的设计思路:前者适合生成器主动往类型里塞东西,后者适合用户主动声明一个契约、由生成器来履约。
1.2 清楚了“范式”这个词,很多设计就顺了
我一直觉得“范式”这个词听起来玄,实际就是在说“固定的写法套路”。源生成器领域的partial范式,主要就是围绕“谁声明、谁实现、谁触发”这三个关系展开的。
- partial class范式:用户声明一个partial类型,生成器在同一类型名下生成额外成员。触发条件通常是某个Attribute,或者就是“碰到partial类就生成”。这种范式的特点是生成器占主导,用户只需要提供一个空壳类。
- partial method范式:用户声明一个或多个带partial的方法签名,生成器扫描这些签名并生成实现。触发条件是方法本身带partial且没有实现体。这种范式的特点是用户占主导,调用点写在用户代码里,生成器负责把方法体的“坑”填上。
- 组合范式:用户声明partial class,类里放若干partial方法声明,生成器同时生成类成员和partial方法实现。这是最完整的用法,既补类型又补逻辑。
把这几种范式放在一起看,你会发现它们之间的关系是“由表及里”的:partial class决定骨架,partial method决定逻辑。写生成器之前先想清楚自己属于哪一种,代码结构几乎就定了一半。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. partial class与partial method:两种最常用的源生成器范式
2.1 partial class范式:给已有类型“续写代码”
partial class范式的核心是“合并”,适用场景非常广。最典型的例子是INotifyPropertyChanged。用户写一个包含属性的partial类,生成器扫描到这些属性后,在同一类型的另一个partial文件里生成属性变更事件、旧值比较、OnPropertyChanged调用等。整个过程中,用户类原本的代码基本不用改,生成器只负责补全。
我遇到过一个真实案例:项目里有个几十个属性的配置类,手写INotifyPropertyChanged不仅枯燥还容易漏,改一个属性名要动三四个地方。后来我用partial class范式写了个生成器,用户代码只需要声明属性,生成器负责生成完整的通知逻辑。最爽的是,每次改动属性,重新编译就生效,不会再出现“改了属性忘了通知界面”这种低级错误。
partial class范式有几个要点:
- 用户类必须显式声明partial,否则生成器生成的第二份类声明会触发CS0260(缺少partial修饰符)。生成器没办法在编译期修改用户文件,只能靠约定和文档提醒。
- 生成的文件建议统一加
partial,并且文件名以.g.cs结尾。这样IDE会把它识别为生成代码,默认折叠,也方便排查问题。 - 生成器侧通常还要加一些标记特性,比如
[global::System.CodeDom.Compiler.GeneratedCode("MyGenerator", "1.0.0")],避免代码分析工具对生成文件做重复检查。
2.2 partial method范式:让用户写好钩子,生成器补全实现
partial method范式的思路完全反过来:用户先写“钩子”,生成器来填实现。
这种范式常见于需要“可插拔”逻辑的场景。比如你在一个partial类里写:
csharp复制public partial class Logger
{
partial void WriteLog(string message);
}
然后在同一个类的另一个方法里调用WriteLog(...)。生成器扫描到WriteLog只有声明没有实现,就自动生成一个实现,把日志输出到控制台、文件或者某个日志框架。如果未来想换实现,只需要删掉生成器或者换一个生成策略,用户代码完全不用动。
这个模式还有一个特别好用的点:旧式partial方法(void、无修饰符)在“没有实现”时,编译器会移除调用点,相当于调用什么都不发生。这意味着生成器没接上的时候,程序也能正常编译运行,只是功能缺失;接上生成器后,功能立刻生效。这种“渐进式增强”体验非常友好,尤其适合库作者给用户提供可选能力。
2.3 C# 9给partial method带来的变化,直接影响生成器怎么写
写生成器之前必须搞清楚C# 9前后的语法差异,否则生成的代码可能编译不过。
C# 9之前,partial方法必须满足这几个条件:返回类型必须是void;不能有访问修饰符(默认private);不能有out参数;不能有virtual、abstract、override等修饰符。声明和实现都必须带partial关键字。如果只有声明没有实现,编译器会悄悄把调用点从代码里抹掉。
C# 9开始,partial方法允许有非void返回类型、允许有访问修饰符、允许带out参数。但一旦你用了这些新特性,就必须提供实现部分,不能再依赖“未实现就移除调用点”的旧行为。换句话说,C# 9的partial method更像是“严格的契约”:声明了就必须实现,不实现就编译报错。
对生成器来说,这两套规则都要兼容。判断一个partial方法是否需要生成实现,核心逻辑是看它有没有对应的实现部分。有就不生成,没有再生成。我在生成器里的判断方式是:
csharp复制if (methodSymbol is null) return;
if (methodSymbol.PartialImplementationPart is not null) return; // 已有实现
if (!methodSymbol.IsPartialDefinition) return; // 不是definition部分
把IsPartialDefinition和PartialImplementationPart两个API结合起来,就能比较稳妥地区分“待生成”和“已实现”。
3. 实操:写一个基于partial method的生成器
3.1 项目结构和引用关系
我建议用一个独立解决方案来做演示,包含三个项目:
Demo.Generator:类库,目标框架netstandard2.0,引用Microsoft.CodeAnalysis.CSharp,实现生成器。Demo.Sample:普通控制台或类库项目,引用Demo.Generator,里面写partial类的用户代码。Demo.Generator.Tests:xunit测试项目,引用生成器项目,用于自动化测试。
生成器项目的.csproj关键配置:
xml复制<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
</ItemGroup>
</Project>
用户项目的引用方式要说一下,得把生成器项目当成Analyzer引进来:
xml复制<ItemGroup>
<ProjectReference Include="..\Demo.Generator\Demo.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
很多人第一次写生成器都栽在引用配置上,以为普通ProjectReference就行。实际上生成器必须作为Analyzer编译进去,OutputItemType="Analyzer"就是关键。
3.2 生成器核心代码:扫描partial方法声明并输出实现
这个生成器的目标很简单:扫描用户代码中所有“没有实现体”的partial方法,以类型为单位生成一个新的partial类文件,并为这些方法补上实现。为了演示方便,生成的方法体统一输出一行Console.WriteLine,再返回default值。
完整代码如下:
csharp复制using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace Demo.Generator
{
[Generator(LanguageNames.CSharp)]
public sealed class PartialMethodGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var methodDeclarations = context.SyntaxProvider.CreateSyntaxProvider(
static (node, _) => node is MethodDeclarationSyntax method
&& method.Modifiers.Any(SyntaxKind.PartialKeyword)
&& method.Body is null
&& method.ExpressionBody is null,
static (ctx, _) =>
{
var symbol = ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) as IMethodSymbol;
if (symbol is null || symbol.PartialImplementationPart is not null || !symbol.IsPartialDefinition)
{
return null;
}
return symbol;
})
.Where(static m => m is not null)
.Collect();
context.RegisterSourceOutput(methodDeclarations, static (spc, methods) =>
{
if (methods.IsDefaultOrEmpty) return;
foreach (var group in methods.GroupBy(m => m!.ContainingType, SymbolEqualityComparer.Default))
{
var type = group.Key;
if (type.ContainingType is not null)
{
// 为简化示例,跳过嵌套类型
continue;
}
var source = GenerateSource(type, group.Where(m => m is not null).Cast<IMethodSymbol>());
var hintName = type.ToDisplayString()
.Replace('<', '_')
.Replace('>', '_')
.Replace(',', '_')
.Replace('.', '_') + ".g.cs";
spc.AddSource(hintName, SourceText.From(source, Encoding.UTF8));
}
});
}
private static string GenerateSource(INamedTypeSymbol type, IEnumerable<IMethodSymbol> methods)
{
var ns = type.ContainingNamespace.IsGlobalNamespace
? null
: type.ContainingNamespace.ToDisplayString();
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System;");
sb.AppendLine();
if (ns is not null)
{
sb.AppendLine("namespace " + ns);
sb.AppendLine("{");
}
var typeDecl = BuildTypeDeclaration(type);
sb.Append(" ");
sb.AppendLine(typeDecl);
sb.AppendLine(" {");
foreach (var method in methods)
{
var signature = BuildMethodSignature(method);
sb.Append(" ");
sb.AppendLine(signature);
sb.AppendLine(" {");
sb.AppendLine(" System.Console.WriteLine(\"[Generated] " + method.Name + " called\");");
if (method.ReturnsVoid)
{
sb.AppendLine(" return;");
}
else
{
sb.AppendLine(" return default;");
}
sb.AppendLine(" }");
sb.AppendLine();
}
sb.AppendLine(" }");
if (ns is not null)
{
sb.AppendLine("}");
}
return sb.ToString();
}
private static string BuildTypeDeclaration(INamedTypeSymbol type)
{
var accessibility = type.DeclaredAccessibility switch
{
Accessibility.Public => "public ",
Accessibility.Internal => "internal ",
_ => ""
};
var name = type.Name;
if (type.TypeParameters.Length > 0)
{
name += "<" + string.Join(", ", type.TypeParameters.Select(t => t.Name)) + ">";
}
return $"{accessibility}partial class {name}";
}
private static string BuildMethodSignature(IMethodSymbol method)
{
var accessibility = method.DeclaredAccessibility switch
{
Accessibility.Public => "public ",
Accessibility.Internal => "internal ",
Accessibility.Protected => "protected ",
Accessibility.Private => "private ",
_ => ""
};
var returnType = method.ReturnsVoid ? "void" : method.ReturnType.ToDisplayString();
var typeParams = method.TypeParameters.Length > 0
? "<" + string.Join(", ", method.TypeParameters.Select(t => t.Name)) + ">"
: "";
var parameters = string.Join(", ", method.Parameters.Select(p =>
(p.RefKind == RefKind.Ref ? "ref " : p.RefKind == RefKind.Out ? "out " : "") +
p.Type.ToDisplayString() + " " + p.Name));
return $"{accessibility}partial {returnType} {method.Name}{typeParams}({parameters})";
}
}
}
这个实现故意保持简单,没有处理嵌套类型、泛型约束、特性复制等复杂情况,但核心逻辑已经完整:识别待生成的partial方法、按类型聚合、生成partial class和partial方法实现。
3.3 用户侧代码长什么样
用户只需要在项目里写一个partial类,类里留一个或多个partial方法声明:
csharp复制using System;
namespace Demo.Sample
{
public partial class Calculator
{
partial void BeforeCalculate();
public int Add(int a, int b)
{
BeforeCalculate();
return a + b;
}
}
}
编译后,生成器会自动补上:
csharp复制// <auto-generated/>
#nullable enable
using System;
namespace Demo.Sample
{
public partial class Calculator
{
partial void BeforeCalculate()
{
System.Console.WriteLine("[Generated] BeforeCalculate called");
return;
}
}
}
两个文件会被编译器合并,Add方法里的BeforeCalculate()调用就会真实执行。如果生成器不生效,BeforeCalculate()调用会被旧式partial方法规则悄悄移除,程序依然能编译运行,只是没有日志输出。这种“接了就有、不接就没有”的效果,非常适合做可插拔功能。
3.4 调试生成器的三个实用技巧
写这玩意儿最头疼的是出问题不知道内部发生了什么。调试技巧比业务代码更重要。
第一招,在生成器代码里加Debugger.Launch()。在Initialize或者RegisterSourceOutput里写一行,运行编译时就会弹出调试器附加窗口。这样能直接单步跟踪生成器内部逻辑,看它到底有没有扫到目标方法。
第二招,用ReportDiagnostic把调试信息暴露出来。你可以生成一个隐藏诊断,把扫描到的partial方法名称输出到错误列表窗口:
csharp复制context.RegisterSourceOutput(methodDeclarations, static (spc, methods) =>
{
foreach (var method in methods)
{
if (method is null) continue;
spc.ReportDiagnostic(Diagnostic.Create(
new DiagnosticDescriptor("SGDEBUG001", "debug", "found partial method: {0}", "Debug", DiagnosticSeverity.Info, true),
method.Locations.FirstOrDefault(),
method.Name));
}
});
第三招,开启生成器文件输出。在用户项目的.csproj中加入:
xml复制<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)GeneratedFiles</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
编译后直接到obj/GeneratedFiles目录看生成的.cs文件。这是排查“生成结果不对”最快的方式,不用猜,文件内容一目了然。
4. 给源生成器写自动化测试:从单测到快照
4.1 为什么生成器更需要测试
生成器是“编译期跑的程序”,它直接影响所有引用它的项目的编译结果。一旦生成逻辑出错,轻则编译失败,重则生成一堆运行期才暴露的坏代码。更麻烦的是,生成器平时没有运行时入口,不像业务代码那样可以直接调用调试,所以自动化测试几乎是唯一可靠的验证手段。
我写生成器项目时,会强制要求核心逻辑都有单测覆盖,尤其是“什么情况该生成、什么情况不该生成”这类边界逻辑。少了测试,改一次生成规则就可能让所有下游项目集体编译报错,还不一定能在第一时间发现。
4.2 最基础的单测驱动方式
测试生成器不需要起进程,直接用Roslyn的API在内存里构建编译对象即可。核心步骤是:把用户代码解析成语法树,创建CSharpCompilation,创建CSharpGeneratorDriver,运行生成器,然后检查生成的源码和编译诊断。
一个最小可用的xunit测试如下:
csharp复制using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Xunit;
namespace Demo.Generator.Tests
{
public class PartialMethodGeneratorTests
{
private static (Compilation outputCompilation, GeneratorDriverRunResult result) RunGenerator(string source)
{
var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp12);
var syntaxTree = CSharpSyntaxTree.ParseText(source, parseOptions);
var references = new[]
{
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
MetadataReference.CreateFromFile(typeof(System.Console).Assembly.Location),
MetadataReference.CreateFromFile(typeof(System.Runtime.GCSettings).Assembly.Location),
};
var compilation = CSharpCompilation.Create(
"TestAssembly",
new[] { syntaxTree },
references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var generator = new PartialMethodGenerator();
GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _);
return (outputCompilation, driver.GetRunResult());
}
[Fact]
public void Should_generate_implementation_for_partial_method()
{
var source = """
public partial class Calculator
{
partial void BeforeCalculate();
}
""";
var (_, result) = RunGenerator(source);
var generated = string.Join("\n", result.GeneratedTrees.Select(t => t.GetText().ToString()));
Assert.Contains("partial void BeforeCalculate()", generated);
Assert.Contains("[Generated] BeforeCalculate called", generated);
}
[Fact]
public void Should_skip_when_partial_method_already_has_implementation()
{
var source = """
public partial class Calculator
{
partial void BeforeCalculate();
}
public partial class Calculator
{
partial void BeforeCalculate() { Console.WriteLine("hi"); }
}
""";
var (outputCompilation, _) = RunGenerator(source);
var errors = outputCompilation.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error)
.Select(d => d.ToString());
Assert.Empty(errors);
}
}
}
注意测试里一定要设置LanguageVersion。不设的话默认可能是较低版本,partial方法的新语法会直接解析失败,测试看起来就像生成器有问题。
4.3 测试“生成后的代码能正常编译”
只检查生成文本包含某段字符串是不够的,最稳的断言是“生成的代码参与编译后没有错误”。上面第二个测试就已经体现了这个思路:生成器跑完,拿到outputCompilation,再检查诊断。
这一步非常关键。很多时候生成器输出的代码肉眼看着没问题,一编译就暴露问题,比如缺少using、类型名写错、访问修饰符不匹配。只有把生成结果塞回编译流程,才能发现这些隐蔽问题。我在实际项目中,几乎每个测试都会同时断言“生成文本符合预期”和“编译诊断无Error”,两个条件缺一不可。
4.4 快照测试与真实项目集成测试
当生成器输出越来越复杂时,逐个断言字符串会变得很啰嗦。这时候可以用快照测试。我用过Verify.SourceGenerators这个库,它能把生成的代码保存成一个.verified.cs文件,后续跑测试时自动比对差异。生成结果有变化时,测试会红,人工确认后可以更新快照。这个流程对重构生成器输出格式特别友好。
除了单测,我还建议在真实的示例项目里加一个“集成测试”:直接引用生成器项目,写一个小的partial类,然后通过反射加载生成的程序集,调用方法确认运行期行为正确。这种测试能覆盖单测模拟不出来的真实编译环境,比如项目引用、NuGet包、各程序集路径等差异。
5. 常见陷阱与排查技巧实录
5.1 partial方法调用点“神秘消失”
旧式partial方法在没有实现时,调用点会被编译器移除,这是语言规则,不是bug。很多第一次用partial method范式的人会写一个partial void Foo();,在某个方法里调用Foo(),发现生成器没生效时一切静悄悄,连个警告都没有,很容易误判“代码已经执行了”。
排查方法:确认生成器是否真的跑起来,直接看obj/GeneratedFiles下有没有生成的文件;或者临时在方法体里加Debugger.Launch();再不行就dotnet build加/v:diag,搜生成器名称。如果生成文件存在但方法调用还是没触发,检查方法声明格式是否满足C# 9要求,比如带访问修饰符的partial方法必须有实现,调用点不会消失,此时报错也是明明白白的错误。
5.2 生成了重复实现导致编译失败
这是我自己踩得最深的一个坑。生成器判断“是否需要生成实现”时,如果只按“语法节点有没有Body”来判断,遇到用户在另一个文件里手写了实现,生成器还是会生成一份,直接导致CS0111(类型已包含同名同参数方法)。
正确的做法必须上升到语义层面,用IMethodSymbol.PartialImplementationPart来判断是否已有实现。这个属性只有在确实存在实现部分时才非空,比肉眼扫描语法树可靠得多。生成器开发中,能用Symbol判断的就不要用Syntax判断,这条经验适用于所有类似场景。
5.3 增量生成器缓存导致代码不更新
IIncrementalGenerator有缓存机制,理论上能明显提升编译速度,但如果你的缓存key设计不合理,就会出现改了用户代码但生成结果没变的诡异现象。常见场景是:生成逻辑依赖某个配置文件的版本号,但缓存key只用了配置文件的路径,没用内容哈希。
解决思路是:所有会影响输出结果的依赖,都必须体现在管道值里。如果你使用的是ForAttributeWithMetadataName,ensure传入的transform返回值包含了所有依赖项。如果是RegisterSyntaxProvider,要注意对语法节点做Equals比较时要带上语义信息,否则缓存可能错误复用。这个问题的排查最难,建议遇到“改代码不生效”先怀疑缓存,清一下生成文件再编译对比。
5.4 IDE不显示生成的文件
有时候生成器运行正常,但IDE的解决方案资源管理器里看不到生成文件,很影响调试体验。Roslyn本身会为生成文件提供“Analyzer”节点,但很多开发者不知道去哪看。
在Visual Studio里,展开项目节点,找到“分析器”或“Dependencies/ Analyzers”,展开对应生成器程序集,里面能找到Source Generators节点,再展开就能看到生成的.cs文件。如果文件名以.g.cs结尾,IDE通常还会默认把它当生成文件折叠处理。用VS Code的同学可以通过obj/GeneratedFiles目录查看,或者装Roslyn相关的扩展辅助浏览。
5.5 测试环境引用缺失导致类型解析失败
单测里创建CSharpCompilation时,最容易出的问题就是引用集不够。typeof(object).Assembly.Location只包含mscorlib/System.Private.CoreLib的一部分,但生成代码里用了System.Console,就需要把System.Console所在程序集也加进来。
我习惯的做法是:先创建编译对象,把各种常见的引用都加上,比如System.Console、System.Runtime、System.Linq。如果测试报“type or namespace not found”,优先检查是不是缺少引用,而不是生成器逻辑问题。还有一个技巧:直接用AppDomain.CurrentDomain.GetAssemblies()把当前已加载的所有程序集转成MetadataReference,虽然会让测试稍慢,但能省掉很多引用上的烦恼。
5.6 partial类跨文件时访问修饰符不一致
partial class有个规则:多个部分声明中,如果有一处写了访问修饰符,其它部分可以省略,但不能冲突。生成器生成文件里的类型声明,我一般建议和用户文件保持一致:用户写public partial class Calculator,生成器也写public partial class Calculator。如果用户只写partial class Calculator(默认internal),生成器写了public,就会报不一致错误。
这个坑看似简单,实际很常见。因为生成器源码里写死public太容易了,一旦用户类不是public就编译失败。稳妥做法是从INamedTypeSymbol.DeclaredAccessibility映射出实际访问级别,再拼到生成文本里。上面示例代码就是这么做的。
6. 最后分享一点个人经验
写到现在,我最大的体会是:Source Generator的魅力不在“能生成代码”,而在“生成的代码能优雅融入手写代码”。而partial就是这个融入过程的核心媒介。与其把生成器做成一个黑盒,不如花点时间把“谁声明、谁实现、谁触发”这几个关系理清楚,设计思路会一下子变得很顺。
还有一点想提醒的是,能先写测试就先写测试。生成器这东西一旦依赖真实编译环境,回归成本很高,自动化测试能帮你兜住大部分低级错误。千万别偷懒跳过快照测试,等到下游项目编译炸了再回头查,那滋味真不好受。
最后再分享一个小技巧:生成器的输出代码里,建议在文件头部加上// <auto-generated/>和#pragma warning disable。前者能让IDE正确识别生成文件、避免误编辑,后者能防止用户的代码分析规则把生成代码报一堆警告。这些细节虽然不影响编译结果,但能少很多日常噪音。希望这篇关于partial范式和测试的总结,能帮你在写源生成器的路上少踩几个坑。
