《Effective C#》笔记(4)

我与影子孤独终老i 提交于 2021-02-02 00:38:24

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

在创建这种返回一系列对象的方法时,应该考虑将其写成迭代器方法,使得调用者能够更为灵活地处理这些对象。 迭代器方法是一种采用yield return语法来编写的方法,采用按需生成(generate-as-needed)的策略,它会等到调用方请求获取某个元素的时候再去生成序列中的这个元素。 类似下面这个简单的迭代器方法,用来生成从0到9的int序列:

public static IEnumerable<int> GetIntList()
  {
    var start = 0;
    while (start<10)
    {
      yield return start;
      start++;
    }
  }

对于这样的写法,编译器会用特殊的办法处理它们。然后在调用端使用方法的返回结果时,只有真正使用这个元素时才会生成,这对于较大的序列来说,优势是很明显的。

那么有没有哪种场合是不适宜用迭代器方法来生成序列的?比方说,如果该序列要反复使用,或是需要缓存起来,那么还要不要编写迭代器方法了? 整体来说,对于集合的使用,可能有两种情况:

  1. 只需在真正用到的时候去获取
  2. 为了让程序运行得更为高效,调用方需要一次获取全部元素

为了兼顾这两种场景,.net类库的处理方法,为IEnumerable<T>提供了ToList()与ToArray(),这两个方法就会根据所表示的序列自行获取其中的元素,并将其保存到集合中。 所以建议任何时候都提供迭代器方法,然后在需要一次性获取全部元素时,再采用逐步返回序列元素的迭代器方法,以同时应对两种情况。

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

C#刚开始就是一门命令式的语言,在后续的发展过程中,也依然了纳入很多命令式语言应有的特性。开发者总是习惯使用手边最为熟悉的工具(因此特别容易采用循环结构来完成某些任务),然而熟悉的工具未必就是最好的。编写循环结构时,总是应该想想能不能改用查询语句或查询方法来实现相同的功能。

查询语句使得开发者能够以更符合声明式模型(declarative model)而非命令式模型(imperative model)的写法来表达程序的逻辑。 与采用循环语句所编写的命令式结构相比,查询语句(也包括实现了查询表达式模式(query expression pattern)的查询方法)能够更为清晰地表达开发者的想法。

比如说要把横、纵坐标均位于0~99之间的所有整数点(X,Y)生成出来,用命令式写法会用到这样的双层循环:

public static IEnumerable<Tuple<int, int>> ProduceIndices()
{
  for (var i = 0; i < 100; i++)
  {
    for (int j = 0; j < 100; j++)
    {
      yield return Tuple.Create(i, j);
    }
  }
}

声明式写法则是这样的:

public static IEnumerable<Tuple<int, int>> QueryIndices()
{
  return
    from x in Enumerable.Range(0, 100)
    from y in Enumerable.Range(0, 100)
    select Tuple.Create(x, y);
}

表面上看两者在代码了、可读性方面差异不大,但命令式写法过分关注了执行的细节。而且在需求变复杂后,声明式写法仍然可以保持简洁,假设增加了要求:把这些点按照与原点之间的距离做降序排列,两种写法的差异就变得很明显了:

public static IEnumerable<Tuple<int, int>> ProduceIndices1()
{
  var storage = new List<Tuple<int, int>>();
  for (var i = 0; i < 100; i++)
  {
    for (int j = 0; j < 100; j++)
    {
      storage.Add(Tuple.Create(i, j));
    }
  }
  
  storage.Sort((point1, point2)=>
    (point2.Item1*point2.Item1+point2.Item2*point2.Item2)
    .CompareTo(point1.Item1*point1.Item1+point1.Item2*point1.Item2));

  return storage;
}

public static IEnumerable<Tuple<int, int>> QueryIndices1()
{
  return
    from x in Enumerable.Range(0, 100)
    from y in Enumerable.Range(0, 100)
    orderby (x * x + y * y) descending
    select Tuple.Create(x, y);
}

可见命令式的模型很容易过分强调怎样去实现操作,而令阅读代码的人忽视这些操作本身是打算做什么的。 还有一种观点是认为通过查询机制实现出来的代码是不是要比用循环写出来的慢一些,确实存在一些情况会出现这个问题,但这种特例并不代表一般的规律。如果怀疑查询式的写法在某种特定情况下运行得不够快,那么应该首先测量程序的性能,然后再做论断。即便确实如此,也不要急着把整个算法都重写一遍,而是可以考虑利用并行化的(parallel)LINQ机制,因为使用查询语句的另一个好处在于可以通过.AsParallel()方法来并行地执行这些查询。

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

有时会对集合做一些变换,甚至会有多种变换,如果用循环来做,可以分多轮循环来做,但这样做内存占用较高;或者可以在一轮循环中完成所有的变换步骤,但这样做的话又不便于复用。 这时使用基于IEnumerable的声明式语法往往是更好的选择。 比如要输出一个序列中不重复的值,用命令式可以实现为:

public static void Unique(IEnumerable<int> nums)
{
  var unique=new HashSet<int>();
  foreach (var num in nums)
  {
    if (!unique.Contains(num))
    {
      unique.Add(num);
      Console.WriteLine(num);
    }
  }
}

用声明式的实现则可以是:

public static IEnumerable<int> Unique2(IEnumerable<int> nums)
{
  var unique=new HashSet<int>();
  foreach (var num in nums)
  {
    if (!unique.Contains(num))
    {
      unique.Add(num);
      yield return num;
    }
  }
}

