前言

为了提升一下自己的姿势水平,买了 Effective C#More Effective C# 两本书,想一边阅读一边强迫自己做点笔记(主要是自己的理解),不然怕是看了就忘。另外手头还有一本《深入解析C# ( C# in Depth )》其实我已经阅读了一遍,但感觉没咋读懂,下次有机会也用这种方式重读一遍。

忙着搞秋招和毕业论文,以及自己是在是太能摸了,导致距离上次更新已经快一年了。不过以后工作还是要写.NET的,所以还是不能把相关学习落下,今天开始边写变更吧。今天这篇是 Effective C# 的第四章。

第四章 合理地运用LINQ

LINQ全名Language Integrated Query,中文译为语言集成查询,简单来说就是用类似SQL语言的方式来对数据进行查询和操作,C#里提供了两种可使用的语法,分别是查询语法和方法语法。查询语法更贴近SQL语法,但其实我从来没写过...方法语法就是通过扩展方法实现了一系列方法,在数据集合之后调用,比如list.Where(n => n > 0).ToList();,我觉得使用起来更加方便,而且这个应该能算作是函数式编程吧。LINQ给数据操作还是带来了很大的方便的,尤其是写后端的时候操作数据库,像Java也是从8开始引进了stream,用起来感觉也很相似,之前实习的时候基本都是在写stream。

第29条 优先考虑提供迭代器方法,而不要返回集合

当我们要返回一系列对象的时候,应该优先考虑提供迭代器方法而不是集合,这样可以立刻返回,并且等调用方需要实际使用的时候才会创建对应数据,节省时间和空间(就是yield return返回IEnumerable<T>而不是直接返回一个List)。举个最简单的例子来说,我可能需要用到所有非负int值组成的序列中的一部分,如果直接创建一个包含这些数值的List<int>很划不来,而通过Enumerable.Range(0, int.MaxValue)会返回IEnumerable<int>,可以在后续使用的时候再去生成数值。而如果调用方真的需要对这些数据反复使用或者缓存下来,那让他们自己调用ToList()或者ToArray()就好了,相当于提供了两种选择,方便灵活使用,因此即使调用方确实需要把数据保存到集合里,我们还是应该优先提供迭代器方法(其实LINQ就是基于这个原则的)。

另外在编写迭代器方法的时候要注意一个问题,就是参数检查和生成序列的逻辑分开写。因为如果合在一起的话:

// 求正数序列
public static IEnumerable<int> GeneratePositiveNumber(int first)
{
    // 参数检查
    if (first <= 0)
    {
        throw new ArgumentException("first must be postive", nameof(first));
    }
    
    // 序列生成
    var num = first;
    while (num <= int.MaxValue)
    {
        yield return num;
        num++;
    }
}

如果按照上面这么写的话,当进行var nums = GeneratePositiveNumber(-11);的时候,并不会抛出异常,只有当真正使用到nums里面的数值的时候,才会抛异常,这样很不合理而且难以诊断错误。因为编译器固定如此,无法修改,但我们可以把检查和生成分成两段函数来规避这个问题,例如:

// 求正数序列
public static IEnumerable<int> GeneratePositiveNumber(int first)
{
    // 参数检查
    if (first <= 0)
    {
        throw new ArgumentException("first must be postive", nameof(first));
    }
    
    // 序列生成
    return GeneratePositiveNumberImpl(first);
}

// 序列生成的实现
private static IEnumerable<int> GeneratePositiveNumberImpl(int first)
{
    var num = first;
    while (num <= int.MaxValue)
    {
        yield return num;
        num++;
    }
}

这样的话,调用的时候,在进入到迭代器方法之前就能抛出异常。

第30条 优先考虑通过查询语句来编写代码,而不要使用循环语句

C#提供了经典的比如for、foreazh、while等循环结构,但他们的功能其实也可以通过查询语句来实现。与采用循环语句所编写的命令式结构相比,查询语句能够更为清晰地表现开发着的意图。比我想要打印一批成绩高于80分的学生的ID和姓名,用循环语句可以写成:

// 学生的定义
class Student
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Score { get; set; }
}
// 假设有100个学生的列表
var students = Enumerable.Repeat(new Student(), 100).ToList();

// 生成存放结果的列表
var idAndNameTextList = new List<string>();

// 逐个保存学生的ID和姓名
for (var i = 0; i < students.Count; i++)
{
    var student = students[i];
    if (student.Score >= 80)
    {
        idAndNameTextList.Add($"{students[i].Id}: {students[i].Name}");
    }
}

// 逐个打印学生的ID和姓名
foreach (var text in idAndNameTextList)
{
    Console.WriteLine(text);
}

可以发现这种写法更注重的是操作的方式而非操作的意图,上面这个例子因为简单所以还比较好能够一眼看出是在干什么,但是复杂的情况下可能就比较麻烦。而查询语句的写法如下,一句话解决:

// 挑选出学生中分数大于80分的,合并ID和姓名后把每个都打印出来
// ToList()是因为微软没给IEnumerable<T>提供ForEach的扩展方法,我不懂为什么,不过不少工具库里都会提供
students.Where(s => s.Score >= 80).Select(s => $"{s.Id}: {s.Name}").ToList().ForEach(t => Console.WriteLine(t));

可以看到这种写法的可读性很好,语义清晰。而且LINQ还提供了比如Max()、Min()、OrderBy()这些方法,可以大幅简化原来使用循环语句的写法(而且我觉的这样的链式调用真的很爽)。

第31条 把针对序列的API设计得更加易于拼接

感觉这几条其实都是在讲差不多相同的事情。这一条依然是说,如果我们需要对一个集合进行多个操作,那么我们一般就需要多次循环遍历集合,并且每次都要生成一个新的集合对象作为中间结果。这样不仅会浪费内存空间和CPU时间,还会使得代码变得冗长和难以维护。所以我们就利用yield return返回IEnumerable<T>。而这样的另一个好处就是可以把处理方法变成类似流水线一样的结构:

// 筛选为正数的元素
public static IEnumerable<int> Positive(this IEnumerable<int> source)
{
    foreach (var element in source)
    {
        // 如果是正数就使用yield return语句返回元素
        if (element > 0)
        {
            yield return element;
        }
    }
}

// 将元素数值翻倍
public static IEnumerable<int> Double(this IEnumerable<int> source)
{
    foreach (var element in source)
    {
        // 使用yield return语句返回元素乘以2的结果
        yield return element * 2;
    }
}
var nums = new int[] { -1, 2, -3, 4, 5 };

// 会输出4,8,10
foreach (var num in nums.Positive().Double())
{
    Console.WriteLine(num);
}

上面代码的例子简单地实现了将一个数组先筛选出其中的正数,再将数值翻倍的操作。因为输出还是IEnumerable<int>,因此后续完全还可以再加上更多额外的处理方法。这样设计的方法既容易拆解复用,又可以首尾相连拼接起来实现复杂的处理(LINQ就是这样啦)。

第32条 将迭代逻辑与操作、谓词及函数解耦

如果我们把迭代和数据操作写到一起,那么后续如果想要实现其他的操作就比较麻烦,最好的办法是将操作作为参数传入,也就是通过委托把我们写得操作方法传入给迭代逻辑。.NET自带了三种委托的包装:

namespace System
{
    public delegate void Action<T>(T obj);
    public delegate bool Predicate<T>(T obj);
    public delegate TResult Func<T, TResult>(T arg);
}

而LINQ就是通过传入这些委托来实现各种操作,比如上一条中我们举的例子,如果改用LINQ:

var nums = new int[] { -1, 2, -3, 4, 5 };

// 会输出4,8,10
foreach (var num in nums.Where(n => n > 0).Select(n => n * 2))
{
    Console.WriteLine(num);
}

Where()用于筛选,因此我们传入n => n > 0这个lambda表达式(编译器会自动帮我们转换成Func<bool, int>),可以得到筛选正数的序列。Select()相当于投影、映射,我们传入n => n * 2(编译器会自动帮我们转换成Func<int, int>)就可以得到将序列元素分别乘2的结果序列。下次如果要改成是获得数字的字符串形式,那就改成往Select()里传入n => n => n.ToString()Func<int, string>)就好了。我们编写迭代操作代码的时候,也最好像这样把迭代和操作逻辑解耦,更加灵活,也更加清晰。

第33条 等真正用到序列中的元素再去生成

我觉得这条是凑数的……其实就是第29条说的,优先考虑提供迭代器方法,而不要直接返回集合。通过yield return,就是用到的时候逐步生成元素,对提高性能和减少内存使用都有帮助。

第34条 考虑通过函数参数来放松耦合关系

说的和第32条类似,也就是考虑把操作通过委托传进来,实现解耦。如果直接把操作逻辑写死,那么耦合性太强,没法复用;或者提供接口也可以交由用户去自定义操作,但是这样相对起来实现麻烦,而且继承接口更倾向于是实现一个承诺(比如我的类实现了IEquatable<T>接口则是承诺我这个类可以进行相等判断)。至于像集合的RemoveAll()方法,我只是想操作删除掉某些特定的元素而已,我只要把判断方法作为参数传入即可,这样最灵活。

但这样的不足之处就是开发工具能提供的帮助会较少,而且我们得自行判断传入参数的合法性等问题,例如判断是否为null,因此需要开发者自行保证这种灵活设计能够正常运作。

第35条 绝对不要重载扩展方法

这里的重载指的是在不同命令空间中实现同一个类的的相同签名的扩展方法,比如有一个Person类,我想输出名字信息,我可能在ConsoleExtensions命名空间里实现了Format()扩展方法,将名字信息输出到控制台。而后来我又想输出成xml格式,我就在XmlExtensions命名空间里实现了另一个Format()扩展方法,通过切换引入的命名空间来决定引用的是哪个扩展方法。这样是绝对错误的!因此太容易因为不小心导致引入错了命名空间,导致使用了错的扩展方法,而且还很可能看不出来。因此完全可以将这两个方法放在同一个命名空间同一个类夏,然后改用不同的名字就好,比如FormatAsText()FormatAsXml(),这样又能保证不会出错,看上去也更加清晰,一眼就知道输出的格式。

另外作者还认为这样的输出功能根本就不是对Person类的扩展,因为输出什么格式是外部决定的而不是Person类自己决定的。而扩展方法应该是实现从道理上讲应该是类型自己的一部分的功能,所以这个输出方法不应该作为Person类的扩展方法实现。 但我个人觉得没啥道理……如果换我实现的话肯定会倾向于将两个方法实现成名字不同的扩展方法,就像微软提供的HttpContent类,自身就带了方法ReadAsStringAsync(),同时也还实现了一个扩展方法ReadAsJsonAsync(),这不是就跟作者说的冲突了吗。所以我觉得主要还是要避免不同命名空间实现相同签名的扩展方法。

第36条 理解查询表达式与方法调用之间的映射关系

待续……

如果觉得我的文章对你有用,请随意赞赏