个性化阅读
专注于IT技术分析

Buggy C#代码:C#编程中最常见的10个错误

点击下载

本文概述

关于C#

C#是针对Microsoft公共语言运行库(CLR)的几种语言之一。面向CLR的语言受益于多种功能, 例如跨语言集成和异常处理, 增强的安全性, 简化的组件交互模型以及调试和性能分析服务。在当今的CLR语言中, C#被广泛用于针对Windows台式机, 移动或服务器环境的复杂, 专业的开发项目。

C#是一种面向对象的强类型语言。在编译和运行时, C#中严格的类型检查会导致尽早报告大多数典型的C#编程错误, 并准确定位其位置。与查找令人费解的错误的原因相比, 这可以节省很多时间, 而这些原因可能导致令人困惑的错误, 而这些错误可能是在使用更严格执行类型安全性的语言进行的冒犯性操作发生之后很久才发生的。但是, 许多C#编码人员无意间(或不小心)放弃了这种检测的好处, 这导致了本C#教程中讨论的一些问题。

关于本C Sharp编程教程

本教程描述了C#程序员犯下的10种最常见的C#编程错误或应避免的问题, 并为他们提供了帮助。

虽然本文中讨论的大多数错误都是C#特定的, 但有些错误也与其他以CLR为目标或使用框架类库(FCL)的语言有关。

常见的C#编程错误#1:使用值等引用, 反之亦然

C ++和许多其他语言的程序员习惯于控制他们分配给变量的值是简单的值还是对现有对象的引用。但是, 在C Sharp编程中, 该决定由编写对象的程序员决定, 而不是由实例化该对象并将其分配给变量的程序员做出。对于那些试图学习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的值保持不变, 而当将新的颜色分配给pen1时, pen1的值被修改。笔2。因此, 我们可以推断出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#编程问题。

常见的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。

常见的C#编程错误#3:使用不正确或未指定的字符串比较方法

比较C#中的字符串有很多不同的方法。

尽管许多程序员使用==运算符进行字符串比较, 但这实际上是最不希望采用的方法之一, 主要是因为它没有在代码中明确指定所需的比较类型。

相反, 在C#编程中测试字符串相等性的首选方法是使用Equals方法:

      public bool Equals(string value);
      public bool Equals(string value, StringComparison comparisonType);

第一个方法签名(即不带有compareType参数)实际上与使用==运算符相同, 但是具有显式应用于字符串的好处。它执行字符串的序数比较, 基本上是逐字节比较。在很多情况下, 这正是你想要的比较类型, 尤其是在比较以编程方式设置值的字符串(例如文件名, 环境变量, 属性等)时。在这些情况下, 只要序数比较确实是正确的类型对于这种情况, 使用不带有compareType的Equals方法的唯一缺点是, 阅读代码的人可能不知道你要进行哪种类型的比较。

但是, 每次比较字符串时, 使用包含一个compareType的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方法提供一个compareType参数。以下是一些基本准则:

  • 在比较用户输入的字符串或要显示给用户的字符串时, 请使用区分区域性的比较(CurrentCulture或CurrentCultureIgnoreCase)。
  • 比较编程字符串时, 请使用序数比较(Ordinal或OrdinalIgnoreCase)。
  • 除非在非常有限的情况下, 否则通常不使用InvariantCulture和InvariantCultureIgnoreCase, 因为顺序比较会更有效。如果需要进行文化意识比较, 则通常应针对当前文化或其他特定文化进行比较。

除了Equals方法之外, 字符串还提供Compare方法, 该方法为你提供有关字符串相对顺序的信息, 而不仅仅是进行相等性测试。出于与上述相同的原因, 此方法优于<, <=, >和> =运算符, 以避免C#问题。

相关:12个.NET基本面试问题

常见的C#编程错误#4:使用迭代(而不是声明性)语句来操作集合

在C#3.0中, 向语言添加语言集成查询(LINQ)永远改变了查询和操作集合的方式。从那以后, 如果你使用迭代语句来操作集合, 那么你本来应该使用LINQ的。

一些C#程序员甚至都不知道LINQ的存在, 但幸运的是, 这个数目正在变得越来越小。但是, 许多人仍然认为, 由于LINQ关键字和SQL语句之间的相似性, 它的唯一用途是在查询数据库的代码中。

