代码改变世界

C# 的 lambda 表达式

2018-02-08 10:51  Evan  阅读(60)  评论(0)    收藏  举报

Bill.Nong 原创文章,转载请标明出处~

零、引言

lambda 表达式在 C# 3.0 时被引入,是可以用于创建委托表达式树的匿名函数。通过 lambda 表达式,可以创建作为参数传递或者作为函数返回值的本地函数
将函数作为参数或返回值来进行传递是我们在 JavaScript 中熟知的操作,例如大家常用的 jQuery Ajax 请求的回调函数

$.ajax({
    url: "http://example.com",
    method: "POST",
    success: function(){
        console.log("done!");
    }
});

当这个异步请求成功后,会在控制台中输出"done!"的文字。可以看到我们实际上是向 $.ajax() 方法传递了一个匿名对象,其中有一个名为 success 的属性,内容是一个 function(而且还是匿名的)。这项特性并不是 jQuery 特有的,而是 JavaScript 所包含的。
例如对数组使用 forEach() 方法:

var arr = [ 2, 4, 6 ];
arr.forEach(function(currentValue, index, array){
    console.log("arr[" + index + "] is " + currentValue);
});

那么这样的功能在 C# 中是如何实现的呢?这就需要使用到委托

一、委托

委托和类一样,是一种用户自定义的类型。但类表示的是数据的集合,而委托保存的是一个或多个方法。若想声明并使用委托我们需要:

1. 声明委托类型

delegate int MyDel(int x, int y);

通过 delegate 关键字来声明委托类型,添加上返回类型签名,这类似于声明方法,不过它并没有实现。
通过上述代码,我们便得到了一个名为 MyDel 的,需要两个 int 类型参数,并且返回 int 的委托类型。

2. 创建委托对象

MyDel byStatic, byInstance, byStatic2, byInstance2;
// 使用静态方法来创建
byStatic = new MyDel(Helper.StaticAdd);
// 使用实例方法来创建
var test = new Test();
byInstance = new MyDel(test.InstanceAdd);

// 便捷语法
byStatic2 = Helper.StaticAdd;
byInstance2 = test.InstanceAdd;

...

public static class Helper
{
    public static int StaticAdd(int x, int y)
    {
        return x + y;
    }
}

public class Test
{
    public int InstanceAdd(int x, int y)
    {
        return x + y;
    }
}

声明好委托类型后,我们需要根据它来创建委托对象,就像类(静态类除外)一样,声明后还需要实例化才能够使用。我们可以使用静态方法或者实例方法来创建委托对象。需要注意的是,只有返回值和签名与委托类型定义的一致的方法才能用于创建委托对象。
操作完成后,委托列表中添加了第一个方法——正如一开始所说,委托可以包含一个或多个方法,其内部维护了一个调用列表,能被加入列表的必须与委托类型定义的返回值及签名一致才行。这样当委托被调用时,会执行列表中所有的方法。
由于委托是一个引用类型,我们可以将一个委托指向另一个方法或委托,这样旧的委托对象会被回收。

MyDel myDel = Helper.StaticAdd;
myDel = test.InstanceAdd;

3. 为委托增加方法

如何向委托的调用列表中添加方法呢,其实非常简单就像加法一样。新添加的方法会添加到调用列表的末尾。

MyDel myDel = Helper.StaticAdd;
myDel += test.InstanceAdd;

注意:由于委托是不易变的,每次为调用列表增加方法后指向的都是一个全新的委托。若要从委托的调用列表中移除方法,使用 -= 即可。若是列表中有多个实例, -= 会从列表最后开始查找并移除第一个匹配的。

4. 调用委托

MyDel myDel = Helper.StaticAdd;
var sum = myDel(1, 2);

我们像调用方法一样调用委托,传入委托类型定义的参数,委托会用同样的参数来执行调用列表中的每个方法。
值得注意的是,当委托具有返回类型,并且调用列表中存在多个方法时,返回的总是列表末尾方法的执行结果。

