《Effective C#》筆記(4) - Linq

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

在建立這種返回一系列對象的方法時,應該考慮將其寫成迭代器方法,使得調用者可以更爲靈活地處理這些對象。
迭代器方法是一種採用yield return語法來編寫的方法,採用按需生成(generate-as-needed)的策略,它會等到調用方請求獲取某個元素的時候再去生成序列中的這個元素。
相似下面這個簡單的迭代器方法,用來生成從0到9的int序列:算法

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

對於這樣的寫法,編譯器會用特殊的辦法處理它們。而後在調用端使用方法的返回結果時,只有真正使用這個元素時纔會生成,這對於較大的序列來講,優點是很明顯的。數據庫

那麼有沒有哪一種場合是不適宜用迭代器方法來生成序列的?比方說,若是該序列要反覆使用,或是須要緩存起來,那麼還要不要編寫迭代器方法了?
總體來講,對於集合的使用,可能有兩種狀況:express

  1. 只需在真正用到的時候去獲取
  2. 爲了讓程序運行得更爲高效,調用方須要一次獲取所有元素

爲了兼顧這兩種場景,.net類庫的處理方法,爲IEnumerable 提供了ToList()與ToArray(),這兩個方法就會根據所表示的序列自行獲取其中的元素,並將其保存到集合中。
因此建議任什麼時候候都提供迭代器方法,而後在須要一次性獲取所有元素時,再採用逐步返回序列元素的迭代器方法,以同時應對兩種狀況。
api

優先考慮經過查詢語句來編寫代碼,而不要使用循環語句

C#剛開始就是一門命令式的語言,在後續的發展過程當中,也依然了歸入不少命令式語言應有的特性。開發者老是習慣使用手邊最爲熟悉的工具(所以特別容易採用循環結構來完成某些任務),然而熟悉的工具未必就是最好的。編寫循環結構時,老是應該想一想能不能改用查詢語句或查詢方法來實現相同的功能。緩存

查詢語句使得開發者可以以更符合聲明式模型(declarative model)而非命令式模型(imperative model)的寫法來表達程序的邏輯。
與採用循環語句所編寫的命令式結構相比,查詢語句(也包括實現了查詢表達式模式(query expression pattern)的查詢方法)可以更爲清晰地表達開發者的想法。app

好比說要把橫、縱座標均位於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()方法來並行地執行這些查詢。.net

把針對序列的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 與IQueryable 看起來功能彷佛相同,並且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 所內置的LINQ to SQL機制,而第二種寫法則是把數據庫對象強制轉爲IEnumerable形式的序列,並把排序等工做放在本地完成。
LINQ to SQL會把相關的查詢操做以及where子句與orderby子句合起來執行,只需向數據庫發出一次調用便可。
第二種寫法則把通過where子句所過濾的結果轉成IEnumerable 型的序列,而後並採用LINQ toObjects機制來完成後續的操做,排序操做是在本地而不是在遠端執行的。

可見採用IQueryable更有優點,但並非全部的數據源都實現了IQueryable,爲此,能夠用AsQueryable()把IEnumerable 試着轉換成IQueryable
AsQueryable()會判斷序列的運行期類型,若是是IQueryable型,那就把該序列當成IQueryable返回。如果IEnumerable型,則會用LINQ toObjects的邏輯來建立一個實現IQueryable的wrapper(包裝器),因此使用AsQueryable()來編寫代碼能夠同時顧及這兩種狀況。

用Single()及First()來明確地驗證你對查詢結果所作的假設

有許多查詢操做其實就是爲了查找某個純量值而寫的。若是你要找的正是這樣的一個值,那麼最好可以設法直接查出該值,而不要返回一個僅含該值的序列。
這些操做同時還具備對查詢結果所作的假設進行驗證的功能:

  • Single:只會在有且僅有一個元素合乎要求時把該元素返回給調用方,若是沒有這樣的元素,或是有不少個這樣的元素,那麼它就拋出異常
  • SingleOrDefault:要麼查不到任何元素,要麼只能查到一個元素
  • First:從序列中取第一個元素,序列爲空則拋出異常
  • FirstOrDefault:序列爲空時返回null

但有時想找的那個元素未必老是序列中的第一個元素,此時能夠從新安排元素順序,使得你想找的那個元素剛好出如今序列開頭;或者可使用Skip跳轉到這個位置,再用First獲取。

參考書籍

《Effective C#:改善C#代碼的50個有效方法(原書第3版)》 比爾·瓦格納

相關文章
相關標籤/搜索