虽然数据库查询是LINQ语句的一种非常普遍的用法, 但它们实际上可以处理任何可枚举的集合(即, 实现IEnumerable接口的任何对象)。因此, 例如, 如果你有一个Accounts数组, 而不是为每个each编写一个C#List:

      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语句可以轻松替换代码中的迭代循环(或嵌套循环)中的数十个语句。更少的通用代码意味着更少的引入错误的机会。但是请记住, 在性能方面可能会有所取舍。在关键性能的情况下, 尤其是在迭代代码能够对LINQ无法进行的集合进行假设的情况下, 请确保在这两种方法之间进行性能比较。

常见的C#编程错误#5:无法考虑LINQ语句中的基础对象

LINQ非常适合抽象处理集合的任务, 无论它们是内存中对象, 数据库表还是XML文档。在理想的世界中, 你无需知道底层对象是什么。但是这里的错误是假设我们生活在一个完美的世界中。实际上, 如果相同的LINQ语句恰好采用不同的格式, 则当它们对完全相同的数据执行时, 它们可以返回不同的结果。

例如, 考虑以下语句:

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

如果对象的帐户之一。状态等于”有效”(请注意大写字母A)会怎样?好吧, 如果myAccounts是一个DbSet对象(使用默认的不区分大小写的默认配置设置), 则where表达式仍将与该元素匹配。但是, 如果myAccounts位于内存中的数组中, 则它将不匹配, 因此将产生总计不同的结果。

等一下在前面讨论字符串比较时, 我们看到==运算符对字符串进行了序数比较。那么为什么在这种情况下==运算符执行不区分大小写的比较呢?

答案是, 当LINQ语句中的基础对象是对SQL表数据的引用时(如本示例中的Entity Framework DbSet对象一样), 该语句将转换为T-SQL语句。然后, 操作员将遵循T-SQL编程规则, 而不是C#编程规则, 因此, 上述情况下的比较最终不区分大小写。

通常, 尽管LINQ是查询对象集合的有用且一致的方法, 但实际上, 你仍然需要知道你的语句是否将转换为C#以外的其他内容, 以确保代码的行为能够在运行时达到预期。

常见的C#编程错误#6:扩展方法使你感到困惑或冒充

如前所述, LINQ语句可在实现IEnumerable的任何对象上工作。例如, 以下简单功能将在任何帐户集合上累加余额:

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

