用 LINQ 编写 C 都有哪些一招必杀的技巧?

.NET
372
0
0
2022-04-22

前言

C# 作为一种全能的编程语言,以及微软近年来的转型,越来越受到开发者的重视。

C# 不但可以用来开发基于 Linux 和 Docker 上运行的轻量的 web 应用,也可以在 xamarin 的加持下开发 Android 和 iOS 移动客户端程序,C#+Unity3D 近年来作为热门的技术被很多网游公司作为首选的方案。

别忘记了,C# 还是目前开发 Windows 桌面程序的第一方案。随着 C# 应用领域的扩展,C# 逐步变得热门起来,所以现在越来越多的程序员开始关注这一语言。因为 C# 的开发领域如此广阔,所以学好这一语言的投入产出越来越高。

很多新学 C# 的开发者有过学习 C++ 和 Java 以及 Javascript 的基础,毕竟大部分的学校都会教授 C++ 和 Java。因此和 C++/Java 语法类似的 C# 上手起来难度不是很高。然而 LINQ 语法是一个例外。

一般程序员看到有 LINQ 代码的 C# 程序就会感觉理解起来很吃力。一部分 C# 程序员也认为,LINQ 是 SQL 的一种替代产物,他们只有在数据查询的时候才会用 LINQ,而且仅仅用 LINQ 来做原先他们用 SQL 做的事情。

然而,LINQ 实际上是 C# 语言语法的一等公民,LINQ 其实一点也不神秘,本文将首先从机制上让你对 LINQ 有个清晰的了解,然后会给出 LINQ 在日常编程中的一些用法。

限于篇幅,本文的内容是启发性的,目的是让你知道 LINQ 的原理和知道原来 LINQ 还可以这么用。至于一些细节,则还需要你额外地学习。

俗话说,纸上得来终觉浅,为了避免纸上谈兵,本文不可避免地会列出很多代码,理解这些代码的最好方式是自己动手尝试下。然而,也许你的电脑上还暂时没有开发环境。

虽然安装开发环境不是难事,但是总归会成为一些人动手尝试的障碍。为了方便你立刻动手进行探索,首先推荐一个网站给读者,这个网站叫做 ideone.com,顾名思义,它提供了一个在线的编辑、编译调试环境,你只要贴上代码,并且提供输入的行,它就能在线编译执行并且返回结果。

这个网站支持很多编程语言,C# 它也是支持的。需要指出,它的编译器是基于 mono 开发的,和最新版本的 C# 编译器略有不同,一些新的语法可能不支持。

另外,这个网站也并不能在线运行 Winforms 或者 ASP.NET Web 程序,所以它并不能完全代替你在自己的 PC 上练习和开发程序。

在浏览器里输入 ideone.com,打开网站,可以看到,默认下编辑语言是 Java,我们点 Java 下拉箭头,出现一个语言选择的菜单,我们可以选择 C#。如图所示。

用 LINQ 编写 C 都有哪些一招必杀的技巧?

我们可以立刻输入一个简单的程序,比如:

Console.WriteLine(“hello world”);

然后点绿色的 submit 按钮,此时稍等片刻,程序就编译执行了,我们可以看到结果出现在 stdout 里,如图:

用 LINQ 编写 C 都有哪些一招必杀的技巧?

本文所列出的代码,会给出 ideone 的链接,这样你不需要做任何事情,就能直接看到代码和结果。

然而,也许你想更改下输入,看看结果有什么不同,或者更改下程序。此时你只要点 fork,就能产生一个新的代码片段,从而可以修改了。

如本程序我放在了 https://ideone.com/juq1Eg 下,你点 fork 会出现一个新的编辑页面,此时你点下面的 stdin 按钮,可以添加你的输入。如下图:

用 LINQ 编写 C 都有哪些一招必杀的技巧?

然后点 run,就能看到结果:

hello zhangsan

LINQ 的本质

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

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

LINQ 有两种写法,LINQ 表达式写法和调用 LINQ 操作符(查询方法)。比如如下两个简单的查询,实现了相同的功能,将一个数组中的偶数挑出来:

使用 LINQ 表达式:

int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8 };
var query = from x in arr where x % 2 == 0 select x;
foreach (int x in query) Console.Write(x + "\t");

使用 LINQ 操作符:

int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8 };
var query = arr.Where(x => x % 2 == 0);
foreach (int x in query) Console.Write(x + "\t");

如果你使用 ideone 来编写如上的代码,请记得在开头加上:

using System.Linq;

以上两段代码是等价的。初看这两段代码,让人觉得很困惑。原因是,第一段代码看上去像 SQL 查询,但是写法却和 SQL 略有不同,给人的印象是,似乎 C# 幕后有个类似数据库的东西在查询,感觉很 “玄”。

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

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

为了揭示 LINQ 的本质,我们抛开 LINQ,自己先实现一个 LINQ。自己实现 LINQ?听上去会很复杂?然而其实很简单,只要一点点代码。

还记得前面我说的,使用 ideone 来编写如上的代码,请记得在开头加上:

using System.Linq;

那么,如果不加上它会如何呢?你可以试试看。

用 LINQ 编写 C 都有哪些一招必杀的技巧?

看到了么?提示缺少 Where 方法。显然 Where 方法是在 System.Linq 这个命名空间中定义的。

你可能会奇怪,我们写的代码中并没有调用 Where 方法啊?但是看了我前面的介绍你应该知道,C# 编译器会自动将 LINQ 表达式转换为对 LINQ 操作符的调用。所以你的代码实际上相当于:

var query = arr.Where(x => x % 2 == 0);

而在这段代码里,我们其实调用了 Where 方法。现在你可能会想,如果我们自己写一个 Where 方法,结果会如何呢?我们来试试看:

using System;
using System.Collections.Generic;
namespace ConsoleApp1
{
    static class MyLinq
    {
        public static IEnumerable<int> Where(this int[] arr, Func<int, bool> cond)
        {
            return new int[] { 2, 4, 6, 8 };
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8 };
            var query = from x in arr where x % 2 == 0 select x;
            foreach (int x in query) Console.Write(x + "\t");
        }
    }
}

或者参见:https://ideone.com/MMM5y7

尝试运行下,结果是:

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<int, bool> cond这是什么鬼,看着又奇怪了。

别着急,这个叫做委托。所谓委托,就是用它表示一个函数,这个函数从调用者看,可以是任意的函数名,甚至没有函数名,从被调用者看,它叫做 cond,我们直接调用它就可以了。后面我们再详细说,看下面的代码:

public static IEnumerable<int> Where(this int[] arr, Func<int, bool> cond)
{
 int[] result = new int[arr.Length];
 int j = 0;
 for (int i = 0; i < arr.Length; i++)
 if (cond(arr[i]))
 result[j++] = arr[i];
 Array.Resize(ref result, j);
 return result;
}

或者参见:https://ideone.com/QbjOPg

这次运行正确了,输出 2 4 6 8 10。

不信?你可以修改下条件,比如输出比 5 小的:

var query = from x in arr where x < 5 select x;

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

bool cond(int x) { ? }

然而实际上我们的程序里没有这么一个函数,而这个函数实际上就是查询里面的 where ?

? 代表这个函数的实现。

之前我们说了,LINQ 还可以用 LINQ 操作符来表示,比如var query = from x in arr where x % 2 == 0 select x;可以写成:

var query = arr.Where(x => x % 2 == 0);

完整的代码是:https://ideone.com/vi1vII

委托相当于一个方法,我们既可以传 Lambda 表达式,也可以传一个传统的方法,比如返回小于 5 的数字,我们可以定义:

static bool myfunc(int x)
{
 return x < 5;
}

于是我们可以写:

var query = arr.Where(myfunc);

