CSharp 实现强制类型限定 之前偶然有些灵感,想着如果遇到了如下情景,需要此处的 object 只能是某种特定类型,比如实现了 ITestInterface
这个接口,那么大概会写成下面这样。
1 2 3 4 5 6 7 public void Method (object obj ){ if (obj is ITestInterface target) { } }
这样做自然没啥问题,但是只有到运行时才知道 obj 的类型到底符不符合要求,那么能不能把检查提前到编译期 呢?也就是在编写代码的时候就能够很大程度避免使用不合要求的类型。
对于 C# 来说,其实可以写一个诊断分析器来实现。
以下使用 Unity 2022.3 对应的环境
编写之前 那么按照我的个人习惯。这里先定义一个 TypeConstraintAttribute
,要注意由于是给参数做一个限定,所以这里需要指定这个 attribute 的目标为 Parameter
,后面的两个一般都是设置 false
就行了。
这里 AllowMultiple
可以考虑设置成 true(也就是一个参数可以挂多个 [TypeConstraint]
),理由后面再说(
1 2 3 4 5 6 7 8 9 10 [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false) ] public sealed class TypeConstraintAttribute : Attribute { public Type[] Constraints { get ; } public TypeConstraintAttribute (params Type[] constraints ) { Constraints = constraints; } }
但是只定义 attr 自然是没啥用的,一般可能会经常配合反射,源生成器,分析器之类的来用。
根据 Unity 官方手册中的说明,对于 Unity2022.3 的 editor,使用的源生成器项目目标框架需要是 netstandard2.0
,然后依赖的 Microsoft.CodeAnalysis.CSharp
需要是 3.8.0
版本
所以去另外创建一个新项目:
1 2 3 dotnet new classlib -n Analysis -f netstandard2.0 cd Analysisdotnet add package Microsoft.CodeAnalysis.CSharp --version 3.8 .0
然后顺便打开 Analysis.csproj
把语言版本设置成 9.0
(要不然默认就只有 C#7.3
)
现在配置文件里面应该大概是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 <Project Sdk ="Microsoft.NET.Sdk" > <PropertyGroup > <TargetFramework > netstandard2.0</TargetFramework > <LangVersion > 9.0</LangVersion > </PropertyGroup > <ItemGroup > <PackageReference Include ="Microsoft.CodeAnalysis.CSharp" Version ="3.8.0" /> </ItemGroup > </Project >
编写分析器 在项目文件夹中新建一个 TypeConstraintAnalyzer.cs
,然后在就是诊断器的起手式(x
1 2 3 4 5 6 7 8 9 10 11 12 13 14 namespace Analysis { [DiagnosticAnalyzer(LanguageNames.CSharp) ] public sealed class TypeConstraintAnalyzer : DiagnosticAnalyzer { public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => throw new NotImplement(); public override void Initialize (AnalysisContext context ) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); } } }
SupportedDiagnostics
可以先不管,然后就是注册一些行为,一般可能注册符号行为或者语法节点行为()
对于我们要实现对类型的限定,那么第一步就是先解析函数定义 处,其参数上的 [TypeConstraint(...)]
中的提供的那些限定类型。这意味我们需要把解析到的相关信息储存,然后第二步解析函数调用时根据先前的信息类判断是否符合限定。
分析 Attribute 定义一个 AnalyzeSymbol
,并注册:
1 2 3 4 5 6 7 8 9 10 public override void Initialize (AnalysisContext context ){ context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.Method); } private void AnalyzeSymbol (SymbolAnalysisContext context ){ }
然后就是检查参数是否有 TypeConstraintAttribute
之类的,最后把对应的参数信息储存,这里我们可以定义一个字段来储存
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 private static readonly Dictionary<IMethodSymbol, Dictionary<IParameterSymbol, ImmutableHashSet<INamedTypeSymbol>>> MethodConstraints = new ();private void AnalyzeSymbol (SymbolAnalysisContext context ){ if (context.Symbol is not IMethodSymbol methodSymbol) return ; if (methodSymbol.Parameters.Length is 0 ) return ; var parametersConstraints = new Dictionary<IParameterSymbol, ImmutableHashSet<INamedTypeSymbol>>(); foreach (var parameter in methodSymbol.Parameters) { var attrs = parameter.GetAttributes().Where(attr => attr.AttributeClass.Name == "TypeConstraintAttribute" ); if (attrs.Any() is false ) continue ; var typeConstraintAttr = attrs.First(); var allowedTypes = typeConstraintAttr.ConstructorArguments .SelectMany(arg => arg.Values) .Select(arg => arg.Value as INamedTypeSymbol) .Where(type => type is not null ) .ToImmutableHashSet(); if (allowedTypes.Any() is false ) continue ; parametersConstraints.Add(parameter, allowedTypes); } if (parametersConstraints.Any()) { MethodConstraints[methodSymbol] = parametersConstraints; } }
如果想要测试有没有正确去解析,可以打一些 log 到文件里面(如果要方便点建议使用绝对路径吧)
这里的直接储存到字段会给你一个警告:不要将每次编译的数据存储到诊断分析器的字段中
如果懒得去优化的话,就直接在文件开头添加一行 #pragma warning disable RS1008
就行。
顺便说明一下上面的 allowedTypes
吧:
在 roslyn 分析器中不能直接去获取一个 Attribute 内部的字段信息,一般是从构造函数来获取信息。
在先前我们定义的 attr 时候是这样的:
1 2 3 4 public TypeConstraintAttribute (params Type[] constraints ){ Constraints = constraints; }
也就是 constraints 参数是一个 Type[] ,虽然使用的时候可以像是任意数量参数那样,但是解析符号的时候这里是一个参数,所以就需要先 SelectMany 再 Select ,如果直接 typeConstraintAttr.ConstructorArguments.Select(...)
就会异常,具体的表现就是在别的项目里使用该分析器的时候会没有任何表现(因为分析器运行到这里就崩溃了)
分析调用 注册一个新的行为,这次我们注册到 SyntaxNodeAction
(因为这个是在 SymbolAction
之后执行的,不过我猜这一步注册 SymbolAction
应该没啥问题)
然后分析的目标是调用表达式(Invocation Expression
)
1 2 3 4 5 6 7 8 9 public override void Initialize (AnalysisContext context ){ context.RegisterSyntaxNodeAction(AnalyzeMethodCall, SyntaxKind.InvocationExpression); } private void AnalyzeMethodCall (SyntaxNodeAnalysisContext context ){ }
判断被调用的函数是不是有 [TypeConstraint(...)]
限定过的函数(也就是检查是不是在之前储存的那个字典就行),并获取调用时的实参,以及定义时的参数信息
1 2 3 4 5 6 7 8 9 10 11 private void AnalyzeMethodCall (SyntaxNodeAnalysisContext context ){ var invocation = (InvocationExpressionSyntax)context.Node; if (context.SemanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol methodSymbol || MethodConstraints.ContainsKey(methodSymbol) is false ) return ; var parametersConstraints = MethodConstraints[methodSymbol]; var arguments = invocation.ArgumentList.Arguments; var parameters = methodSymbol.Parameters; }
然后遍历参数,并判断是否满足限制,这里需要注意 arg 和 param 要对应上
1 2 3 4 5 6 7 8 9 10 11 12 foreach (var (argument, parameter) in arguments.Zip(parameters, (arg, param) => (arg, param))){ var argumentType = context.SemanticModel.GetTypeInfo(argument.Expression).Type; if (parametersConstraints.TryGetValue(parameter, out var allowedTypes)) { var isValid = allowedTypes.Any(allowed => SymbolEqualityComparer.Default.Equals(allowed, argumentType) || argumentType.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, allowed)) || (argumentType.BaseType != null && SymbolEqualityComparer.Default.Equals(argumentType.BaseType, allowed))); } }
最后就是根据实参是否合法,来抛出提示就行了。我们定义一个新的规则,比如这样:
这里应该也会有一个警告,懒得搞也是在文件开头加一行: #pragma warning disable RS2008
也可以按照官方的说明:https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
创建 AnalyzerReleases.Unshipped.md
和 AnalyzerReleases.Shipped.md
两个文件,并且按照格式把规则信息补充(
1 2 3 4 5 6 7 8 9 10 11 private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( id: "ANALY001" , title: "无效的参数类型" , messageFormat: "参数类型 '{0}' 不满足 TypeConstraintAttribute 中的约束条件, 类型 '{0}' 需要为 '{1}' 中的一种, 或派生于其中之一。" , category: "Usage" , defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true , description: "参数类型不满足 TypeConstraintAttribute 中的约束条件" ); public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
然后在原来检测的地方获取上下文并抛出错误
1 2 3 4 5 if (isValid is false ){ var diagnostic = Diagnostic.Create(Rule, argument.GetLocation(), argumentType, string .Join(", " , allowedTypes.Select(type => type.Name))); context.ReportDiagnostic(diagnostic); }
在 Unity 中使用分析器 把项目 build 一下,把 dll 文件按照 Unity 官方手册的说明 https://docs.unity3d.com/cn/2022.3/Manual/roslyn-analyzers.html 应用到项目中,之后就可以测试了。
在前面的实现中,我由于是判断只有满足多个限制中的一个就行,所以多个类型之间其实是 or
的关系。
在最开始我说定义 TypeConstraintAttribute
的时候,可以把 AllowMultiple
设置为 true,这样的话就可以考虑多个 [TypeConstraint(...)]
之间是 and
关系,这里我就不实现了(