NET 3.5中委托的写法(Lambda表达式)示例

NET 3.5中委托的写法(Lambda表达式)示例yuanl 【转载学习】

  Lambda表达式在C#中的写法是“arg-list => expr-body”,“=>”符号左边为表达式的参数列表,右边则是表达式体(body)。参数列表可以包含0到多个参数,参数之间使用逗号分割。例如,以下便是一个使用Lambda表达式定义了委托的示例1

Func<int, int, int> max = (int a, int b) =>
{
if (a > b)
{
return a;
}
else
{
return b;
}
};

  Lambda表达式的作用也是为了定义一个匿名方法。因此,下面使用delegate的代码和上面是等价的:

Func<int, int, int> max = delegate(int a, int b)
{
if (a > b)
{
return a;
}
else
{
return b;
}
};

  那么您可能就会问,这样看来Lambda表达式又有什么意义呢?Lambda表达式的意义便是它可以写的非常简单,例如之前的Lambda表达式可以简写成这样:

Func<int, int, int> max = (a, b) =>
{
if (a > b)
{
return a;
}
else
{
return b;
}
};

  由于我们已经注明max的类型是Func<int, int, int>,因此C#编译器可以明确地知道a和b都是int类型,于是我们就可以省下参数之前的类型信息。这个特性叫做“类型推演”,也就是指编译器可以自动知道某些成员的类型2。请不要轻易认为这个小小的改进意义不大,事实上,您会发现Lambda表达式的优势都是由这一点一滴的细节构成的。那么我们再来一次改变:

Func<int, int, int> max = (a, b) => a > b ? a : b;//yuanl注释:body中是表达式且只有一个故省略大括号和return

  如果Lambda表达式的body是一个表达式(expression),而不是语句(statement)的话,那么它的body就可以省略大括号和return关键字。此外,如果Lambda表达式只包含一个参数的话,则参数列表的括号也可以省略,如下:

Func<int, bool> positive = a => a > 0;yuanl注释:等价于Func<int,bool> positive(int a)=a=>{if(a>0){return a;}};

  如今的写法是不是非常简单?那么我们来看看,如果是使用delegate关键字来创建的话会成为什么样子:

Func<int, bool> positive = delegate(int a)
{
return a > 0;
};

  您马上就可以意识到,这一行和多行的区别,这几个关键字和括号的省略,会使得编程世界一下子变得大为不同。

  当然,Lambda表达式也并不是可以完全替代delegate写法,例如带ref和out关键字的匿名方法,就必须使用.NET 2.0中的delegate才能构造出来了。

使用示例一

  Lambda表达式的增强在于“语义”二字。“语义”是指代码所表现出来的含义,说的更通俗一些,便是指一段代码给阅读者的“感觉”如何。为了说明这个例子,我们还是使用示例来说明问题。

  第一个例子是这样的:“请写一个方法,输入一个表示整型的字符串列表,并返回一个列表,包含其中偶数的平方,并且需要按照平方后的结果排序”。很简单,不是吗?相信您一定可以一蹴而就:

//yuanl注释:一静态方法返回值类型List<int>参数类型List<string>注意泛型的使用

static List<int> GetSquaresOfPositive(List<string> strList)

{
List<int> intList = new List<int>();//建立以存放整形数据的List
foreach (var s in strList)
     intList.Add(Int32.Parse(s));//数据转换Sring->int
List<int> evenList = new List<int>();//
foreach (int i in intList)
{
if (i % 2 == 0)
         evenList.Add(i);//获取偶数
}
List<int> squareList = new List<int>();//
foreach (int i in evenList)
    squareList.Add(i * i);//获取数据的平方值
squareList.Sort();
return squareList;//返回以List<int>类型集合
}

  我想问一下,这段代码给您的感觉是什么?它给我的感觉是:做了很多事情。有哪些呢?

  1. 新建一个整数列表intList,把参数strList中所有元素转化为整型保存起来。
  2. 新建一个整数列表evenList,把intList中的偶数保存起来。
  3. 新建一个整数列表squareList,把evenList中所有数字的平方保存起来。
  4. 将squareList排序。
  5. 返回squareList。

  您可能会问:“当然如此,还能怎么样?”。事实上,如果使用了Lambda表达式,代码就简单多了:

static List<int> GetSquaresOfPositiveByLambda(List<string> strList)
{
return strList
.Select(s => Int32.Parse(s)) // 转成整数
.Where(i => i % 2 == 0) // 找出所有偶数
.Select(i => i * i) // 算出每个数的平方
.OrderBy(i => i) // 按照元素自身排序
.ToList(); // 构造一个List
}

  在第一个方法中,我们构造了多个容器,然后做一些转化,过滤,并且向容器填充内容。其实这些都是“怎么做”,也就是所谓的“how (to do)”。但是这些代码并不能直接表示我们想要做的事情,我们想要做的事情其实是“得到XXX”,“筛选出YYY”,而不是“创建容器”,“添加元素”等 操作。

  在使用Lambda表达式的实现中,代码变得“声明式(declarative)”了许多。所谓“声明式”,便是“声称代码在做什么”,而不像 “命令式(imperative)”的代码在“操作代码怎么做”。换句话说,“声明式”关注的是“做什么”,是指“what (to do)”。上面这段声明式的代码,其语义则变成了:

  1. 把字符串转化为整数
  2. 筛选出所有偶数
  3. 把每个偶数平方一下
  4. 按照平方结果自身排序 
  5. 生成一个列表

  至于其中具体是怎么实现的,有没有构造新的容器,又是怎么向容器里添加元素的……这些细节,使用Lambda表达式的代码一概不会关心——这又不是我们想要做的事情,为什么要关心它呢?

  虽然扩展方法功不可没,但我认为,Lambda表达式在这里的重要程度尤胜前者,因为它负责了最关键的“语义”。试想,“i => i * i”给您的感觉是什么呢?是构造了一个委托吗(当然,您一定知道在这里其实构造了一个匿名方法)?至少对我来说,它的含义是“把i变成i * i”;同样,“i => i % 2 == 0”给我的感觉是“(筛选标准为)i模2等于零”,而不是“构造一个委托,XXX时返回true,否则返回false”;更有趣的是,OrderBy(i => i)给我的感觉是“把i按照i自身排序”,而不是“一个返回i自身的委托”。这一切,都是在“声明”这段代码在“做什么”,而不是“怎么做”。

  没错,“类型推演”,“省略括号”和“省略return关键字”可能的确都是些“细小”的功能,但也正是这些细微之处带来了编码方式上的关键性改变。

使用示例二

  使用Lambda表达式还可以节省许多代码(相信您从第一个示例中也可以看出来了)。不过我认为,最省代码的部分更应该可能是其“分组”和“字典转化”等功能。因此,我们来看第二个示例。

  这个示例可能更加贴近现实。不知您是否关注过某些书籍后面的“索引”,它其实就是“列出所有的关键字,根据其首字母进行分组,并且要求对每组内部的关键字进行排序”。简单说来,我们需要的其实是这么一个方法:

static Dictionary<char, List<string>> GetIndex(IEnumerable<string> keywords) { ... }

  想想看,您会怎么做?其实不难(作为示例,我们这里只关注小写英文,也不关心重复关键字这种特殊情况):

static Dictionary<char, List<string>> GetIndex(IEnumerable<string> keywords)
{
// 定义字典
var result = new Dictionary<char, List<string>>();

// 填充字典
foreach (var kw in keywords)
{
var firstChar = kw[0];
List<string> groupKeywords;

if (!result.TryGetValue(firstChar, out groupKeywords))
{
groupKeywords = new List<string>();
result.Add(firstChar, groupKeywords);
}

groupKeywords.Add(kw);
}

// 为每个分组排序
foreach (var groupKeywords in result.Values)
{
groupKeywords.Sort();
}

return result;
}

  那么如果利用Lambda表达式及.NET框架中定义的扩展方法,代码又会变成什么样呢?请看:

static Dictionary<char, List<string>> GetIndexByLambda(IEnumerable<string> keywords)
{
return keywords
.GroupBy(k => k[0]) .ToDictionary( g => g.Key, g => g.OrderBy(k => k).ToList());
}

  光从代码数量上来看,前者便是后者的好几倍。而有关“声明式”,“what”等可读性方面的优势就不再重复了,个人认为它比上一个例子给人的“震撼”有过之而无不及。

  试想,如果我们把GetIndexByLambda方法中的Lambda表达式改成.NET 2.0中delegate形式的写法:

static Dictionary<char, List<string>> GetIndexByDelegate(IEnumerable<string> keywords)
{
return keywords
.GroupBy(delegate(string k) { return k[0]; })
 .ToDictionary(delegate(IGrouping<char, string> g) { return g.Key; },delegate(IGrouping<char, string> g){
return g.OrderBy(delegate(string s) { return s; })
.ToList();}
);
}

 

