Effective-CSharp-15不要创建无谓的对象

垃圾回收器可以帮你把内存管理好,并高效地移除那些用不到的对象,但这并不是在鼓励你毫无节制地创建对象,因为创建并摧毁一个基于堆 (heap-based) 的对象无论如何都要比根本不生成这个对象耗费更多的处理器时间。在方法中创建很多局部的引用对象可能会大幅降低程序的性能。

因此,开发者不应该给垃圾回收器 (GC) 带来太多的负担,而是应该利用一些简单的技巧,尽量降低 GC 的工作量。所有引用类型的对象都需要先分配内存,然后才能使用,即使是局部变量也不例外。如果跟对象与这些对象之间没有路径可通,那么他们就变成了垃圾。具体到局部变量来看,如果声明这些变量的那个方法不再活跃于程序中,那么很可能导致这些变量成为垃圾。

例如很多人喜欢在窗口的paint handler 里面分配 GDI 对象,这样做容易出现这个问题:

1
2
3
4
5
6
7
8
9
protected override void OnPaint(PaintEventArgs e)
{
// Bad. Created the same font every paint event.
using (Font MyFont = new Font("Arial", 10.0f))
{
e.Graphics.DrawString(DataTime.Now.ToStirng(), MyFont, Brushes.Black, new PointF(0, 0));
}
base.OnPaint(e);
}

系统会频繁调用 OnPaint(),而每次调用时,都会创建新的 Font 对象,但是这并没有必要,因为实际上这些对象都是一样的,因此垃圾回收器总是得回收旧的 Font。GC 的执行时机与程序所分配的内存数量以及分配的频率有关,如果总是分配内存,那么 GC 的工作压力就比较大,这自然会降低程序效率。

反之,将 Font 对象从局部变量改为成员变量,那么就可以复用同一个 Font:

1
2
3
4
5
6
7
private readonly Font myFont = new Font("Arial", 10.0f);

protected override void OnPaint(PaintEventArgs e)
{
e.Graphics.DrawString(DataTime.Now.ToStirng(), MyFont, Brushes.Black, new PointF(0, 0));
base.OnPaint(e);
}

改版之后就不会进行频繁的垃圾回收了,这将使得程序运行的稍快一点。对于像本例的 Font 这样实现了 IDisposable 接口的类型来说,把该类型的局部变量提升为成员之后,需要在类中实现这个接口(见17条)。

如果局部变量是引用类型而非值类型,并且出现在需要频繁运行的例程 (routine) 中,那就应该将其提升为成员变量。上文的 OnPaint 例程中的 myFont 就是如此。请注意,只有当例程调用得较为频繁时材质的这样做,如果不太频繁,那么可以不用考虑这个问题。要避免频繁的创建相同的对象,而不是说把每个局部变量都转化为成员变量。

上文的代码用到了 Brushes。Black 这个静态属性,该属性采用另一种技术来避免频繁创建相似的对象。如果程序中有很多地方都要用到某个引用类型的实例,那么可以把它创建成静态的成员变量。每次用黑色绘制窗口时,都要使用这样的画笔,但如果每次绘制时都去重新分配,那么程序在执行过程中要创建并销毁大量的 Brush 对象。即便按照刚才那条技巧将这个对象从局部提升为成员变量,也无法避免该问题。由于程序会创建很多窗口与控件,而且在绘制时会用到大量的黑色画笔,因此.NET框架的设计者决定,只创建一支黑色的画笔给程序中的各个地方公用。Brushes 类里面有大量的 Brush 对象,每个对象对应于一种颜色,这种颜色的画笔是程序中的每个例程都可以使用的。Brushes 类在其内部采用惰性求值算法 (lazy evaluation algorithm) 来创建画笔,这种算法的逻辑可以表示成下面这样:

1
2
3
4
5
6
7
8
9
10
11
private static Brush blackBrush;
public static Brush Black
{
get
{
if (blackBrush == null)
blackBrush = new SolidBrush(Color.Black);
return blackBrush;
}
}
}

