← 所有文章

使用CSharp表达式树优化代码

近期在研究 C# 的表达式树,感觉部分场景下使用表达式树会很有优势,不知道表达式树是什么直接看官方文档吧

下面是我遇到的几种觉得可以使用表达式树的场景(具体情况具体分析,建议辩证的看待)

使用 Expression 代替嵌套 Lambda

假设一种场景,我们需要构建一个节点树(简单点就只用一个单链了),然后根据节点上的一些信息构建成一个 lambda,比如这样:

public delegate int Foo(int x);

public enum Operation
{
    Add,
    Sub,
    Mul,
    Div,
}

public class Node
{
    public required Operation Op { get; init; }
    public required int Value { get; init; }
    public Node? Next = null;

    public Foo BuildLambda()
    {
        Foo proc = Op switch
        {
            Operation.Add => x => x + Value,
            Operation.Sub => x => x - Value,
            Operation.Mul => x => x * Value,
            Operation.Div => x => x / Value,
            _ => x => x,
        };
        return x => proc((Next?.BuildLambda() ?? (x => x))(x));
    }
}

很容易发现问题,如果树比较复杂,会导致 lambda 套了很多层,自然就导致运行效率不高

编写一段测试代码

public const int N = 1000000;

public static void Main(string[] args)
{
    Console.WriteLine($"Test {N}");

    var node = Generate();
    var sw = new Stopwatch();
    sw.Start();
    var f = node.BuildLambda();
    sw.Stop();
    Console.WriteLine($"Lambda Build used {sw.ElapsedMilliseconds} ms");
    sw.Restart();
    for (int i = 0; i < N; i++)
    {
        f(i);
    }
    sw.Stop();
    Console.WriteLine($"Lambda Run used {sw.ElapsedMilliseconds} ms");
}

public static Node Generate()
{
    var root = new Node { Op = Operation.Add, Value = 114514 };
    var node = root;
    for (int i = 0; i < 10; i++)
    {
        var op = (Operation)Random.Shared.Next(4);
        var val = Random.Shared.Next(1919810);
        node!.Next = new Node { Op = op, Value = val };
        node = root.Next;
    }
    return root;
}

简单生成了一个套了大概10层的节点,然后构建成 lambda 去跑数据,测试了几个 N 得到了如下数据

Test 1000
Lambda Build used 0 ms
Lambda Run used 0 ms

Test 10000
Lambda Build used 0 ms
Lambda Run used 21 ms

Test 100000
Lambda Build used 0 ms
Lambda Run used 36 ms

Test 1000000
Lambda Build used 0 ms
Lambda Run used 102 ms

接下来可以考虑一下表达式树会有什么优势,很显然,我们可以操作节点树,自下而上合并成一个函数

public Expression<Foo> BuildExpression(ParameterExpression param)
{
    var value = Expression.Constant(Value);
    Expression next = Next?.BuildExpression(param).Body ?? param;
    Expression proc = Op switch
    {
        Operation.Add => Expression.Add(next, value),
        Operation.Sub => Expression.Subtract(next, value),
        Operation.Mul => Expression.Multiply(next, value),
        Operation.Div => Expression.Divide(next, value),
        _ => next,
    };
    return Expression.Lambda<Foo>(proc, param);
}

通过每次获取子表达式,然后把函数体拼接,其实就是把一个树状结构平坦化了

这里要注意,参数通过函数来传递而不是每次build都在里面定义一个变量是因为即便设置的变量名一样,但是本质也是绑定的不同的参数,这时候就不能直接使用 Body 拼接,否则会运行时异常

做一下基准测试

public class Benchmark
{
    private Node node;
    private const int N = 10000;

    [GlobalSetup]
    public void Setup()
    {
        node = Program.Generate();
    }

    [Benchmark]
    public void TestExpression()
    {
        var param = Expression.Parameter(typeof(int), "x");
        var expr = node.BuildExpression(param);
        var f2 = expr.Compile();
        for (int i = 0; i < N; i++)
        {
            f2(i);
        }
    }

    [Benchmark]
    public void TestLambda()
    {
        var f = node.BuildLambda();
        for (int i = 0; i < N; i++)
        {
            f(i);
        }
    }
}

public class Program
{
	public static void Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<Benchmark>();
        Console.WriteLine(summary);
    }
    
    // other code
}

benchmark

基本上这个表达式树需要大概 15-30 ms 的时间编译,而运行的性能非常高,这个优势会在数据数量级越高,树结构越复杂的时候由很显著的表现

不过 expression 缺点是写起来很麻烦,也不直观,而且容易写出 bug,特别是类型对应不上的情况,往往要到运行时报错了才能发现

一般的情况用嵌套函数已经够了

反射 + Expression 代替纯反射

比如通过反射获取一个 MethodInfo,调用它的方式亦有差别。通常可能会直接 .Invoke(...) 调用了,但是这种动态调用机制会在运行时对传入进行参数类型检查,装拆箱等操作,以及方法的动态派发,同时也没有 JIT 的优化,这导致就算是缓存了这个 info,多次调用也不能有很高的性能。

这时候可以通过表达式树来提供更好的性能

比如我们要获取一个类型的 property 的 setter 来进行赋值操作

public class Benchmark2
{
    private Bar bar;
    private const int N = 10000;

    [GlobalSetup]
    public void Setup()
    {
        bar = new Bar(114514);
    }

    private MethodInfo? setter;

    [Benchmark]
    public void TestReflection()
    {
        setter ??= typeof(Bar).GetProperty("value", BindingFlags.NonPublic | BindingFlags.Instance)?
            .GetSetMethod(true);
        for (int i = 0; i < N; i++)
        {
            setter?.Invoke(bar, new object[] { i });
        }
    }
}

这是很自然的操作,如果需要多次调用,把这个 methodInfo 缓存下来,之后直接 invoke 这个 methodinfo

然后我们使用同样的逻辑来使用表达式树,这里使用 Expression.Call 来调用一个 MethodInfo

private Action<Bar, int>? setter2;

[Benchmark]
public void TestExpression()
{
    if (setter2 is null)
    {
        var barParam = Expression.Parameter(typeof(Bar), "bar");
        var valueParam = Expression.Parameter(typeof(int), "value");
        var setterMethod = typeof(Bar).GetProperty("value", BindingFlags.NonPublic | BindingFlags.Instance)?
            .GetSetMethod(true);
        setter2 = Expression.Lambda<Action<Bar, int>>(
            Expression.Call(barParam, setterMethod!, valueParam),
            barParam, valueParam
        ).Compile();
    }
    for (int i = 0; i < N; i++)
    {
        setter2(bar, i);
    }
}

这次我们不需要缓存这个 methodInfo,而是编译成一个委托然后缓存该委托

同样跑一下基准测试

benchmark2

表达式树编译后的委托是直接去调用目标方法的 IL 代码,性能几乎是接近直接调用的,避免动态调用开销,所以性能会远高于 MethodInfo.Invoke

· · ·

感谢阅读。愿这份记录也能帮到你。

继续探索其他文章 ↗