使用CSharp表达式树优化代码

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

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

使用 Expression 代替嵌套 Lambda

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 套了很多层,自然就导致运行效率不高

编写一段测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 得到了如下数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 拼接,否则会运行时异常

做一下基准测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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 来进行赋值操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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

Welcome to my other publishing channels