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/