☰
C#表达式树突破泛型约束:高性能带参构造函数工厂实现
2026/10/15 3:46:14 网站建设 项目流程

这一篇是表达式目录树系列的第4篇。前几篇聊了表达式树的基础结构、常见节点和基本遍历,今天来聊一个让我当初卡了很久的问题:C#泛型约束里只能写where T : new(),它只认无参构造函数,可真实业务里带参数的构造函数到处都是。想写一个通用的泛型工厂,既要类型安全,又要创建带参数的实例,直接撞在语言约束的墙上。后来发现表达式目录树正好能填这个坑,而且性能比反射好得多。

如果你也封装过IoC容器、对象映射器、配置实体加载之类的东西,八成遇到过这种“泛型约束表达不了、反射又太慢”的尴尬。这篇就直接把它拆开,从矛盾的本质讲到完整实现,再附上我踩过的坑和性能实测结果。

1. 泛型约束与带参构造函数的“死结”在哪

1.1where T : new()到底限制了什么

C#的泛型约束里,new()是一个非常容易让人误解的约束。它的字面意思很直白:类型参数 T 必须有一个 public 的、无参数的构造函数。但问题是它只能表达“无参可构造”,完全没有“带参可构造”这种约束的语法。

public static T Create<T>() where T : new() { return new T(); }

这段代码能编译、能跑,但只适用于无参构造。一旦某个类型的构造函数需要传参,比如一个订单类必须传入订单号才能创建,这个泛型方法就直接歇菜了。你说我能不能约束 T 必须继承某个基类或实现某个接口,然后通过接口里的方法去创建实例?当然可以,但那等于把构造逻辑硬塞到接口里,业务代码会变得很别扭,而且很多第三方类型根本不会实现你定义的接口。

有人会说,那我用Activator.CreateInstance不行吗?这个方法确实能创建带参数的实例,但它绕过了编译期约束,把参数塞进一个object[]数组里,本质上是在和运行时反射打交道。这就是矛盾的第一个痛点。

1.2 反射方案为什么只能救急

Activator.CreateInstance(Type type, object[] args)是很多人第一反应能想到的解法。它确实写出了“通用创建带参实例”的效果:

object obj = Activator.CreateInstance(typeof(Order), new object[] { "SO-2024-001", 128m });

问题在于它慢了不止一个量级。每次调用都要在运行时查找构造函数、做参数匹配、执行反射调用,这个开销在一两万次调用的场景下可能感受不明显,但放到一个日请求量很大的Web服务或者批量数据处理管道里,分分钟变成性能瓶颈。我见过一个同事在大批量导入任务里用Activator.CreateInstance创建上百万个对象,那一段跑了将近十秒,后来换成表达式树编译委托之后,耗时直接降到一秒以内。

更麻烦的是,Activator.CreateInstance返回的是object,你拿回来还得自己做强转;而且编译器完全不管参数数量、参数类型对不对,所有问题都在运行期爆发。一旦某个构造函数的签名发生了变更,你只能靠运行时异常去发现问题。

1.3 表达式树的切入点

表达式目录树恰好能同时解决“带参数”和“性能”这两个问题。核心思路并不复杂:构造函数在.NET里对应一个ConstructorInfo,而Expression.New可以把一次构造函数调用描述成一棵表达式树;把参数声明成表达式节点,把“new 表达式”作为函数体,最后用LambdaExpression.Compile()编译成一个强类型委托。编译后的委托本质上就是一段可以直接执行的代码,它不再走反射查找那条路,调用开销和普通代码几乎一样。

换句话说,我们用“运行时动态构造一个构造函数调用”代替了“每次运行都去反射创建对象”。这个过程只发生一次,之后的每次调用都只是一次普通的委托调用。表达式树在这里扮演的不是黑魔法,而是一个“编译器助手”,它把无法用泛型约束表达的东西,延后到了运行时去构造,又不像反射那样每次都付出查找代价。

2. 用表达式树设计一个“通用版参数化工厂”

2.1 核心思路:把构造函数编译成委托

上面的场景里,真正需要解决的问题是:在运行时才知道类型 T 和它的构造函数签名,但希望之后每次创建实例都像直接new一样快。表达式树方案的设计目标就是建立一条“构造函数到委托”的转化管线。

