C#編程中最常見的10個錯誤

C#編程中最常見的10個錯誤

英文原文鏈接:https://www.toptal.com/c-sharp/top-10-mistakes-that-c-sharp-programmers-make

原文作者:
帕特里克·賴德
Pat (BMath/CS)在微軟工作時幫助創建了vb1.0以及後來的。
net平臺。自2000年以來,他專注於全棧項目。

關於C#

C#是Microsoft公共語言運行庫(CLR)所支持的幾種主流語言之一。受益於CLR在跨語言集成,異常處理、安全性、組件交互以及調試分析服務等方面的優異特性,使得它是現如今CLR所支持的語言中使用最廣泛的一門語言,它廣泛的應用於Windows桌面開發、移動及複雜服務的開發、以及其他專業的開發項目。

C#是一種面向對象的強類型語言。在編譯和運行時,它會嚴格的執行類型檢查,以便於使絕大部分典型的語法錯誤可以儘早的被檢查出來,而且錯誤的位置會被準確地定位。這些優異的特性可以讓你在使用C#的編程時,節省大量的時間。相比之下,在那些沒有類型安全檢查的語言中,我們可能會在發生錯誤很久之後才能跟蹤到那些令人困惑的原因。然而,有許多C#程序員在不知不覺中(或粗心大意地)拋棄了這種檢測的好處,所以就引出了我寫這篇教程的原因。

關於這篇C#編程教程

本教程主要講解C#程序員最容易犯的10個常見的編程錯誤以及告訴他們如何避免這些問題,希望可以幫助到他們。
雖然本文中討論的大多數錯誤都是c#特有的,但也有一些錯誤在其他的類似CLR的語言上同樣存在。具有相同的參考價值。

常見的c#編程錯誤梳理

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對象的創建方式完全相同,但是當將一個新的X座標值賦值給point2時,point1的值保持不變,而當將一個新的顏色賦值給pen2時,pen1的值被修改了。因此,我們可以推斷,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#關鍵字之間的許多相似之處引入了一種錯誤的安全感,這種行爲可能會讓你感到驚訝,你可能會向c#教程尋求幫助。
如果你要依靠值類型和引用類型做一些事情——比如可以傳遞一個對象作爲方法參數,該方法改變對象的狀態,那麼就請你先確保你處理類型的對象是正確的,以避免c#編程問題。

2. 誤解未初始化變量的默認值

在c#中,值類型不能爲空。根據定義,值類型有一個值,甚至值類型的未初始化變量也必須有一個值。這稱爲該類型的默認值。這將導致在檢查變量是否未初始化時引發異常:

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不爲零?答案是,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 or OrdinalIgnoreCase)。
  • 除非在非常有限的情況下,否則一般不使用InvariantCulture和不變的InvariantCultureIgnoreCase,因爲順序比較更有效。如果需要對文化敏感的比較,通常應該針對當前文化或另一個特定文化執行比較。

除了Equals方法之外,string還提供了Compare方法,它爲您提供關於字符串的相對順序的信息,而不僅僅是一個相等性測試。出於與上面討論的相同的原因,此方法優於<、<=、>和>=操作符,以避免c#問題。

4. 使用迭代(而不是聲明式)語句來操作集合

在c# 3.0中,語言集成查詢(LINQ)的加入永遠地改變了集合查詢和操作的方式。從那以後,如果您使用迭代語句來操作集合,您可能知道應該使用LINQ,但卻並沒有使用。
一些c#程序員甚至不知道LINQ的存在,但幸運的是這個數字正變得越來越小。但是,許多人仍然認爲,由於LINQ關鍵字和SQL語句之間的相似性,它能只在查詢數據庫的代碼中使用。
雖然數據庫查詢是LINQ語句的一種非常流行的用法,但它們實際上可以用於任何可枚舉的集合(即,任何實現IEnumerable接口的對象)。例如,如果你有一個帳戶數組,你就不應該只知道寫一個c#列表foreach它:

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();

