C# 程序員易犯的 10 個錯誤
關于C#
C#是針對微軟公共語言運行庫(CLR)的開發語言之一。針對CLR的開發語言得益于如跨語言集成的性能,異常處理,安全性增強,組件交互的簡化模型,調試和分析服務。對于今日的CLR來說,C#是定位到Windows桌面,移動設備或服務器環境中,在處理復雜,專業的開發項目方面使用最廣泛的開發語言。
C#是面相對象,強類型的語言。C#中嚴格的類型檢查,在編譯和運行時,使得典型的編程錯誤能盡早報告,并且能精準給出錯誤位置。這能幫助程序員節省很多時間,相比于跟蹤那些可以發生在違規操作很長時間之后的令人費解的錯誤,類型安全的執行更加自由。但是,許多程序員不知不覺地(或不經意地)丟棄了這種檢測的好處,引出了一些在本文中討論的問題。
關于本文
本文描述了C#程序員最常見的10個編程錯誤或者要避免的缺陷。
大多數在本文中討論的錯誤是特定于C#的,有些也涉及到CLR或者利用框架類庫(FCL)的其他語言。
常見錯誤1:像值類型一樣使用引用類型或者相反
C++程序員,和其他許多編程語言的程序員,習慣于把他們分配給變量的是否只是值或者對已存在對象的引用置于掌控之中。但在C#中,這個是由寫這個對象的程序員,而不是由實例化對象并給它賦值的程序員決定的。這對于C#新手來說是一個常見的“騙到你了”的實例。
如果你不知道你使用的對象是值類型還是引用類型,你可能會碰到驚喜。例如:
Point point1 = new Point(20, 30); Point point2 = point1; point2.X = 50; Console.WriteLine(point1.X); // 20 (does this surprise you ) Console.WriteLine(point2.X); // 50 Pen pen1 = new Pen(Color.Black); Pen pen2 = pen1; pen2.Color = Color.Blue; Console.WriteLine(pen1.Color); // Blue (or does this surprise you ) Console.WriteLine(pen2.Color); // Blue
正如你所看到的, Point
和 Pen
對象是以完全相同的方式創建的。但 point1
在一個新的 X
坐標值設置到 point2
后保持不變,而 pen1
的值在一個新的顏色值設置到 pen2
后被改變了。因此我們可以推斷 point1
和 point2
分別包含 Point
對象的拷貝,而 pen1
和 pen2
包含對同一個 Pen
對象的引用。但我們怎么能不通過這個實驗而知道結果呢?
答案是查看對象類型的定義(在Visual Studio中你可以很容易得把你的光標放在對象類型名上并點擊F12):
public struct Point { … } // defines a “value” type public class Pen { … } // defines a “reference” type
正如上面所示,在C#中,關鍵字struct
是用于定義值類型,同時關鍵字class
是用來定義引用類型的。對于有C++編程背景的程序員,對C++和C#有許多相似的關鍵字而誤認為安全的話,這種行為可能會是一個驚喜。
如果你要依賴某些因值類型和引用類型的不同而產生的行為—-比如傳遞一個對象作為方法參數并且在方法中改變該對象的狀態這種行為—一定要確保你處理的是正確的對象類型。
常見錯誤2:誤解未初始化變量的默認值
在C#中,值類型不能為null。通過定義,值類型會有一個值,甚至沒有初始化的值類型變量也必須有個值。這稱為值類型的默認值。這會導致當檢查一個變量是否初始化時不可預期的結果,如下所示:
class Program { static Point point1; static Pen pen1; static void Main(string[] args) { Console.WriteLine(pen1 == null); // True Console.WriteLine(point1 == null); // False (huh ) } }
為什么 point1
不是null?答案是 Point
是值類型,并且 Point
的默認值是(0,0),而不是null。沒有認識到這點是C#中一個易犯(并且常見)的錯誤。
許多(但不是全部)值類型都有一個 IsEmpty 屬性,你可以用這個屬性來檢查該值類型是否等于它的默認值:
Console.WriteLine(point1.IsEmpty); // True
當你去檢查一個變量是否被初始化,確保你知道那個類型未被初始化的變量將有的默認值并且不依賴它為null。
常見錯誤3:使用不合適或者未特別指定的字符串比較方法
在C#中有許多不同的方法比較字符串。
盡管許多程序員用 == 操作符來比較字符串,但其實這是許多方法中最不理想的方法之一,主要是因為它在代碼中沒有明確指明需要哪一種比較。
相反,在C#中測試字符串想等的首選方式是使用 Equals 方法:
public bool Equals(string value); public bool Equals(string value, StringComparison comparisonType);
第一個方法的簽名(例如,沒有 comparisonType
參數),實際上和使用 ==
操作符完全一樣,但具有對字符串明確化的好處。它執行一個字符串的序號比較,基本上就是字節與字節比較。在許多情況下,這正是你想要的比較類型,特別是當比較的字符串的值參數化,例如文件名,環境變量,屬性等等。在這種情況下,只要順序比較的確是這種情況下正確的類型比較即可,唯一的缺點是使用沒有 comparisonType
的 Equals
方法,會使得某些讀你代碼的人不知道你用什么比較類型做的比較。
使用帶 comparisonType
參數的 Equals
方法,你每次比較字符串的時候,雖說,不光會使得你的代碼更清晰,而且會使你明確你需要使用的比較類型。這是值得做的事情,因為盡管英語在按順序比較與語言區域性比較之間沒什么差異,但其他語言提供了很多,而忽略其他語言的可能性則為你自己在未來的路上提供了犯很多錯誤的可能。例如:
string s = "strasse"; // outputs False: Console.WriteLine(s == "stra?e"); Console.WriteLine(s.Equals("stra?e")); Console.WriteLine(s.Equals("stra?e", StringComparison.Ordinal)); Console.WriteLine(s.Equals("Stra?e", StringComparison.CurrentCulture)); Console.WriteLine(s.Equals("stra?e", StringComparison.OrdinalIgnoreCase)); // outputs True: Console.WriteLine(s.Equals("stra?e", StringComparison.CurrentCulture)); Console.WriteLine(s.Equals("Stra?e", StringComparison.CurrentCultureIgnoreCase));
最安全的實踐是總是為 Equals
方法提供一個 comparisonType
參數。這是一些基本準則:
- 當比較有用戶輸入的字符串,或者將顯示給用戶的字符串,使用本地化比較(
CurrentCulture
或者CurrentCultureIgnoreCase
)。 - 當比較程序設計用的字符串,使用原始比較(
Ordinal
或者OrdinalIgnoreCase
)。 InvariantCulture
和InvariantCultureIgnoreCase
通常并不使用,除非在受限的條件下,因為原始比較更加有效。如果本地性文化比較是必須的話,它應該基于當前文化或另一個明確的文化來執行。
此外,對于 Equals
方法來說,字符串也提供了 Compare
方法,用來給你提供關于字符串相對順序信息而不僅僅測試是否相等。這個方法更適用 <
, <=
, >
和>=
操作符,與上文討論的原因相同。
常見錯誤4:使用迭代(而不是聲明)來操作集合
在C# 3.0中,LINQ(Language-Integrated Query)的引入永遠改變了集合的查詢和修改操作。自那以后,當你使用迭代式操作集合,而不是使用LINQ的時候,其實你也許應該使用后者。
一些C#程序員甚至不知道LINQ的存在,但慶幸的是這個數字正在逐步減少。但因為LINQ的關鍵字和SQL的語句的相似性,很多人還是誤以為LINQ只用于數據庫的查詢中。
雖然數據庫的查詢操作是LINQ的一個非常常用的功能,但是它同樣適用于各種枚舉的集合(例如,任何實現了IEnumerable 接口的對象)。舉例來說,如果你有一個Accounts類型的數組,不要寫成:
decimal total = 0; foreach (Account account in myAccounts) { if (account.Status == "active") { total += account.Balance; } }
你只要寫成:
decimal total = (from account in myAccounts where account.Status == "active" select account.Balance).Sum();
雖是一個簡單的例子,在有些情況下,一個單一的LINQ語句可以輕易地替換你代碼中一個迭代循環(或嵌套循環)里的幾十條語句。更少的代碼通常意味著更少產生bugs的機會。然而,記住在性能方面可能要權衡一下。在性能決定的情況下,特別是當你的迭代代碼能對你的集合進行假設而LINQ做不到的時候,確保在兩種方法間做一個性能比較。
常見錯誤5:沒有考慮LINQ語句中底層對象
對于處理抽象操作集合LINQ是強大的,無論它們是內存的對象,數據庫表,或者XML文檔。在完美的世界中,你無須考慮底層對象是什么。但這里的錯誤是假設我們生活在一個完美的世界中。事實上,當用完全相同的數據時相同的LINQ語句能返回不同的結果,如果這個數據以不同的格式給出的話。
例如,考慮如下語句:
decimal total = (from account in myAccounts where account.Status == "active" select account.Balance).Sum();
如果其中一個對象的 account.Status
等于 “Active”(注意大寫A)會發生什么?好,如果myAccounts
是一個 DbSet
的對象(默認設置了不區分大小寫的配置), where
表達式仍會匹配該元素。但是,如果 myAccounts
是在內存中的數組,那么它將不匹配,并將產生不同的總的結果。
等一下,在我們之前討論字符串比較過程中,我們發現 ==
操作符進行了字符串的順序比較。那么,為什么在這個條件下, ==
操作符表現出不區分大小寫的比較呢?
答案是,當在LINQ語句中的底層對象都引用到SQL表中的數據(如在這個例子中,實體框架為DbSet對象的情況下),該語句被轉換為一個T-SQL語句。操作符遵循T-SQL的規則,而不是C#的,所以在上述情況的比較中不區分大小寫。
通常來說,盡管LINQ是一個有用的和以持續的方式查詢對象的集合,但在現實中你仍然需要知道你的語句是否會被解釋成頂著C#的帽子的其他類型的語句,以確保你代碼的功能在運行時仍如預期的那樣。
常見錯誤6:對擴展方法感到困惑或被欺騙
正如之前提到的,LINQ語句依賴于任何實現了IEnumerable 接口的對象。比如,下面的簡單函數將賬戶上任何集合的余額相加:
public decimal SumAccounts(IEnumerable<Account> myAccounts) { return myAccounts.Sum(a => a.Balance); }
在上面的代碼中,myAccounts參數的類型被聲明為IEnumerable<Account>
。myAccounts
引用了一個 Sum
方法(C#使用類似”dot notation”引用類或者接口中的方法),我們期望在 IEnumerable<T>
接口中定義一個 Sum()
方法。但是, IEnumerable<T>
沒有為 Sum
方法提供任何引用并且只有如下所示的簡單定義:
public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); }
那么 Sum()
方法在哪里定義的呢?C#是強類型語言,因此如果 Sum
方法的引用是無效的,C#編譯器就會將其標示為錯誤。我們知道它必須存在,但是在哪呢?此外,LINQ提供的供查詢和聚集集合的所有方法定義在哪里呢?
答案是 Sum()
并不是定義在 IEnumerable
接口內的方法。而是一個定義在 System.Linq.Enumerable
類中的靜態方法(叫做”擴展方法”):
namespace System.Linq { public static class Enumerable { ... // the reference here to “this IEnumerable<TSource> source” is // the magic sauce that provides access to the extension method Sum public static decimal Sum<TSource>(this IEnumerable<TSource> source, Func<TSource, decimal> selector); ... } }
那么擴展方法和其他靜態方法有什么不同之處,是什么確保我們可以在其他類訪問它呢?
擴展方法的顯著特點是,第一個形參前的 this
修飾符。這就是編譯器知道它是一個擴展方法的“奧妙”。它所修飾的參數類型(在這里是IEnumerable<TSource>
)說明這個類或者接口將會實現這個方法。
(另外需要說明的一點,定義擴展方法的IEnumerable
接口和Enumerable
類的名字間的相似性并沒有什么可奇怪的。這種相似性僅是隨意的風格。)
理解了這一點后,我們可以看到上面介紹的 sumAccounts
方法可以用下面的方法來實現:
public decimal SumAccounts(IEnumerable<Account> myAccounts) { return Enumerable.Sum(myAccounts, a => a.Balance); }
事實是我們可能已經以這種方式實現它了,而不是問為什么要有擴展方法呢?擴展方法本質上是C#語言的一種便利方式,它允許你對已存在的類型“添加”方法,而無須創建一個新的派生類型,重新編譯或者修改原類型代碼。
擴展方法通過在文件頭部添加 using [namespace];
而引入作用域。你需要知道你尋找的擴展方法所在的命名空間,但一旦你知道你要找什么的時候這就變得非常簡單了。
當C#編譯器遇到一個對象實例調用一個方法,并且該方法沒有定義在引用對象類中時,它就會搜尋所有定義在作用域中的擴展方法以尋找相匹配的方法簽名和類。如果它找到了,它就會把實例的引用作為第一個參數傳給擴展方法,如果有其他參數的話,再把它們傳遞給擴展方法。(如果C#編譯器在作用域中沒有找到任何相符合的擴展方法,它就會拋出異常。)
對于C#編譯器來說,擴展方法是個“語法糖”,(大多數情況下)使得我們把代碼寫得更清晰,更易于維護。顯然,前提是你知道它們的用法。否則,它會讓人感覺比較困惑,尤其是一開始的時候。
使用擴展方法確實有優勢,但也讓不了解它或者不能很好理解它的開發者感到頭疼,還浪費時間。尤其是看網上代碼示例的時候,或者任何其它事先寫好的代碼的時候。當這些代碼發生編譯錯誤(因為它調用了顯然沒被定義在調用類中的方法),傾向是認為代碼是否應用于類庫的不同版本,或者是不同的類庫。很多時間都會花在尋找新版本上,或者被認為“丟失”的類庫上。
在擴展方法的名字和類中的名字一樣,但只是在方法簽名上有微小差異的時候,即使對擴展方法熟悉的開發者偶爾也可能犯上面的錯誤。很多時間會花在尋找不存在的類型或者錯誤上。
使用C#類庫的擴展方法變得越來越普遍了。除了LINQ,另外兩個出自微軟被廣泛使用的類庫Unity Application Block和Web API framework也應用了擴展方法,并且還有其它也應用的。框架越新,它就越可能使用擴展方法。
當然你也可以寫自己的擴展方法。但必須意識到擴展方法看上去和其它實例方法一樣,但這只是假象。事實上,你的擴展方法不能引用它擴展的類的私有成員變量或者保護成員變量,并且不能被當做傳統類的完全替代品。
常見錯誤7:對手上的任務使用錯誤的集合類型
C#提供了大量的集合對象,下面只列出其中的一部分:
Array, ArrayList, BitArray, BitVector32, Dictionary<K,V>, HashTable, HybridDictionary, List<T>,NameValueCollection, OrderedDictionary, Queue, Queue<T>, SortedList, Stack, Stack<T>, StringCollection,StringDictionary
有這樣的情況,太多的選擇和沒有選擇一樣糟糕。但這種情況不適用于集合對象。數量眾多的選擇當然對你有益。花一些時間提前研究一下集合類型,以便選擇一個你需要的集合類型。這樣可能性能更好,更少出錯。
如果有一個集合類型和你操作的類型一樣(String或bit),你最好使用它。當指定具體的元素類型時,集合更有效率。
為了利用C#類型安全特性,通常你應該選擇泛型接口而不是非泛型的。泛型接口的元素是當你聲明對象的時候指定的類型,而非泛型接口中的元素是對象類型的。當使用非泛型接口時,C#編譯器不能對你的代碼做類型檢查。同樣,當你操作原生類型集合的時候,使用非泛型接口會導致對這些類型頻繁得進行裝箱/拆箱操作,和使用了合適類型的泛型集合相比,這么做會帶來明顯的負面的性能影響。
另一個常見的陷阱是你自己創建集合對象。并不是說永遠不要這么做,但是和.NET提供的廣泛使用的集合類型相比,通過使用或擴展已存在的集合類型,你可能節省下大量的時間,勝于重復造輪子。特別是,C#的C5 Generic Collection Library和CLI提供了很多額外的集合類型,例如持久化樹形數據結構,基于堆的優先級隊列,哈希索引的數組列表,鏈表和更多。
常見錯誤8:忽略資源釋放
CLR運行環境才用一個垃圾收集器,所以你不要顯式釋放已創建的任何對象所占用的內存。事實上,你也不能這么做。C#中沒有和C++delete
對應的運算符或者C中free()
對應的方法。但這并不意味著在你可以忽略所有你使用過的對象。許多對象類型封裝了一些其他類型的系統資源(例如,磁盤文件,數據連接,網絡端口等等)。保持這些資源處于使用狀態會很快耗盡系統資源,降低性能并最終導致程序出錯。
雖然析構方法可以定義在任何一個C#的類中,但是析構方法(C#中也叫終結器)的問題是你不能確定他們什么時候將被調用。在未來一個不確定的時間它們被垃圾回收器調用(在一個異步線程中,可能會引發額外的并發)。試圖避免這種由垃圾回收器所強制調用的 GC.Collect()
并不是一個好的實踐,因為在垃圾回收器回收適合回收的對象時,這么做會導致在不可預知的時間內阻塞進程。
這并不是使用終結器沒好處,但顯式得釋放資源并不是其中之一。更確切地說,當你操作文件,網絡端口或者數據庫連接的時候,當你不再使用它們的時,你應該顯式釋放這些資源。
資源泄露在幾乎任何環境中都會引起關注。但是,C#使用了一種健壯的機制,使得資源的使用變得簡單,如果使用合理的話,會使資源泄露極少發生。.NET框架定義了IDisposable
接口,僅由Dispose()
構成。任何實現了IDisposable
接口的對象都會在對象生命周期結束之后調用析構方法。這會顯式得,確定得釋放資源。
如果在一段代碼中創建并釋放對象,忘記調用Dispose()
是不可原諒的,因為C#提供了一個 using
語句以確保 Dispose()
被調用而不論代碼塊以什么方式退出(不管是異常,是返回值,或是簡單的代碼塊結束)。沒錯,這是和之前文中提到的在你文件的頭部引入命名空間一樣的 using
語句。它有一個許多c#開發者沒有察覺到的,完全不相關的目的,也就是當代碼塊退出的時候確保 Dispose()
被調用。
using (FileStream myFile = File.OpenRead("foo.txt")) { myFile.Read(buffer, 0, 100); }
在上面的創建 using
代碼示例中,你可以確定一旦你處理完文件之后, myFile.Dispose()
方法會被調用,不論 Read()
方法是否拋出異常。
常見錯誤9:回避異常
C#在運行時也會強制類型安全檢查。比起像C++這樣會因錯誤類型轉換而賦給對象的域一個隨機值的語言來說,C#讓你更快得找出錯誤的位置。然而,程序員再一次忽視了C#的這種特性。由于C#提供了兩種不同的類型檢查方式,一種會拋出異常而另一種不會,從而導致他們掉進這個陷阱。有些人選擇回避拋異常這種方式,想著不去寫try/catch語句塊可以節省一些代碼。
例如,這里演示了C#在顯式類型轉換中兩種不同的方式:
// METHOD 1: // Throws an exception if account can't be cast to SavingsAccount SavingsAccount savingsAccount = (SavingsAccount)account; // METHOD 2: // Does NOT throw an exception if account can't be cast to // SavingsAccount; will just set savingsAccount to null instead SavingsAccount savingsAccount = account as SavingsAccount;
方法2中可能發生的最明顯錯誤是對返回值類型檢查的失敗。這最終很可能導致NullReferenceException的異常,這可能出現在稍晚的時候,使得追蹤問題根源變得更加困難。相比之下,方法1會立即拋出一個 InvalidCastException
異常,使得問題根源十分明顯。
此外,即使你知道要檢查方法2 的返回值,那么如果你發現值為空你會怎么做?在這個方法中報出錯誤合適嗎?如果類型轉換失敗你還能嘗試著做什么?如果不能,那么拋出異常是正確的選擇,并且異常的拋出點離問題根源越近越好。
這里演示了另外一組常見的方法,其中一種會拋出異常,另一種不會:
int.Parse(); // throws exception if argument can’t be parsed int.TryParse(); // returns a bool to denote whether parse succeeded IEnumerable.First(); // throws exception if sequence is empty IEnumerable.FirstOrDefault(); // returns null/default value if sequence is empty
有些程序員認為“異常不利”,從而他們自然得認為不拋異常的方法是極好的。雖然在某些情況下,這種觀點是正確的,但是它并不適用于普遍的情況。
舉個具體的例子,在某種情況下當異常發生時你有一個可選的合理的措施(比如,默認值),那么不拋出異常將是一個合理的選擇。這種情況下,最好像下面這么寫:
if (int.TryParse(myString, out myInt)) { // use myInt } else { // use default value }
用來替代:
try { myInt = int.Parse(myString); // use myInt } catch (FormatException) { // use default value }
然而,這并不說明 TryParse
方法更好,某些情況下適合,某些情況下不適合。這就是為什么有兩種選擇。在你的上下文中使用正確的方法,并記住作為程序員,異常無疑可以成為你的朋友。
常見錯誤10:允許編譯器警告累積
雖說這并不是C#特有的,但它棄用了由C#編譯器提供的嚴格類型檢查的優勢,這是非常過分的。
警告的出現是有原因的。所有的C#編譯器錯誤表明你的代碼有缺陷,許多警告同樣也表明這個問題。兩者的區別是,對警告來說,編譯器可以按照你的代碼指示工作。即便如此,如果編譯器發現你的代碼有一點可疑,那么很可能你的代碼不會完全按照你的預期運行。
一個常見的簡單例子是當你修改你的算法并刪除了你之前使用的變量時,但是你忘了刪除變量的聲明。程序可以很好地執行,但是編譯器將會標示無用的變量聲明。程序完美運行的事實使得程序員忽視了修正警告。再者,程序員利用了Visual Studio的特性,使得他們很容易得在“錯誤列表”窗口中隱藏了警告,從而只關注錯誤信息。用不了多久就會積累許多警告,所有這些警告都被歡樂得忽略了(或更糟糕的,隱藏了)。
但如果你忽略這種警告,遲早類似下面的例子會出現在你的代碼里:
class Account { int myId; int Id; // compiler warned you about this, but you didn’t listen! // Constructor Account(int id) { this.myId = Id; // OOPS! } }
伴隨著我們編碼時編譯器的快速智能提示,這種錯誤很可能發生。
現在你的程序里有了一個嚴重的錯誤(盡管編譯器只將其標示為警告,原因已經解釋過了),你可能花大量時間找出這個問題,這取決于你程序的復雜度。如果你一開始就注意到這個警告,那么你僅需5秒鐘就可以修正它并避免這個問題。
記住,C#編譯器對于你程序的健壯性,提出了許多有用的信息。如果你在聽。不要忽視警告。它們通常僅需要花幾秒鐘去修正,當出現新的警告時就修正它,會為你節省很多時間。訓練你自己以期待Visual Studio的“錯誤窗口”顯示“0錯誤,0警告”,以至于一旦出現任何警告,你都會感覺不舒服而立刻把警告修正。
當然,每個規則都有例外。因此,有這樣的情況,就是你的代碼在編譯器看來有點可疑,即使它們是完全按照你的意圖去完成的。在這種極少數的情況下,僅在觸發警告的代碼上使用#pragma warning disable [warning id]
,并且僅針對其觸發的警告ID。這樣會壓制這條警告,以便當新的警告出現時,你還可以獲得新的警告的提示。
總結
C#是一門強大并且靈活的語言,它有很多機制和規范用來顯著提升效率。相比任何一種軟件工具或者語言,如果對其能力只有有限了解或者認識,有時可能更多的是阻礙而不是好處,正如一句諺語所說“自以為知道很多,能夠做某事了,其實不然”。
熟悉C#的一些細微關鍵之處,如本文中提到的問題(但不限于),將會有助于我們更好地使用語言,從而避免更多的易犯錯誤。
原文鏈接: PATRICK RYDER 翻譯: 伯樂在線 - EluQ
譯文鏈接: http://blog.jobbole.com/71124/