← 所有文章

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

Test

· · ·

感谢阅读。愿这份记录也能帮到你。

继续探索其他文章 ↗