管线分成三段:

  1. 拿到类型 T 的构造函数信息,也就是代码里的ConstructorInfo。
  2. 把构造函数的每个参数映射成一个表达式节点,用Expression.New把它们“拼”成一次构造调用。
  3. 用Expression.Lambda将整棵树包成一个委托签名,Compile()成可执行委托并缓存。

这里一个容易被忽略的点是:Expression.New在表达式树里并不是真的执行构造函数,它只是描述“我要调用这个构造函数”。真正执行要等到Compile()之后。所以整个设计里最贵的一次操作是Compile(),但它只发生一次,之后就一劳永逸了。

2.2 参数数组方案:object[] 与 Convert 的配合

怎么让一个通用工厂适配任意构造函数?最直接的思路是把参数统一塞进一个object[]数组,工厂方法的签名写成T Create(params object[] args)。这样做的好处是方法签名固定,无论构造函数有几个参数、参数类型是什么,都能适配。

但问题来了:表达式树里Expression.New要求传入的参数表达式类型必须和构造函数形参类型匹配。你把一个类型为object的数组元素表达式直接传给构造函数形参,类型对不上,编译表达式树时会报参数匹配异常。解决办法是在每个参数表达式上套一层Expression.Convert,把object转换为对应的形参类型。

这个转换在运行时会对值类型执行拆箱操作,对引用类型执行类型转换。也正是因为每次调用都要拆箱、装箱,object[]方案在性能上略逊于强类型委托,但它换来了极高的通用性。

2.3 强类型变体:Func<TArg1, TArg2, TResult>

如果不想承受拆箱和装箱的开销,或者希望调用方在编译期就获得参数类型检查,还可以做一个强类型版本。思路是让泛型参数个数固定,显式声明参数类型:

public static class TypedFactory<TArg1, TArg2, T> { private static readonly Func<TArg1, TArg2, T> Creator; static TypedFactory() { ConstructorInfo ctor = typeof(T).GetConstructor( new[] { typeof(TArg1), typeof(TArg2) }); if (ctor == null) { throw new InvalidOperationException( $"类型 {typeof(T).Name} 上找不到 ({typeof(TArg1).Name}, {typeof(TArg2).Name}) 构造函数"); } ParameterExpression p1 = Expression.Parameter(typeof(TArg1), "p1"); ParameterExpression p2 = Expression.Parameter(typeof(TArg2), "p2"); NewExpression body = Expression.New(ctor, p1, p2); var lambda = Expression.Lambda<Func<TArg1, TArg2, T>>(body, p1, p2); Creator = lambda.Compile(); } public static T Create(TArg1 a1, TArg2 a2) { return Creator(a1, a2); } }

调用方式变成了TypedFactory<int, string, Order>.Create(1024, "SO-2024-001"),编译器就能帮你检查参数数量和类型。代价是你得为每种参数个数单独定义一个泛型类,三个参数写一版,四个参数再写一版,代码比较冗余。

从实际角度讲,object[]版本适合“你也不知道具体签名是什么”的场景,比如IoC容器按注册信息解析组件;强类型版本适合调用方明确知道参数形态的场合。两种方案可以共存,先用object[]版做一个通用底座,在热点路径上再提供强类型入口。

3. 从零到一的完整实现

3.1 第一步:安全地拿到构造函数信息

第一步是获取类型 T 的构造函数。直接用typeof(T).GetConstructors()只能拿到 public 构造函数,如果目标构造函数是 internal 或者 private,还需要指定BindingFlags.NonPublic | BindingFlags.Instance。不过我不推荐默认这样做,反射私有构造函数本身就是一种对封装边界的破坏,除非你明确知道自己在做什么。

在实际开发里,一个类型往往有多个构造函数,工厂应该选哪一个?这没有一个普适答案。我在自定义工具里通常默认选择“参数最多的那个 public 构造函数”,因为这对应依赖注入场景中“尽可能注入全部依赖”的直觉;如果你有特定的选择策略,把这个策略做成一个委托参数暴露给外部,比写死在工厂类里更好,后面调整起来不用改工厂代码。

下面是获取构造函数的那段代码,我加上了参数最多的筛选逻辑:

ConstructorInfo[] ctors = typeof(T).GetConstructors( BindingFlags.Instance | BindingFlags.Public); ConstructorInfo selected = ctors .OrderByDescending(c => c.GetParameters().Length) .FirstOrDefault(); if (selected == null) { throw new InvalidOperationException($"类型 {typeof(T).Name} 没有可访问的实例构造函数"); } ParameterInfo[] ps = selected.GetParameters();

