LINQ与数据处理


1 作为语法糖的LINQ

1.1 IEnumerable 接口

LINQ只能用于查询IEnumerable的集合,接下来通过例程来演示IEnumerable对象的构建方法与其特性。

using System;
using System.Collections;

// 一个测试类
public class Person
{
    public Person(string fName, string lName)
    {
        this.firstName = fName;
        this.lastName = lName;
    }

    public string firstName;
    public string lastName;
}

// 定义了一个IEnumerable的集合,集合中元素是上文定义的Person对象
public class People : IEnumerable
{
    private Person[] _people;
    public People(Person[] pArray)
    {
        _people = new Person[pArray.Length];

        for (int i = 0; i < pArray.Length; i++)
        {
            _people[i] = pArray[i];
        }
    }
    IEnumerator IEnumerable.GetEnumerator()
    {
       return (IEnumerator) GetEnumerator();
    }
    public PeopleEnum GetEnumerator()
    {
        return new PeopleEnum(_people);
    }
}

// 定义一个枚举器类
public class PeopleEnum : IEnumerator
{
    public Person[] _people;
    int position = -1;
    public PeopleEnum(Person[] list)
    {
        _people = list;
    }
    public bool MoveNext()
    {
        position++;
        return (position < _people.Length);
    }
    public void Reset()
    {
        position = -1;
    }
    object IEnumerator.Current
    {
        get
        {
            return Current;
        }
    }
    public Person Current
    {
        get
        {
            try
            {
                return _people[position];
            }
            catch (IndexOutOfRangeException)
            {
                throw new InvalidOperationException();
            }
        }
    }
}

class Program
{
    static void Main()
    {
        // 新建一个Person类的数组并赋值
        Person[] peopleArray = new Person[3]
        {
            new Person("John", "Smith"),
            new Person("Jim", "Johnson"),
            new Person("Sue", "Rabon"),
        };
        // 将person数组转换成可枚举的People类对象
        People peopleList = new People(peopleArray);
        // 使用foreach遍历集合元素
        foreach (Person p in peopleList)
            Console.WriteLine(p.firstName + " " + p.lastName);
    }
}

编译并执行例程后,运行结果如下:

image-20210723135807306

通过本例程,我们从头定义并创建了一个IEnumerable的集合,并采用foreach方法遍历了集合中每一个元素。

1.2 基于LINQ的集合操作

1.2.1 使用Except方法取差集

1.2.1.1 简单类型集合的差集

对于简单类型的集合,我们可以直接通过调用集合的Except来进行操作,例如:

using System;
using System.Collections.Generic;
using System.Linq;

class Program
{
    static void Main()
    {
        // 定义两个简单类型的集合
        int[] first = { 1, 2, 3, 4 };
        int[] second = { 0, 2, 3, 5 };
        // 取差集
        IEnumerable<int> result = first.Except(second);
        foreach (var r in result)
        {
            Console.WriteLine(r);
        }
    }
}

编译并运行后,其输出为:

image-20210723141354258

可以看到first集合中与second集合相同的元素已经被除去。

1.2.1.2 复杂自定义类型的差集

对于自定义的复杂类型,想要通过Except方法来取差集,就需要实现一个IEquatable<>的接口,如例程所示:

using System;
using System.Collections.Generic;
using System.Linq;

// 定义一个user类,其派生自IEquatable
class User : IEquatable<User>
{
    public string Name { get; set; }
    // 定义用于比较两类是否相等的方法
    public bool Equals(User other)
    {
        return Name == other.Name;
    }

    public override int GetHashCode()
    {
        return Name?.GetHashCode() ?? 0;
    }
}

class Program
{
    static void Main(string[] args)
    {
        var list1 = new List<User>
        {
            new User{ Name = "User1"},
            new User{ Name = "User2"},
        };

        var list2 = new List<User>
        {
            new User{ Name = "User2"},
            new User{ Name = "User3"},
        };
        // 取差集
        var result = list1.Except(list2);
        foreach (var u in result)
        {
            Console.WriteLine(u.Name);
        }
    }
}