注意这代码等价于 arr.Where(x => x < 5);

完整的代码:https://ideone.com/a7G6AP

因此,我们知道了,x => x < 5 的 x 是怎么回事,其实它相当于你定义了一个函数(和 myfunc 类似),而 x 是这个函数的参数,这个函数被传入 Where,由 Where 调用,每次遍历一个元素就会调用一次,每次 x 代表数组中的一个元素,判断你的条件并且返回是否应该被放入结果还是应该舍弃。

因此,x 就像你写的函数的参数一样,它可以任意命名 x => x < 5 和 y => y < 5 是一样的。好比:

static bool myfunc(int y)
{
 return y < 5;
}

将这个函数的参数全部修改为 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),则arr2={2,4,6,8}。对于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 代表了此元素在序列中的位置。

下面的代码演示了这个重载的用法:

string s = "hello";
var query = s.Select((x, i) => new { x, i });
foreach (var item in query) Console.WriteLine(item);

结果是:

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

可以看出,i 是以 0 开始的元素的下标。关于 Select 操作符更多信息,具体可以参考:

https://msdn.microsoft.com/zh-cn/library/system.linq.enumerable.select.aspx

第二个要提到的是 GroupBy,它的作用是分组,因此原始序列按照分组规则能分多少组,那么结果序列的长度就是几。而结果序列的每一项,又是一个序列,这个序列是所有符合这个分组规则的原始数据的每一项。

对于结果序列来说,还有一个 Key 属性,代表分组的规则。

比如对学生成绩以 10 分为单位分组,代码如下:

int[] scores = { 90, 65, 82, 71, 84, 88, 52, 78, 61, 75, 85, 79 };
var query = scores.OrderBy(x => x).GroupBy(x => x / 10);
foreach (var g in query)
{
    Console.Write("group key = {0} values = ", g.Key);
    foreach (var item in g) Console.Write(item + " ");
    Console.WriteLine();
}

运行结果:

group key = 5 values = 52
group key = 6 values = 61 65
group key = 7 values = 71 75 78 79
group key = 8 values = 82 84 85 88
group key = 9 values = 90

下面的图可以很好地展示一个分组的过程。

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 的所有的分组又放入一个序列中。

int[] scores = { 90, 65, 82, 71, 84, 88, 52, 78, 61, 75, 85, 79 };
var query = scores.OrderBy(x => x).GroupBy(x => x / 10);
var query2 = query.SelectMany(x => x);
foreach (var item in query2)
{
    Console.Write(item + " ");
}

结果是:

52 61 65 71 75 78 79 82 84 85 88 90

SelectMany 最常用的操作是生成笛卡尔集,也就是把第一个集合的每一项和第二个集合的每一项匹配,得到数量为两个集合元素数量相乘的新的集合。

比如:

string[] fruits = { " 橘子 ", " 香蕉 ", " 西瓜 ", " 苹果 " };
string[] people = { " 张三 ", " 李四 ", " 王二麻 " };
var query = people.SelectMany(x => fruits.Select(y => x + " 喜欢吃 " + y));
foreach (var item in query)
{
    Console.WriteLine(item);
}

结果是:

张三喜欢吃橘子
张三喜欢吃香蕉
张三喜欢吃西瓜
张三喜欢吃苹果
李四喜欢吃橘子
李四喜欢吃香蕉
李四喜欢吃西瓜
李四喜欢吃苹果
王二麻喜欢吃橘子
王二麻喜欢吃香蕉
王二麻喜欢吃西瓜
王二麻喜欢吃苹果

对于一个 4 个元素和一个 3 个元素的集合的笛卡尔积,是 12 个元素,如上。

以上代码的 SelectMany 还可以通过它的另一种重载形式简化:

var query = people.SelectMany(x => fruits, (x, y) => x + " 喜欢吃 " + y);

这段代码和之前的代码,作用是一样的。特别需要指出的是,在不同的 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)则返回空序列。

Skip 和 Take 最常见的用法是做分页。

最后我想介绍下 Aggregate,聚合函数,它对于查询的时候需要前面元素参与计算的需求非常有用。比如下面的代码,可以根据前后两个元素得到一个结果:

string[] stations = { " 北京 ", " 石家庄 ", " 郑州 ", " 武汉 ", " 衡阳 ", " 广州 " };
List<string> result = new List<string>();
var query = stations.Skip(1).Aggregate(stations[0], (acc, curr) => { result.Add(acc + "->" + curr); return curr; }, x => "");
foreach (var item in result)
{
    Console.WriteLine(item);
}

结果如下:

北京-> 石家庄
石家庄-> 郑州
郑州-> 武汉
武汉-> 衡阳
衡阳-> 广州

LINQ 中还有一些操作符,比如 OrderBy,Join,Distinct,和它们在数据库查询中的用法类似,这里限于篇幅就不再展开介绍了。

初学者容易犯的错误

LINQ 中大部分情况下返回的是序列,序列没有办法直接赋值给数组、列表,并且,LINQ 是延迟查询的,只有用 foreach 迭代,它才会真正执行。用 ToArray 和 ToList 可以立刻执行并且放入数组、列表。看下面的代码:

List<int> list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8 };
var query = list.Where(x => x % 2 == 0);
list.Add(10);
list.Add(12);
foreach (int i in query) Console.Write(i + " ");

结果是 2 4 6 8 10 12,因为 LINQ 是延迟查询的。为了固定查询结果,我们可以加上 ToList:

var query = list.Where(x => x % 2 == 0).ToList();

这样就不会出现 10 和 12 了。如果我们希望删除 list 里的奇数项,也许你会这么写:

List<int> list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8 };
list = list.Where(x => x % 2 == 0);

然而这样写是不能通过编译的,这是因为 list 是序列(IEnumerable类型),而不是 List。为此,我们也可以加上 ToList:

List<int> list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8 };
list = list.Where(x => x % 2 == 0).ToList();

这样写就没问题了。请注意,像

Select/Where/Take/GroupBy/OrderBy/Join 这样的查询,返回的是序列,即便序列是空的,或者只有一个元素。比如:

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

这是不行的,虽然这样查询,返回的序列只有一个元素,但是一个元素的序列还是序列,而不是这个元素本身。类似一个装着一个苹果的篮子,是篮子,而不是苹果。

正确的做法是使用 Single 或者 First 操作符来得到这个序列的唯一元素或者第一个元素。

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

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

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

以上代码等价为:

User u = users. First();

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

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

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

所以,下面的介绍仅仅有助于帮你打开思路,让你发现原来 LINQ 可以做这么多有趣的事情。

第一个例子:洗牌算法

将一个数组每个元素的顺序随机打乱。

int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var query = arr.OrderBy(x => Guid.NewGuid());
foreach (int i in query)
{
    Console.Write(i + " ");
}

运行结果:

4 7 6 8 5 10 2 3 1 9

注意,这个结果是随机的,每次运行的都不同。我们还可以用洗牌算法实现对一个 m 个元素的数组,任意选 n 个的操作:

int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var query = arr.OrderBy(x => Guid.NewGuid()).Take(3);
foreach (int i in query)
{
    Console.Write(i + " ");
}

比如以上代码,就可以实现在 arr 里不重复地取 3 个。

第二个例子:排列组合

排列:

int[] arr = { 1, 2, 3, 4 };
IEnumerable<IEnumerable<int>> result = arr.Select(x => new List<int>() { x });
for (int i = 1; i < arr.Length; i++)
    result = result.SelectMany(x => arr.Except(x), (x, y) => x.Concat(new int[] { y }));
foreach (var item in result)
{
    foreach (int i in item)
    Console.Write(i + " ");
    Console.WriteLine();
}

完整代码:https://ideone.com/XH2tDr

组合:

static IEnumerable<IEnumerable<int>> SelectNElements(int[] arr, int n)
{
    IEnumerable<IEnumerable<int>> result = arr.Select(x => new List<int>() { x });
    for (int i = 1; i < n; i++)
        result = result.SelectMany(x => arr.Where(y => y > x.Max()), (x, y) => x.Concat(new int[] { y }));
    return result;
}

static void Main(string[] args)
{
    int[] arr = { 1, 2, 3, 4 };
    for (int i = 1; i <= arr.Length; i++)
    {
        var result = SelectNElements(arr, i);
        foreach (var item in result)
        {
            foreach (int x in item)
            Console.Write(x + " ");
            Console.WriteLine();
        }
    }
}

完整代码:https://ideone.com/MqlZzp

其中,SelectNElements 函数也可以单独拿出来用来做 m 选 n 的算法。

第三个例子:用 C# 制作一个年历

这是一个经典的面试题,用 LINQ 可以简化代码的编写。

代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            string calendar = "";
            calendar = (from x in Enumerable.Range(1, 12)
            group x by (x + 2) / 3 into g
            select (BuildCalendar(DateTime.Now.Year, g.ToList()[0]).Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Union(new string[] { "\r\n" })
            .Zip(BuildCalendar(DateTime.Now.Year, g.ToList()[1]).Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Union(new string[] { "\r\n" }), (x, y) => x.TrimEnd().PadRight(23, ' ') + y)
            .Zip(BuildCalendar(DateTime.Now.Year, g.ToList()[2]).Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Union(new string[] { "\r\n" }), (x, y) => x.TrimEnd().PadRight(46, ' ') + y))
            .Zip(Enumerable.Repeat("\r\n", 8), (x, y) => x + y)
            .Aggregate((serials, current) => serials + current))
            .Aggregate((serials, current) => serials + current);
            Console.WriteLine(DateTime.Now.Year + "\r\n" + calendar);
        }
        static string BuildCalendar(int year, int month)
        {
            string calendar = new string[] { month.ToString(), "SU MO TU WE TH FR SA" }
            .Union(Enumerable.Range(
                1 - (int)new DateTime(year, month, 1).DayOfWeek,
                new DateTime(year, month, 1).AddMonths(1).AddDays(-1).Day + (int)new DateTime(year, month, 1).DayOfWeek
            )
            .GroupBy(x => ((x + (int)(new DateTime(year, month, 1).DayOfWeek + 6)) / 7), (key, g) => new { GroupKey = key, Items = g })
                .Select(x =>
                x.Items.Select(y => y < 1 ? " " : Convert.ToString(y).PadLeft(2, '0') + " ")
                .Aggregate((serials, current) => serials + current))
             )
            .Aggregate((serial, current) => serial + "\r\n" + current);
            return calendar;
        }
    }
}

这是运行结果:

用 LINQ 编写 C 都有哪些一招必杀的技巧?

你也可以通过 ideone 得到在线的程序,https://ideone.com/wcRosk 。如果你看到这个程序的时候是 2018 年了,那么 fork 一份重新运行,会得到 2018 年的年历。

第四个例子:使用 LINQ 代码简化递归遍历

对于数组或者集合,我们直接使用 foreach 调用就好了,但是如果我们要遍历层次结构怎么办呢?必须定义一个方法,递归调用。

虽然遍历的代码从结构上看大同小异,但是具体到不同的场景,比如遍历数据库中的字段、遍历控件、遍历文件系统、遍历二叉树、遍历 TreeView……,则需要编写不同的代码,似乎不太好进行代码的重用。

下面给出的例子,就是 Lambda 表达式大显身手的地方了。我们可以用它写出一个通用的代码,借助它,再遍历各种层次结构,都可以轻松搞定。

我们使用 VS 新建一个 WinForms 程序,在主窗体上添加一个菜单条(MenuStrip)、一个 TreeView、三个按钮(用于遍历菜单、TreeView 和控件)、一个 ListBox(用于输出结果)、以及若干控件构成的层次结构。

创建菜单条后,我们可以利用 “插入标准项” 功能快速插入一些标准的菜单条目,如图所示:

用 LINQ 编写 C 都有哪些一招必杀的技巧?

为了演示递归,我们给菜单条多加上一些层次和项目:

用 LINQ 编写 C 都有哪些一招必杀的技巧?

完成的界面如下:

用 LINQ 编写 C 都有哪些一招必杀的技巧?

添加一个类,比如叫 class1,然后编写如下代码:

用 LINQ 编写 C 都有哪些一招必杀的技巧?

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WindowsFormsApp1
{
    static class Class1
    {
        private static IEnumerable<TNode> GetChildren<TNode>(
        TNode node,
        Func<TNode, IEnumerable<TNode>> GetNodes)
        {
            var nodes = GetNodes(node);
            return nodes.Concat(nodes.SelectMany(x => GetChildren(x, GetNodes)));
        }
        public static IEnumerable<TNode> GetChildrenRecursively<TRoot, TNode>(
        this TRoot obj,
        Func<TRoot, IEnumerable<TNode>> EnumRoot,
        Func<TNode, IEnumerable<TNode>> GetNodes)
        {
            var nodes = EnumRoot(obj);
            return nodes.Concat(nodes.SelectMany(x => GetChildren(x, GetNodes)));
        }
    }
}

然后我们就可以使用了。首先双击第一个按钮,我们来遍历菜单条:

private void button1_Click(object sender, EventArgs e)
{
    var items = menuStrip1.GetChildrenRecursively(x => x.Items.OfType<ToolStripMenuItem>(), x => x.DropDownItems.OfType<ToolStripMenuItem>());
    listBox1.Items.Clear();
    foreach (var item in items) listBox1.Items.Add(item.Text);
}

运行结果:

用 LINQ 编写 C 都有哪些一招必杀的技巧?

然后双击第二个按钮,遍历 TreeView:

private void button2_Click(object sender, EventArgs e)
{
    var items = treeView1.GetChildrenRecursively(x => x.Nodes.OfType<TreeNode>(), x => x.Nodes.OfType<TreeNode>());
    listBox1.Items.Clear();
    foreach (var item in items) listBox1.Items.Add(item.Text);
}

运行结果:

用 LINQ 编写 C 都有哪些一招必杀的技巧?

最后,第三个按钮,遍历控件:

private void button3_Click(object sender, EventArgs e)
{
    var items = this.GetChildrenRecursively(x => x.Controls.OfType<Control>(), x => x.Controls.OfType<Control>());
    listBox1.Items.Clear();
    foreach (var item in items) listBox1.Items.Add(item.Name);
}

运行结果:

用 LINQ 编写 C 都有哪些一招必杀的技巧?

如果我们在结尾处加上OfType<TextBox>,那么我们可以遍历界面上所有的文本框。

如果我们需要对界面上所有的输入做一个统一判断,避免任意一个文本框为空,那么这段代码也可以用来做控件的验证。

此递归代码不但可以用来遍历各种树状结构,甚至也可以用来解决之前说的排列组合问题:

int[] arr = { 1, 2, 3, 4 };
var query = arr.GetChildrenRecursively<IEnumerable<int>, IEnumerable<int>>(x => arr.Select(y => new int[] { y }), x => arr.Except(x).Select(y => x.Concat(new int[] { y }.ToArray())));
foreach (var item in query)
{
    foreach (var i in item) Console.Write(i + " ");
    Console.WriteLine();
}

完整的程序:https://ideone.com/jaQlel

运行结果:

1
2
3
4
1 2
1 3
1 4
1 2 3
1 2 4
1 2 3 4
1 2 4 3
1 3 2
1 3 4
1 3 2 4
1 3 4 2
1 4 2
1 4 3
1 4 2 3
1 4 3 2
2 1
2 3
2 4
2 1 3
2 1 4
2 1 3 4
2 1 4 3
2 3 1
2 3 4
2 3 1 4
2 3 4 1
2 4 1
2 4 3
2 4 1 3
2 4 3 1
3 1
3 2
3 4
3 1 2
3 1 4
3 1 2 4
3 1 4 2
3 2 1
3 2 4
3 2 1 4
3 2 4 1
3 4 1
3 4 2
3 4 1 2
3 4 2 1
4 1
4 2
4 3
4 1 2
4 1 3
4 1 2 3
4 1 3 2
4 2 1
4 2 3
4 2 1 3
4 2 3 1
4 3 1
4 3 2
4 3 1 2
4 3 2 1

可以看到,Lambda 表达式允许我们在一个方法中将需要重用的算法主体先写出来,然后将需要自定义的地方用 Lambda 表达式交给调用者实现,从而让编写的类库代码具有更大的重用价值。

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

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

File.ReadAllLines
File.WriteAllLines
File.ReadAllBytes
File.WriteAllBytes

使用它们可以很方便地读写文件。假设我们有一个文本文件,叫做 1.txt,里面包含以下内容:

1
2
3
4
5
6
7
8
9
10

我们希望编写一个程序求和,我们可以这么写:

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

结果是 55。

用表达式树构建查询

在前面的介绍中,我们的 LINQ 查询都是在代码中写好的,然而有时候我们希望在代码运行的过程中动态产生一个条件判断的 Lambda 表达式,这就需要使用表达式树来构建。

让我们回到文章开始的那个例子,把数组中的偶数挑选出来。但是我们这次使用表达式树来动态生成和编译 Lambda 表达式。

为了使用表达式树,我们需要先导入如下命名空间:

using System.Linq.Expressions;

先看代码:

int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8 };
var param = Expression.Parameter(typeof(int), "x");
var modexp = Expression.Modulo(param, Expression.Constant(2));
var body = Expression.Equal(modexp, Expression.Constant(0));
Expression<Func<int, bool>> cond = Expression.Lambda<Func<int, bool>>(body, param);
var query = arr.Where(cond.Compile());
foreach (int x in query) Console.Write(x + "\t");

完整代码参考:https://ideone.com/LLMxgZ

这段代码创建了一个叫做 cond 的表达式树,它实现了类似 x => x % 2 == 0 的 Lambda,我们在运行期间构造了它并且用 Compile 编译成方法,传入 where 执行了查询。

cond 的结构如下:

用 LINQ 编写 C 都有哪些一招必杀的技巧?

我用括号标注了对应的表达式等价的代码。可以看到 cond body modexp 三个节点分别代表 Lambda 表达式的 =>、== 和 % 三个二元操作符。0 和 2 代表常数节点,而 param 则是 Lambda 表达式的参数。

使用表达式树还可以调用函数甚至包括循环、判断等代码逻辑。限于篇幅,这里不展开介绍了。

总结

本文首先介绍了 LINQ 的本质,它是利用类库中编写好的一组代码实现的,完全在内存中,由 C# 代码执行的数据操作。特别需要理解的是,Lambda 表达式的用法。

然后介绍了几个 LINQ 的操作符,所有的操作符都可以在 System.Linq 命名空间中找到,并且一些操作符有不止一个重载形式。

接下来,我们给出了几个 LINQ 的使用例子,尽管代码对于初学 LINQ 的读者有些偏难,但是借助 MSDN 和 Google,读者可以体会到 LINQ 编程的简洁和便利。

最后,我们介绍了表达式树的概念,这种动态创建代码的方式也被称作元编程(meta programming)。希望这篇文章能够给你一些有趣的信息,并且让你对 LINQ 有一个初步的了解。

限于篇幅,很多内容并没有深入,不过不要紧,在文章最后,我提供了一些参考书籍和链接,让你可以进一步学习 LINQ 这项有用的技术。