雖然這是一個關於如何避免常見c#編程問題的簡單小例子,但是在某些情況下,一個LINQ語句可以很容易地替換代碼中涉及迭代循環(或嵌套循環)中的許多語句。代碼越少,引入bug的機會就越少。但是,請記住,這可能會涉及一些性能方面的因素,在性能和簡潔兩個方面可能存在權衡。在一些對性能有較高要求的特殊業務場景中,特別是當您的迭代代碼能夠對您的集合做出LINQ無法做出的操作時,一定要對這兩種方法進行性能方面的兼顧對比。

5. 沒有在LINQ語句中考慮底層對象

LINQ非常適合於抽象操作集合的任務,無論集合是內存中的對象、數據庫表還是XML文檔。在一個完美的世界裏,你不需要知道底層的對象是什麼。但這裏的錯誤是假設我們生活在一個完美的世界。事實上,相同的LINQ語句在對完全相同的數據執行時可以返回不同的結果,如果數據的格式恰好不同的話。
例如,考慮下面的聲明:

decimal total = (from account in myAccounts
             where account.Status == "active"
             select account.Balance).Sum();

如果一個對象的account 狀態等於“Active”(注意大寫的A),會發生什麼?如果myAccounts是一個DbSet對象(它是用默認的大小寫不敏感配置設置的),where表達式仍然會匹配該元素。但是,如果myAccounts在內存中的數組中,那麼它將不匹配,因此將爲total生成不同的結果。
但是等一下。在前面討論字符串比較時,我們看到 == 操作符對字符串執行順序比較。那麼,爲什麼在這種情況下,== 操作符執行不區分大小寫的比較呢?
答案是,當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。由於myAccounts引用了一個Sum方法(c#使用熟悉的“點表示法”來引用類或接口上的方法),所以我們希望在Enumerable的定義上看到一個名爲Sum()的方法。接口。然而,IEnumerable<T>的定義沒有提到任何求和方法,只是看起來像這樣:

public interface IEnumerable<out T> : IEnumerable {
    IEnumerator<T> GetEnumerator();
}

那麼Sum()方法是在哪裏定義的呢?c#是強類型的,所以如果對Sum方法的引用無效,c#編譯器肯定會將其標記爲錯誤。因此我們知道它一定存在,但是在哪裏呢?此外,LINQ爲查詢或聚合這些集合提供的所有其他方法的定義在哪裏?
答案是Sum()不是在IEnumerable接口上定義的方法。相反,它是一個在System.Linq上定義的靜態方法(稱爲“擴展方法”)。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)表示類或接口,在隨後它將實現這個方法。
(順便說一句,IEnumerable接口的名稱與定義擴展方法的Enumerable類的名稱之間的相似性並沒有什麼神奇之處。這種相似性只是一種隨意的風格選擇。)
理解以上的內容後,我們就可以繼續講解,上面介紹的那個sumAccounts功能還可以用如下的代碼來實現:

public decimal SumAccounts(IEnumerable<Account> myAccounts) {
    return Enumerable.Sum(myAccounts, a => a.Balance);
}