编译并运行后,程序输出如下:

image-20210723144608449

通过构建一个比较是否相等的函数,Except方法可以除去两个集合之间相差的值。

1.2.2 使用SelectMany对集合降维

设想这样的场景:数据库中有两个集合,分别代表公司中两个不同的部门,在某次活动中需要统计这两个部门中所有员工的情况,就可以用SelectMany将两个集合摊平成一个集合。如例程所示:

using System;
using System.Collections.Generic;
using System.Linq;
// 定义一个部门集合
class Department
{
    public Employee[] Employees { get; set; }
}
// 定义一个员工类
class Employee
{
    public string Name { get; set; }
}

class Program
{
    static void Main()
    {
        // 创建有两个部门的集合
        var departments = new[]
        {
            new Department()
            {
                Employees = new []
                {
                    new Employee { Name = "Bob" },
                    new Employee { Name = "Jack" }
                }
            },
            new Department()
            {
                Employees = new []
                {
                    new Employee { Name = "Jim" },
                    new Employee { Name = "John" }
                }
            }
        };
        // 使用SelectMany将二维集合降维
        var allEmployees = departments.SelectMany(x => x.Employees);
        // 遍历摊平后的集合
        foreach(var emp in allEmployees)
        {
            Console.WriteLine(emp.Name);
        }
    }
}

编译并运行后,其输出为:

image-20210723150557111

在本例程中,我们创建了一个二维的集合,并通过SelectMany方法将其降维至一维。

1.2.3 使用SelectMany进行集合间的笛卡尔积运算

集合的笛卡尔积运算是指集合中每个元素与其他参与运算的集合中的每个元素,进行两两间的操作的过程。在传统方法中,进行此类运算需要用嵌套循环来进行遍历,且参与运算的集合越多,需要嵌套的循环层数也就越多,使得代码过于臃肿。但使用SelectMany方法,就能使得这一运算的实现变得更简洁,如例程所示:

  • 两个集合间的笛卡尔积运算:
using System.Collections.Generic;
using System.Linq;

class Program
{
    static void Main()
    {
        var list1 = new List<string> { "a1", "a2" };
        var list2 = new List<string> { "b1", "b2", "b3" };
        var result = list1.SelectMany(x => list2.Select(y => $"{x}{y}"));
    }
}

通过调试器可以看到result的值:

image-20210723152931899
  • 多个集合间的笛卡尔积运算
using System;
using System.Collections.Generic;
using System.Linq;

class Program
{
    static void Main(string[] args)
    {
        // 定义一个含有多个集合的列表
        var arrList = new List<string[]>
        {
            new string[] { "a1", "a2" },
            new string[] { "b1", "b2", "b3" },
            new string[] { "c1" ,"c2"},
            new string[] { "d2" }
        };
        // 进行笛卡尔积运算
        var result = CartesianProduct(arrList, 0, new List<string>());
        result.ForEach(x => Console.WriteLine(x));
    }
    // 使用递归进行笛卡尔积运算
    static List<string> CartesianProduct(List<string[]> list, int start, List<string> result)
    {
        if (start >= list.Count)
            return result;

        if (result.Count == 0)
            // 将第一个集合展开至result
            result = list[start].ToList();
        else
            // 将已经在result中的集合与list中的下一个集合进行笛卡尔积运算,并将结果保存在result中
            result = result.SelectMany(x => list[start].Select(y => x + y)).ToList();
        // 进入下一层递归
        result = CartesianProduct(list, start + 1, result);

        return result;
    }
}

编译并运行例程,结果如下:

image-20210723160136859

在这两个例程中,SelectMany能对集合进行方便的操作,通过其与lambda表达式的协作,可以避免传统写法中繁杂的循环与中间变量的创建,无疑能让编码更为优雅。

1.3 使用LINQ进行数据库访问

等SQLserver装好了再回来填坑

