标题里的问题,我猜很多写C#的人迟早都会遇到:泛型约束里确实写不了where T : new(参数),但你业务里又确实需要根据运行时条件去创建带参数的泛型实例。前面几篇我们把表达式树的基础、遍历和拼接聊得比较透,这一篇就集中解决这个矛盾。我的思路很简单——既然编译期没法直接在泛型代码里写new T(...),那就用表达式树在运行时把“构造调用”重新拼出来,再编译成委托。这样既绕开了泛型约束的限制,又比裸用Activator.CreateInstance干净得多。
这一篇的目标读者,是那些正在写对象映射、依赖注入容器、ORM实体工厂,或者任何需要“反射式创建对象但不想被反射拖累性能”的人。读完你应该能自己写出一个带缓存、可处理多种构造函数参数的泛型实例工厂。
1. 先理清矛盾:泛型约束与带参构造函数为什么水火不容
1.1 泛型约束的那点尴尬
C# 的泛型约束里,最常见的实例化写法是:
public T Create<T>() where T : new() { return new T(); }这个new()约束的意思是:类型 T 必须有一个公开的、无参数的构造函数。它在编译期给了你一个极简的强类型保证,但也仅此而已。问题在于,真实业务里的实体类,很多并没有无参构造函数,它们的构造函数形如:
public class Customer { public Customer(string name, decimal creditLimit) { ... } }如果你试着写where T : new(),然后调用new T("张三", 5000m),编译器会直接拒绝。C# 的泛型约束体系里根本没有“带参数构造函数”这个维度的约束,没有where T : new(string, decimal),未来也大概率不会有。这不是编译器不想做,而是一旦允许这种约束,泛型类型擦除和重载解析会变得异常复杂,C# 团队选择只保留最保守的无参约束。
于是我们就站在了一个很别扭的位置:泛型代码想要创建对象,只能访问无参构造函数;可实际对象往往需要带参构造才能建立有效状态。很多团队的妥协方案是:把无参构造函数补上,再通过属性赋值初始化状态。这在简单场景下能跑,但有些对象一旦创建出来就必须处于有效状态,比如数据库实体、配置项、领域模型,补无参构造函数和可变属性会破坏封装性,甚至会引入额外的 bug 源。
1.2 为什么偏偏用表达式树来解
最常见的替代方案是反射:
object obj = Activator.CreateInstance(typeof(T), args);这能工作,但它有两个让人不舒服的地方。第一,Activator.CreateInstance内部走的是完整的反射通道,不管构造函数本身多简单,调用前都要做参数校验、权限检查、异常包装。第二,返回值是object,调用方拿到手之后还得自己强转。在高频场景下,比如一次性映射十万行数据,反射带来的开销会被放大得非常明显。
表达式树则给了我们一条折中的路。它的本质是“把一段代码表示成数据结构”,然后可以在运行时编译成真实委托。这意味着我们可以用Expression.New去描述一个构造函数调用,把外部传入的object[]参数一个个“接”进构造参数里,再编译成一个类似Func<object[], T>的委托。编译完成之后,后续每次调用本质上就是一次普通委托调用,不再需要反射查找构造函数,也不再需要Invoke那套包装。
用生活类比来解释:反射像是你每次去餐厅都现场打电话问菜单、问做法,然后让后厨临时开火;表达式树则是你头一次去的时候把菜谱抄走,之后直接下指令“按这张单子做”。第一次有成本,后面次次都快。
所以在我看来,表达式树解决这个矛盾的两大核心价值就是:第一,让泛型代码可以在不知道具体类型的情况下,动态选择并调用带参构造函数;第二,把这种动态行为从“每次反射”优化成“一次编译,多次使用”。接下来的篇幅,我会围绕这两点展开。
2. 表达式树解决矛盾的核心思路:把构造函数调用当成表达式
2.1 Expression.New 与构造函数的选择
要从表达式树里构造一个对象,最核心的节点是Expression.New。它有两个常用重载,一个是直接传Type,让表达式树自己去找无参构造函数;另一个是传ConstructorInfo加一组参数表达式,适合带参构造。在我们的场景里,构造函数是动态选出来的,所以典型写法是:
ConstructorInfo ctor = typeof(T).GetConstructors() .OrderBy(c => c.GetParameters().Length) .FirstOrDefault(); Expression[] arguments = ...; NewExpression newExpr = Expression.New(ctor, arguments);这是整个方案的支点。拿到NewExpression以后,它和普通表达式节点没有本质区别,可以被包进LambdaExpression,再通过Compile()变成可执行委托。
构造函数的选择非常关键。同一个类型有可能存在多个构造函数,我们不能只随便拿一个。在我的代码里,一个比较稳妥的策略是:先看外部传入的object[] args的长度,再反过来匹配构造函数参数个数;如果多个构造函数参数个数相同,再进一步判断各参数类型的兼容性。这个过程放在首次构建表达式树时做,之后缓存编译结果,后续调用就不需要再做任何匹配。
2.2 从“外部参数数组”到“构造函数参数表达式”的桥接
这里有个细节很容易被忽略。object[] args是运行时才有的数据,而Expression.New需要的是静态参数表达式。怎么把动态数组元素送进构造函数?答案是数组索引与类型转换组合起来。
比如构造函数第一个参数是string name,外部args[0]是一个object。那么我可以构造这样的表达式:
Expression.Convert( Expression.ArrayIndex(argsExpression, Expression.Constant(0)), typeof(string) )这里argsExpression是指向整个object[]的参数表达式。只要args[0]实际装箱的是一个字符串,运行时转换就不会有问题;如果传错了类型,得到的会是异常而不是编译错误,这是动态实例化必须接受的代价。
如果构造参数是值类型,还需要额外考虑空值保护。因为表达式树里没有 C# 编译器帮忙做“可空判断 + 默认值”的语法糖,你需要自己拼接一个条件表达式:
Expression.Condition( Expression.Equal(argFromArray, Expression.Constant(null, typeof(object))), Expression.Default(intType), Expression.Convert(argFromArray, intType) )这样当初如果传了null进来,值类型参数会落到默认值0,而不是抛异常。当然,这个策略是否合理取决于业务,如果你希望传null到值类型参数时报错,那就去掉这个默认值保护。
整个桥接过程完成后,把所有这些参数表达式放进Expression.New,再包成Expression.Lambda<Func<object[], T>>,编译一次,存进静态字段,问题就解决了。核心代码量不多,但每一步都值得写清楚,否则调试时很容易被各种奇葩异常折磨。
3. 一个可直接套用的泛型实例工厂:代码与关键细节
3.1 第一版:按参数数量匹配构造函数
话不多说,直接给一个可以直接跑起来的版本。下面的代码会把构造函数匹配和表达式树构造统一封装在一个泛型静态类里:
public static class GenericFactory<T> where T : class { private static readonly Func<object?[], T> _creator; static GenericFactory() { ConstructorInfo? ctor = typeof(T) .GetConstructors(BindingFlags.Instance | BindingFlags.Public) .FirstOrDefault(); if (ctor is null) throw new InvalidOperationException($"{typeof(T).FullName} 不存在公共实例构造函数。"); ParameterExpression args = Expression.Parameter(typeof(object?[]), "args"); ParameterInfo[] parameters = ctor.GetParameters(); Expression[] argumentExpressions = new Expression[parameters.Length]; for (int i = 0; i < parameters.Length; i++) { Type parameterType = parameters[i].ParameterType; Expression arrayItem = Expression.ArrayIndex(args, Expression.Constant(i)); if (parameterType.IsValueType && Nullable.GetUnderlyingType(parameterType) is null) { argumentExpressions[i] = Expression.Condition( Expression.Equal(arrayItem, Expression.Constant(null, typeof(object))), Expression.Default(parameterType), Expression.Convert(arrayItem, parameterType)); } else { argumentExpressions[i] = Expression.Convert(arrayItem, parameterType); } } NewExpression newExpr = Expression.New(ctor, argumentExpressions); _creator = Expression.Lambda<Func<object?[], T>>(newExpr, args).Compile(); } public static T Create(params object?[] args) { if (args.Length != typeof(T) .GetConstructors(BindingFlags.Instance | BindingFlags.Public) .First() .GetParameters() .Length) { throw new ArgumentException("参数数量不匹配。", nameof(args)); } return _creator(args); } }这段代码故意做了简化,目的是先把主线讲清楚。构造时我们取了第一个公共构造函数,如果类型里只有带参构造,也无所谓;因为GetConstructors()返回的就是所有公共实例构造函数,第一个通常能满足演示需求。但真实项目里这样写不够稳,我建议至少按参数数量匹配。
3.2 参数绑定与类型转换的坑
上面循环里有一个容易被忽略的关键点:Expression.Convert并不只是做“强转”,它会在运行时生成对应的 IL 指令。引用类型之间的转换会变成castclass,值类型的装箱对象转换会变成unbox.any,这两种指令在语义上已经有区别了。
拿int举例。如果外部传入的是一个装箱后的int,那么Expression.Convert到int是完全没问题的,它可以拆箱。但如果外部传入的是一个long或者double,即使它们内部可以隐式转换到int,表达式树也不会自动帮你做数值转换。你要么提前把参数改成正确的类型,要么在传给工厂前自己完成转换。我在实践里的经验是:调用方最清楚自己传的是什么,工厂类不要做强类型的猜测。
另外,引用类型参数也很容易踩坑。比如构造函数参数类型是string,外部却传了一个StringBuilder,Expression.Convert在执行时会尝试把StringBuilder转成string,结果必然失败。所以在选择构造函数时,最好利用Type.IsAssignableFrom做一层过滤,选出真正兼容的那个,而不是只按参数个数蒙。
3.3 缓存表达式树,避免重复编译
表达式树如果每次调用都现场构建并Compile(),性能优势就全丢了。编译操作本身有开销,第一次编译消耗可能是普通构造调用的几十倍。正确的做法是把编译结果保存在静态字段或字典里。
上面代码里的_creator是static readonly,意味着每个泛型闭合类型(比如GenericFactory<Customer>)在整个进程生命周期里只编译一次。这已经覆盖了大多数使用场景。
如果你要处理的是完全动态的类型,没法写在泛型参数里,那就需要一种按Type缓存Func<object?[], object>的字典。常见的实现是:
private static readonly ConcurrentDictionary<Type, Func<object?[], object>> _cache = new ConcurrentDictionary<Type, Func<object?[], object>>(); public static object Create(Type type, params object?[] args) { return _cache.GetOrAdd(type, BuildFactory)(args); } private static Func<object?[], object> BuildFactory(Type type) { // 构建逻辑与上面泛型类基本一致,只是返回值不强制为 T }用ConcurrentDictionary.GetOrAdd可以减少多线程下重复编译的竞争,同时保证缓存查询性能足够好。如果你在某个框架里需要频繁动态创建类型,我通常不会直接用泛型静态类,而是用这种字典缓存版本,因为它的适用范围更广。
4. 性能对比:表达式树工厂比反射快在哪儿
4.1 三种常见方案的成本模型
在谈性能之前,先明确一点:直接new Customer(...)是永远最快的方式,因为它连委托跳转都不需要。表达式树的优势是在“无法直接写new T(...)”的前提下,尽可能逼近直接调用的性能。
我们可以把常见方案的调用开销简单列一下:
- 直接构造调用:编译期确定构造函数,几乎零额外开销。
- 表达式树编译后的委托:首次编译较慢,后续调用是委托调用,参数类型要经过
object装箱/拆箱,但整体成本可控。 Activator.CreateInstance:每次调用都要动态解析构造函数、包装参数数组、做安全检查和异常处理,开销大得多。- 反射
ConstructorInfo.Invoke:和Activator类似,甚至更慢,因为少了内部的一些缓存优化。
从成本模型上看,表达式树工厂的收益集中在“高频调用 + 动态类型”这个组合里。如果调用次数很少,比如一秒钟只创建几个对象,用Activator反而更省心,代码也更短。
4.2 为什么编译后的委托比反射快
我们可以从 IL 的视角来理解。Activator.CreateInstance内部调用构造函数时,需要做一层层解释性的操作:找构造函数、匹配参数、格式化异常,最后才能真正执行构造逻辑。而表达式树编译出的委托,Compile()生成的方法是直接把参数数组里的元素拆箱、转型,然后调用构造函数。两者在 IL 层面上的差距非常明显。
用大白话说,反射是“边看菜谱边做菜”,表达式树是把菜谱背下来,直接进入烹饪流程。菜谱看一次和看一万次的差距,就是性能差的来源。
当然,表达式树的收益也不是完全没有代价。如果你构造表达式的过程本身很复杂,比如每次都要用GetConstructors()去扫描类型成员,那么这部分开销会被计入首次解析。所以我才强调缓存的重要性。缓存做得好的表达式树工厂,性能可以做到比反射快一个数量级;缓存做得不好,反复编译表达式树甚至可能比反射更慢。
4.3 什么时候不该用这套方案
写到这里我想泼一盆冷水。表达式树不是银弹,有些场景不该硬上。
如果类型本身有稳定的无参构造函数,并且初始化可以通过属性赋值完成,直接用new T()配合泛型约束是最简单的。这时候上表达式树属于过度设计。如果类型只有内部构造函数,而且你真的有权修改这个类型,那么更推荐在类型内部提供一个工厂方法或静态创建方法,让调用方通过委托传入。这样一来,强类型和语义清晰度都远胜表达式树。
还有,如果泛型参数只是用来约束接口或抽象基类,工厂类实际上不应负责创建具体实现。这种职责更应该交给依赖注入容器或注册表。表达式树适合的是“你已经拿到一个具体Type,需要在运行时动态构造它”的场景,它不应该替你承担架构设计上的职责。
5. 常见问题与排查实战记录
5.1 Expression.New 参数与构造函数签名不匹配
这是最容易犯的错。Expression.New(ctor, args)里的args数量和顺序必须与ctor.GetParameters()完全一致。如果少了参数,或者表达式中的某个参数类型不能隐式转换到构造函数参数类型,Compile()时不会立即报错,但在表达式树创建阶段可能就会抛出参数异常,甚至拖到运行时才炸。
我排查这个问题的顺序很简单:先把ctor.GetParameters()的所有参数类型打印出来,再用同样的顺序去拼argumentExpressions,两个列表放一起对比。九成的问题都出在“反射到的参数顺序”和“外部传入的数组顺序”不一致。解决方案也很直接:调用方传参时必须严格按构造函数的参数顺序来,工厂类不要试图重新排序。
5.2 泛型参数是抽象类或接口
如果一个类型是abstract或者interface,GetConstructors()返回要么是空,要么只包含保护构造器。这种情况下表达式树没法创建实例,因为根本不存在可调用的实例构造函数。
我见过不少同学在这里卡住,然后开始给工厂加各种类型判断。我的建议是:如果业务上允许用具体类型,就让工厂接收一个具体类型的Type参数;如果确实只能拿到抽象类型,那说明设计里缺少“从抽象类型解析具体实现”的一步,这一步应该在更上层完成,而不是塞进表达式树工厂里。
5.3 值类型与可空类型
泛型参数T有可能是值类型,比如GenericFactory<int>。int 的构造函数就是无参默认构造,用Expression.New创建会有特殊性。更常见的是构造函数参数中包含可空值类型,比如decimal?。Expression.Convert从object转decimal?的语义我前面提过,它可以工作,但要小心外部传入的null。
对可空值类型来说,null是合法值,所以我在代码里的空值保护不会触发Expression.Default,而是正常走Convert。别在空值处理后对可空类型再强制转成基础值类型,那样反而会把合法的空值破坏掉。
5.4 构造函数数量不止一个时
如果类型有两个构造函数,一个接收string,另一个接收int,而外部传进来的数组是空,或者参数个数相同但类型不同,工厂就需要更聪明的匹配策略。我的做法是:在首次构建时遍历所有公共构造函数,先用参数个数过滤,再用Type.IsAssignableFrom逐个判断每个参数是否兼容。如果最终候选构造函数多于一个,就选择参数类型“最具体”的那个。这里的“最具体”可以通过类型继承关系判断,但大多数时候只需要按传入参数的实际类型做一次精确匹配就够了。
如果实在无法匹配,我会抛出一个带详细信息的异常,把可用构造函数列表和实际传入参数类型都列出来,方便调用方排查。这样看起来代码啰嗦,但能在线上环境省下很多时间。
6. 我踩过几次坑之后的选择
最后聊点私货。起初我做这类动态实例化,第一反应就是反射,觉得Activator.CreateInstance一行代码搞定,何必折腾表达式树。后来在真实的高频场景里一压测,发现反射方案成了瓶颈,才回过头来研究表达式树。等我真正把Expression.New和Compile组合跑通之后,我发现难的不是代码本身,而是之前对“表达式树到底在解决什么问题”理解得不够。
如果你正准备在项目里复用这篇文章里的思路,我会建议你先写一个小型性能测试,拿你的真实类型跑一遍Activator和表达式树工厂的对比。你大概率会看到明显的差距,也会更理解为什么很多框架底层宁可花力气缓存表达式树,也不愿意频繁反射。至于要不要用Expression.New之外的花活,比如复杂初始化逻辑、调用实例方法后再返回对象,我真心建议保持克制。表达式树一旦复杂起来,排查难度是成倍上升的。
这篇文章里给的工厂类,是我自己会采用的保守版本:按类型缓存编译结果、构造函数匹配做到够用、参数转换明确。你完全可以在这个基础上继续扩展。只要记住,表达式树的价值不是“看起来高深”,而是让动态创建这件事从解释型变成编译型,这种性能上的手感差异,只有真的跑过一轮压测才能感受到。