LINQ 的本质

首先要说明一点,本文讨论的 LINQ,如果没有特指,说的是 LINQ To Object,这种方式使用 System.Linq 命名空间的本地代码实现查询,虽然语法上和 LINQ To EF 类似,但是后者是通过表达式树翻译成 SQL 查询,在数据库端执行的。

LINQ To Object(以下简称 LINQ)的操作符,并非是在语法层面定义和实现,而是通过类库实现的,这意味着 LINQ 的查询,和调用一般的 C# 代码没有什么区别。

LINQ 有两种写法,LINQ 表达式写法和调用 LINQ 操作符(查询方法)。比如如下两个简单的查询,实现了相同的功能,将一个数组中的偶数挑出来:
使用 LINQ 表达式:
未命名图片.png
使用 LINQ 操作符:
未命名图片.png
以上两段代码是等价的。初看这两段代码,让人觉得很困惑。原因是,第一段代码看上去像 SQL 查询,但是写法却和 SQL 略有不同,给人的印象是,似乎 C# 幕后有个类似数据库的东西在查询,感觉很 “玄”。

第二段代码,似乎 “正常” 一些,然而 Where() 这个方法里的 Lambda 表达式却让人看不明白,这个 x 是什么,好像没看到在哪里定义啊。

我想,很多人学了 LINQ,并且从入门到放弃的根本原因就在这里。他们对 Lambda 表达式不理解,所以不太会用第二种办法,他们会结合 SQL 的经验和举一反三地使用第一种方法,但是对 LINQ 的运行机制不太了解,而遇到复杂的查询就晕了。

为了揭示 LINQ 的本质,我们抛开 LINQ,自己先实现一个 LINQ。自己实现 LINQ?听上去会很复杂?然而其实很简单,只要一点点代码。
C# 编译器会自动将 LINQ 表达式转换为对 LINQ 操作符的调用。
如果我们自己写一个 Where 方法,结果会如何呢?我们来试试看:
未命名图片.png
尝试运行下,结果是:
2 4 6 8

细心的读者看到 int[] arr 前面有个 this,这说明这是一个扩展方法,扩展方法允许将静态方法模拟成第一个参数所代表的对象的成员方法。关于扩展方法的有关内容不是本文的讨论范围。
有需要了解的可以参考这篇文章:(https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/classes-and-structs/extension-methods)

看,我们实现了一个最基本的 LINQ。这代码看上去太简单了吧,看着让人怀疑啊,你肯定想试试看,把 arr 修改为{1,2,3,4,5,6,7,8,9,10}。一试,果然露馅了。结果还是 2 4 6 8,没有 10。

这很好理解,因为我们是硬编码返回的 2,4,6,8,并没有将数组传入,也没有将条件传入,自然结果是写死的。这样的“LINQ” 自然没用。另外Func cond这是什么鬼,看着又奇怪了。

别着急,这个叫做委托。所谓委托,就是用它表示一个函数,这个函数从调用者看,可以是任意的函数名,甚至没有函数名,从被调用者看,它叫做 cond,我们直接调用它就可以了。后面我们再详细说,看下面的代码:
未命名图片.png
或者参见:https://ideone.com/QbjOPg

这次运行正确了,输出 2 4 6 8 10。
不信?你可以修改下条件,比如输出比 5 小的:
未命名图片.png

怎么样,可以吧。为什么这段代码可以过滤任意的条件呢?奥妙就在 cond 这个委托上,对于内部来说,我们看上去像有这么一个函数:

  1. bool cond(int x) { ? }

然而实际上我们的程序里没有这么一个函数,而这个函数实际上就是查询里面的 where ?
? 代表这个函数的实现。
之前我们说了,LINQ 还可以用 LINQ 操作符来表示,比如
未命名图片.png
可以写成:
未命名图片.png

委托相当于一个方法,我们既可以传 Lambda 表达式,也可以传一个传统的方法,比如返回小于 5 的数字,我们可以定义:
未命名图片.png
于是我们可以写:
未命名图片.png
注意这代码等价于
未命名图片.png
完整的代码:https://ideone.com/a7G6AP
因此,我们知道了,x => x < 5 的 x 是怎么回事,其实它相当于你定义了一个函数(和**myfunc 类似),而 x 是这个函数的参数,这个函数被传入 Where,由 Where调用,每次遍历一个元素就会调用一次,每次 x **代表数组中的一个元素,判断你的条件并且返回是否应该被放入结果还是应该舍弃
因此,x 就像你写的函数的参数一样,它可以任意命名 x => x < 5 和 y => y < 5 是一样的。好比:
未命名图片.png
将这个函数的参数全部修改为 y,这个函数和之前定义的并没有什么区别。

让我们对 LINQ 的本质做一个简单的总结。LINQ 表达式会被 C# 编译器编译为对 LINQ 方法的调用。事实上,LINQ 表达式是 LINQ 的一个子集。这意味着,所有的 LINQ 表达式都可以用 LINQ 的方法调用实现,反之则不一定。