7/26/2021 update: 安装失败

2 作为函数式编程的LINQ

通过前面的例子,我们了解到SelectMany这一方法在于lambda结合时,会有更广阔的应用面。在本节,我将会尝试将此类用法进行推广,并使用一些函数式编程的思维来综合考虑。

2.1 范畴、函子与LINQ

2.1.1 范畴(category)

在数学中,范畴是指一个满足以下特性的集合:

  • 集合中的每个实例通过箭头相连,一个实例可以通过箭头转换为另一个实例
  • 集合中的箭头之间可以联立结合成起来使用,即一个实例可以通过首尾相连的箭头经过多次转换成为另一个实例

我们将这些连接实例之间的箭头叫做态射(morphism)。

在一个范畴内,通过态射以及其结合,我们可以实现实例的变换。比如在C#中,所有的int类型数据构成了一个范畴,通过某个特定的操作,就能将一个int数据变为另一个int数据,这种操作就是态射。如例程所示:

// 通过较为函数式的方法定义三个int范畴上的态射
Func<int, int> Times2 = x => x * 2;
Func<int, int> Plus5 = x => x + 5;
Func<int, int> minus1 = x => x - 1;
// 定义一个复合函数,其作用是将两个函数复合起来
Func<X, Z> Compose<X, Y, Z>(Func<Y, Z> g, Func<X, Y> f) => (X x) => g(f(x));
// 使用复合态射来对输入值进行操作
Console.WriteLine(Compose(Times2, Compose(minus1, Plus5))(1));
// 改变复合顺序,输入同样的值以验证结合律
Console.WriteLine(Compose(Compose(Times2,minus1), Plus5)(1));
// 输出为10 10

在此例程中,我们定义了一些int范畴上的态射,并验证了范畴的结合律。

2.1.2 函子(functor)

如果我们将范畴的定义进行推广,设想这样一个范畴A:该范畴内部的每个实例n均为一个范畴(记为 A[n]),那么A中的态射实质上是从一个范畴到另一个范畴的变换,这样的态射被称为函子

接下来我们尝试在例程中构建一个函子:

using System;

class Program
{
	static void Main(string[] args)
	{
		// 通过较为函数式的方法定义三个int范畴上的态射
		Func<int, int> Times2 = x => x * 2;
		Func<int, int> Plus5 = x => x + 5;
		Func<int, int> minus1 = x => x - 1;
		// 定义一个复合函数,其作用是将两个函数复合起来
		Func<X, Z> Compose<X, Y, Z>(Func<Y, Z> g, Func<X, Y> f) => (X x) => g(f(x));
		// 定义两个用于将数值在int范畴与double范畴间相互转换的函数
		Func<double, int> ToInt = d => Convert.ToInt32(d);
		Func<int, double> ToDouble = x => Convert.ToDouble(x);
		// 定义一个函子,用于将以上操作转换到double上
		Func<double, double> Functor (Func<int,int> selector) => x => Compose(Compose(ToDouble, selector), ToInt)(x);
		// 测试是否映射成功
		var dTimes2 = Functor(Times2);
		Console.WriteLine(dTimes2(3.0));
		// 验证结合律
		Console.WriteLine(Compose(Functor(Times2), Compose(Functor(minus1), Functor(Plus5)))(5.0) == Compose(Compose(Functor(Times2), Functor(minus1)), Functor(Plus5))(5.0));
	}
}

编译并运行该例程,运行结果为:

image-20210726115108243

通过这个例程,我们构建了三个从int范畴到double范畴的映射,其中两个用于值的转换(ToInt方法与ToDouble方法),还有一个用于函数的转换(Functor方法),这三个映射就是int范畴与double范畴间的函子。同时,我们也验证了范畴的结合律。

总之,函子是在两个范畴间的映射,在这两个范畴同属于一个更大的范畴时,函子是这个大范畴的态射。函子被用于两个范畴间实例的相互转换,这在一定程度上昭示着两个范畴结构上的相似性。