注意:如果你准备把这个工厂类用在泛型容器里,选构造函数这个动作应该尽量只做一次,并把结果缓存,而不是每次创建实例都重新扫一遍构造函数列表。

3.2 第二步:构造参数表达式并拼接 NewExpression

拿到ConstructorInfo和参数列表ParameterInfo[]之后,下面这一步是整个方案的核心。我用一个object[]类型的参数表达式作为整个委托的唯一入参,然后对每个构造参数都去数组里取对应位置的值。

这里有个细节值得单独说:如果构造函数的参数类型恰好是object,套一层Expression.Convert反而可能是多余的,虽然多数情况下也不会报错,但为了避免无谓的表达式节点,我会在加转换之前先判断一下类型是否一致。逻辑如下面的代码所示。

ParameterExpression args = Expression.Parameter(typeof(object[]), "args"); Expression[] argumentExpressions = new Expression[ps.Length]; for (int i = 0; i < ps.Length; i++) { Expression arrayItem = Expression.ArrayIndex(args, Expression.Constant(i)); if (ps[i].ParameterType != typeof(object)) { argumentExpressions[i] = Expression.Convert(arrayItem, ps[i].ParameterType); } else { argumentExpressions[i] = arrayItem; } } NewExpression body = Expression.New(selected, argumentExpressions);

Expression.ArrayIndex负责从参数数组里取出第 i 个元素,它的返回类型是object。Expression.Convert负责把它转换为构造函数所要求的形参类型。如果是值类型,最终编译出来的 IL 就是一次unbox.any;如果是引用类型,就是一次castclass。

还需要特别留意的是,Expression.New会自动匹配构造函数的参数个数和顺序。你提供几个参数表达式,它就会按顺序去找构造函数签名。如果参数表达式的个数、顺序、类型和ConstructorInfo所声明的参数不一致,编译时就会抛出异常。所以前面构造argumentExpressions时,千万别打乱ParameterInfo[]的顺序。

3.3 第三步:编译委托并缓存

表达式树建好之后,用Expression.Lambda把它包装成Func<object[], T>类型的委托。这里指定的委托签名决定了后续调用方看到的工厂方法形态。

Expression<Func<object[], T>> expr = Expression.Lambda<Func<object[], T>>(body, args); Creator = expr.Compile();

这里的Creator是一个静态只读字段:

private static readonly Func<object[], T> Creator;

因为我把编译逻辑放进了泛型类的静态构造函数里,而静态构造函数是线程安全的,CLR 保证它在第一次访问该类型成员之前恰好执行一次,所以不需要加锁,也不用写双重检查,直接靠静态初始化机制把委托缓存起来即可。

要注意的是:缓存的是编译后的委托,而不是ConstructorInfo本身。这个差别很关键,委托相当于把构造函数调用固化成了可直接执行的代码路径,后续创建实例时不会再碰反射。

3.4 完整代码与调用方式

把上面这几步拼起来,完整工厂类长这样:

public static class ParameterizedFactory<T> { private static readonly int ArgCount; private static readonly Func<object[], T> Creator; static ParameterizedFactory() { ConstructorInfo[] ctors = typeof(T).GetConstructors( BindingFlags.Instance | BindingFlags.Public); ConstructorInfo selected = ctors .OrderByDescending(c => c.GetParameters().Length) .FirstOrDefault(); if (selected == null) { throw new InvalidOperationException( $"类型 {typeof(T).Name} 没有可访问的实例构造函数"); } ParameterInfo[] ps = selected.GetParameters(); ArgCount = ps.Length; ParameterExpression args = Expression.Parameter(typeof(object[]), "args"); Expression[] argumentExpressions = new Expression[ps.Length]; for (int i = 0; i < ps.Length; i++) { Expression arrayItem = Expression.ArrayIndex(args, Expression.Constant(i)); if (ps[i].ParameterType != typeof(object)) { argumentExpressions[i] = Expression.Convert(arrayItem, ps[i].ParameterType); } else { argumentExpressions[i] = arrayItem; } } NewExpression body = Expression.New(selected, argumentExpressions); Expression<Func<object[], T>> expr = Expression.Lambda<Func<object[], T>>(body, args); Creator = expr.Compile(); } public static T Create(params object[] args) { if (args == null) { throw new ArgumentNullException(nameof(args)); } if (args.Length != ArgCount) { throw new ArgumentException( $"构造函数要求 {ArgCount} 个参数,实际传入 {args.Length} 个", nameof(args)); } return Creator(args); } }

