Skip to content
Go back

C# 表达式树替代反射:何时该做性能升级

C# 表达式树替代反射:何时该做性能升级

写过序列化器、对象映射器或 DI 容器的人,大概都踩过同一个坑:PropertyInfo.GetValue() 调用一多,profiler 里出现一根明显的柱子。解法不难理解——把反射换成编译后的委托——但真正落地时往往没有一套顺手的代码。

这篇文章讲的就是这个转换过程:从原始反射,到表达式树,到编译缓存委托。原文作者给出了属性 getter、setter、构造工厂、方法调用四个场景的完整实现,以及一个把它们串起来的泛型对象映射器。

表达式树是什么

表达式树把代码表示成对象树——每个节点是一个 Expression 子类:BinaryExpressionMethodCallExpressionMemberExpressionLambdaExpression 等等。树本身只是在描述”要做什么计算”,.Compile() 才是把这个描述交给 JIT、生成真正的 IL、产出一个委托。

using System.Linq.Expressions;

// 构建 (int x) => x * 2 的表达式树
ParameterExpression param = Expression.Parameter(typeof(int), "x");
BinaryExpression body = Expression.Multiply(param, Expression.Constant(2));
Expression<Func<int, int>> lambda = Expression.Lambda<Func<int, int>>(body, param);

// 编译一次,这是唯一的一次性开销
Func<int, int> doubler = lambda.Compile();

// 之后每次调用都没有反射开销
int result = doubler(7); // 14

关键点在于:.Compile() 触发运行时代码生成,只需要支付一次。把结果委托缓存起来,后续每次调用都是 JIT 编译后的原生速度。

反射的代价则不同。每次调用 PropertyInfo.GetValue(obj) 时,运行时都要:

每次调用都重走一遍。表达式树把这些工作全部前置到一次性的 .Compile() 步骤里。

四步转换管线

把一个反射调用替换成编译委托的通用模式只有四步:

  1. 获取 MemberInfoPropertyInfoMethodInfoConstructorInfo 等,这是唯一需要用反射的地方,只做一次
  2. 构建表达式树 — 用 Expression.* 工厂方法描述操作
  3. 包装成 LambdaExpression 并调用 .Compile()
  4. 缓存委托 — 存到 static 字段、ConcurrentDictionary<Type, Delegate>,或 .NET 8+ 的 FrozenDictionary<Type, Delegate>(只读性能最佳)
// 概念骨架
static TDelegate BuildAndCache<TDelegate>(Type type, string memberName)
    where TDelegate : Delegate
{
    // 第 1 步:一次反射
    var member = type.GetProperty(memberName) ?? throw new ArgumentException(...);

    // 第 2 步:构建表达式树
    var param = Expression.Parameter(typeof(object), "obj");
    var cast = Expression.Convert(param, type);
    var access = Expression.Property(cast, (PropertyInfo)member);
    var body = Expression.Convert(access, typeof(object));
    var lambda = Expression.Lambda<TDelegate>(body, param);

    // 第 3 步:编译
    return lambda.Compile();
}

下面逐个看四个常见场景。

编译属性 Getter

读取属性值是反射最常见的用途——ORM、映射器、序列化器都需要。下面是一个泛化的 getter,接受任意对象,返回属性值(以 object 形式):

using System.Linq.Expressions;
using System.Collections.Frozen;

public static class CompiledPropertyAccessor
{
    private static readonly ConcurrentDictionary<(Type, string), Func<object, object?>> _cache = new();

    public static Func<object, object?> GetGetter(Type type, string propertyName)
        => _cache.GetOrAdd((type, propertyName), static key =>
        {
            var (t, name) = key;
            var prop = t.GetProperty(name)
                ?? throw new ArgumentException($"Property '{name}' not found on {t.Name}");

            // 构建: (object obj) => (object)(((TOwner)obj).PropertyName)
            var objParam = Expression.Parameter(typeof(object), "obj");
            var typedAccess = Expression.Property(
                Expression.Convert(objParam, t),
                prop);
            var boxed = Expression.Convert(typedAccess, typeof(object));
            var lambda = Expression.Lambda<Func<object, object?>>(boxed, objParam);

            return lambda.Compile();
        });
}

使用方式直接:

public sealed record Customer(string Name, int Age);

var customer = new Customer("Alice", 30);

var nameGetter = CompiledPropertyAccessor.GetGetter(typeof(Customer), "Name");
var ageGetter  = CompiledPropertyAccessor.GetGetter(typeof(Customer), "Age");

Console.WriteLine(nameGetter(customer)); // Alice
Console.WriteLine(ageGetter(customer));  // 30

每个属性的第一次调用支付编译成本,后续每次调用都是委托调用,没有反射。

如果需要强类型 getter 以避免值类型装箱,可以用泛型类型参数构建 Func<TOwner, TProperty> 版本。

编译属性 Setter

Setter 稍微复杂一点,需要用到 Expression.Assign,还需要注意 recordinit-only 属性(它们没有普通的 setter):

public static class CompiledPropertySetter
{
    private static readonly ConcurrentDictionary<(Type, string), Action<object, object?>> _cache = new();

    public static Action<object, object?> GetSetter(Type type, string propertyName)
        => _cache.GetOrAdd((type, propertyName), static key =>
        {
            var (t, name) = key;
            var prop = t.GetProperty(name)
                ?? throw new ArgumentException($"Property '{name}' not found on {t.Name}");

            if (prop.SetMethod is null)
            {
                throw new InvalidOperationException($"Property '{name}' has no setter.");
            }

            // 构建: (object obj, object? value) => ((TOwner)obj).PropertyName = (TProp)value
            var objParam   = Expression.Parameter(typeof(object), "obj");
            var valueParam = Expression.Parameter(typeof(object), "value");

            var typedObj   = Expression.Convert(objParam, t);
            var typedValue = Expression.Convert(valueParam, prop.PropertyType);
            var propAccess = Expression.Property(typedObj, prop);
            var assign     = Expression.Assign(propAccess, typedValue);

            var lambda = Expression.Lambda<Action<object, object?>>(assign, objParam, valueParam);

            return lambda.Compile();
        });
}

对于 init-only 属性(.NET 10 的 record 里很常见),需要通过 ConstructorInfo 构建新实例,而不是修改现有实例——下一节讲的工厂就是这个用途。

编译构造工厂

Activator.CreateInstance 很方便,但每次调用都有反射开销。表达式树能给无参数和有参数构造函数都提供零额外开销的路径:

public static class CompiledFactory
{
    // 无参数工厂
    public static Func<T> BuildFactory<T>()
    {
        var ctor = typeof(T).GetConstructor(Type.EmptyTypes)
            ?? throw new InvalidOperationException($"{typeof(T).Name} has no parameterless constructor.");

        var newExpr = Expression.New(ctor);
        var lambda  = Expression.Lambda<Func<T>>(newExpr);
        return lambda.Compile();
    }

    // 带参数工厂 — 支持任意构造函数签名
    public static Func<object[], object> BuildFactory(Type type, Type[] paramTypes)
    {
        var ctor = type.GetConstructor(paramTypes)
            ?? throw new InvalidOperationException($"No matching constructor found on {type.Name}.");

        // 构建: (object[] args) => new T((T0)args[0], (T1)args[1], ...)
        var argsParam = Expression.Parameter(typeof(object[]), "args");
        var ctorArgs  = paramTypes.Select((t, i) =>
            Expression.Convert(
                Expression.ArrayIndex(argsParam, Expression.Constant(i)),
                t) as Expression).ToArray();

        var newExpr = Expression.New(ctor, ctorArgs);
        var boxed   = Expression.Convert(newExpr, typeof(object));
        var lambda  = Expression.Lambda<Func<object[], object>>(boxed, argsParam);
        return lambda.Compile();
    }
}
// 在启动时缓存——此后每次调用零分配
var customerFactory = CompiledFactory.BuildFactory<Customer>();
var c1 = customerFactory(); // 没有反射,没有装箱

编译方法调用

MethodInfo.Invoke() 是出了名的慢。Expression.Call 能把它替换成编译委托:

public static class CompiledMethodInvoker
{
    public static Func<object, object?[], object?> Build(MethodInfo method)
    {
        var instanceParam = Expression.Parameter(typeof(object), "instance");
        var argsParam     = Expression.Parameter(typeof(object?[]), "args");

        var paramInfos = method.GetParameters();

        // 把每个参数从 object[] 转换为期望的参数类型
        var castArgs = paramInfos.Select((p, i) =>
            Expression.Convert(
                Expression.ArrayIndex(argsParam, Expression.Constant(i)),
                p.ParameterType) as Expression).ToArray();

        // 把实例转换为声明类型
        var instance = Expression.Convert(instanceParam, method.DeclaringType!);
        var call = method.ReturnType == typeof(void)
            ? (Expression)Expression.Block(
                Expression.Call(instance, method, castArgs),
                Expression.Constant(null, typeof(object)))
            : Expression.Convert(Expression.Call(instance, method, castArgs), typeof(object));

        var lambda = Expression.Lambda<Func<object, object?[], object?>>(
            call, instanceParam, argsParam);

        return lambda.Compile();
    }
}

静态方法的话,省略 instance 参数,改用 Expression.Call(null, method, castArgs)

实战:泛型对象映射器

把上面这些组合成一个有实际用途的东西——一个把匹配属性从 source 对象复制到 destination 对象的映射器,这也是 AutoMapper 等库的核心机制:

public sealed class ExpressionMapper<TSource, TDest>
    where TDest : new()
{
    private readonly List<Action<TSource, TDest>> _mappings;

    public ExpressionMapper()
    {
        _mappings = BuildMappings();
    }

    public TDest Map(TSource source)
    {
        var dest = new TDest();
        foreach (var mapping in _mappings)
        {
            mapping(source, dest);
        }
        return dest;
    }

    private static List<Action<TSource, TDest>> BuildMappings()
    {
        var sourceProps = typeof(TSource).GetProperties()
            .ToDictionary(p => p.Name);

        var destProps = typeof(TDest).GetProperties()
            .Where(p => p.CanWrite);

        var mappings = new List<Action<TSource, TDest>>();

        foreach (var destProp in destProps)
        {
            if (!sourceProps.TryGetValue(destProp.Name, out var sourceProp))
                continue;

            if (sourceProp.PropertyType != destProp.PropertyType)
                continue;

            // 构建: (TSource src, TDest dst) => dst.Prop = src.Prop
            var srcParam  = Expression.Parameter(typeof(TSource), "src");
            var dstParam  = Expression.Parameter(typeof(TDest), "dst");
            var getValue  = Expression.Property(srcParam, sourceProp);
            var setValue  = Expression.Assign(Expression.Property(dstParam, destProp), getValue);
            var lambda    = Expression.Lambda<Action<TSource, TDest>>(setValue, srcParam, dstParam);

            mappings.Add(lambda.Compile());
        }

        return mappings;
    }
}

注意这个实现只在构造函数里做了一次反射,编译了委托,然后 Map() 方法就不再调用任何反射 API。反射开销是一次性的启动成本。

// 启动时(通常在 DI 注册阶段)
var mapper = new ExpressionMapper<CustomerEntity, CustomerDto>();

// 热路径 — 没有反射
var dto = mapper.Map(entity);

代价和适用边界

表达式树不是免费的。原文作者对代价有清醒的描述:

代价:

优势:

甜蜜区:高频调用(序列化、映射、属性绑定),类型集合在启动后固定,但在编译时未知。

什么情况下不该用

对于只需要动态实例化、不需要走完整表达式树路径的场景,Activator.CreateInstanceConstructorInfo 直接调用通常就够了。

与 .NET 10 的关系

表达式树 API 自 .NET Framework 3.5 以来基本稳定,.NET 10 没有根本改变它。.NET 10 带来的是更广的背景:

选择参考

原文作者给了三个频率维度的判断:

还有一点值得注意:source generators 在表达式树适合但启动成本敏感的情况下是另一个选项。它们产出等委托速度的代码,且零启动成本,但代价是更复杂的构建管线,以及只能在编译时已知的类型上工作。


如果你关注 AI 助手、开发工具和软件工程实践,可以关注 Aide Hub。这里会继续分享能落地的工具教程、技术观察和项目经验。

参考


Tags


Previous

C# 责任链设计模式:完整指南与示例

Next

C# 自定义特性实战:验证、插件注册和命令路由的完整实现