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#编程中最常见的10个错误
      • 关于C#
      • 关于这篇C#编程教程
      • 常见的c#编程错误梳理
        • 1. 使用类似于值的引用,反之亦然
        • 2. 误解未初始化变量的默认值
        • 3. 使用不适当的或未指定的字符串比较方法
        • 4. 使用迭代(而不是声明式)语句来操作集合
        • 5. 没有在LINQ语句中考虑底层对象
        • 6. 被扩展方法所迷惑
        • 7. 为手头的任务使用错误类型的集合
        • 8. 忽视释放资源
        • 9. 回避异常处理
        • 10. 允许编译器累积警告
      • 小结

关于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的定义没有提到任何求和方法,只是看起来像这样:

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 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, 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#优化,同时避免该语言中一些更常见的陷阱。

你可能感兴趣的:(.NET,c#,编程语言,经验分享,程序人生,asp.net)