而 LINQ 方法的实现放在了 System.Linq 下,就是普通的 C# 代码,而没有幕后任何玄妙的机制。Lambda 表达式的本质是一个匿名的方法,箭头前面的部分是它的参数,后面的语句就是这个函数的返回值。

几个常见的 LINQ 操作符和它们的使用技巧

限于篇幅,这里只能以点带面地介绍几个最频繁使用的 LINQ 操作符,掌握它们你就可以写出很多有趣的程序,而且对它们原理的揭示,将会有助于你自学其它的 LINQ 操作符。
第一个要提到的是select,它的作用是投影,对一个序列的每一项做一个运算,得到一个结果,而select 的结果是一个和原序列等长的新的序列,它的每一项是经过运算变化以后的每一个结果。
比如对于arr = {1,2,3,4}来说,,则arr2 = arr.Select(x => x * 2);。对于List<Person> list来说,names = list.Select(x => x.name),结果是原来 Person 集合中每个元素的 name 字段。
Select 有个很有用的重载形式,是Select<TSource,TResult>(IEnumerable<TSource>,Func<TSource, Int32, TResult>)的形式,注意其中的委托的 Int32 参数,写起来一般是Select((x,i) => ?)这样的形式,这个 i 代表了此元素在序列中的位置。
下面的代码演示了这个重载的用法:
未命名图片.png
结果是:

  1. { x = h, i = 0 } { x = e, i = 1 } { x = l, i = 2 } { x = l, i = 3 } { x = o, i = 4 }

可以看出,i 是以 0 开始的元素的下标。关于 Select 操作符更多信息,具体可以参考:Enumerable.Select 方法
第二个要提到的是 GroupBy,它的作用是分组,因此原始序列按照分组规则能分多少组,那么结果序列的长度就是几。而结果序列的每一项,又是一个序列,这个序列是所有符合这个分组规则的原始数据的每一项。
对于结果序列来说,还有一个 Key 属性,代表分组的规则。
比如对学生成绩以 10 分为单位分组,代码如下:
未命名图片.png
运行结果:
未命名图片.png

下面的图可以很好地展示一个分组的过程。
Scores: 90 65 82 71 84 88 52 78 61 75 85 79
分组后:
Query:
Key=5 52
Key=6 61 65
Key=7 71 75 78 79
Key=8 82 84 85 88
Key=9 90
第三个要提到的是 SelectMany,它的作用是对于一个序列的序列,将一个序列的每一项提取出来作为结果的每一项。因此它有点类似 GroupBy 的反操作:
如下代码的 query2 将会把 query 的所有的分组又放入一个序列中。
未命名图片.png
结果是:
未命名图片.png
SelectMany 最常用的操作是生成笛卡尔集,也就是把第一个集合的每一项和第二个集合的每一项匹配,得到数量为两个集合元素数量相乘的新的集合。
比如:
未命名图片.png
结果是:
张三喜欢吃橘子 张三喜欢吃香蕉 张三喜欢吃西瓜 张三喜欢吃苹果
李四喜欢吃橘子 李四喜欢吃香蕉 李四喜欢吃西瓜 李四喜欢吃苹果
王二麻喜欢吃橘子 王二麻喜欢吃香蕉 王二麻喜欢吃西瓜 王二麻喜欢吃苹果
对于一个 4 个元素和一个 3 个元素的集合的笛卡尔积,是 12 个元素,如上。
以上代码的 SelectMany 还可以通过它的另一种重载形式简化:
未命名图片.png
这段代码和之前的代码,作用是一样的。特别需要指出的是,在不同的 Lambda 表达式中,相同的变量名其实没有任何关系,比如第一个x => fruits和第二个(x, y) => x + “ 喜欢吃 “ + y的 x 就是两回事
这也很好理解,比如有两个不同的函数,它们都有参数 x,显然它们没有任何联系。
Skip 和 Take 操作符。顾名思义,Skip(n) 在指定的序列上跳过 n 个元素,而 Take(n) 则取 n 个元素。如果 Skip 和 Take 的 n 比序列上剩下的元素多,那么执行不会报错,但是会返回能返回的最多的元素
比如arr={1,2,3,4,5},arr.Skip(1) 返回{2,3,4,5}而arr.Skip(1).Take(2)返回{2,3},arr.Skip(1).Take(10),因为序列中并没有那么多元素,所以返回{2,3,4,5},arr.Skip(100).Take(10)则返回空序列
未命名图片.png
Skip 和 Take 最常见的用法是做分页。
最后我想介绍下 Aggregate聚合函数,它对于查询的时候需要前面元素参与计算的需求非常有用。比如下面的代码,可以根据前后两个元素得到一个结果:
未命名图片.png
结果如下:
北京-> 石家庄 石家庄-> 郑州 郑州-> 武汉 武汉-> 衡阳 衡阳-> 广州
LINQ 中还有一些操作符,比如 OrderBy,Join,Distinct,和它们在数据库查询中的用法类似,这里限于篇幅就不再展开介绍了。

