65.9K
CodeProject 正在变化。 阅读更多。
Home

有效的 C# - 性能注意事项

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.75/5 (32投票s)

2005年7月4日

CPOL

10分钟阅读

viewsIcon

402923

downloadIcon

2

不要强调在某些情况下可能影响性能的做法。

引言

世界上充满了听起来合理但实际上并非如此的建议。很多时候,一条建议的合理性在多次转述中丢失,导致它进入我们的普遍实践。当我们不断重复某件事作为真理时,其原因却永远丢失了。我们都听过很多次这些建议:“过马路前左右看”,“不要拿着剪刀跑”,你懂的。技术建议也是如此。很容易简单地重复你以前听到的内容,直到它被毫无疑问地接受。

这是错误的(尽管我不建议拿着剪刀跑)。

所有建议都基于一些真实经验,包括为何应遵循该建议的理由。当你只知道建议时,你就无法发现例外情况,也无法识别特定建议何时已过时。

这让我想到了高效软件开发系列的宗旨以及我自己的贡献,即高效 C#。Scott Meyer 给我的建议是专注于经验丰富的 C# 开发人员给其他 C# 开发人员的建议。然而,即使高效系列书籍包含了对实际开发人员最有用的条目,但很少有建议是普遍正确的。这就是为什么高效系列书籍会详细说明每个条目的理由。这个理由让读者掌握了遵循或忽略任何单个条目所需的知识。许多有用的建议不止有一个简单的理由。一个很好的例子是关于“as”和“is”关键字而不是强制转换的建议。确实,这些关键字允许您测试运行时类型信息,而无需编写 try/catch 块或让方法抛出异常。在您发现意外类型时,抛出异常也确实是正确的行为。但是,异常的性能开销并非这两个运算符的全部故事。“as”和“is”运算符执行运行时类型检查,忽略任何用户定义的转换运算符。类型检查运算符的行为与强制转换不同。您会在《高效 C#》中的每个条目中发现类似的细微之处。通过至少一次检查所有这些细节,您将更好地理解每个指南的理由,以及何时应该忽略特定建议。

尽管上一段提到运行时性能是选择不同语言构造的一个理由,但值得注意的是,很少有高效项完全基于性能进行论证。简单的事实是,低级优化不会是普遍适用的。低级语言构造在不同编译器版本或不同使用场景下会表现出不同的性能特征。简而言之,不进行分析和测试就不应该进行低级优化。然而,有一些设计层面的指导方针可以对程序的性能产生重大影响。《高效 C#》中的第 34 条讨论了 Web 服务 API 粒度的一些指导方针。这类设计问题可以对面向服务应用程序的性能产生巨大影响。《高效 C#》的第 2 章全部讨论了资源管理。这包括 IDisposable 和终结器,以及高效的类和对象初始化。但这些指导方针并非严格用于性能;它们包括确保应用程序健壮、正确并随着时间更容易扩展的策略。

本文的其余部分将探讨您可能听说过的一些常见建议。我将指出这些项何时可能有效,以及何时遵循它们会给您带来大麻烦。我将从本文中唯一普遍适用的建议开始

唯一的普遍建议:优化意味着性能分析。

这是您实际测试代码更改的最佳实践的一个特例。当您修复一个 bug 时,您遵循一套既定的步骤:通过测试验证 bug 是否存在。进行更改。通过测试验证 bug 是否已修复(您还应该执行合理的回归测试,以确保没有破坏其他任何东西)。如果您声称要优化代码,也需要相同的过程。您必须在进行更改之前测量性能,进行更改,然后再次测量性能。无论您遵循谁的建议,您都需要执行这些步骤。任何不足都根本不是工程。

传统观点:字符串连接开销很大

毫无疑问,你听说过字符串连接是一个开销很大的操作。每当你编写看起来会修改字符串内容的代码时,实际上你都在创建一个新的字符串对象,并将旧的字符串对象作为垃圾留下。我在《Effective C#》(第 16 条)中使用了以下示例

string msg = "Hello, ";
msg += thisUser.Name;
msg += ". Today is ";
msg += System.DateTime.Now.ToString();

与你写成下面的代码一样低效

string msg = "Hello, ";
string tmp1 = new String( msg + thisUser.Name );
string msg = tmp1; // "Hello " is garbage.
string tmp2 = new String( msg + ". Today is " );
msg = tmp2;        // "Hello <user>" is garbage.
string tmp3 = new String( msg + DateTime.Now.ToString( ) );
msg = tmp3;        // "Hello <user>. Today is " is garbage. 

字符串 tmp1tmp2tmp3,以及最初构造的 msg ("Hello") 都变成了垃圾。字符串类的 += 方法会创建一个新的字符串对象并返回该字符串。它不会通过将字符连接到原始存储来修改现有字符串。对于上面这样简单的构造,您应该使用 string.Format() 方法

string msg = string.Format ( "Hello, {0}. Today is {1}", 
                             thisUser.Name, DateTime.Now.ToString( ));

对于更复杂的字符串操作,您可以使用 StringBuilder 类

StringBuilder msg = new StringBuilder( "Hello, " );
msg.Append( thisUser.Name );
msg.Append( ". Today is " );
msg.Append( DateTime.Now.ToString());
string finalMsg = msg.ToString();

最后,你有两种选择来避免频繁创建字符串对象:string.FormatStringBuilder 类。我根据可读性进行选择。

传统观念被驳斥:检查 Length 属性比相等比较快

有人评论说这个习惯用法

if ( str.Length != 0 )

优于此

if ( str != "" )

通常的理由是速度。人们会告诉你,检查字符串的长度比检查两个字符串是否相等要快。这可能是真的,但我真的怀疑你会看到任何可测量的性能改进。两者都涉及一个函数调用,而且两者都可能由 .NET Framework 团队进行了很好的优化。我个人的偏好是使用 String.Empty 常量进行这些比较,因为我认为它更具可读性

if ( str != string.Empty )

传统观念调查:String.Equal 优于 ==

我将首先向您推荐《Effective C#》中的第 9 条。我花费了数页讨论 .NET 中进行相等比较的不同方法的全部细节。在这里,我将指出一些需要考虑的规则。最重要的一条是:对于字符串类,Operator ==String.Equal() 总是给出相同的结果。句号。您可以互换使用它们,而不会影响程序的行为。选择哪一个取决于您自己的个人风格以及您正在使用的编译时类型。

== 运算符是强类型,并且只有当两个参数都是字符串时才会编译。这意味着它的实现中没有强制转换或转换。另一方面,String.Equal 有多个重载。有一个强类型版本,以及另一个覆盖 System.Object.Equals() 方法的版本。如果您编写代码调用 Object 版本的覆盖,您将付出少量的性能代价。这种代价来自于虚函数调用(轻微的)性能损失,以及转换(轻微的)性能损失。当然,如果任一操作数具有 object 的编译时类型,您无论如何都要付出转换成本,所以这无关紧要。

关于相等性和字符串的最终结论是,如果您使用与您正在使用的编译时类型匹配的方法,您肯定会得到预期的行为,并且很可能会获得最佳性能。如果您想了解所有细节,请参阅《Effective C#》中的第 9 条。

传统观念:装箱和拆箱不好

没错,装箱和拆箱通常与程序中的负面行为相关。很多时候,其理由是性能。是的,装箱和拆箱会导致性能问题。但有时,创建自己的集合类或求助于数组也会同样糟糕。这取决于您访问集合中值的频率。只访问一次?那就使用 ArrayList 类。您可能会没问题。数百次?考虑一下自定义集合。数百次,但您也数百次地改变集合的大小?我不知道哪个更好,这就是为什么要进行性能分析。

比性能更重要的是正确性。我在《Effective C#》(第 106-107 页)中展示了两个不同的例子,其中装箱和拆箱可能导致应用程序中的不正确行为。寻找装箱和拆箱很棘手,因为编译器不会帮助你,而且它会在多个地方发生。每个人都见过集合类如何导致装箱和拆箱。但是,当你通过接口访问值类型以及当你将值传递给期望 System.Object 的方法时,也会发生同样的效果。例如,这一行代码会发生三次装箱和拆箱。

Console.WriteLine("A few numbers:{0}, {1}, {2}",  25, 32, 50);

应该避免装箱和拆箱,但这并不意味着你应该避免对值使用 Systems.Collections 类。有更多的方法可以装箱值,有时代码的节省是值得性能开销的。

传统观念解释:“as”和“is”与强制转换

我这里不讨论性能,因为这不是选择其中一个的原因。“as”和“is”运算符不检查用户定义的转换运算符。强制转换可以使用显式和隐式转换运算符。用户定义转换和强制转换的具体行为变得复杂,并且依赖于参数的编译时类型。我在《Effective C#》(第 3 条)的第 18-24 页讨论了所有细节。

下一个重要问题是,如果转换失败,这是否是一个错误条件。如果接收到的输入不是预期类型是预期行为,那么“as”和“is”运算符提供了比引入异常更简单的检查。但是,如果你要写成这样

if ( ! o is ExpectedType )
  throw new ArgumentTypeException( "You didn't use the right type" );

请直接使用强制转换。它更清晰。另外,考虑一下 double.Parse()double.TryParse() 之间的区别。一个假设成功,并在失败时抛出异常。另一个返回错误代码以指示问题。

传统观点被驳斥:for 循环比 foreach 快

我不会说 foreach 更快。但是,我要说的是,我很高兴在一个编译器编写者努力优化最多功能的语言构造的环境中工作。C# 1.1 编译器为 foreach 循环和数组生成的代码添加了优化。在 1.0 版本中,for 循环要快得多。现在已经不再是这样了。(还记得之前建议你对代码进行性能分析的建议吗?)

这里的最佳实践是编写尽可能清晰的代码,并期望编译器能够正确优化。如有必要,进行性能分析和改进。

传统观点被驳斥:NGen 会提高性能

也许吧。但也许不会。

这是一个危险的建议。是的,NGen 确实会缩短某些程序集的启动时间。但你也因此会失去 JIT 编译的一些优势。JIT 编译器有一些优化只能在运行时执行。这篇白皮书很好地概述了将 JIT 步骤保留到运行时的优势。

这是一个只针对性能的建议,我绝不会在不分析两种版本(启动时间和运行时间)的情况下盲目遵循。此外,您还应该分析 NGen 过的和常规程序集的组合。

结论

正如我在开篇所说,《高效系列》图书的目的是为大多数开发人员提供最有用的建议。这些建议必须有技术信息作为依据,以帮助读者判断何时适用该建议,以及在何种情况下可以安全地忽略它。我向您展示了我在《高效 C#》中向读者提供的理由的一些例子,以及这些理由有时如何与 C# 开发人员之间经常重复的建议相冲突。Addison-Wesley、Code Project 和我正在努力为 Code Project 读者摘录《高效 C#》中的一些条目。

© . All rights reserved.