我們本可以用這種方式來實現它,但問題是,爲什麼要使用擴展方法呢?擴展方法本質上是c#編程語言的一個便利之處,它使您能夠向現有類型“添加”方法,而無需創建新的派生類型、重新編譯或以其他方式修改原始類型。
擴展方法包括using [namespace];語句位於文件的頂部。您需要知道哪個c#名稱空間包含您正在尋找的擴展方法,一旦您知道您正在搜索的是什麼,就很容易確定它所在的命名空間了。
當c#編譯器遇到對象實例上的方法調用時,並沒有找到在引用的對象類上定義的方法,然後它會查看範圍內的所有擴展方法,試圖找到一個與所需的方法簽名和類匹配的方法。如果找到一個,它將實例引用作爲該擴展方法的第一個參數傳遞,然後其餘的參數(如果有的話)將作爲後續參數傳遞給擴展方法。(如果c#編譯器沒有在範圍內找到任何對應的擴展方法,它將拋出一個錯誤。)
擴展方法是c#編譯器“語法糖”的一個例子,它允許我們編寫(通常)更清晰和更容易維護的代碼。更清楚,就是說,如果你知道它們的用法。否則,可能會有點混亂,尤其是在一開始。
雖然使用擴展方法當然有很多優點,但是它們會導致一些問題,對於那些沒有意識到它們或者沒有正確理解它們的開發人員來說,他們可能需要c#編程幫助。在在線查看代碼示例或任何其他預先編寫的代碼時尤其如此。當這樣的代碼產生編譯器錯誤(因爲它調用的方法顯然沒有在它們所調用的類上定義)時,傾向於認爲代碼適用於庫的不同版本,或者完全適用於不同的庫。很多時間可以花在尋找一個新的版本,或幽靈“失蹤的庫”,不存在。
當對象上有一個同名方法,但是它的方法簽名與擴展方法有細微的區別時,即使是熟悉擴展方法的開發人員偶爾也會遇到這樣的問題。很多時間都浪費在尋找一個打印錯誤或根本不存在的錯誤上。
在c#庫中使用擴展方法變得越來越普遍。除了LINQ之外,Unity應用程序塊和Web API框架是微軟大量使用的兩個現代庫的例子,它們也使用了擴展方法,還有很多其他的。框架越先進,就越有可能包含擴展方法。
當然,您也可以編寫自己的擴展方法。但是,要認識到,雖然擴展方法看起來就像常規實例方法一樣被調用,但這實際上只是一種幻覺。特別是,您的擴展方法不能引用它們正在擴展的類的私有成員或受保護成員,因此不能完全替代更傳統的類繼承。

7. 爲手頭的任務使用錯誤類型的集合

c#提供了各種各樣的集合對象,下面只是一個部分列表:
Array, ArrayList, BitArray, BitVector32, Dictionary<K,V>, HashTable, HybridDictionary, List, NameValueCollection, OrderedDictionary, Queue, Queue, SortedList, Stack, Stack, StringCollection, StringDictionary.
雖然在某些情況下,選擇太多與選擇不足一樣糟糕,但集合對象不是這樣。選擇的數量肯定會對你有利。花一點額外的時間提前研究和選擇最適合你的幾何類型。它可能會帶來更好的性能和更少的出錯機會。
如果有一個集合類型專門針對您擁有的元素類型(例如string或bit),則傾向於首先使用該集合類型。當它針對特定類型的元素時,實現通常更有效。
爲了利用c#的類型安全性,您通常應該選擇泛型接口而不是非泛型接口。泛型接口的元素是在聲明對象時指定的類型,而非泛型接口的元素是object類型。當使用非泛型接口時,c#編譯器不能對代碼進行類型檢查。此外,在處理基元值類型的集合時,使用非泛型集合將導致這些類型的重複裝箱/拆箱,與適當類型的泛型集合相比,這會導致顯著的負面性能影響。
另一個常見的c#問題是編寫自己的集合對象。這並不是說它永遠都不合適,但是有了.net所提供的那樣全面的集合對象選擇之後,您完全可以通過使用或擴展一個已經存在的集合類型,從而來節省大量時間,而不是重新造輪子。特別是,C5通用集合庫爲c#和CLI提供了一個廣泛的額外集合“開箱即用”,比如持久樹數據結構、基於堆的優先級隊列、散列索引數組列表、鏈表等等。

8. 忽視釋放資源