2.1.3 用LINQ构建函子

上文中,我们介绍了如何在普通类型范畴上构建函子,对于高阶类型(aka. 泛型)如List<T>来说,构建函子就需要用的LINQ了。如例程所示,我们尝试构建一个函子,用于把List<int>转换为List<string>。

var a = new List<int>{1, 2, 123, 56, 8910};
// 定义一个从List<int>到List<string>的函子
Func<List<int>, List<string>> F = x => x.Select(y => y.ToString()).ToList();
var b = F(a);

编译并运行后,通过调试器可以看到我们定义的函子成功将list中的每个元素都转换成了string。

image-20210726135616169

2.2 LINQ实现的Monad

2.2.1 自函子 (Endofunctor)

自函子就是一个将范畴映射到自身的函子 (A functor that maps a category to itself)

在进入自函子的部分之前,我们先来谈谈自函数以及恒等函数与恒等函子(identity function and identity functor)。

2.2.1.1 自函数 (Endofunction)

自函数是一个从类型到同一个类型的映射,比如,在C#中,Func<int,int> F = x => x*3是一个自函数,其传入参数与传出参数的类型都是一样的。

恒等函数是一种特殊的自函数,其只会返回传入的参数。

2.2.1.2 恒等函子与自函子

与恒等函数类似,恒等函子是一种特殊的自函子。若在某个范畴中有恒等函子F~i~,则F~i~(int)的输出值仍然是int类型,若给F~i~输入一个函数,则其输出也将是这个函数,恒等函子不会对范畴内的类型与态射做出任何改变。

那么对于一个非恒等函子的自函子,应该具有怎样的特征呢,请看接下来的例程:

var a = new List<int>{1, 2, 123, 56, 8910};
// 定义一个从List<int>到List<string>的函子
Func<List<int>, List<string>> F = x => x.Select(y => y.ToString()).ToList();
// 构建一个从List<int>到List<List<string>>的函子
Func<List<int>, List<List<string>>> FF = x => x.Select(y => F(new List<int>(){y})).ToList();
var b = FF(a);

编译并执行后,通过调试器可以看到,来自a中的每一个元素,均被转换成string类型放入了独立的List中。

image-20210726142911603

在这里,我们如果对b这个嵌套List使用本文 1.2.2 提到的方法进行展开降维。如下所示:

using System;
using System.Collections.Generic;
using System.Linq;

class Program
{
	static void Main(string[] args)
	{
		var a = new List<int>{1, 2, 123, 56, 8910};
		// 定义一个从List<int>到List<string>的函子
		Func<List<int>, List<string>> F = x => x.Select(y => y.ToString()).ToList();
		// 构建一个从List<int>到List<List<string>>的函子
		Func<List<int>, List<List<string>>> FF = x => x.Select(y => F(new List<int>(){y})).ToList();
		var b = FF(a);
		// 对集合进行降维
		var c = b.SelectMany(x => x.Select(y=> Int32.Parse(y)));
	}
}

通过调试器,我们可以看到,我们将List<int>转换成了List<string>,随后又转回了List<int>。

image-20210726145820003

这说明了List<>不仅是一个int范畴到List范畴的函子(通过new List<int>{ intValue }),其也是一个List<>范畴到List<>范畴的函子。也就是说,List<>是一个自函子

2.2.2 自函子与单子 (Monad)

2.2.2.1 单位元 (Identity Element)

在数学上,单位元是指一个单位元素,其在与其他元素进行二元运算是不会改变另一个元素的值,比如加法中单位元就是0,在乘法中单位元是1。

而在函数式编程中,单位元用于指代那些内部为空的类型,比如对于一个List,我们可以通过var a = new List<int>来创建一个单位元。对于a这个列表,在与其他列表进行运算操作,比如concat(a, List)时,得到的结果还是List的值。

2.2.2.2 单子 (Monad)

