CSharp实现强制类型限定

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 Analysis
dotnet 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)
{
// ...
// SymbolKind.Method 表示 AnalyzeSymbol 会用来分析函数的符号信息
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)
{
// 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 时候是这样的:

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.mdAnalyzerReleases.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 关系,这里我就不实现了(

Test

Welcome to my other publishing channels