CLR環境使用了垃圾收集器,因此不需要顯式地釋放爲任何對象創建的內存。事實上,你不能完全這樣做。在C語言中沒有等價的刪除操作符或free()函數。但這並不意味着您可以在使用完所有對象之後就忘記它們。許多類型的對象封裝了其他類型的系統資源(例如,磁盤文件、數據庫連接、網絡套接字等)。讓這些資源保持開放狀態會迅速耗盡系統資源的總數,降低性能並最終導致程序錯誤。
雖然析構函數方法可以在任何c#類中定義,但是析構函數(在c#中也稱爲終結器)的問題是,您不能確定何時會調用它們。垃圾收集器(在單獨的線程上,這可能會導致額外的複雜性)將在未來某個不確定的時間調用它們。試圖通過使用GC.Collect()強制進行垃圾收集來繞過這些限制並不是c#的最佳實踐,因爲這將在收集所有符合收集條件的對象時阻塞線程一段未知的時間。
這並不是說終結器沒有好的用途,但不包括以確定性的方式釋放資源。相反,當您在文件、網絡或數據庫連接上進行操作時,您希望在使用完底層資源後立即顯式釋放它。
幾乎在任何環境中,資源泄漏都是一個問題。然而,c#提供了一種健壯且易於使用的機制,如果利用這種機制,泄漏的情況就會少得多。net框架定義了IDisposable接口,它僅由Dispose()方法組成。任何實現IDisposable的對象都希望在對象的使用者完成操作之後調用該方法。這將導致顯式的、確定性的資源釋放。
如果你的上下文中創建和處理對象的一個代碼塊,並且在事後忘了調用Dispose()方法基本上是不可原諒的,因爲c#提供了一個using聲明,將確保無論如何退出代碼塊(無論是一個異常,返回語句,或者只是塊)都會調用Dispose()方法自動釋放資源。是的,這就是前面提到的using語句,用於在文件頂部包含c#名稱空間。它還有一個完全不相關的目的,許多c#開發人員都沒有意識到這一點;也就是說,爲了確保代碼塊退出時,調用作用在這個對象上的Dispose()方法。

using (FileStream myFile = File.OpenRead("foo.txt")) {
  myFile.Read(buffer, 0, 100);
}

通過在上面的示例中創建一個using塊,您可以確定一旦處理完文件,不管是Read()了還是拋出異常了,
都會調用myFile.Dispose()釋放資源。

9. 迴避異常處理

c#在運行時會持續執行類型安全。這使您可以比在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中的返回值,如果發現它爲空,您將如何處理?您正在編寫的方法是否適合報告錯誤?如果強制轉換失敗,您還可以嘗試其他方法嗎?如果不是,那麼拋出異常是正確的做法,所以您最好讓它儘可能靠近問題的根源。
下面是一些其他常見方法對的例子,其中一個拋出異常,而另一個沒有:
有些c#開發人員非常“反對異常”,他們會自動假設不拋出異常的方法是更好的方法。雖然在某些特定的情況下,這可能是正確的,但對於大多數情況來說,這一說法是不完全正確的。
再舉另一個具體的例子,如果已經生成了一個異常,那麼在這種情況下,您可以採取另一個合法的(例如,默認)操作,那麼非異常方法可能是一個合法的選擇。在這種情況下,也許這樣寫更好:

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#編譯器提供的嚴格類型檢查的好處。
產生警告是有原因的。雖然所有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!
    }
}

在智能感知允許我們編寫代碼的速度下,這個錯誤並不像看起來那麼不可能。
現在,您的程序中出現了一個嚴重的錯誤(儘管編譯器只是將其標記爲警告,原因已經解釋過了),根據程序的複雜程度,您可能會浪費大量時間來跟蹤這個錯誤。如果您一開始就注意到這個警告,您當時就可以僅用五秒鐘來避免以後的這個大問題。
記住,如果你在聽的話,C Sharp編譯器會給你很多關於代碼健壯性的有用信息。不要忽視警告。它們通常只需要幾秒鐘就可以修復,而修復新問題可以節省您的時間。訓練自己期望Visual Studio“錯誤列表”窗口顯示“0錯誤,0警告”,這樣任何警告都會讓您感到不舒服,從而立即處理它們。
當然,任何規則都有例外。因此,有時您的代碼在編譯器看來可能有點可疑,即使它確實是您想要的。在那些非常罕見的情況下,只針對觸發警告的代碼使用#pragma warning disable [warning id],並且只針對它觸發的警告id。這將禁止該警告,並且只禁止該警告,以便您仍然可以對新的警告保持警惕。

小結

c#是一種強大而靈活的語言,具有許多機制和範例,可以極大地提高生產率。然而,與任何軟件工具或語言一樣,對其能力的有限理解或欣賞有時可能是一種障礙,而不是一種好處,就如同一句諺語所說的那樣,“知道的足夠多是一種危險”。
使用像本文這樣的C Sharp教程來熟悉c#的關鍵細微差別,比如(但絕不僅限於)本文中提出的問題,這將有助於c#優化,同時避免該語言中一些更常見的陷阱。

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