1. 表达式目录树到底是个啥
1.1 从一次真实报错说起
先讲个我自己的经历。几年前做某个数据查询系统,需要根据前端传过来的筛选条件动态拼SQL。一开始用字符串拼接,条件一多直接失控:字段名写错不报错、SQL注入的风险要自己扛、每次改需求都要动一大片代码。后来听同事说可以用表达式目录树,当时我还不太理解,就试着写了一句:
Expression<Func<Product, bool>> expr = p => p.Price > 100;然后把它传给某个查询方法,发现居然能正常工作。但真正让我震撼的是后面:我居然可以拿到这棵"树"的每一个节点,看到p.Price这个属性访问、100这个常量、>这个比较操作。也就是说,这段C#代码不只是"能被执行",它还能被分析和重写。那一刻我才意识到,表达式目录树本质上就是把代码变成数据,而数据是可以被程序理解和操作的。
这篇笔记就是围绕这个核心展开的,适合正在学习C#/.NET、对Lambda表达式有一定了解但没深入过表达式树的开发者。不管你是做ORM封装、动态条件查询,还是想做性能优化替代反射,表达式目录树都是一项绕不开的基础能力。
1.2 表达式树和委托的本质区别
很多人刚开始容易混淆Func<T>和Expression<Func<T>>。表面上看,它们的写法几乎一样:
Func<Product, bool> func = p => p.Price > 100; Expression<Func<Product, bool>> expr = p => p.Price > 100;但底层完全不是一回事。func是一个委托,它指向一段已经编译好的 IL 代码,运行时直接被CLR执行。而expr保存的是一棵语法树,编译器并没有把它编译成方法,而是按照代码的结构生成了一个个节点对象,比如ParameterExpression(参数)、MemberExpression(属性访问)、ConstantExpression(常量)、BinaryExpression(比较运算)。
我打个比方:委托是"做好的菜",端上来就能吃;表达式树是"菜谱",不但能照着做菜,还能把菜谱里的步骤拿出来分析、修改,甚至翻译成另一种描述方式(比如SQL)。
这个区别带来两个直接后果:
- 委托能干的活,表达式树不一定能干(比如表达式树不能转换带语句体的Lambda)。
- 表达式树能干的活,委托基本干不了(比如把代码结构暴露出来进行翻译和改写)。
理解了这个本质,后面所有内容都好办了。表达式目录树的全部价值,就建立在"可分析、可改写、可翻译"这三个能力之上。
2. 构建表达式树的三种姿势
2.1 懒人方式:Lambda表达式直接转换
最常用的方式就是把 Lambda 表达式直接赋值给Expression<TDelegate>类型的变量,编译器会自动完成转换。
Expression<Func<User, bool>> predicate = u => u.Age >= 18 && u.Name.Contains("张");这种方式写起来最舒服,可读性也最好,日常开发中90%的场景都够用了。它生成的树结构大致是:一个AndAlso的二元表达式节点,左边是u.Age >= 18,右边是u.Name.Contains("张"),叶子节点是参数u对应的ParameterExpression。
这里有个容易忽略的点:Lambda表达式转换为表达式树,要求Lambda必须是"表达式体"(expression body),不能是"语句体"(statement body)。也就是说下面这种写法会直接编译报错:
// 编译错误:无法将带有语句体的 lambda 表达式转换为表达式树 Expression<Func<User, bool>> invalid = u => { return u.Age >= 18; };因为语句体里可以有if、for、return等控制流,这些无法用表达式树节点直接描述。所以当你需要复杂逻辑的时候,要么写成表达式体的形式(用三元表达式? :代替if),要么考虑用 API 手工构建。
2.2 硬核方式:API 手工拼装
有时候Lambda表达式根本写不出来,比如:你事先不知道要访问哪个属性,只知道属性名是字符串;或者要根据运行时的条件决定节点类型。这时候就得用System.Linq.Expressions命名空间下的 API 手工拼装。
举个例子,我想构建一个表达式u => u.Age >= 18,用API来写是这样的:
// 参数 u ParameterExpression param = Expression.Parameter(typeof(User), "u"); // u.Age MemberExpression ageProp = Expression.Property(param, "Age"); // 常量 18 ConstantExpression constant = Expression.Constant(18, typeof(int)); // u.Age >= 18 BinaryExpression body = Expression.GreaterThanOrEqual(ageProp, constant); // 组装成表达式树 Expression<Func<User, bool>> expr = Expression.Lambda<Func<User, bool>>(body, param);这段代码不复杂,但它体现了表达式树的本质:每个节点都是一个对象,节点和节点之间通过引用关系组成一棵树。手工拼装的典型场景是动态排序:
public IQueryable<T> OrderByDynamic<T>(IQueryable<T> source, string propertyName, bool ascending) { ParameterExpression param = Expression.Parameter(typeof(T), "x"); MemberExpression property = Expression.Property(param, propertyName); LambdaExpression lambda = Expression.Lambda(property, param); string methodName = ascending ? "OrderBy" : "OrderByDescending"; MethodCallExpression call = Expression.Call( typeof(Queryable), methodName, new[] { typeof(T), property.Type }, source.Expression, Expression.Quote(lambda)); return source.Provider.CreateQuery<T>(call); }这段代码解决了"字段名是字符串、不能直接用Lambda写死"的问题。调用方式非常自然:
IQueryable<User> sorted = OrderByDynamic(users.AsQueryable(), "Age", false);2.3 修改与重写:ExpressionVisitor 的正确玩法
构建表达式树只是基础,真正让它"封神"的是修改能力。这里要引出ExpressionVisitor类,它是表达式树的"遍历器+改写器"。
最经典的需求是"替换参数"。比如你有一个现成的表达式u => u.Age > 18,但你想把它应用到另一个参数类型或者换一个参数对象上。直接Compile()后再调用是不行的,因为表达式树里的参数节点还是旧的。这时候可以写一个参数替换器:
public class ParameterReplaceVisitor : ExpressionVisitor { private readonly Expression _target; public ParameterReplaceVisitor(Expression target) => _target = target; protected override Expression VisitParameter(ParameterExpression node) { return _target; } } // 使用 ParameterExpression newParam = Expression.Parameter(typeof(User), "newUser"); Expression<Func<User, bool>> expr = u => u.Age > 18; var rewritten = new ParameterReplaceVisitor(newParam).Visit(expr.Body); var newExpr = Expression.Lambda<Func<User, bool>>(rewritten, newParam);我当初第一次看到这种写法很困惑:VisitParameter明明只处理参数节点,为什么整棵树的访问路径都会被替换?后来才明白,ExpressionVisitor是深度优先递归遍历整棵树的,当它发现某个参数节点被替换成了另一个表达式,那么包含该参数的所有父节点——属性访问、比较运算、Lambda表达式——全部都会重建。这就是表达式树"不可变"特性带来的连锁反应:要么不改,要改就是从叶子到根全部重建。
ExpressionVisitor 还有很多实际用途,比如:把AndAlso(&&)节点替换成OrElse(||),把>=替换成>,统计某个表达式里用到了哪些属性。掌握它,等于掌握了表达式树的"手术刀"。
3. 实战:表达式树在项目里能干什么
3.1 动态拼接查询条件(最常用)
做后台系统的人应该都写过这种代码:根据前端传的条件参数,查数据库,条件是可选的那种。最朴素的写法是:
var query = db.Products.AsQueryable(); if (minPrice.HasValue) query = query.Where(p => p.Price >= minPrice.Value); if (!string.IsNullOrEmpty(name)) query = query.Where(p => p.Name.Contains(name));当条件只有两三个时,这种写法没问题。但条件一旦到十个以上,每个条件还要支持"并且/或者"的组合,代码就会变得非常臃肿。表达式树给了我们一种更优雅的思路:先用不完整条件构建表达式树,然后动态地把新条件 And 进去。
public static Expression<Func<T, bool>> AndAlso<T>( this Expression<Func<T, bool>> left, Expression<Func<T, bool>> right) { // 用一个参数替换器把两个表达式的参数统一 ParameterExpression param = Expression.Parameter(typeof(T), "x"); var leftBody = new ParameterReplaceVisitor(param).Visit(left.Body); var rightBody = new ParameterReplaceVisitor(param).Visit(right.Body); var combined = Expression.AndAlso(leftBody, rightBody); return Expression.Lambda<Func<T, bool>>(combined, param); }然后可以这样拼接:
Expression<Func<Product, bool>> predicate = p => true; if (minPrice.HasValue) predicate = predicate.AndAlso(p => p.Price >= minPrice.Value); if (!string.IsNullOrEmpty(name)) predicate = predicate.AndAlso(p => p.Name.Contains(name)); var result = db.Products.Where(predicate).ToList();这里的核心难点是两个Lambda表达式各自拥有不同的参数实例,直接Expression.AndAlso(left.Body, right.Body)会报参数节点不一致的错误。所以必须先做参数统一。我见过不少人在这里踩坑,后面第5章会专门讲这个。
3.2 用表达式树干掉反射
反射是运行时获取类型信息的利器,但性能一直是它的软肋。尤其是在循环里调用反射来访问属性,性能差距可以到几十倍甚至上百倍。表达式树提供了一种两全其美的方式:第一次用反射拿到PropertyInfo,然后基于它构建一个访问器表达式树,编译成强类型委托,之后每次都调用这个委托。
public static class PropertyAccessor { public static Func<T, object> BuildGetter<T>(string propertyName) { ParameterExpression param = Expression.Parameter(typeof(T), "x"); MemberExpression property = Expression.Property(param, propertyName); // 装箱:值类型转 object UnaryExpression convert = Expression.Convert(property, typeof(object)); return Expression.Lambda<Func<T, object>>(convert, param).Compile(); } } // 使用:只会构建一次,后面都是高性能调用 var getter = PropertyAccessor.BuildGetter<User>("Age"); for (int i = 0; i < 100000; i++) { int age = (int)getter(user); // 不再是反射调用 }这个模式的本质是把"运行时才能确定的成员访问"编译成"运行前就确定好的委托"。所有ORM框架里的实体映射、属性拷贝工具,底层基本都是这个思路。我在给某跨平台系统做数据同步模块时,就用这个方式把实体属性映射的性能提升了将近一个数量级。
3.3 把表达式树翻译成别的语言:SQL 适配器
表达式树最有"魔法"的应用场景,是把C#表达式翻译成 SQL。这也是 EF Core 这类 ORM 的底层原理:你写db.Users.Where(u => u.Age > 18),EF 不会直接执行这段C#代码,而是遍历表达式树,把GreaterThan节点翻译成>操作符,把MemberExpression翻译成字段名(列名),最后生成SELECT * FROM Users WHERE Age > 18这样的 SQL。
我们也可以模仿这个思路,给某些不支持原生 IQueryable 的数据源写一个简单的"翻译器"。比如我有一次对接一个接口,它只接收 JSON 格式的查询条件,不支持函数调用。我就写了一个访问器,把表达式树翻译成 JSON:
public class JsonQueryTranslator : ExpressionVisitor { private readonly StringBuilder _sb = new StringBuilder(); public string Translate(Expression expr) { Visit(expr); return _sb.ToString(); } protected override Expression VisitBinary(BinaryExpression node) { _sb.Append("{"); Visit(node.Left); _sb.Append(","); _sb.Append("\"op\":\"" + GetOp(node.NodeType) + "\","); Visit(node.Right); _sb.Append("}"); return node; } // 其他节点的翻译逻辑省略... }虽然实际生产代码要比这个复杂得多,但核心思想是一致的:把表达式树当成一种中间表示(IR),把它翻译成目标端能理解的格式。掌握这个思路,你在面对任何"需要动态生成规则"的场景时都会多一把利器。
4. 手把手写一个迷你表达式翻译器
4.1 整体设计
前面讲了不少概念,这一章我用一个完整的迷你示例把整体流程串起来。目标:写一个能把Expression<Func<T, bool>>翻译成SQL WHERE 子句的翻译器,支持==、>、&&、||这几种常见操作,以及属性访问和常量。
这个例子的意义不在于"能用在生产环境",而在于帮你彻底理解表达式树可以被程序遍历并翻译的这个过程。整个过程分为三步:拿到表达式树的根节点、深度优先遍历每个节点、根据节点类型做翻译。
4.2 核心代码实现
public class SqlTranslator : ExpressionVisitor { private readonly StringBuilder _where = new StringBuilder(); public string Translate(Expression<Func<object, bool>> expr) { Visit(expr.Body); return _where.ToString(); } protected override Expression VisitBinary(BinaryExpression node) { _where.Append("("); Visit(node.Left); _where.Append(" " + GetSqlOperator(node.NodeType) + " "); Visit(node.Right); _where.Append(")"); return node; } protected override Expression VisitMember(MemberExpression node) { _where.Append("[" + node.Member.Name + "]"); return node; } protected override Expression VisitConstant(ConstantExpression node) { var value = node.Value; if (value is string || value is DateTime || value is bool) _where.Append("'" + value + "'"); else _where.Append(value); return node; } private string GetSqlOperator(ExpressionType type) { switch (type) { case ExpressionType.Equal: return "="; case ExpressionType.NotEqual: return "<>"; case ExpressionType.GreaterThan: return ">"; case ExpressionType.GreaterThanOrEqual: return ">="; case ExpressionType.LessThan: return "<"; case ExpressionType.LessThanOrEqual: return "<="; case ExpressionType.AndAlso: return "AND"; case ExpressionType.OrElse: return "OR"; default: throw new NotSupportedException($"不支持的操作:{type}"); } } }使用示例:
Expression<Func<object, bool>> expr = u => ((User)u).Age > 18 && ((User)u).Name == "张三"; string where = new SqlTranslator().Translate(expr); // 输出:([Age] > 18 AND [Name] = '张三')4.3 测试用例与结果验证
我建议你自己动手跑一遍这个例子,顺便验证几个有趣的行为:
- 如果常量换成
DateTime.Now,翻译结果是什么?答:会把DateTime.Now作为常量值抓出来,翻译成一个固定时间字符串,因为这个属性调用在表达式树里已经被求值为常量了。这个行为需要注意,和"每次执行都取当前时间"的语义不同。 - 如果表达式里用了局部变量,比如
int n = 18; expr = u => u.Age > n;,翻译结果是什么?答:VisitConstant拿到的值是18,因为编译器会把闭包捕获的变量封装成常量放进表达式树。这就是所谓的"闭包陷阱",后面会细说。
翻译器虽然简单,但它已经涵盖了ExpressionVisitor最重要的用法:重写VisitXxx方法,控制每个节点的输出。你甚至可以在此基础上扩展支持Contains方法调用(翻译成LIKE '%value%'),或者支持值类型转换等更复杂的功能。动手改一改,对理解表达式树会有质的提升。
5. 坑与性能:写在最后的实战忠告
5.1 六个高频坑
坑一:两个Lambda表达式无法直接拼装
Expression.AndAlso(left.Body, right.Body)会直接抛异常,因为两个表达式的参数节点不是同一个实例。解决办法就是我在前面提到的参数替换器,把两个参数统一成一个。这个问题在动态拼接查询条件时几乎必踩,提前准备好一个ReplaceParameterVisitor工具类能省很多事。
坑二:闭包变量变成常量
Lambda表达式里使用局部变量,比如int min = 18; expr = u => u.Age >= min;,生成的表达式树把min当作常量处理。这带来一个实际问题:如果你在循环里反复创建这种表达式,每个循环里min的值变化了,但表达式树可能"记住"的是之前的常量值。要小心这一点,必要的时候用Expression.Constant(variable, variable.GetType())显式控制。
坑三:反射属性 vs 表达式树属性访问的性能误判
很多人以为用表达式树访问属性就能自动比反射快,其实不对。Expression.Property(param, "Age")构建表达式树的过程本身也会用到反射查找。真正的性能优势在于一次编译、多次使用。所以一定要缓存编译后的委托,别在每次循环里都做Compile()。
坑四:Quote方法搞晕新手
在动态构建OrderBy这类带泛型方法的调用时,Lambda表达式需要被Expression.Quote包裹,否则会报参数类型不匹配。这个细节我一开始完全摸不着头脑,后来才知道Quote的作用就是告诉编译器"这是一个表达式树而不是委托",相当于一种类型标记。
坑五:表达式树是不可变的
几乎所有修改操作都不是原位修改,而是返回一棵新树。比如Visit一个节点,如果没有改动,返回原对象;一旦改动,整个父链都会产生新对象。这个特性是好事,因为它保证了表达式树的线程安全性,但也意味着你不能保存"修改后的引用"再期待原树发生变化。
坑六:调试时只看到一堆内部方法名
表达式树在调试器里默认显示不好看。建议重写ToString()或者使用 Expression 的DebugView属性(在调试器中查看expr.DebugView可以看到格式化后的树结构)。很多项目里也会加自定义的表达式格式化工具,方便日志输出。
5.2 性能考量:编译一次到底有多贵
Compile()的代价主要花在:生成动态方法、JIT编译、以及委托的创建和缓存。我实测过:第一次Compile()可能需要几百微秒到几毫秒不等,之后每次调用编译后的委托,开销几乎和普通委托调用一样。而反射调用属性,单次可能就要几十微秒,而且在循环里没法优化。
所以核心原则是:能缓存就缓存,别在热路径里重复编译。我自己常用的做法是搞一个静态的并发字典做缓存:
private static readonly ConcurrentDictionary<string, Delegate> Cache = new(); public static Func<T, object> GetGetter<T>(string propName) { string key = typeof(T).FullName + "." + propName; return (Func<T, object>)Cache.GetOrAdd(key, _ => BuildGetter<T>(propName)); }这样同一个类型的同一个属性,全局只会编译一次。在实际项目里,这个模式可以直接把大量使用反射的代码性能拉到和手写代码同一个量级。
5.3 学习路线建议
如果你刚开始接触表达式树,我建议按这个顺序推进:先把 Lambda 转表达式树、ToString()、Compile()这几个基础操作跑通;然后熟练使用Expression.XXX系列 API 手工构建树;接着写一个简单的ExpressionVisitor做参数替换;最后再考虑研究 EF Core 这类框架的源码,看那些复杂的翻译逻辑是怎么实现的。不要一上来就钻到ExpressionVisitor的源码细节里,容易劝退。
我个人在这条路上踩过的坑,比网上教程提到的还要多。比如有次写一个通用查询组件,用了表达式树拼接排序字段,结果在某个类型上反复抛InvalidOperationException,最后才发现是Expression.Property传了一个不存在的属性名。这种错误在编译期根本看不出来,只有运行到那一行才会炸。所以建议给动态属性访问加一层校验,提前用typeof(T).GetProperty(propertyName)验证属性是否存在,找不到就抛出明确的友好错误信息,而不是等表达式树执行到一半才爆出晦涩的异常。
表达式目录树这门技术,初看是语法糖,再看是数据结构,深入之后你会发现它其实是C#语言提供的一把"代码手术刀"。用它来写业务,可能几天都用不上一次;但一旦需要做动态规则、查询翻译、性能优化这类高价值需求,它就是无可替代的底牌。希望这份笔记能帮你把这张底牌翻开,真正用起来。