循环分离及其性能

  在上面的第一个示例中, 我们演示了如何使用Lambda表达式配合.NET 3.5中定义的扩展方法来方便地处理集合中的元素(筛选,转化等等)。不过有朋友可能会提出,那个“普通写法”并非是性能最高的实现方法。方便起见,也为 了突出“性能”方面的问题,我们把原来的要求简化一下:将序列中的偶数平方输出为一个列表。按照那种“普通写法”可能就是:

static List<int> EvenSquare(IEnumerable<int> source)
{
var evenList = new List<int>();
foreach (var i in source)
{
if (i % 2 == 0) evenList.Add(i);
}

var squareList = new List<int>();
foreach (var i in evenList) squareList.Add(i * i);

return squareList;
}

  从理论上来说,这样的写法的确比以下的做法在性能要差一些:

static List<int> EvenSquareFast(IEnumerable<int> source)
{
List<int> result = new List<int>();
foreach (var i in source)
{
if (i % 2 == 0) result.Add(i * i);
}

return result;
}

  在第二种写法直接在一次遍历中进行筛选,并且直接转化。而第一种写法会则根据“功能描述”将做法分为两步,先筛选后转化,并使用一个临时列表进 行保存。在向临时列表中添加元素的时候,List<int>可能会在容量不够的时候加倍并复制元素,这便造成了性能损失。虽然我们通过“分 析”可以得出结论,不过实际结果还是使用CodeTimer来测试一番比较妥当:

List<int> source = new List<int>();
for (var i = 0; i < 10000; i++) source.Add(i);

// 预热
EvenSquare(source);
EvenSquareFast(source);

CodeTimer.Initialize();
CodeTimer.Time("Normal", 10000, () => EvenSquare(source));
CodeTimer.Time("Fast", 10000, () => EvenSquareFast(source));

  我们准备了一个长度为10000的列表,并使用EvenSquare和EvenSquareFast各执行一万次,结果如下:

Normal
Time Elapsed: 3,506ms
CPU Cycles: 6,713,448,335
Gen 0: 624
Gen 1: 1
Gen 2: 0

Fast
Time Elapsed: 2,283ms
CPU Cycles: 4,390,611,247
Gen 0: 312
Gen 1: 0
Gen 2: 0

  结果同我们料想中的一致,EvenSquareFast无论从性能还是GC上都领先于EvenSquare方法。不过,在实际情况下,我们该选择哪种做法呢?如果是我的话,我会倾向于选择EvenSquare,理由是“清晰”二字。

  EvenSquare虽然使用了额外的临时容器来保存中间结果(因此造成了性能和GC上的损失),但是它的逻辑和我们需要的功能较为匹配,我们 可以很容易地看清代码所表达的含义。至于其中造成的性能损失在实际项目中可以说是微乎其微的。因为实际上我们的大部分性能是消耗在每个步骤的功能上,例如 每次Int32.Parse所消耗的时间便是一个简单乘法的几十甚至几百倍。因此,虽然我们的测试体现了超过50%的性能差距,不过由于这只是“纯遍历” 所消耗的时间,因此如果算上每个步骤的耗时,性能差距可能就会变成10%,5%甚至更低。

  当然,如果是如上述代码那样简单的逻辑,则使用EvenSquareFast这样的实现方式也没有任何问题。事实上,我们也不必强求将所有步骤 完全合并(即仅仅使用1次循环)或完全分开。我们可以在可读性与性能之间寻求一种平衡,例如将5个步骤使用两次循环来完能是更合适的方式。

  说到“分解循环”,其实这类似于Martin Fowler在他的重构网站所上列出的重构方式之一:“Split Loop”。虽然Split Loop和我们的场景略有不同,但是它也是为了代码的可读性而避免将多种逻辑放在一个循环内。将循环拆开之后,还可以配合“Extract Method”或“Replace Temp with Query”等方式实现进一步的重构。自然,它也提到拆分后的性能影响:

You often see loops that are doing two different things at once, because they can do that with one pass through a loop. Indeed most programmers would feel very uncomfortable with this refactoring as it forces you to execute the loop twice - which is double the work.

But like so many optimizations, doing two different things in one loop is less clear than doing them separately. It also causes problems for further refactoring as it introduces temps that get in the way of further refactorings. So while refactoring, don't be afraid to get rid of the loop. When you optimize, if the loop is slow that will show up and it would be right to slam the loops back together at that point. You may be surprised at how often the loop isn't a bottleneck, or how the later refactorings open up another, more powerful, optimization.

  这段文字提到,当拆分之后,您可能会发现更好的优化方式。高德纳爷爷也认为“过早优化是万恶之源”。这些说法都在“鼓励”我们将程序写的更清晰而不是“看起来”更有效率

