《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++;
    }
  }

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

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

  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版)》 比爾·瓦格納

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章