对于同时拥有SelectMany与单位元的自函子,我们将其称为单子。单子具有幺半子群的特征,所以单子也被称为自函子上的幺半子群

C#中,很多泛型类都是一个单子,比如前文论证过的List,还有array等。对于不符合单子特征的类,我们可以通过为其构建Select方法与SelectMany方法,来让其具有单子的特征,从而参与到运算中来。

2.3 C#上的函数式编程

前文已经大致描述了如何使用C#来实现一些函数式编程的特性,接下来的几个小节将会继续补全这方面的内容。

2.3.1 用C#来进行Map、Reduce和Fliter

Map(映射),Reduce(过程),Fliter(滤镜)以及其组合运算,是所有具有函数式编程特点的语言中不可或缺的一部分,接下来我会尝试通过C#来构建这两种运算。

using System;
using System.Collections.Generic;
using System.Linq;

class Program
{
   static void Main(string[] args)
   {
      // 定义一个用于测试的List
      var a = new List<int>{1, 2, 123, 56, 8910};
      // 实现Map的功能,使list中每个元素翻倍
      var b = a.Select(x => x * 2).ToList();
      // 实现reduce的功能,求这个list的总和
      var c = a.Aggregate(0, (acc, e) => acc += e);
      // 实现fliter的功能,取出list中的奇数
      var d = a.Where(x => x%2 == 1).ToList();
   }
}

从调试器可以看到,运行结果如下:

image-20210726154143522

通过此例我们可以得知,在C#中,对于IEnumerable对象,Map运算用Select方法进行,Reduce运算用Aggregate方法进行,Flitter运算用Where方法进行。

2.3.2 lambda算子

在前文的例程中,我们已经广泛的使用了C#中的匿名函数来进行lambda演算。lambda算子是所有函数式编程语言的核心,其可以很方便的构造函数并进行调用,结合Map、Reduce等运算,能够让代码更为简洁,逻辑更清晰。下面给出一个例子,分别使用传统方法和lambda演算来进行同样的操作。

using System;
using System.Collections.Generic;
using System.Linq;

class Program
{
	static List<string> trans(List<int> a)
	{
		var b = new List<string>();
		foreach (var num in a)
		{
			b.Add(num.ToString());
		}
		return b;
	}
	static void Main(string[] args)
	{
		// 定义一个用于测试的List
		var a = new List<int>{1, 2, 123, 56, 8910};
		var b = a.Select(z => z.ToString()).ToList();
		var c = trans(a);
	}
}

编译并执行,通过调试器看到如下结果,说明lambda操作与自定义的trans函数是等价的。

image-20210726160540318

在本例程中,我们通过自定义函数的形式实现了与lambda运算同样的功能,但明显可以看出,采用lambda运算的代码更为简洁易懂。

2.4 小结

这一章主要介绍了LINQ在C#函数式编程中扮演的角色,同时简要的介绍了函数式编程的一些基本概念、运算与操作,以及其在C#中的实现。通过这一章的分析,LINQ与函数式编程的便捷性给了我深刻的印象,在下一章,我会对LINQ操作的性能进行分析,来看看这种便捷与优雅的编程方法是否是牺牲了较多的性能得到的。

3 LINQ与传统方法的性能对比

为了测试LINQ与传统方法的性能差异,我构建了一系列例程用于测试LINQ中各种函数对比传统方法的性能。

3.1 Select性能分析

例程如下,大致是上文2.3中例程的改版,分别用Select和foreach实现了将一个List<int>转换为List<string>的函子。

using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class Program
{
	public class test
	{
		//使用foreach遍历list的方法
		[Benchmark]
		public void foreachTrans()
		{
			var a =  new List<int>() {1234,2234,3234,4234,5234,6234,7234,8234,9234};
			var b = new List<string>();
			foreach (var num in a)
			{
				b.Add(num.ToString());
			}
		}
		// 使用Select的方法
		[Benchmark]
		public void selectTrans()
		{
			var a =  new List<int>() {1234,2234,3234,4234,5234,6234,7234,8234,9234};
			a.Select(y => y.ToString()).ToList();
		}
	}
	
	static void Main(string[] args)
	{
		BenchmarkRunner.Run<test>();
	}
}