首次请求获取黑色画笔时,Brushes 类会创建该画笔,并把指向它的引用保存起来。以后如果还要获取这种颜色的画笔,那么 Brushes 类久把早前保存的引用直接返回给你,而不用再去重新创建。并且还有一个好处,如果某种画笔从始至终根本没有用到,那么 Brushes 类就根本不会创建该画笔。在编程工作中使用该技术会有正反两方面的效果, 正面效果是可以令程序少创建一些对象,而负面效果则是有可能导致对象在内存中待的比较久,这还意味着开发者无法释放非托管资源,因为你不知道什么时候调用 Dispose() 方法才好。

前面讲的这两项技巧可以令程序在运行过程中尽量少分配一些对象,第一项技巧是把经常使用的局部变量提升为成员变量,第二项技巧是采用依赖注入 (dependency injection) 的办法创建并复用那些经常使用的实例。此外还有一项针对不可变类型 (immutable type) 的技巧,该技巧可以把这种类型对象最终所应具备的取值分步骤地构建好。比方说,System.String 类就是不可变的,这种字符串创建好之后,其内容无法修改。某些代码看上去好像是修改了字符串内容,但其实还是创建了新的string对象,并用它来替换原有的string,从而导致后者变为垃圾。下面这种写法看起来似乎没有问题:

1
2
3
4
string msg = "Hello, ";
msg += thisUser.name;
msg += ". Today is ";
msg += System.DateTime.Now.ToString();

但是这样写很没有效率,因为它相当于:

1
2
3
4
5
6
7
8
// Not legal, for illustration only:
string msg = "Hello, ";
string msg1 = new string(msg + thisUser.Name);
msg = msg1; // "Hello, " is garbage
string msg2 = new string(msg + ". Today is ");
msg = msg2; // "Hello, <user>" is garbage
string msg3 = new string(msg + System.DateTime.Now.ToString());
msg = msg3; // "Hello, <user>. Today is " is garbage

tmp1、tmp2、tmp3 以及最初的 msg 全都成了垃圾,因为在 string 类的对象上面运用 += 运算符会导致程序创建出新的字符串对象,并且指令向原字符串的引用指向这个新的对象。程序并不会把这两个字符串中的字符连接起来并将其保存在原来那个字符串的存储空间中。如果想用效率较高的办法完成刚才那个例子所执行的操作,那么可以考虑内插字符串实现:

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

相较于 string.Format(),字符串内插避免了因写入太多参数而对错位置的情况:(见第4条)

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

如果要执行更为复杂的操作,那么可以使用 StringBuilder 类:

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

由于这个例子很简单,因此用内插字符串来做就足够了(内插字符串的用法见第4条)。如果最终要构建的字符串很复杂,不方便用内插字符串实现,那么可以考虑改用 StringBuilder 处理,这是一种可变的字符串,提供了修改其内容的机制,使得开发者能够以此来构建不可变 string 对象。与 StringBuilder 类本身的功能相比,更值得学习的是它所体现的设计思路,也就是说,如果要设计不可变的类型,那就应该考虑提供相应的 builder(构建器),令开发者能够以分阶段的形式来指定不可变的对象最终所应具备的取值。这既可以保证构建出来的对象不会遭到修改,又能够给开发者提供较大的余地,使其可以将整个构建过程划分为多个步骤。

垃圾回收器能够有效地管理应用程序使用的内存,但需注意,在堆上创建并销毁对象需要耗费一定的时间,因此,不要过多地创建对象,不要创建那些根本不用去重新构建的对象。此外,在函数中以局部变量的形式频繁创建引用类型的对象也是不合适的,应该把这些变量提升为成员变量,或是考虑把常用的那几个实例设置成相关类型中的静态对象。最后还有一条技巧,就是要考虑给不可变的类型设计相应的 builder 类,以供用户通过可变 builder 对象来构建不可变的对象。