C# 程式設計師易犯的 10 個錯誤

EluQ發表於2014-06-16

關於C#

C#是針對微軟公共語言執行庫(CLR)的開發語言之一。針對CLR的開發語言得益於如跨語言整合的效能,異常處理,安全性增強,元件互動的簡化模型,除錯和分析服務。對於今日的CLR來說,C#是定位到Windows桌面,移動裝置或伺服器環境中,在處理複雜,專業的開發專案方面使用最廣泛的開發語言。

C#是面相物件,強型別的語言。C#中嚴格的型別檢查,在編譯和執行時,使得典型的程式設計錯誤能儘早報告,並且能精準給出錯誤位置。這能幫助程式設計師節省很多時間,相比於跟蹤那些可以發生在違規操作很長時間之後的令人費解的錯誤,型別安全的執行更加自由。但是,許多程式設計師不知不覺地(或不經意地)丟棄了這種檢測的好處,引出了一些在本文中討論的問題。

關於本文

本文描述了C#程式設計師最常見的10個程式設計錯誤或者要避免的缺陷。

大多數在本文中討論的錯誤是特定於C#的,有些也涉及到CLR或者利用框架類庫(FCL)的其他語言。

toptal-blog-image-1398391053854

常見錯誤1:像值型別一樣使用引用型別或者相反

C++程式設計師,和其他許多程式語言的程式設計師,習慣於把他們分配給變數的是否只是值或者對已存在物件的引用置於掌控之中。但在C#中,這個是由寫這個物件的程式設計師,而不是由例項化物件並給它賦值的程式設計師決定的。這對於C#新手來說是一個常見的“騙到你了”的例項。

如果你不知道你使用的物件是值型別還是引用型別,你可能會碰到驚喜。例如:

正如你所看到的, PointPen 物件是以完全相同的方式建立的。但 point1 在一個新的 X 座標值設定到 point2後保持不變,而 pen1 的值在一個新的顏色值設定到 pen2後被改變了。因此我們可以推斷 point1point2 分別包含 Point 物件的拷貝,而 pen1pen2 包含對同一個 Pen 物件的引用。但我們怎麼能不通過這個實驗而知道結果呢?

答案是檢視物件型別的定義(在Visual Studio中你可以很容易得把你的游標放在物件型別名上並點選F12):

正如上面所示,在C#中,關鍵字struct是用於定義值型別,同時關鍵字class是用來定義引用型別的。對於有C++程式設計背景的程式設計師,對C++和C#有許多相似的關鍵字而誤認為安全的話,這種行為可能會是一個驚喜。

如果你要依賴某些因值型別和引用型別的不同而產生的行為—-比如傳遞一個物件作為方法引數並且在方法中改變該物件的狀態這種行為—一定要確保你處理的是正確的物件型別。

 

常見錯誤2:誤解未初始化變數的預設值

在C#中,值型別不能為null。通過定義,值型別會有一個值,甚至沒有初始化的值型別變數也必須有個值。這稱為值型別的預設值。這會導致當檢查一個變數是否初始化時不可預期的結果,如下所示:

為什麼 point1 不是null?答案是 Point 是值型別,並且 Point 的預設值是(0,0),而不是null。沒有認識到這點是C#中一個易犯(並且常見)的錯誤。

許多(但不是全部)值型別都有一個 IsEmpty 屬性,你可以用這個屬性來檢查該值型別是否等於它的預設值:

當你去檢查一個變數是否被初始化,確保你知道那個型別未被初始化的變數將有的預設值並且不依賴它為null。

 

常見錯誤3:使用不合適或者未特別指定的字串比較方法

在C#中有許多不同的方法比較字串。

儘管許多程式設計師用 == 操作符來比較字串,但其實這是許多方法中最不理想的方法之一,主要是因為它在程式碼中沒有明確指明需要哪一種比較。

相反,在C#中測試字串想等的首選方式是使用 Equals 方法:

第一個方法的簽名(例如,沒有 comparisonType 引數),實際上和使用 == 操作符完全一樣,但具有對字串明確化的好處。它執行一個字串的序號比較,基本上就是位元組與位元組比較。在許多情況下,這正是你想要的比較型別,特別是當比較的字串的值引數化,例如檔名,環境變數,屬性等等。在這種情況下,只要順序比較的確是這種情況下正確的型別比較即可,唯一的缺點是使用沒有 comparisonTypeEquals 方法,會使得某些讀你程式碼的人不知道你用什麼比較型別做的比較。