测试结果如下:

image-20210726164056103

可以看到,Select方法不仅更为简洁,甚至也比foreach拥有更高的平均性能。

3.2 Aggregate性能分析

using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class Program
{
	public class test
	{
		//使用foreach遍历list的方法
		[Benchmark]
		public void foreachTrans()
		{
			var a =  new List<int>() {1234,2234,3234,4234,5234,6234,7234,8234,9234};
			var b = new int();
			foreach (var num in a)
			{
				b += num;
			}
		}
		// 使用Aggregate的方法
		[Benchmark]
		public void aggregateTrans()
		{
			var a =  new List<int>() {1234,2234,3234,4234,5234,6234,7234,8234,9234};
			a.Aggregate(0, (acc, e) => acc += e);
		}
	}
	
	static void Main(string[] args)
	{
		BenchmarkRunner.Run<test>();
	}
}

测试结果如下:

image-20210726165329417

aggregate方法几乎比foreach慢了一倍。

3.3 复杂查询语句的性能分析

使用lambda编辑一个较为复杂的LINQ操作,并实现其等价的传统方法,同时加入AsParallel方法进行并行计算,比较三者性能。测试用例程如下:

using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class Program
{
	public class test
	{
		//使用foreach遍历list的方法
		[Benchmark]
		public void foreachTrans()
		{
			var a =  new List<int>() {1234,2234,3234,4234,5234};
			var b = new List<int>();
			foreach (var num in a)
			{
				if (num % 3 == 0)
				{
					b.Add(num*num);
				}
			}
		}
		// 使用Aggregate的方法
		[Benchmark]
		public void LinqWithoutParallelTrans()
		{
			var a =  new List<int>() {1234,2234,3234,4234,5234};
			var result=a.Where(i=>i%3==0).Select(i=>i*i).ToArray();

		}
		// 使用AsParallel方法并行计算
		[Benchmark]
		public void LinqWithParallelTrans()
		{
			var a =  new List<int>() {1234,2234,3234,4234,5234};
			var result=a.AsParallel().Where(i=>i%3==0).Select(i=>i*i).ToArray();

		}
	}
	
	static void Main(string[] args)
	{
		BenchmarkRunner.Run<test>();
	}
}

三个方法的性能消耗分别如下:

image-20210726195124993
image-20210726195141169
image-20210726195157337

可以看到,所用时间为 非并行计算的LINQ > 传统方法 >> 并行计算的LINQ。同时还注意到,在调用AsParallel方法时,CPU占用率几乎达到100%,这是因为并行计算能充分释放多核处理器的性能,达到更快的速度。

4 总结

LINQ作为一种语法特性,可以帮助我们更高效地查询与处理数据、访问数据库等。同时,其提供的一些方法也为我们在C#中使用函数式编程思维提供了很大的帮助。LINQ在提高编写代码的效率的同时,并未显著地降低性能,甚至通过并行计算还能充分利用现代处理器多核心的优势,来取得更高的执行速度。

但与此同时,LINQ也不是没有任何缺点,在不加优化的情况下其可能会拖慢系统关键部分的性能,尤其是在涉及到高并发数据库访问的项目中,使用这样不加优化的代码甚至是对整个软件的运维有害的。并且,在多人协作的项目中使用LINQ(或许还要加上Lambda)十分不利于协作者阅读理解代码,因此其比起一些相对传统的写法更离不开详细的注释与软件设计文档的支持,同时其也对团队素质具有较高的要求。

关于LINQ的要点实在是太多,我花了两天时间来整理相关知识点,编写例程与本文档。即使如此,本文也并未完全覆盖LINQ的所有方面,且由于本人是C#新手,文中有遗漏与错误的地方也是在所难免,希望能通过以后的学习工作进一步加深对LINQ的认识。