CSharp实现强制类型限定
CSharp 实现强制类型限定
之前偶然有些灵感,想着如果遇到了如下情景,需要此处的 object 只能是某种特定类型,比如实现了 ITestInterface 这个接口,那么大概会写成下面这样。
public void Method(object obj)
{
if (obj is ITestInterface target)
{
// ...
}
}
这样做自然没啥问题,但是只有到运行时才知道 obj 的类型到底符不符合要求,那么能不能把检查提前到编译期呢?也就是在编写代码的时候就能够很大程度避免使用不合要求的类型。
对于 C# 来说,其实可以写一个诊断分析器来实现。
以下使用 Unity 2022.3 对应的环境
编写之前
那么按照我的个人习惯。这里先定义一个 TypeConstraintAttribute ,要注意由于是给参数做一个限定,所以这里需要指定这个 attribute 的目标为 Parameter ,后面的两个一般都是设置 false 就行了。
这里
AllowMultiple可以考虑设置成 true(也就是一个参数可以挂多个[TypeConstraint]),理由后面再说(
[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版本
所以去另外创建一个新项目:
dotnet new classlib -n Analysis -f netstandard2.0
cd Analysis
dotnet add package Microsoft.CodeAnalysis.CSharp --version 3.8.0
然后顺便打开 Analysis.csproj 把语言版本设置成 9.0 (要不然默认就只有 C#7.3 )
现在配置文件里面应该大概是这样的:
<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
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 ,并注册:
public override void Initialize(AnalysisContext context)
{
// ...
// SymbolKind.Method 表示 AnalyzeSymbol 会用来分析函数的符号信息
context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.Method);
}
private void AnalyzeSymbol(SymbolAnalysisContext context)
{
}
然后就是检查参数是否有 TypeConstraintAttribute 之类的,最后把对应的参数信息储存,这里我们可以定义一个字段来储存
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)
{
// check if the parameter have TypeConstraintAttribute
// 这里有点忘了,"TypeConstraintAttribute" 不行的话就改成 "TypeConstraint" 吧
var attrs = parameter.GetAttributes().Where(attr => attr.AttributeClass.Name == "TypeConstraintAttribute");
if (attrs.Any() is false) continue;
var typeConstraintAttr = attrs.First();
// Correctly handle the type of ConstructorArguments
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 时候是这样的:
public TypeConstraintAttribute(params Type[] constraints) { Constraints = constraints; }也就是 constraints 参数是一个 Type[] ,虽然使用的时候可以像是任意数量参数那样,但是解析符号的时候这里是一个参数,所以就需要先 SelectMany 再 Select ,如果直接
typeConstraintAttr.ConstructorArguments.Select(...)就会异常,具体的表现就是在别的项目里使用该分析器的时候会没有任何表现(因为分析器运行到这里就崩溃了)
分析调用
注册一个新的行为,这次我们注册到 SyntaxNodeAction (因为这个是在 SymbolAction 之后执行的,不过我猜这一步注册 SymbolAction 应该没啥问题)
然后分析的目标是调用表达式(Invocation Expression)
public override void Initialize(AnalysisContext context)
{
// ...
context.RegisterSyntaxNodeAction(AnalyzeMethodCall, SyntaxKind.InvocationExpression);
}
private void AnalyzeMethodCall(SyntaxNodeAnalysisContext context)
{
}
判断被调用的函数是不是有 [TypeConstraint(...)] 限定过的函数(也就是检查是不是在之前储存的那个字典就行),并获取调用时的实参,以及定义时的参数信息
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 要对应上
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两个文件,并且按照格式把规则信息补充(
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);
然后在原来检测的地方获取上下文并抛出错误
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关系,这里我就不实现了(