调用时非常直观:

Order order = ParameterizedFactory<Order>.Create("SO-2024-001", 128m);

Order 的构造函数是Order(string orderNo, decimal amount),这个调用会先触发静态构造函数完成表达式树编译,之后再调用就只是执行一个委托。对第一次调用来说,静态构造的编译时间是不可忽略的,但它只产生一次;对后续的千万次调用来说,这就是纯赚的收益。

3.5 应用现场:对象映射与轻量级容器

这个通用工厂我第一次真正用起来,是在一个轻量级依赖注入容器里。容器注册组件时只提供了Type,创建实例需要按最长构造函数动态解析依赖。以前的写法是每次解析都做一次反射构造函数查找,后来统一改成表达式树工厂,把“构造”这件事变成了纯粹的委托调用,解析耗时明显下降,代码也直观了很多。

另一个常用的地方是DTO映射。比如从数据库实体映射到带参构造的视图模型时,视图模型通常有public ViewModel(int id, string name, decimal score)这样的构造函数。过去要么在映射代码里一行行地new ViewModel(...),要么反射创建,前者重复代码多,后者性能差。用表达式树工厂做一个统一的映射辅助函数,参数从源对象里抽取出来,然后直接调用编译器级的构造委托,代码既紧凑又保持了可读性。

如果你连属性初始化也想做,表达式树还支持Expression.MemberInit,也就是把“构造函数 + 属性赋值”一并编译进同一个委托。这一步可以作为后续扩展,效果和效率都很好。

4. 实操中容易踩的坑

4.1 参数顺序不对称导致编译失败

Expression.New(ConstructorInfo ctor, params Expression[] arguments)对参数顺序非常敏感。假设构造函数签名是Order(string orderNo, decimal amount),你写表达式树时如果把第二个参数当成了第一个,Expression.New在构造阶段通常不会立刻报错,等到Compile()的时候,它会发现这里塞给orderNo的表达式类型是decimal,与形参不兼容,于是抛出一个ArgumentException或类似异常。

排查这个报错有个技巧:先看好ParameterInfo[]的实际顺序,再对照你构造argumentExpressions的索引一一核对。不要凭记忆去排参数,尤其当构造函数有八九个参数的时候,顺序错一个都跑不起来。

4.2 值类型参数传 null 的奇怪报错

Expression.Convert在值类型参数上的行为很微妙。比如构造函数有一个int类型的参数,而你在调用Create时传给object[]里的对应位置恰巧是个null。表面上没有提示,运行时一执行委托,就会在unbox.any那个步骤抛NullReferenceException,而且报错位置根本不指向你的源码,指向的是表达式树编译生成的内部代码。

我第一次遇到时被这个报错误导了很久,以为工厂类本身有 bug。后来才意识到是调用方传入了非法数据。解决方式有两个方向:一是在Create入口做参数校验,对值类型参数逐一检查是否为 null;二是在文档或注释里明确约定“值类型参数不得传 null”。我一般两者都做,毕竟异常信息越早暴露越好。

4.3 构造函数存在但代码里拿不到

typeof(T).GetConstructors()默认只返回 public 构造函数。如果你的目标类型把构造函数声明成了 internal,外部程序集拿不到,工厂就会抛出“没有可访问的构造函数”。这个坑在重构时特别容易出现:原来构造函数是 public,后来改成 internal 或者加了访问修饰符,编译期不报错,运行期工厂突然就罢工了。

另外一个容易被忽略的场景是构造函数参数本身不符合预期。强类型版本的GetConstructor(Type[])要求参数类型“精确匹配”,接口参数和实现类参数在反射里是两个不同的类型。调用方以为传一个接口泛型就行,但构造函数给的是具体类,结果就是返回 null,工厂初始化失败。遇到这类问题,先打日志把typeof(T).FullName和尝试匹配的签名打出来,排查速度会快很多。

4.4 缓存失效与误用 Activator 的后果

