優先考慮提供迭代器方法,而不要回傳集合
在創建這種回傳一系列物件的方法時,應該考慮將其寫成迭代器方法,使得呼叫者能夠更為靈活地處理這些物件,
迭代器方法是一種采用yield return語法來撰寫的方法,采用按需生成(generate-as-needed)的策略,它會等到呼叫方請求獲取某個元素的時候再去生成序列中的這個元素,
類似下面這個簡單的迭代器方法,用來生成從0到9的int序列:
public static IEnumerable<int> GetIntList()
{
var start = 0;
while (start<10)
{
yield return start;
start++;
}
}
對于這樣的寫法,編譯器會用特殊的辦法處理它們,然后在呼叫端使用方法的回傳結果時,只有真正使用這個元素時才會生成,這對于較大的序列來說,優勢是很明顯的,
那么有沒有哪種場合是不適宜用迭代器方法來生成序列的?比方說,如果該序列要反復使用,或是需要快取起來,那么還要不要撰寫迭代器方法了?
整體來說,對于集合的使用,可能有兩種情況:
- 只需在真正用到的時候去獲取
- 為了讓程式運行得更為高效,呼叫方需要一次獲取全部元素
為了兼顧這兩種場景,.net類別庫的處理方法,為IEnumerable
所以建議任何時候都提供迭代器方法,然后在需要一次性獲取全部元素時,再采用逐步回傳序列元素的迭代器方法,以同時應對兩種情況,
優先考慮通過查詢陳述句來撰寫代碼,而不要使用回圈陳述句
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
比如下面這兩條針對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會把相關的查詢操作以及where子句與orderby子句合起來執行,只需向資料庫發出一次呼叫即可,
第二種寫法則把經過where子句所過濾的結果轉成IEnumerable
可見采用IQueryable更有優勢,但并不是所有的資料源都實作了IQueryable,為此,可以用AsQueryable()把IEnumerable
AsQueryable()會判斷序列的運行期型別,如果是IQueryable型,那就把該序列當成IQueryable回傳,若是IEnumerable型,則會用LINQ toObjects的邏輯來創建一個實作IQueryable的wrapper(包裝器),所以使用AsQueryable()來撰寫代碼可以同時顧及這兩種情況,
用Single()及First()來明確地驗證你對查詢結果所做的假設
有許多查詢操作其實就是為了查找某個純量值而寫的,如果你要找的正是這樣的一個值,那么最好能夠設法直接查出該值,而不要回傳一個僅含該值的序列,
這些操作同時還具有對查詢結果所做的假設進行驗證的功能:
- Single:只會在有且僅有一個元素合乎要求時把該元素回傳給呼叫方,如果沒有這樣的元素,或是有很多個這樣的元素,那么它就拋出例外
- SingleOrDefault:要么查不到任何元素,要么只能查到一個元素
- First:從序列中取第一個元素,序列為空則拋出例外
- FirstOrDefault:序列為空時回傳null
但有時想找的那個元素未必總是序列中的第一個元素,此時可以重新安排元素順序,使得你想找的那個元素恰好出現在序列開頭;或者可以使用Skip跳轉到這個位置,再用First獲取,
參考書籍
《Effective C#:改善C#代碼的50個有效方法(原書第3版)》 比爾·瓦格納
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/255790.html
標籤:C#
