Effective-CSharp-38考虑用lambda表达式来代替方法

这条建议可能听起来有些奇怪,因为 lambda 表达式代替方法会重复编写代码。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var allEmployees = FindAllEmployees();

// Find the first employees:
var earlyFolks =
from e in allEmployees
where e.Classification == EmployeeType.Salary
where e.YearsOfService > 20
where e.MonthlySalary < 4000
select e;

// Find the newest people:
var newest =
from e in allEmployees
where e.Classification == EmployeeType.Salary
where e.YearsOfService < 20
where e.MonthlySalary < 4000
select e;

你可以将这些 where 合并为一条子句,然而这并不会带来太大区别。查询操作之间本就可以拼接 (见31条),而简单的 where 谓词还会有可能内联 (inline),因此,这两种写法在性能上是一样的。

看到刚才那段代码,你可能会想把重复的 lambda 表达式提取到方法,以便复用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Factor out method:
private static bool LowPaidSalaried(Employee e) =>
e.MonthlySalary < 4000 && e.Classification == EmployeeType.Salary;

// else where
var allEmployees = FindAllEmployees();

var earlyFolks =
from e in allEmployees
where LowPaidSalaried(e) && e.YearsOfService > 20
select e;

// Find the newest people:
var newest =
from e in allEmployees
where e.Classification == EmployeeType.Salary
where e.YearsOfService < 20
where e.MonthlySalary < 4000
select e;

如果将来需要修改员工的类别 (Classification),或修改筛选底薪员工时所依据的最低月薪 (MonthlySalary),那么只需要改动 LowPaidSalaried() 里的逻辑即可。

但是这样的重构不能提高其复用性,这与 lambda 表达式求值、解析及执行机制有关:

  • LINQ to Objects

    为执行表达式中代码,将 lambda 表达式转化成为委托

  • LINQ to SQL

    根据 lambda 表达式创建表达式树,并解析,使其能在其他环境执行

LINQ to Objects 针对本地数据存储 (local data store) 来执行查询,数据通常放在泛型集合。系统根据 lambda 表达式中的逻辑建立匿名委托,并执行相关代码。LINQ to Objects 扩展方法使用 IEnumerable 来表示输入序列。

LINQ to SQL 使用表达式树,根据所写查询逻辑构建表达式树,将其解析为适当的 T-SQL 查询,这种查询是针对数据库执行的,LINQ to SQL 把 T-SQL 形式的查询字符串发送给数据库引擎并执行。这种处理方式要求 LINQ to SQL引擎必须解析表达式树,并把其中每一项操作都替换成等价的 SQL,这意味着所有的方法调用都需要换成 Expression.MethodCall 节点。然而 LINQ to SQL 引擎并不能把每一种方法调用都顺利的转化为 SQL 表达式,无法转换将会引发异常。

如果所写的程序库需要支持任意类型的数据源,必须考虑上述情况。需要分开编写 lambda 表达式,且内联至代码中,以保证程序库正常运行。

这并不是在鼓励一味的复制代码,而是提醒,涉及查询表达式及 lambda 的地方应该用更为合理的方法去创建复用代码块。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static IQueryable<Employee> LowPaidSalariedFilter(this IQueryable<Employee> sequence) =>
from s in sequence
where s.Classification == EmployeeType.Salary && s.MonthlySalary < 4000
select s;

// else where
var allEmployees = FindAllEmployees();

// Find the first employees:
var salaried = allEmployees.LowPaidSalariedFilter();

var earlyFolks = salaried.Where(e => e.YearsOfService > 20);

// Find the newest people:
var newest = salaried.Where(e => e.YearsOfService < 2);

并不是每一种查询都能像这样简单的改写,可能需要沿着调用链寻找,才能发现可供复用的列表处理逻辑 (list-processing logic),从而提取相同的 lambda 表达式。31条提过,只要当程序真的需要遍历集合的时,enumerator 方法才会得以执行。可利用此特征,将查询操作分成许多小方法来写,这些小方法都能复用某一套 lambda 表达式来执行它所应该完成的那一部分查询工作。这些方法需要将待处理的序列当成输入值,并以 yield return 形式返回处理结果。这些小方法可构成较大的查询模块。避免重复的代码,使得代码结构更合理。

也可按照同样的思路建立表达式树,以此拼接 IQueryable 形式的 enumerator,令查询操作能够远程执行。寻找相关员工所用的那棵表达式树可以先于其他查询拼接,然后执行。IQueryProvider 对象 (LINQ to SQL 引擎的数据源就是这种对象) 可以把全套查询操作一次执行完毕,而不必将其分解成多个部分放到本地执行。

若想在复杂的查询中有效地复用 lambda 表达式,可以考虑针对封闭的泛型类型创建扩展方法。LowPaidSalariedFilter 方法就是这么写的,它接受有待筛选的 Employee 对象序列,输出经过筛选后的 Employee 对象序列。在实际工作中,还应该创建以 IEnumerable 作阐述的重载版本,以便同时支持 LINQ to Objects 及 LINQ to SQL。

你可以把查询操作分成多个小方法,其中一些方法在其内部用 lambda 表达式处理序列,另一些方法以 lambda 表达式作参数。把这些小方法拼接起来,以实现整套操作。这样既可以支持 IEnumerable 与 IQueryable,又能令系统有机会构建出表达式树,以便高效执行查询。