foreach (var num in Unique2(nums))
{
  Console.WriteLine(num);
}

后者看起来更繁琐,但后者有两个很大的好处。首先,它推迟了每一个元素的求值时机,更为重要的是,这种延迟执行机制使得开发者能够把很多个这样的操作拼接起来,从而可以更为灵活地复用它们。 比方说,如果要输出的不是源序列中的每一种数值而是这些数值的平方:

public static IEnumerable<int> Square(IEnumerable<int> nums)
{
  foreach (var num in nums)
  {
    yield return num * num;
  }
}

调用时改为:

foreach (var num in Square(Unique2(nums)))
{
  Console.WriteLine(num);
}

这样把复杂的算法拆解成多个步骤,并把每个步骤都表示成这种小型的迭代器方法,然后借助延迟执行机制,就可以将这些方法拼成一条管道,使得程序只需把源序列处理一遍即可对其中的元素执行许多种小的变换。

掌握尽早执行与延迟执行之间的区别

尽早执行与延迟执行可以对应于命令式的代码(imperative code)与声明式的代码(declarative code),前者重在详细描述实现该结果所需的步骤,而后者则重在把执行结果定义出来。 命令式的代码

var answer = DoStuff(Method1()
  ,Method2()
  ,Method3());

声明式的代码

var answer = DoStuff(()=>Method1()
  ,()=>Method2()
  ,()=>Method3());

在上面DoStuff的两种实现中,命令式代码的执行顺序为:Method1->Method2->Method3->DoStuff; 而声明式代码只是将三个lambda传到DoStuff方法,然后方法内部在需要的时候再单独调用各自的方法,甚至有的方法不会被调用到。 在函数没有副作用的前提下,两种写法的结果是相同的。但如果函数有副作用,那么两种写法的结果可能就不一样了。 标准函数是否会产生副作用,既要考虑函数本身的代码,又要考虑其返回值是否会变化,如果方法还带有参数,那么参数也是需要考虑的。

在两种写法可以得出相同结果的前提下,使用那个更好呢?要回答这个问题要考虑多方面的因素。 其中一个问题是要考虑用作输入值与输出值的那些数据所占据的空间,并将该因素与计算输出值所花费的时间相权衡,在有些情况下更关心空间,在另一些情况写更关心时间,实际工作中更多的情况或许介于两极之间,因此答案往往不是唯一的。 **然后,还要考虑自己会怎样使用计算出来的结果。**如果方法的结果比较固定,而且使用得较为频繁,那么及早求出查询结果是合理的;而如果查询结果只是会偶尔才会用到,那么更适合采用惰性求值的方式。 最后一条判断标准是看这个方法要不要放在远程数据库上面执行,LINQ to SQL需要将代码解析表达式树,采用及早求值还是惰性求值会对LINQ to SQL处理查询请求的方式产生很大影响,这时应优先考虑惰性求值方式。

注意IEnumerable与IQueryable形式的数据源之间的区别

IEnumerable<T>与IQueryable<T>看起来功能似乎相同,而且IQueryable继承自IEnumerable,但实际上两者的行为是有所区别的,而且这种区别可能会极大地影响程序的性能。 比如下面这两条针对db的查询语句

var q = from c in dbContext.Customer
        where c.City == "London"
        select c;
var finalAnswer = from c in q
        order by c.Name
        select c;
var q = (from c in dbContext.Customer
        where c.City == "London"
        select c).AsEnumerable();
var finalAnswer = from c in q
        order by c.Name
        select c;

第一种写法采用的是IQueryable<T>所内置的LINQ to SQL机制,而第二种写法则是把数据库对象强制转为IEnumerable形式的序列,并把排序等工作放在本地完成。 LINQ to SQL会把相关的查询操作以及where子句与orderby子句合起来执行,只需向数据库发出一次调用即可。 第二种写法则把经过where子句所过滤的结果转成IEnumerable<T>型的序列,然后并采用LINQ toObjects机制来完成后续的操作,排序操作是在本地而不是在远端执行的。

可见采用IQueryable更有优势,但并不是所有的数据源都实现了IQueryable,为此,可以用AsQueryable()把IEnumerable<T>试着转换成IQueryable<T>。 AsQueryable()会判断序列的运行期类型,如果是IQueryable型,那就把该序列当成IQueryable返回。若是IEnumerable型,则会用LINQ toObjects的逻辑来创建一个实现IQueryable的wrapper(包装器),所以使用AsQueryable()来编写代码可以同时顾及这两种情况。

用Single()及First()来明确地验证你对查询结果所做的假设

有许多查询操作其实就是为了查找某个纯量值而写的。如果你要找的正是这样的一个值,那么最好能够设法直接查出该值,而不要返回一个仅含该值的序列。 这些操作同时还具有对查询结果所做的假设进行验证的功能:

  • Single:只会在有且仅有一个元素合乎要求时把该元素返回给调用方,如果没有这样的元素,或是有很多个这样的元素,那么它就抛出异常
  • SingleOrDefault:要么查不到任何元素,要么只能查到一个元素
  • First:从序列中取第一个元素,序列为空则抛出异常
  • FirstOrDefault:序列为空时返回null

但有时想找的那个元素未必总是序列中的第一个元素,此时可以重新安排元素顺序,使得你想找的那个元素恰好出现在序列开头;或者可以使用Skip跳转到这个位置,再用First获取。

参考书籍

《Effective C#:改善C#代码的50个有效方法(原书第3版)》 比尔·瓦格纳

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!