这个系列写到第4篇今天要解决的问题是我在实际项目中纠结过很久的一个矛盾C#泛型约束只认new()不允许泛型代码里写带参数的new T(...)。但真实的业务里一个实体对象、一个策略类、一个处理器构造函数十有八九要接收参数。于是写通用代码时就陷入两难——想用泛型抽象语法不答应想创建带参实例只能走反射性能和类型安全都打了折扣。今天这篇专门聊这个矛盾用表达式目录树把“调用构造函数”编译成强类型委托绕开泛型约束的限制。适合读过前面几篇、想在真实项目里把表达式树用起来的读者也适合看不懂Activator一大片反射代码、想找个更优雅方案的朋友。1. 先把矛盾看清楚泛型约束天生管不到构造函数参数1.1new()约束的真实能力边界在C#里写泛型大家最熟悉的约束就是where T : new()它保证T有一个公开的无参构造函数于是你可以在代码里放心写new T()。这个约束用起来很爽但它的能力边界一旦遇到“我想new T(someArg)”就露馅了——编译器直接报错不允许在泛型代码里以带参数的方式创建实例。为什么不是C#语法缺这个能力而是泛型约束体系里压根没有“构造函数签名约束”这种类型。CLR的泛型约束只支持基类型约束、接口约束、class/struct约束、new()无参约束、notnull约束。想表达“T必须有一个接受一个int参数的构造函数”在语法层面就无处安放。注意new()约束的全名其实是“无参构造约束”它不可能帮你传达任何关于参数列表的信息。有朋友会说我可以这样设计public class MyFactoryT where T : IHasName, new() { public T Create(string name) { var item new T(); item.SetName(name); // 接口方法 return item; } }确实能绕。但绕行的代价是所有T的实现类都必须额外暴露一个SetName这样的可变方法还要承担“先创建再赋值”这个两阶段初始化的风险——对象在构造完成到属性赋值之间可能处于一个不完整状态。如果这个对象在构造函数里就要完成自校验、注册事件、分配资源这种绕法根本行不通。1.2 三个日常场景让这个矛盾暴露无遗实际开发中这个矛盾最常在三个场景冒出来。第一个是通用数据访问层。我早年在某中间件项目里写过一套基于泛型的仓储基类希望把从数据库行到实体对象的映射统一收口public abstract class RepositoryBaseTEntity where TEntity : class { public virtual TEntity Load(int id) { // 这里想 new TEntity(reader.GetInt32(0))根本编译不过 return Activator.CreateInstance(typeof(TEntity), id) as TEntity; } }每次都要用Activator绕代码里到处是转型和try-catch看着就别扭。第二个是配置驱动的对象创建。打个比方写一个消息处理器分发器消息类型和处理器类型通过配置映射。处理器构造函数需要传入消息本身泛型约束没法表达“T要接收一个Msg参数”于是只能用反射找构造函数再Invoke。反射方案能用但每次创建都要走元数据查找性能肉眼看得到的慢。第三个是单元测试和Mock场景。写泛型测试基类时希望被测对象能按测试参数构造public abstract class TestBaseTTarget, TArg { protected TTarget BuildTarget(TArg arg) { // 编译期不知道TArg和TTarget的构造函数关系 return CreateInstanceTTarget, TArg(arg); } }需求不难但泛型语法就是给不了这个能力。这三个场景指向同一个核心诉求我需要在泛型上下文中按运行时才知道的参数列表去创建T的实例并且希望这个过程尽量快、尽量类型安全。2. 租一个“构造函数代理”表达式目录树的破局思路2.1 核心原理把构造行为编译成委托既然编译器不让我在泛型代码里直接new T(args)那就换个思路我把“调用构造函数”这件事当成一段代码动态生成这段代码编译成一个委托然后在泛型函数里调用这个委托。委托签名在编译期确定构造函数的实参通过参数传进去——这不就绕开了“泛型里不能写带参构造”的限制吗关键就是表达式目录树。System.Linq.Expressions提供的Expression.New方法可以把一个ConstructorInfo和一组参数表达式组装成一个NewExpression表示“调用这个构造函数参数是这些表达式”。然后把NewExpression放进Lambda表达式里再调用Compile()编译成可执行的委托。var ctor typeof(T).GetConstructor(new[] { typeof(int) }); var arg Expression.Parameter(typeof(int), id); var newExpr Expression.New(ctor, arg); var lambda Expression.LambdaFuncint, T(newExpr, arg); var factory lambda.Compile();这一步做完factory就是一个强类型的Funcint, T。调用factory(42)等价于直接执行new T(42)只是中间隔了一层委托调用。由于表达式目录树在编译时会生成接近手写代码的IL性能损失极小远比反射快。2.2 为什么不用反射/Activator一把梭Activator.CreateInstance(type, args)或者ConstructorInfo.Invoke(args)也能创建带参实例为什么非要表达式目录树答案就一个字性能再加两个字体验。反射的Invoke走的是运行时后期绑定每次调用都要做参数数组匹配、类型转换、安全检查。构造函数的调用本身可能很快但反射这一层会平白多出几十倍的损耗。在我之前的基准测试里反射创建100万次对象耗时是直接new的50到80倍而编译好的表达式委托只比直接new慢大概2到3倍。而且反射方案有个很烦人的体验问题参数类型不匹配时错误要等到运行那一刻才爆出来报错信息还是晦涩的TargetParameterCountException、“无法将System.String强制转换为System.Int32”这类。而如果用表达式目录树在启动期就编译好出错会在应用启动阶段立刻暴露配合调试器能直接看到是哪个构造函数的哪个参数出了问题。还有一点容易被忽略表达式目录树方案产出的委托是强类型的可以像普通函数一样被继续传递、组合。比如我可以把几个构造委托组装成一个更大的Func链这在反射方案里操作成本很高。提示并不是说反射完全不能用。如果是低频的、启动期一次性创建的场景用Activator简单省事完全没必要上表达式树。表达式目录树的优势在“高频率调用”和“需要给泛型体系补能力”这两类场景。2.3 表达式目录树里最关键的两个API动手写之前先认一下本场景最常用的两个API理解了它们后面的代码你就能自己推导。第一个是Expression.New(ConstructorInfo, params Expression[])。它有两个重载在实战里最有用只传ConstructorInfo是创建无参构造传入一组Expression则是在调用带参构造函数。注意传入的参数Expression数量和顺序必须和ConstructorInfo的参数列表精确匹配类型上允许可隐式转换的情况但最好保持完全一致避免踩坑。第二个是Expression.Parameter(Type, string)。它声明一个“输入参数”在Lambda表达式里代表这个函数的形参。编译成的委托类型完全由Parameter和Body共同决定。比如Expression.Parameter(typeof(int))对应Funcint, T的入参int。还有Expression.Convert在通用工厂里我们会用它把object类型的数组元素转成目标参数类型。这个API容易出问题后面我专门讲。另外可能有人会问Reflection.Emit也能干这事为什么不直接用IL因为Emit写起来又长又绕对普通人来说维护成本太高。表达式树在NetCore/Net5之后性能和Emit差距已经很小API友好度却高出一个数量级。绝大多数场景表达式树是性价比最优解。3. 动手实现一个可复用的带参构造工厂3.1 强类型版本一参一工厂类型安全优先先从最简单、最实用的版本开始。假设我知道T的构造函数需要什么类型的参数——比如TEntity(Int32 id)——我可以写一个小工具类为这个具体场景生成强类型工厂public static class ObjectFactoryT where T : class { public static Funcint, T CreateWithInt() { var ctor typeof(T).GetConstructor(new[] { typeof(int) }); if (ctor null) throw new InvalidOperationException(${typeof(T)} 缺少接收 int 参数的构造函数); var idParam Expression.Parameter(typeof(int), id); var body Expression.New(ctor, idParam); var lambda Expression.LambdaFuncint, T(body, idParam); return lambda.Compile(); } }这个版本的好处是类型安全返回的委托签名是Funcint, T调用方在编译期就能看到参数类型不会出现把字符串当int传入的情况。坏处是每搞一种参数列表就要写一个新方法扩展开来会爆炸。实际用起来长这样。比如一个简单的消息处理器分发器消息类型固定处理器构造函数都接收一个消息ID参数var handler ObjectFactoryOrderCreatedHandler.CreateWithInt(); handler(1001);你完全可以再补CreateWithIntAndString之类的变体按需扩充。虽然笨但它够稳没有运行时转换性能接近直接new。3.2 通用版本object[]参数数组的万能工厂写通用版本就不能写死参数类型了。我的思路是外部传入参数数组object[]工厂在内部根据这个数组去匹配T的构造函数然后把每个参数表达式都统一写成“从object[]里取第i个元素Convert到目标类型”。public static class GenericFactoryT where T : class { private static readonly Funcobject[], T _factory; private static readonly ConstructorInfo _matchedCtor; private static readonly Type[] _paramTypes; static GenericFactory() { _factory CompileFactory(out _matchedCtor, out _paramTypes); } public static T Create(params object[] args) { ValidateArgs(args); return _factory(args); } public static ConstructorInfo MatchedCtor _matchedCtor; public static Type[] MatchedParamTypes _paramTypes; private static void ValidateArgs(object[] args) { if (_paramTypes.Length ! args.Length) throw new ArgumentException( $期望 {_paramTypes.Length} 个参数实际传入 {args.Length} 个); for (int i 0; i args.Length; i) { if (args[i] null) { if (_paramTypes[i].IsValueType Nullable.GetUnderlyingType(_paramTypes[i]) null) throw new ArgumentException( $第{i}个参数是值类型 {_paramTypes[i]}不能传 null); } else { var targetType _paramTypes[i]; var nullableType Nullable.GetUnderlyingType(targetType); if (!targetType.IsInstanceOfType(args[i]) (nullableType null || !nullableType.IsInstanceOfType(args[i]))) throw new ArgumentException( $第{i}个参数类型不匹配期望 {targetType}实际 {args[i].GetType()}); } } } private static Funcobject[], T CompileFactory( out ConstructorInfo matchedCtor, out Type[] paramTypes) { var type typeof(T); var ctor type.GetConstructors() .OrderByDescending(c c.GetParameters().Length) .FirstOrDefault() ?? throw new InvalidOperationException( $类型 {type} 没有公有构造函数); var ps ctor.GetParameters(); var argArray Expression.Parameter(typeof(object[]), args); var args new Expression[ps.Length]; for (int i 0; i ps.Length; i) { var pt ps[i].ParameterType; var item Expression.ArrayIndex(argArray, Expression.Constant(i)); // 值类型和引用类型都需要从 object 拆箱/转换 args[i] Expression.Convert(item, pt); } var body Expression.New(ctor, args); var lambda Expression.LambdaFuncobject[], T(body, argArray); matchedCtor ctor; paramTypes ps.Select(p p.ParameterType).ToArray(); return lambda.Compile(); } }使用方式var entity GenericFactoryOrderEntity.Create(1001); var service GenericFactoryPaymentService.Create(1001, alipay, 88.5m);这个通用工厂的选参策略是“参数最多的公有构造函数优先”。这个策略有其合理性参数越多的构造函数信息量越大通常也是更完整的初始化路径。生产环境中如果T存在多个重载构造我会建议在CompileFactory里加一个更细的匹配规则而不是简单取最长。不过对大多数业务类来说参数最长的主构造函数就是你要的那个。3.3 缓存机制Compile结果要省着用无论强类型版本还是通用版本都有同一个性能要点Compile()不能每次创建对象时都调用。Compile()本身要做IL生成和JIT编译成本不低。正确姿势是把编译结果缓存只编译一次之后每次调用都复用委托。上面的通用工厂用了静态构造函数这是最简缓存方式类第一次被引用时编译一次之后整个进程生命周期都复用同一个委托。如果不想让每个T都占一个静态类也可以用ConcurrentDictionaryType, Funcobject[], object以类型为key缓存委托private static readonly ConcurrentDictionaryType, Funcobject[], object _cache new ConcurrentDictionaryType, Funcobject[], object(); public static object Create(Type type, params object[] args) { var factory _cache.GetOrAdd(type, t CompileFactory(t)); return factory(args); }这里还有一个细节很多人容易忽略缓存key用type就好但参数类型应该参与匹配逻辑不能简单用args长度当key。因为同一个类型可能有多个不同签名的构造函数如果只缓存了一条链第二次传入不同参数类型组合时就会拿到不匹配的委托。我踩过这个坑最初用“类型长度”做key结果两个长度相同但类型不同的构造请求互相污染彼时调试了两个小时才发现是缓存命中错了对象。4. 实测验证性能、可靠性和边界行为4.1 三方案性能对比实测光说表达式树快不够贴一组我本地环境的实测数据.NET 8Release模式创建100万次对象。被测对象是一个带两个参数的普通类public class SampleObject { public SampleObject(int id, string name) { Id id; Name name; } public int Id { get; } public string Name { get; } }测试三种方案直接new作为基准、反射ConstructorInfo.Invoke、表达式树编译委托。方案100万次耗时相对基准直接new约18ms1x反射Invoke约850ms约47x表达式树编译委托约43ms约2.4x这个数据和我预期一致。表达式树委托的代价主要是一次间接调用和参数装箱因为我们走的是object[]入口如果改用强类型版本比如专门的Funcint, string, T还能再压到差不多28ms和直接new基本可以忽略差异。注意不同机器差异会很大但“反射比表达式树慢一个数量级以上”这个结论在常见CLR版本上都很稳。这个性能差异在热点路径上极其关键。比如一个消息分发器每秒要创建几千个处理器实例反射方案的额外开销会直接反映到吞吐率上。换成表达式树编译委托之后处理器创建时间几乎可以忽略。顺带说一下反射为什么慢。第一是每次Invoke都要做元数据查找定位到具体构造函数第二是参数数组要经过一轮匹配和类型校验第三是安全检查与调用包装这些在表达式树编译委托里全部被打包进了启动阶段的一次性工作运行期只是纯粹的委托调用。4.2 常见坑重载、值类型、私有构造函数与可选参数实战中会踩到几个边界坑单独列出来。第一个坑是构造函数重载的选择策略。上面通用工厂用了“按参数个数降序取第一个能匹配的”策略。这个策略有其合理性但如果不做类型兼容检查很可能选中一个参数类型不符的构造函数导致运行时Convert失败。我的建议是在匹配前先用反射把参数类型和目标数组元素类型做IsAssignableFrom检查只放行完全兼容的组合。这个检查在构造函数数量不多的场景下成本可以忽略。第二个坑是值类型参数与可空类型。表达式树里用Expression.Convert把object转换成int、DateTime这些值类型时必须处理拆箱。如果传入的object是装箱后的可空类型比如int?装箱后是int而目标参数是int?Convert通常没问题但反过来——传了一个int?的装箱对象给int参数——就可能报错。处理方式是在TryCreateBody里对NullableT做专门判断要么主动提升为T要么在匹配时排除掉不兼容组合。千万别指望表达式树自动帮你做这种转换它不会。第三个坑是私有构造函数和内嵌类型。GetConstructors()默认只拿公有构造函数如果是工厂要做单例复用或者测试辅助工具往往需要访问非公有构造函数。这时候要显式传BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public。还有一个容易忽略的内嵌类型的构造函数在IL层面默认是私有的取决于声明方式不显式处理同样拿不到。这个坑在写通用对象工厂的单元测试辅助类时相当常见。第四个坑是可选参数。C#里可以声明SampleObject(int id, string name default)但表达式树的Expression.New不会自动帮你应用默认值。你如果想拿到和直接new一样的效果要么手动补上默认值表达式要么老老实实把所有参数传全。默认值信息可以通过ParameterInfo.DefaultValue拿到但如果纠结成本我的建议是不要在表达式树工厂里过度自动化默认值调用方传全参数反而更清晰。5. 不止是创建对象把表达式树工厂嵌入更复杂的场景5.1 用表达式树做参数校验与默认值注入一旦你习惯了“把构造行为变成委托”你会发现这个思路可以顺带解决很多构造期的小问题。比如参数校验、默认值注入、单例化、AOP式的拦截全都能在生成表达式的那一刻一起揉进去。我举个参数校验的例子。假设你的T的构造函数里有一个string参数你希望创建时自动把null、空字符串替换成默认值unknown。最直接的做法是写一个包装函数但更彻底的是在构造表达式树时手动给参数表达式加一层判断。用Expression.Condition组装条件表达式把“如果arg null用代替值否则用arg”的语义直接编译进委托里var rawValue Expression.Convert( Expression.ArrayIndex(args, Expression.Constant(1)), typeof(string)); var fallback Expression.Constant(unknown); var condition Expression.Condition( Expression.OrElse( Expression.Equal(rawValue, Expression.Constant(null)), Expression.Equal(rawValue, Expression.Constant(string.Empty))), fallback, rawValue);这样做的好处是调用方完全无感知参数转换逻辑被固化在工厂内部。同理你可以给int参数做范围钳制给DateTime参数做本地化转换原理一致。这种“用表达式树给构造过程注入逻辑”的能力是反射方案很难优雅实现的。反射Invoke之前你要先手动改参数数组而表达式树方案把这个预处理编进了IL里运行期零额外判断成本。5.2 表达式树工厂与IoC容器分工而不是对立有朋友看完前几节可能会问你折腾这么多IoC容器不是早就能干这事了吗对主流IoC容器解析对象时内部本质就是“找到构造函数→准备参数→激活实例”而且激活环节大多是表达式树编译或类似的高效机制。但容器解决的是依赖关系管理和生命周期注册、解析、作用域它要管的东西比“创建带参实例”宽得多。我们的表达式树工厂定位是轻量级组件适合三种场景不想为了两三个类就引入整套容器依赖关系明确、不需运行时解析只是泛型写在语法层面不通需要在某个局部模块里高频创建对象且不想为容器注册表增加负担。我自己的经验是早期先用表达式树工厂顶着等项目里类数量涨到十几二十个、依赖开始出现网状结构时再迁移到容器。这样过渡期代码完全不用推翻重写因为表达式树工厂的结构和容器注册逻辑很接近替换成本低。还有个小技巧表达式树工厂可以和容器互补。比如你在扩展点里要从配置字符串创建策略对象策略类型和参数类型都是运行时才知道这时候拿容器的Resolve反而麻烦——因为容器需要编译期类型。表达式树工厂这个“只要给我Type和参数列表我就能高效构造”的能力恰好补齐了容器的盲区。写到最后简单聊两句我这几年用下来的体会。表达式目录树在C#里一直是被低估的一块很多人学完基础语法就扔一边了总觉得它是库作者才需要碰的东西。但像“泛型约束与带参构造矛盾”这种问题日常业务代码里真会踩到而且一旦踩到手边没有现成方案就会很被动。我建议你把上面的通用工厂类存一份到自己的工具库里平时可能用不上真用到时能省下大半天排查时间。最后提醒一句通用工厂里的类型匹配逻辑一定要写完整别嫌啰嗦我自己在这上面吃过亏也见过同事在生产环境里因为少了一次IsAssignableFrom检查半夜被叫起来查一个神秘的强转异常。