扩展方法的延迟特性

  对于上面的简化需求,使用Lambda表达式和.NET 3.5中内置的扩展方法便可以写成这样:

static List<int> EvenSquareLambda(IEnumerable<int> source)
{
return source.Where(i => i % 2 == 0).Select(i => i * i).ToList();
}

  应该已经有许多朋友了解了.NET 3.5中处理集合时扩展方法具有“延迟”的效果,也就是说Where和Select中的委托(两个Lambda表达式)只有在调用ToList方法的时候 才会执行。这是优点也是陷阱,在使用这些方法的时候我们还是需要了解这些方法的效果如何。不过这些方法其实都没有任何任何“取巧”之处,换句话说,它们的 行为和我们正常思维的结果是一致的。如果您想得明白,能够自己写出类似的方法,或者能够“自圆其说”,十有八九也不会有什么偏差。但是如果您想不明白它们 是如何构造的,还是通过实验来确定一下吧。实验的方式其实很简单,只要像我们之前验证“重复计算”陷阱那种方法就可以了,也就是观察委托的执行时机和顺序进行判断。

  好,回到我们现在的问题。我们知道了“延迟”效果,我们知道了Where和Select会在ToList的时候才会进行处理。不过,它们的处理 方式是什么样的,是像我们的“普通方法”那样“创建临时容器(如List<T>),并填充返回”吗?对于这点我们不多作分析,还是通过“观察 委托执行的时机和顺序”来寻找答案。使用这种方式的关键,便是在委托执行时打印出一些信息。为此,我们需要这样一个Wrap方法(您自己做试验时也可以使 用这个方法):

static Func<T, TResult> Wrap<T, TResult>(
Func<T, TResult> func,
string messgaeFormat)
{
return i =>
{
var result = func(i);
Console.WriteLine(messgaeFormat, i, result);
return result;
};
}

  Wrap方法的目的是将一个Func<T, TResult>委托对象进行封装,并返回一个类型相同的委托对象。每次执行封装后的委托时,都会执行我们提供的委托对象,并根据我们传递的messageFormat格式化输出。例如:

var wrapper = Wrap<int, int>(i => i + 1, "{0} + 1 = {1}");
for (var i = 0; i < 3; i++) wrapper(i);

  则会输出:

0 + 1 = 1
1 + 1 = 2
2 + 1 = 3

  那么,我们下面这段代码会打印出什么内容呢?

List<int> source = new List<int>();
for (var i = 0; i < 10; i++) source.Add(i);

var finalSource = source
.Where(Wrap<int, bool>(i => i % 3 == 0, "{0} can be divided by 3? {1}"))
.Select(Wrap<int, int>(i => i * i, "The square of {0} equals {1}."))
.Where(Wrap<int, bool>(i => i % 2 == 0, "The result {0} can be devided by 2? {1}"));

Console.WriteLine("===== Start =====");
foreach (var item in finalSource)
{
Console.WriteLine("===== Print {0} =====", item);
}

  我们准备一个列表,其中包含0到9共十个元素,并将其进行Where…Select…Where的处理,您可以猜出经过foreach之后屏幕上的内容吗?

===== Start =====
0 can be divided by 3? True
The square of 0 equals 0.
The result 0 can be devided by 2? True
===== Print 0 =====
1 can be divided by 3? False
2 can be divided by 3? False
3 can be divided by 3? True
The square of 3 equals 9.
The result 9 can be devided by 2? False
4 can be divided by 3? False
5 can be divided by 3? False
6 can be divided by 3? True
The square of 6 equals 36.
The result 36 can be devided by 2? True
===== Print 36 =====
7 can be divided by 3? False
8 can be divided by 3? False
9 can be divided by 3? True
The square of 9 equals 81.
The result 81 can be devided by 2? False

  列表中元素的执行顺序是这样的:

  1. 第一个元素“0”经过Where…Select…Where,最后被Print出来。
  2. 第二个元素“1”经过Where,中止。
  3. 第三个元素“2”经过Where,中止。
  4. 第四个元素“4”经过Where…Select…Where,中止。
  5. ……

  这说明了,我们使用.NET框架自带的Where或Select方法,最终的效果和上一节中的“合并循环”类似。因为,如果创建了临时容器保存 元素的话,就会在第一个Where中把所有元素都交由第一个委托(i => i % 3 == 0)执行,然后再把过滤后的元素交给Select中的委托(i => i * i)执行。请注意,在这里“合并循环”的效果对外部是隐藏的,我们的代码似乎还是一步一步地处理集合。换句话说,我们使用“分解循环”的清晰方式,但获得 了“合并循环”的高效实现。这就是.NET框架这些扩展方法的神奇之处1

  在我们进行具体的性能测试之前,我们再来想一下,这里出现了那么多IEnumerable对象实现了哪个GoF 23中的模式呢?枚举器?看到IEnumerable就说枚举器也太老生常谈了。其实这里同样用到了“装饰器”模式。每次Where或Select之后其 实都是使用了一个新的IEnumerable对象来封装原有的对象,这样我们遍历新的枚举器时便会获得“装饰”后的效果。因此,以后如果有人问您 “.NET框架中有哪些的装饰器模式的体现”,除了人人都知道的Stream之外,您还可以回答说“.NET 3.5中System.Linq.Enumerable类里的一些扩展方法”,多酷。

扩展方法的性能测试

  经过上节的分析,我们知道了Where和Select等扩展方法统一了“分解循环”的外表和“合并循环”的内在,也就是兼顾了“可读性”和“性能”。我们现在就使用下面的代码来验证这一点:

List<int> source = new List<int>();
for (var i = 0; i < 10000; i++) source.Add(i);

EvenSquare(source);
EvenSquareFast(source);
EvenSquareLambda(source);

CodeTimer.Initialize();
CodeTimer.Time("Normal", 10000, () => EvenSquare(source));
CodeTimer.Time("Fast", 10000, () => EvenSquareFast(source));
CodeTimer.Time("Lambda", 10000, () => EvenSquareLambda(source));

  结果如下:

Normal
Time Elapsed: 3,127ms
CPU Cycles: 6,362,621,144
Gen 0: 624
Gen 1: 3
Gen 2: 0

Fast
Time Elapsed: 2,031ms
CPU Cycles: 4,070,470,778
Gen 0: 312
Gen 1: 0
Gen 2: 0

Lambda
Time Elapsed: 2,675ms
CPU Cycles: 5,672,592,948
Gen 0: 312
Gen 1: 156
Gen 2: 0

  从时间上看,“扩展方法”实现的性能介于“分解循环”和“合并循环”两者之间。而GC方面,“扩展方法”实现也优于“分解循环”(您同意这个看法吗?)。因此我们可以得出以下结论:

  性能 可读性 分解循环 合并循环 扩展方法
No. 3 No. 2
No. 1 No. 3
No. 2 No. 1

  至于选择哪种方式,就需要您自行判断了。

  值得注意的是,无论是“延迟”还是“分解循环”的效果,刚才我们都是针对于Where和Select来谈的。事实上,还有并不是所有的扩展方法都有类似的特性,例如:

  • 非延迟:ToArray、ToList、Any,All,Count……
  • 非分解循环:OrderBy,GroupBy,ToDictionary……

  不过别担心,正如上节所说,是否“延迟”,是否“分解循环”都是非常明显的。如果您可以写出类似的方法,或者能够“自圆其说”,一般您的判断也 不会有什么错误。例如,OrderBy为什么不是“分解循环”的呢?因为在交由下一步枚举之前,它必须将上一步中的所有元素都获取出来才能进行排序。如果 您无法“很自然”地想象出这些“原因”,那么就写一段程序来自行验证一番吧。

其他性能问题

  一般来说,这些扩展方法本身不太会出现性能问题,但是任何东西都可能被滥用,这才是程序中的性能杀手。例如:

IEnumerable<int> source = ...;
for (var i = 0; i < source.Count(); i++)
{
...
}

  这段代码的问题,在于每次循环时都需要不断地计算出source中元素的数量,这意味着不断地完整遍历、遍历。对于一些如Count或Any这 样“立即执行”的方法,我们在使用时脑子里一定要留有一些概念:它会不会出现性能问题。这些问题的确很容易识别,但是我的确看到过这样的错误。即使在出现 这些扩展方法之前,我也看到过某些朋友编写类似的代码,如在for循环中不断进行str.Split(',').Length。

  还是再强调一下“重复计算”这个问题吧,它可能更容易被人忽视。如果您“重复计算”的集合是内存中的列表,这对性能来说可能影响不大。但是,试想您每次重复计算时,都要重复去外部数据源(如数据库)获取原始数据,那么此时造成的性能问题便无法忽视了。

你可能感兴趣的:(lambda)