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

語言: CN / TW / HK

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

在建立這種返回一系列物件的方法時,應該考慮將其寫成迭代器方法,使得呼叫者能夠更為靈活地處理這些物件。 迭代器方法是一種採用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版)》 比爾·瓦格納

分享到: