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

已发表: 2022-03-11

关于 C Sharp

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

C# 是一种面向对象的强类型语言。 C# 中严格的类型检查,无论是在编译时还是运行时,都会导致大多数典型的 C# 编程错误尽早被报告,并且它们的位置非常准确。 这可以在 C Sharp 编程中节省大量时间,与追踪令人费解的错误的原因相比,这种错误可能在违规操作发生后很长时间内发生在对类型安全执行更加自由的语言中。 但是,许多 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

如您所见, PointPen对象的创建方式完全相同,但是当将新的X坐标值分配给point2时, point1的值保持不变,而在分配新颜色时修改了pen1的值pen2 。 因此我们可以推断出point1point2每个都包含它们自己的Point对象副本,而pen1pen2包含对同一个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# 中,值类型不能为空。 根据定义,值类型是有值的,即使是值类型的未初始化变量也必须有值。 这称为该类型的默认值。 在检查变量是否未初始化时,这会导致以下通常意外的结果:

 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..

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

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

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

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

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

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

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

  • 在比较用户输入的字符串或要显示给用户的字符串时,请使用区分区域性的比较( CurrentCultureCurrentCultureIgnoreCase )。
  • 比较编程字符串时,使用序数比较( OrdinalOrdinalIgnoreCase )。
  • 通常不使用InvariantCultureInvariantCultureIgnoreCase ,除非在非常有限的情况下,因为序数比较更有效。 如果需要进行文化感知比较,则通常应针对当前文化或其他特定文化进行比较。

除了Equals方法之外,字符串还提供了Compare方法,它为您提供有关字符串相对顺序的信息,而不仅仅是一个相等性测试。 这种方法优于<<=>>=运算符,原因与上面讨论的相同——以避免 C# 问题。

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

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

在 C# 3.0 中,向语言添加语言集成查询 (LINQ) 永远改变了查询和操作集合的方式。 从那时起,如果您使用迭代语句来操作集合,那么您可能没有使用 LINQ。

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

虽然数据库查询是 LINQ 语句的一种非常普遍的用法,但它们实际上适用于任何可枚举集合(即,任何实现 IEnumerable 接口的对象)。 因此,例如,如果您有一个 Accounts 数组,而不是编写 C# List 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 语句可以轻松替换代码中迭代循环(或嵌套循环)中的数十个语句。 更少的通用代码意味着更少的引入错误的机会。 但是请记住,在性能方面可能会有所取舍。 在性能关键场景中,尤其是在您的迭代代码能够对您的集合做出 LINQ 无法做出假设的情况下,请务必在这两种方法之间进行性能比较。

常见的 C# 编程错误 #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 表数据的引用时(如本示例中的 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# 编程语言的一种便利,它使您能够向现有类型“添加”方法,而无需创建新的派生类型、重新编译或以其他方式修改原始类型。

通过包含using [namespace];将扩展方法纳入范围。 文件顶部的声明。 您需要知道哪个 C# 命名空间包含您要查找的扩展方法,但是一旦您知道要查找的是什么,就很容易确定。

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

扩展方法是 C# 编译器中“语法糖”的一个例子,它允许我们编写(通常)更清晰、更易于维护的代码。 更清楚的是,如果您知道它们的用法。 否则,它可能会有点混乱,尤其是一开始。

虽然使用扩展方法当然有好处,但它们可能会导致问题,并为那些不了解它们或没有正确理解它们的开发人员提供 C# 编程帮助的呼声。 在在线查看代码示例或任何其他预先编写的代码时尤其如此。 当这样的代码产生编译器错误时(因为它调用的方法显然没有在它们被调用的类上定义),倾向于认为代码适用于不同版本的库,或者完全适用于不同的库。 可能会花费大量时间来寻找不存在的新版本或幻像“缺失的库”。

即使是熟悉扩展方法的开发人员也偶尔会被抓到,当对象上有一个同名的方法,但它的方法签名与扩展方法的签名有细微的不同时。 寻找不存在的错字或错误可能会浪费大量时间。

在 C# 库中使用扩展方法正变得越来越普遍。 除了 LINQ,Unity 应用程序块和 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# 的类型安全性,您通常应该更喜欢泛型接口而不是非泛型接口。 泛型接口的元素是您在声明对象时指定的类型,而非泛型接口的元素是对象类型。 使用非泛型接口时,C# 编译器无法对代码进行类型检查。 此外,在处理原始值类型的集合时,使用非泛型集合将导致这些类型的重复装箱/拆箱,与适当类型的泛型集合相比,这可能会导致显着的负面性能影响。

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

常见的 C# 编程错误 #8:忽视释放资源

CLR 环境使用垃圾收集器,因此您无需显式释放为任何对象创建的内存。 事实上,你不能。 在 C 中没有与 C++ delete运算符或free()函数等效的功能。 但这并不意味着您可以在使用完所有对象后忘记它们。 许多类型的对象封装了一些其他类型的系统资源(例如,磁盘文件、数据库连接、网络套接字等)。 让这些资源保持打开状态会迅速耗尽系统资源的总数,降低性能并最终导致程序故障。

虽然可以在任何 C# 类上定义析构函数方法,但析构函数(在 C# 中也称为终结器)的问题是您无法确定何时调用它们。 它们由垃圾收集器(在单独的线程上,这可能导致额外的复杂性)在未来不确定的时间调用。 试图通过使用GC.Collect()强制垃圾收集来绕过这些限制并不是 C# 的最佳实践,因为这会在线程收集所有符合收集条件的对象时阻塞线程一段未知的时间。

这并不是说终结器没有很好的用途,但是以确定性的方式释放资源并不是其中之一。 相反,当您在文件、网络或数据库连接上进行操作时,您希望在完成后立即显式释放底层资源。

资源泄漏几乎在任何环境中都是一个问题。 但是,C# 提供了一种强大且易于使用的机制,如果使用该机制,可以使泄漏发生的几率大大降低。 .NET 框架定义了IDisposable接口,该接口仅由Dispose()方法组成。 任何实现IDisposable的对象都希望在对象的使用者完成操作时调用该方法。 这导致了明确的、确定性的资源释放。

如果您在单个代码块的上下文中创建和处置对象,那么忘记调用Dispose()基本上是不可原谅的,因为 C# 提供了一个using语句,该语句将确保无论代码块如何调用Dispose()退出(无论是异常、返回语句,还是简单地关闭块)。 是的,这与前面提到的用于在文件顶部包含 C# 命名空间的using语句相同。 它还有第二个完全不相关的目的,许多 C# 开发人员不知道。 即,确保在退出代码块时调用对象上的Dispose()

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

通过在上面的示例中创建using块,您可以确定myFile.Dispose()将在您完成文件后立即调用,无论Read()是否引发异常。

常见的 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中的返回值,如果你发现它为null,你会怎么做? 你写的方法在合适的地方报错吗? 如果演员阵容失败,你还有什么可以尝试的吗? 如果不是,那么抛出异常是正确的做法,所以你不妨让它发生在尽可能靠近问题根源的地方。

以下是其他常见方法对的几个示例,其中一个抛出异常,另一个不抛出:

 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 个警告”,这样任何警告都会让您感到不舒服,以便立即解决它们。

当然,每条规则都有例外。 因此,有时您的代码在编译器看来会有些可疑,即使这正是您想要的样子。 在那些非常罕见的情况下,仅在触发警告的代码周围使用#pragma warning disable [warning id] ,并且仅针对它触发的警告 ID。 这将抑制该警告,并且仅抑制该警告,以便您仍然可以对新警告保持警惕。

包起来

C# 是一种强大而灵活的语言,具有许多可以大大提高生产力的机制和范例。 然而,与任何软件工具或语言一样,对其功能的理解或评价有限有时可能更像是一种障碍而不是一种好处,从而使一个人处于众所周知的“知道足够危险”的状态。

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


进一步阅读 Toptal 工程博客:

  • 基本 C# 面试问题
  • C# 与 C++:核心是什么?