在上面的代码中, myAccounts参数的类型声明为IEnumerable <Account>。由于myAccounts引用了Sum方法(C#使用熟悉的”点符号”来引用类或接口上的方法), 因此我们希望在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#编程语言的一种便利, 它使你可以将方法”添加”到现有类型, 而无需创建新的派生类型, 重新编译或修改原始类型。

扩展方法通过使用[namespace]纳入范围。文件顶部的语句。你需要知道哪个C#名称空间包含要查找的扩展方法, 但是一旦知道要搜索的内容, 就很容易确定。

当C#编译器在对象的实例上遇到方法调用, 但没有找到在引用的对象类上定义的方法时, 它将查看范围内的所有扩展方法, 以尝试查找与所需方法匹配的方法签名和类。如果找到一个, 它将实例引用作为该扩展方法的第一个参数传递, 然后其余参数(如果有)将作为后续参数传递给扩展方法。 (如果C#编译器在范围内找不到任何相应的扩展方法, 则会引发错误。)

扩展方法是C#编译器中”语法糖”的一个示例, 它使我们能够编写(通常)更清晰, 更可维护的代码。更清楚的是, 如果你知道它们的用法。否则, 可能会有些混乱, 尤其是在开始时。

使用扩展方法固然有很多优点, 但它们可能会引起问题, 并且对于那些不了解或不正确理解它们的开发人员, C#编程的帮助也会大打折扣。当在线查看代码示例或任何其他预先编写的代码时, 尤其如此。当此类代码产生编译器错误时(由于它调用的类上未明确定义方法), 人们倾向于认为代码适用于不同版本的库或完全适用于不同的库。可能会花费大量时间搜索不存在的新版本或虚拟的”缺少库”。

当对象上存在具有相同名称的方法时, 即使熟悉扩展方法的开发人员仍然偶尔会被捕获, 但是其方法签名与扩展方法的方法签名之间存在细微的差异。寻找错别字或错误可能会浪费很多时间。

在C#库中使用扩展方法变得越来越普遍。除LINQ之外, Unity Application Block和Web API框架是Microsoft的两个频繁使用的现代库的示例, 它们也使用扩展方法, 还有许多其他方法。框架越现代, 就越有可能包含扩展方法。

当然, 你也可以编写自己的扩展方法。请意识到, 尽管扩展方法看起来像常规实例方法一样被调用, 但这实际上只是一种幻想。特别是, 你的扩展方法无法引用正在扩展的类的私有或受保护成员, 因此不能完全替代更传统的类继承。

常见的C#编程错误#7:为当前任务使用错误的集合类型

C#提供了各种各样的集合对象, 以下仅是部分列表:

Array, ArrayList, BitArray, BitVector32, Dictionary <K, V>, HashTable, HybridDictionary, List <T>, NameValueCollection, OrderedDictionary, Queue, Queue <T>, SortedList, Stack, Stack <T>, StringCollection, StringDictionary。

虽然在某些情况下选择过多和选择不足是很糟糕的, 但对于集合对象却并非如此。可用的选项数量肯定可以使你受益。预先花一些时间进行研究, 然后为你的目的选择最佳的收集类型。这可能会导致更好的性能和更少的错误空间。

如果有一种收集类型专门针对你拥有的元素类型(例如字符串或位), 则倾向于先使用该元素。以特定类型的元素为目标时, 通常实现效率更高。

为了利用C#的类型安全性, 通常应首选使用通用接口而不是非通用接口。泛型接口的元素是你在声明对象时指定的类型, 而非泛型接口的元素则是object类型。使用非通用接口时, C#编译器无法对你的代码进行类型检查。同样, 当处理原始值类型的集合时, 使用非泛型集合将导致这些类型的重复装箱/拆箱, 与适当类型的泛型集合相比, 这可能会对性能产生重大的负面影响。

另一个常见的C#问题是编写你自己的集合对象。但这并不是说它永远不合适, 但是通过.NET提供的广泛选择, 你可以通过使用或扩展现有的.NET而不是重新发明轮子来节省大量时间。特别是, 用于C#和CLI的C5通用集合库”开箱即用”提供了各种各样的附加集合, 例如持久树数据结构, 基于堆的优先级队列, 哈希索引数组列表, 链接列表等等。

常见的C#编程错误#8:忽略免费资源

CLR环境使用垃圾回收器, 因此你无需显式释放为任何对象创建的内存。实际上, 你不能。 C中没有等效的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()。

常见的C#编程错误#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

一些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因此必然是”更好”的方法是不正确的。有时候是这样, 有时候不是。这就是为什么有两种方法可以做到这一点。在你所处的环境中使用正确的方法, 请记住, 作为开发人员, 异常肯定可以成为你的朋友。

常见的C#编程错误#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!
          }
  
      }

而且, 以Intellisense允许我们编写代码的速度, 此错误看起来并不那么不可能。

现在, 你的程序中出现了一个严重错误(尽管出于已经说明的原因, 编译器仅将其标记为警告), 并且根据程序的复杂程度, 你可能会浪费大量时间来跟踪该错误。如果你首先注意了此警告, 则只需五秒钟即可解决此问题。

请记住, 如果你正在侦听, C Sharp编译器会为你提供有关代码健壮性的许多有用信息。不要忽略警告。通常, 它们只需要花费几秒钟的时间进行修复, 而在发生新问题时修复它们可以节省你的时间。训练自己, 使Visual Studio”错误列表”窗口显示” 0错误, 0警告”, 以便所有警告使你感到不舒服, 无法立即解决它们。

当然, 每个规则都有例外。因此, 有时你的代码对编译器来说似乎有些混乱, 即使这正是你的预期。在极少数情况下, 仅在触发警告的代码周围以及仅对其触发的警告ID周围使用#pragma warning disable [警告ID]。这将取消该警告, 并且仅禁止该警告, 因此你仍然可以保持警惕以防出现新的警告。

本文总结

C#是一种功能强大且灵活的语言, 具有许多可以极大地提高生产率的机制和范例。但是, 就像使用任何软件工具或语言一样, 对其功能的有限了解或欣赏有时可能更多的是障碍而不是收益, 而使人们处于”知道足够危险”的谚语状态。

使用像这样的C Sharp教程来熟悉C#的关键细微差别, 例如(但绝不限于)本文中提出的问题, 将有助于C#优化, 同时避免了C#的一些更常见的陷阱。语言。

相关:6个基本的C#面试问题

赞(0)
未经允许不得转载:srcmini » Buggy C#代码:C#编程中最常见的10个错误

评论 抢沙发

评论前必须登录!