使用帶 comparisonType 引數的 Equals 方法,你每次比較字串的時候,雖說,不光會使得你的程式碼更清晰,而且會使你明確你需要使用的比較型別。這是值得做的事情,因為儘管英語在按順序比較與語言區域性比較之間沒什麼差異,但其他語言提供了很多,而忽略其他語言的可能性則為你自己在未來的路上提供了犯很多錯誤的可能。例如:

最安全的實踐是總是為 Equals 方法提供一個 comparisonType 引數。這是一些基本準則:

  • 當比較有使用者輸入的字串,或者將顯示給使用者的字串,使用本地化比較(CurrentCulture 或者 CurrentCultureIgnoreCase)。
  • 當比較程式設計用的字串,使用原始比較(Ordinal 或者 OrdinalIgnoreCase)。
  • InvariantCultureInvariantCultureIgnoreCase 通常並不使用,除非在受限的條件下,因為原始比較更加有效。如果本地性文化比較是必須的話,它應該基於當前文化或另一個明確的文化來執行。

此外,對於 Equals 方法來說,字串也提供了 Compare 方法,用來給你提供關於字串相對順序資訊而不僅僅測試是否相等。這個方法更適用 <, <=, >>= 操作符,與上文討論的原因相同。

 

常見錯誤4:使用迭代(而不是宣告)來操作集合

在C# 3.0中,LINQ(Language-Integrated Query)的引入永遠改變了集合的查詢和修改操作。自那以後,當你使用迭代式操作集合,而不是使用LINQ的時候,其實你也許應該使用後者。

一些C#程式設計師甚至不知道LINQ的存在,但慶幸的是這個數字正在逐步減少。但因為LINQ的關鍵字和SQL的語句的相似性,很多人還是誤以為LINQ只用於資料庫的查詢中。

雖然資料庫的查詢操作是LINQ的一個非常常用的功能,但是它同樣適用於各種列舉的集合(例如,任何實現了IEnumerable 介面的物件)。舉例來說,如果你有一個Accounts型別的陣列,不要寫成:

你只要寫成:

雖是一個簡單的例子,在有些情況下,一個單一的LINQ語句可以輕易地替換你程式碼中一個迭代迴圈(或巢狀迴圈)裡的幾十條語句。更少的程式碼通常意味著更少產生bugs的機會。然而,記住在效能方面可能要權衡一下。在效能決定的情況下,特別是當你的迭代程式碼能對你的集合進行假設而LINQ做不到的時候,確保在兩種方法間做一個效能比較。

 

常見錯誤5:沒有考慮LINQ語句中底層物件

對於處理抽象操作集合LINQ是強大的,無論它們是記憶體的物件,資料庫表,或者XML文件。在完美的世界中,你無須考慮底層物件是什麼。但這裡的錯誤是假設我們生活在一個完美的世界中。事實上,當用完全相同的資料時相同的LINQ語句能返回不同的結果,如果這個資料以不同的格式給出的話。

例如,考慮如下語句:

如果其中一個物件的 account.Status 等於 “Active”(注意大寫A)會發生什麼?好,如果myAccounts 是一個 DbSet 的物件(預設設定了不區分大小寫的配置), where 表示式仍會匹配該元素。但是,如果 myAccounts 是在記憶體中的陣列,那麼它將不匹配,並將產生不同的總的結果。

等一下,在我們之前討論字串比較過程中,我們發現 == 操作符進行了字串的順序比較。那麼,為什麼在這個條件下, == 操作符表現出不區分大小寫的比較呢?

答案是,當在LINQ語句中的底層物件都引用到SQL表中的資料(如在這個例子中,實體框架為DbSet物件的情況下),該語句被轉換為一個T-SQL語句。操作符遵循T-SQL的規則,而不是C#的,所以在上述情況的比較中不區分大小寫。

通常來說,儘管LINQ是一個有用的和以持續的方式查詢物件的集合,但在現實中你仍然需要知道你的語句是否會被解釋成頂著C#的帽子的其他型別的語句,以確保你程式碼的功能在執行時仍如預期的那樣。

 

常見錯誤6:對擴充套件方法感到困惑或被欺騙

正如之前提到的,LINQ語句依賴於任何實現了IEnumerable 介面的物件。比如,下面的簡單函式將賬戶上任何集合的餘額相加:

在上面的程式碼中,myAccounts引數的型別被宣告為IEnumerable<Account>myAccounts 引用了一個 Sum 方法(C#使用類似”dot notation”引用類或者介面中的方法),我們期望在 IEnumerable<T> 介面中定義一個 Sum() 方法。但是, IEnumerable<T> 沒有為 Sum 方法提供任何引用並且只有如下所示的簡單定義:

那麼 Sum() 方法在哪裡定義的呢?C#是強型別語言,因此如果 Sum 方法的引用是無效的,C#編譯器就會將其標示為錯誤。我們知道它必須存在,但是在哪呢?此外,LINQ提供的供查詢和聚集集合的所有方法定義在哪裡呢?

答案是 Sum() 並不是定義在 IEnumerable 介面內的方法。而是一個定義在 System.Linq.Enumerable 類中的靜態方法(叫做”擴充套件方法”):

那麼擴充套件方法和其他靜態方法有什麼不同之處,是什麼確保我們可以在其他類訪問它呢?

擴充套件方法的顯著特點是,第一個形參前的 this 修飾符。這就是編譯器知道它是一個擴充套件方法的“奧妙”。它所修飾的引數型別(在這裡是IEnumerable<TSource>)說明這個類或者介面將會實現這個方法。

(另外需要說明的一點,定義擴充套件方法的IEnumerable 介面和Enumerable 類的名字間的相似性並沒有什麼可奇怪的。這種相似性僅是隨意的風格。)

理解了這一點後,我們可以看到上面介紹的 sumAccounts 方法可以用下面的方法來實現:

事實是我們可能已經以這種方式實現它了,而不是問為什麼要有擴充套件方法呢?擴充套件方法本質上是C#語言的一種便利方式,它允許你對已存在的型別“新增”方法,而無須建立一個新的派生型別,重新編譯或者修改原型別程式碼。

擴充套件方法通過在檔案頭部新增 using [namespace]; 而引入作用域。你需要知道你尋找的擴充套件方法所在的名稱空間,但一旦你知道你要找什麼的時候這就變得非常簡單了。

當C#編譯器遇到一個物件例項呼叫一個方法,並且該方法沒有定義在引用物件類中時,它就會搜尋所有定義在作用域中的擴充套件方法以尋找相匹配的方法簽名和類。如果它找到了,它就會把例項的引用作為第一個引數傳給擴充套件方法,如果有其他引數的話,再把它們傳遞給擴充套件方法。(如果C#編譯器在作用域中沒有找到任何相符合的擴充套件方法,它就會丟擲異常。)

對於C#編譯器來說,擴充套件方法是個“語法糖”,(大多數情況下)使得我們把程式碼寫得更清晰,更易於維護。顯然,前提是你知道它們的用法。否則,它會讓人感覺比較困惑,尤其是一開始的時候。

使用擴充套件方法確實有優勢,但也讓不瞭解它或者不能很好理解它的開發者感到頭疼,還浪費時間。尤其是看網上程式碼示例的時候,或者任何其它事先寫好的程式碼的時候。當這些程式碼發生編譯錯誤(因為它呼叫了顯然沒被定義在呼叫類中的方法),傾向是認為程式碼是否應用於類庫的不同版本,或者是不同的類庫。很多時間都會花在尋找新版本上,或者被認為“丟失”的類庫上。

在擴充套件方法的名字和類中的名字一樣,但只是在方法簽名上有微小差異的時候,即使對擴充套件方法熟悉的開發者偶爾也可能犯上面的錯誤。很多時間會花在尋找不存在的型別或者錯誤上。

使用C#類庫的擴充套件方法變得越來越普遍了。除了LINQ,另外兩個出自微軟被廣泛使用的類庫Unity Application BlockWeb 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 程式碼示例中,你可以確定一旦你處理完檔案之後, myFile.Dispose() 方法會被呼叫,不論 Read() 方法是否丟擲異常。

 

常見錯誤9:迴避異常

C#在執行時也會強制型別安全檢查。比起像C++這樣會因錯誤型別轉換而賦給物件的域一個隨機值的語言來說,C#讓你更快得找出錯誤的位置。然而,程式設計師再一次忽視了C#的這種特性。由於C#提供了兩種不同的型別檢查方式,一種會丟擲異常而另一種不會,從而導致他們掉進這個陷阱。有些人選擇迴避拋異常這種方式,想著不去寫try/catch語句塊可以節省一些程式碼。

例如,這裡演示了C#在顯式型別轉換中兩種不同的方式:

方法2中可能發生的最明顯錯誤是對返回值型別檢查的失敗。這最終很可能導致NullReferenceException的異常,這可能出現在稍晚的時候,使得追蹤問題根源變得更加困難。相比之下,方法1會立即丟擲一個 InvalidCastException 異常,使得問題根源十分明顯。

此外,即使你知道要檢查方法2 的返回值,那麼如果你發現值為空你會怎麼做?在這個方法中報出錯誤合適嗎?如果型別轉換失敗你還能嘗試著做什麼?如果不能,那麼丟擲異常是正確的選擇,並且異常的丟擲點離問題根源越近越好。

這裡演示了另外一組常見的方法,其中一種會丟擲異常,另一種不會:

有些程式設計師認為“異常不利”,從而他們自然得認為不拋異常的方法是極好的。雖然在某些情況下,這種觀點是正確的,但是它並不適用於普遍的情況。

舉個具體的例子,在某種情況下當異常發生時你有一個可選的合理的措施(比如,預設值),那麼不丟擲異常將是一個合理的選擇。這種情況下,最好像下面這麼寫:

用來替代:

然而,這並不說明 TryParse 方法更好,某些情況下適合,某些情況下不適合。這就是為什麼有兩種選擇。在你的上下文中使用正確的方法,並記住作為程式設計師,異常無疑可以成為你的朋友。

 

常見錯誤10:允許編譯器警告累積

雖說這並不是C#特有的,但它棄用了由C#編譯器提供的嚴格型別檢查的優勢,這是非常過分的。

警告的出現是有原因的。所有的C#編譯器錯誤表明你的程式碼有缺陷,許多警告同樣也表明這個問題。兩者的區別是,對警告來說,編譯器可以按照你的程式碼指示工作。即便如此,如果編譯器發現你的程式碼有一點可疑,那麼很可能你的程式碼不會完全按照你的預期執行。

一個常見的簡單例子是當你修改你的演算法並刪除了你之前使用的變數時,但是你忘了刪除變數的宣告。程式可以很好地執行,但是編譯器將會標示無用的變數宣告。程式完美執行的事實使得程式設計師忽視了修正警告。再者,程式設計師利用了Visual Studio的特性,使得他們很容易得在“錯誤列表”視窗中隱藏了警告,從而只關注錯誤資訊。用不了多久就會積累許多警告,所有這些警告都被歡樂得忽略了(或更糟糕的,隱藏了)。

但如果你忽略這種警告,遲早類似下面的例子會出現在你的程式碼裡:

伴隨著我們編碼時編譯器的快速智慧提示,這種錯誤很可能發生。

現在你的程式裡有了一個嚴重的錯誤(儘管編譯器只將其標示為警告,原因已經解釋過了),你可能花大量時間找出這個問題,這取決於你程式的複雜度。如果你一開始就注意到這個警告,那麼你僅需5秒鐘就可以修正它並避免這個問題。

記住,C#編譯器對於你程式的健壯性,提出了許多有用的資訊。如果你在聽。不要忽視警告。它們通常僅需要花幾秒鐘去修正,當出現新的警告時就修正它,會為你節省很多時間。訓練你自己以期待Visual Studio的“錯誤視窗”顯示“0錯誤,0警告”,以至於一旦出現任何警告,你都會感覺不舒服而立刻把警告修正。

當然,每個規則都有例外。因此,有這樣的情況,就是你的程式碼在編譯器看來有點可疑,即使它們是完全按照你的意圖去完成的。在這種極少數的情況下,僅在觸發警告的程式碼上使用#pragma warning disable [warning id] ,並且僅針對其觸發的警告ID。這樣會壓制這條警告,以便當新的警告出現時,你還可以獲得新的警告的提示。

 

總結

C#是一門強大並且靈活的語言,它有很多機制和規範用來顯著提升效率。相比任何一種軟體工具或者語言,如果對其能力只有有限瞭解或者認識,有時可能更多的是阻礙而不是好處,正如一句諺語所說“自以為知道很多,能夠做某事了,其實不然”。

熟悉C#的一些細微關鍵之處,如本文中提到的問題(但不限於),將會有助於我們更好地使用語言,從而避免更多的易犯錯誤。

相關文章