初学者容易犯的错误

LINQ 中大部分情况下返回的是序列,序列没有办法直接赋值给数组、列表,并且,LINQ **延迟查询的,只有用 foreach **迭代,它才会真正执行用** ToArray ToList **可以立刻执行并且放入数组、列表。看下面的代码:
未命名图片.png
结果是 2 4 6 8 10 12,因为 LINQ 是延迟查询的。为了固定查询结果,我们可以加上 **ToList**:
未命名图片.png
这样就不会出现 10 和 12 了。如果我们希望删除 list 里的奇数项,也许你会这么写:
未命名图片.png
然而这样写是不能通过编译的,这是因为 list 是序列(IEnumerable类型),而不是 List。为此,我们也可以加上 ToList:
未命名图片.png
这样写就没问题了。请注意,像Select/Where/Take/GroupBy/OrderBy/Join 这样的查询,返回的是序列,即便序列是空的,或者只有一个元素。比如:

  1. User u = users.Where(x => x.id == 1);

这是不行的,虽然这样查询,返回的序列只有一个元素,但是一个元素的序列还是序列,而不是这个元素本身。类似一个装着一个苹果的篮子,是篮子,而不是苹果。
正确的做法是使用 Single 或者 First 操作符来得到这个序列的唯一元素或者第一个元素。

  1. User u = users.Where(x => x.id == 1).First();

这样就可以了。类似地,如果用 Take(1) 取得一个元素,也需要用 First():

  1. User u = users.Take(1).First();

以上代码等价为:

  1. User u = users.First();

也有一些操作符,比如 Single/First,返回的是单个的元素,除此之外,Max()、Average()、Aggregate()之类的聚合方法,也是返回的单一元素。
**

LINQ 在 C# 编程中的技巧案例

说了那么多,那么 LINQ 在实际编程中有哪些作用呢?下面将通过几个例子代码让你大开眼界。很明显,LINQ 可以做的事情远远不是可以列举的,对于某个编程任务,我们甚至有不止一种写法。
所以,下面的介绍仅仅有助于帮你打开思路,让你发现原来 LINQ 可以做这么多有趣的事情。

第一个例子:洗牌算法

将一个数组每个元素的顺序随机打乱。
未命名图片.png
运行结果:
4 7 6 8 5 10 2 3 1 9
注意,这个结果是随机的,每次运行的都不同。我们还可以用洗牌算法实现对一个 m 个元素的数组,任意选 n 个的操作:
未命名图片.png
比如以上代码,就可以实现在 arr 里不重复地取 3 个。

第二个例子:排列组合

排列:
未命名图片.png
结果:https://ideone.com/XH2tDr
组合:
未命名图片.png
完整代码:https://ideone.com/MqlZzp
其中,SelectNElements 函数也可以单独拿出来用来做 m 选 n 的算法。

第五个例子:读取和写入文件

在 .NET 4.0 以后的版本中,System.IO 命名空间的 File 静态类下,多了几个很实用的文件读写方法:

  1. File.ReadAllLines
  2. File.WriteAllLines
  3. File.ReadAllBytes
  4. File.WriteAllBytes

使用它们可以很方便地读写文件。假设我们有一个文本文件,叫做 1.txt,里面包含以下内容:
1 2 3 4 5 6 7 8 9 10
我们希望编写一个程序求和,我们可以这么写:

  1. var lines = System.IO.File.ReadAllLines(@"X:\path\1.txt");
  2. var sum = lines.Select(x => int.Parse(x)).Sum();
  3. Console.WriteLine(sum);

结果是 55。

总结

本文首先介绍了 LINQ 的本质,它是利用类库中编写好的一组代码实现的,完全在内存中,由 C# 代码执行的数据操作。特别需要理解的是,Lambda 表达式的用法。
然后介绍了几个 LINQ 的操作符,所有的操作符都可以在 System.Linq 命名空间中找到,并且一些操作符有不止一个重载形式。
接下来,我们给出了几个 LINQ 的使用例子,尽管代码对于初学 LINQ 的读者有些偏难,但是借助 MSDN 和 Google,读者可以体会到 LINQ 编程的简洁和便利。
最后,我们介绍了表达式树的概念,这种动态创建代码的方式也被称作元编程(meta programming)。希望这篇文章能够给你一些有趣的信息,并且让你对 LINQ 有一个初步的了解。
限于篇幅,很多内容并没有深入,不过不要紧,在文章最后,我提供了一些参考书籍和链接,让你可以进一步学习 LINQ 这项有用的技术。

原文链接:https://blog.csdn.net/gitchat/article/details/78977188