既然用了静态构造函数缓存委托,就要小心一种情况:静态构造函数里抛异常,比如找不到构造函数、参数类型不匹配,类型初始化就会失败。之后程序里每次调用ParameterizedFactory<T>.Create都会抛TypeInitializationException,而不是原来的InvalidOperationException。这意味着问题会被包装一层,排查时要通过InnerException才能看到真正原因。

也有人在工厂缓存逻辑里加了双重锁,实际上完全没有必要。静态构造函数由 CLR 保证线程安全,加锁只是增加复杂度。真正需要小心的反倒是那些想省事直接混用Activator.CreateInstance的做法:两者混用并不会出错,但一旦走了 Activator,前面费劲建立的高性能路径就形同虚设了,整条慢代码还是慢。要保持统一,要么全走表达式树,要么全走反射。

5. 性能实测与适用场景判断

5.1 用 Stopwatch 实测三种方式

为了让你看清差距,我做一个很直观的对比。定义一个简单的Order类,构造函数有两个参数:一个string,一个decimal。然后分别用三种方式循环创建 1000 万次:

for (int i = 0; i < N; i++) { _ = new Order("SO-" + i, 128m); } for (int i = 0; i < N; i++) { _ = ParameterizedFactory<Order>.Create("SO-" + i, 128m); } for (int i = 0; i < N; i++) { _ = Activator.CreateInstance(typeof(Order), new object[] { "SO-" + i, 128m }); }

直接new Order(...)是测试的基准线;ParameterizedFactory<Order>.Create走的是表达式树编译出来的委托;Activator.CreateInstance则是每次走完整反射查找。

5.2 测试结果怎么看

在我的本机环境下(常见的 x64 运行时),三次结果大致呈一个阶梯状:

创建方式1000万次耗时相对直接 new 的倍数
直接调用构造函数约 0.4 秒1x
表达式树编译委托约 0.8 秒约 2 倍
Activator.CreateInstance约 8 秒以上约 20 倍以上

这里有个重要前提:上面测试里表达式树用的是object[]参数版本,每次调用都要分配参数数组,还有装箱拆箱,所以比直接new慢了大约一倍。这个差距不是表达式树调用本身造成的,而是object[]封装的固有开销。如果你改用强类型委托版本,差距会显著缩小,接近直接构造的水平。

再看 Activator,它的慢是“每次调用都慢”,倍率随着构造参数越多越明显。数量级上它比表达式树慢 10 到 20 倍是一个很常见的实测结果。

5.3 什么情况下别用表达式树

表达式树不是万能的,有些场景我反而建议别用。如果你的目标是“写一个最简单的东西快速跑通”,且创建实例的次数还不到几次,那直接用Activator.CreateInstance完全没问题,表达式树的前期编译成本反而是一种过度设计。

另一个不建议用的场景是极频繁的小对象创建,每次参数类型完全不同、签名频繁变化。表达式树工厂虽然能编译新签名,但编译本身有开销,如果每次都要编译新类型,整体性能会非常差。这种情况下更适合老老实实写普通构造函数调用,或者调整设计让对象的构造函数收敛成少数几个固定形态。

还有一类特殊情况要提一下:如果你需要创建的对象根本不是通过构造函数实例化的,比如某些框架允许用FormatterServices.GetUninitializedObject跳过构造函数,那表达式树方案就别硬套了。表达式树解决的核心矛盾始终是“我能知道构造签名但泛型表达不了”,不是“我想绕过构造函数”。

收个尾,说点个人体会

做通用的创建逻辑时,最忌讳的就是一上来就反射。反射写起来是一行的事,但性能债会不知不觉变成架构债。表达式树方案真正解决的是一个“编译期到运行期的桥梁”问题:泛型在编译期给不了你带参构造函数的能力,那就到运行期用表达式树把调用逻辑构建出来,再编译回一个强类型委托,让后续调用回归高速通道。我个人在实际项目中,通常会把ParameterizedFactory<T>作为通用底座,在确定存在热点的路径上再补一个强类型工厂类或直接手写构造调用。最后多提一句,表达式树构造出的委托可以直接放进一个并发字典里按类型缓存,这样你的应用里无论经过多少条不同的创建路径,同一类型都只需要编译第一次。能把这一整套玩明白,后面再做对象映射、依赖注入、动态代理这些偏底层的框架逻辑,都会顺手很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询