我如何用表达式树打造零开销的枚举转换器

一、问题的起源

在我们的工业自动化系统中,存在这样一个场景:

视觉识别DLL上位机软件 各自定义了功能相似的枚举,但命名和数值都不一致。在系统架构调整之前,代码中充斥着这样的转换逻辑:

csharp

// 到处可见的硬编码转换
if (visionResult == VisionStatus.Success) 
{
    plcStatus = PlcCommand.Execute;
}

这不仅让代码臃肿,更埋下了维护的隐患。

二、我的目标:一个简洁的API

我希望框架的使用者能这样调用:

csharp

// 一行代码完成转换,无需任何初始化
var target = EnforceParse<SourceEnum, TargetEnum>.Convert(sourceValue);

核心理念:API必须简洁,所有复杂性都封装在框架内部。

三、方案的演进

方案1:受Stateless库启发的最初实现

csharp

internal static class EnumUtils<TSource, TTarget> 
    where TSource : struct, Enum 
    where TTarget : struct, Enum
{
    public static TTarget Converter(TSource source)
    {
        object value = Convert.ChangeType(source, Enum.GetUnderlyingType(typeof(TSource)));
        try
        {
            return (TTarget)Enum.ToObject(typeof(TTarget), value);
        }
        catch (Exception ex)
        {
            throw new InvalidOperationException(
                $"Cannot convert enum value '{source}' to {typeof(TTarget).Name}", ex);
        }
    }
}

测试结果:9毫秒/次

这个性能在工业控制场景完全不可接受。PLC的扫描周期通常是20-50毫秒,一次转换就占用近一半的时间。

方案2:引入字典缓存

csharp

public static Dictionary<TSource, TTarget> EnumConvert = new();

public static TTarget Resolve(TSource source)
{
    EnumConvert.TryGetValue(source, out var target);
    return target;
}

字典查找的时间复杂度是O(1),后续调用几乎零开销。

问题:字典需要预热。这意味着框架的使用者必须在程序启动时手动初始化,这违背了API简洁性的设计原则。

方案3:表达式树 — 最终答案

csharp

public static class EnforceParse<TSource, TTarget> 
    where TSource : struct, Enum 
    where TTarget : struct, Enum
{
    public static readonly Func<TSource, TTarget> Convert;

    static EnforceParse()
    {
        var sourceParam = Expression.Parameter(typeof(TSource), "source");
        
        // 将源枚举转换为底层类型(如 int)
        var toUnderlying = Expression.Convert(sourceParam, 
            Enum.GetUnderlyingType(typeof(TSource)));
        
        // 再转换为目标枚举类型
        var toTarget = Expression.Convert(toUnderlying, typeof(TTarget));
        
        // 编译成原生委托
        var lambda = Expression.Lambda<Func<TSource, TTarget>>(toTarget, sourceParam);
        Convert = lambda.Compile();
    }
}

四、性能实测

测试代码:

csharp

var sw = Stopwatch.StartNew();
var s1 = EnforceParse<TestLowercase, TestUppercase>.Convert(TestLowercase.e);
Logger.Info($"首次调用:{sw.ElapsedTicks} ticks");

sw.Restart();
var s2 = EnforceParse<TestLowercase, TestUppercase>.Convert(TestLowercase.e);
Logger.Info($"后续调用:{sw.ElapsedTicks} ticks");

实测结果:

text

2026-06-13 10:57:37.982 [1] INFO TangdaoFrameworkDemo.AppStartup
初次调用:54537,结果E

2026-06-13 10:57:37.982 [1] INFO TangdaoFrameworkDemo.AppStartup
后续调用:2,结果E

性能提升从 9 毫秒 到 0.0003 毫秒,提升了 3,0,000 倍

五、为什么这么快?

很多人误以为表达式树是反射的一种。这是一个常见的认知误区。

反射:每次调用都要解析元数据,产生装箱拆箱开销。

il

// 反射方案的IL:6+条指令 + 方法调用 + 装箱
IL_0000: ldarg.0
IL_0001: call Convert.ChangeType
IL_0006: box
...

表达式树:只在首次编译时有开销,之后就是纯原生代码。

il

// 表达式树编译后的IL:3条指令,无方法调用
IL_0000: ldarg.0
IL_0001: conv.i4
IL_0002: ret

这就是零开销抽象的威力。

六、技术要点总结

关键技术 作用
泛型静态构造器 类型首次使用时自动初始化,对使用者透明
表达式树 运行时生成高效的IL代码
Func委托缓存 后续调用直接执行原生代码
readonly字段 确保委托只编译一次,线程安全

七、给框架开发者的建议

  1. 不要过早放弃:我的方案2(字典)被否定后,差点放弃。表达式树打开了新思路。
  2. 突破认知边界:表达式树不是反射,它是代码生成器。这个认知转变是关键。
  3. 性能目标要激进:工业场景下,"够用"往往意味着"不够用"。
  4. API简洁是最高优先级:使用者不该知道你的内部实现有多复杂。

八、延伸思考

这个模式可以推广到更多场景:

  • 类型之间的零开销转换
  • 高性能的AOP实现
  • 动态生成的计算逻辑

表达式树的 Compile() 方法,是连接动态灵活性静态性能的桥梁。

posted @ 2026-06-13 11:12  孤沉  阅读(11)  评论(0)    收藏  举报