二、匿名方法

我们了解了委托的声明和调用后,会发现若遇上一些只会被使用一次的方法时,仍需要单独声明方法。于是 C# 2.0 中引入的匿名方法解决了这一问题,我们可以通过内联的方式来声明方法了。

1. 通过匿名方法创建委托

MyDel byAnonymous = delegate (int x, int y)
{
    return x * y;
};
byAnonymous(2, 4);

可以发现,byAnonymous 被一个既不属于实例的方法,也不是一个静态方法的函数创建了;这个函数通过 delegate 关键字创建,拥有参数列表和实现,不过没有名称,也没有返回类型,因为返回类型由委托类型来进行定义,所以它的实现的返回类型应该与委托类型定义的一致。这便是一个匿名方法

MyDel byAnonymous2 = delegate
{
    return 1;
};
byAnonymous2(2, 4);

不过,当匿名函数没有使用任何参数时,参数列表可以省略。但即便是这样,在调用时仍然是需要传递符合委托类型签名中定义的参数的。

2. 匿名方法的变量及参数的作用域

参数及在匿名方法内部声明的局部变量的作用域仅限于匿名方法的方法主体内

MyDel byAnonymous3 = delegate (int x, int y)
{
    var sum = x + y;
    return sum;
};
byAnonymous(2, 4);
// Console.WriteLine(sum);

上面的匿名方法中定义的局部变量 sum 是无法在外部访问的,尝试访问会得到编译错误。

3. 外部变量和捕获

int i = 10;
MyDel byAnonymous4 = delegate (int x, int y)
{
    var sum = i + x + y;
    return sum;
};

虽然无法从外部访问匿名方法内部定义的变量,但匿名方法可以访问外围作用域的变量的。在外围作用域中定义的变量成为外部变量,若该外部变量出现在匿名方法的实现中,则称为被方法捕获
由于这样的特性存在,若在匿名方法中操作外部变量,改动将直接反映在该变量的值上,即便离开了匿名方法的作用域。

MyObject obj = new MyObject { Num = 10 };
Console.WriteLine("执行前 obj.Num = " + obj.Num);

MyDel byAnonymous5 = delegate (int x, int y)
{
    obj.Num = 5;
    return x + y;
};
byAnonymous5(2, 4);
Console.WriteLine("执行后 obj.Num = " + obj.Num);

...

public class MyObject
{
    public int Num { get; set; }
}

执行后,将得到 obj.Num 为 5 的结果。
由于外部变量和能被捕获的特性,产生了闭包,即:匿名方法能够使用在声明该匿名方法的方法内部定义的局部变量。被捕获的变量的生命周期会被延长,此处就不再延伸了。

三、 lambda 表达式

1. 通过 lambda 表达式简化匿名方法

即便匿名方法带来了委托创建时的一些简化,但仍有繁琐的地方,例如匿名方法的参数列表,编译器完全可以通过给定的条件来进行推断!于是 C# 3.0 引入了 lambda 表达式,简化了匿名方法的语法。我们看看通过 lambda 表达式声明的匿名方法会是什么样

// 匿名方法
MyDel d = delegate (int x, int y) { return x + y; };

// lambda 表达式
MyDel d2 = (int x, int y) => { return x + y; };

// 简化参数类型
MyDel d3 = (x, y) => { return x + y; };
// 简化只含一行实现的花括号和 return(如果有返回值的话)
MyDel d4 = (x, y) => x + y;

2. 使用泛型委托类型

lambda 表达式虽然简化了匿名方法的使用,但是我们仍离不开对委托类型的声明。不过在 .Net Framework 2.0 中引入了泛型委托,其为无返回值的 Action ,到了 .Net Framework 3.5 还新增了带有返回值的 Func<TResult>,他们的类型参数都有多个重载以应用于不同情况,到了 .Net Framework 4.0 更是将类型参数扩展为了 17 个(即 Func<T1, T2, ... , T16, TResult>)。那如何使用这些泛型委托类型呢?

Func<int, int, int> funcSum = delegate (int x, int y)
{
    return x + y;
};
Console.WriteLine(funcSum(2, 4));

我们先通过匿名方法的形式创建一个泛型委托对象,可以看到我们不需要再通过 delegate 关键字去声明委托类型了! Func<TResult> 的类型参数列表的最后一个类型即为该委托的返回类型,此前的则为匿名方法的参数列表。不过既然得知 lambda 表达式可以简化匿名方法,那便来改写一下

Func<int, int, int> funcSum2 = (x, y) => x + y;

四、 表达式树

我们已经见识到如何使用 lambda 表达式来创建委托了,但它的功夫不止如此;正如开篇所说,lambda 也可以用来创建表达式树。那么什么是表达式树呢,就是将代码表示成对象树的方式。

1. 构建一个表达式树

要使用表达式树,我们需要明确几个类,位于 System.Linq.Expressions 命名空间下的几个派生自 Expression 的类涵盖了各种表达式。其中 Expression 类中包含:

  • Type 属性:表示表达式求值后的 .Net 类型的属性。例如访问一个字符串的 Length 属性,该表达式的类型就为 int
  • NodeType 属性:是 ExpressionType 枚举的成员,用于表示表达式的种类。仍例如访问字符串的 Length 属性来说,其表达式种类为 MemberAccess(从字段或属性进行读取的运算)

我们先来创建一个表达式树吧。

var leftArg = Expression.Constant(2);
var rightArg = Expression.Constant(4);
var add = Expression.Add(leftArg, rightArg);

Console.WriteLine(add);

我们创建好了一个非常简单的表达式树,它包含:

add
BinaryExpression
NodeType = Add
Type = System.Int32

leftArg
ConstantExpression
NodeType = Constant
Type = System.Int32

rightArg
ConstantExpression
NodeType = Constant
Type = System.Int32

表达式的创建是自下而上的,叶先被创建,并且是不易变的——可以放心的缓存和重用表达式。当我们通过 Console.WriteLine() 来输出 add 时,会在屏幕上得到 “( 2 + 4 )”这样的结果。但似乎并没有执行加法计算,那如何才能得到这个算式的结果呢?

2. 将表达式树编译为委托

Expression<TDelegate> 是一个泛型表达式,派生自 LambdaExpression,其泛型参数为一个委托类型。就我们上面创建的加法运算而言,它需要返回一个 int 类型的结果,那么这个泛型表达式的泛型参数签名即为 Func<int>,也就是说我们可以通过Expression<Func<int>>来表示我们的表达式。
那这样又有什么用呢?原来,通过使用 LambdaExpression 的 Compile() 方法可以用于创建恰当类型的委托,而Expression<TDelegate>也有一个同名方法,用于创建TDelegate类型的委托!

Func<int> compiled = Expression.Lambda<Func<int>>(add).Compile();
Console.WriteLine(compiled());

经过Compile()方法编译后的表达式就成为了我们熟悉的委托了,可以尽情的使用它!
那么我们大费周章的只为了实现两数相加未免有点牛刀杀鸡的味道,不过这么一大段代码以来,今天的主角 lambda 表达式还未登场!

3. 将 lambda 表达式转换为表达式树

前面我们已经了解到 lambda 表达式可以显示或隐式的转换为委托的实例,除此之外,编译器还能通过 lambda 表达式来构建表达式树。

Expression<Func<int>> return2 = () => 2;
Func<int> compiled2 = return2.Compile();
Console.WriteLine(compiled2());

虽然有这样便捷的转换,但一些限制需要注意:只能转换只有一行语句的 lambda表达式(即语句块无法转换),如果对语句块进行转换会得到一个编译错误。

参考文献

  • 《C# 4.0 图解教程》
  • 《深入理解 C#》