Ryuu 的个人博客

一个计算机初学者

本条目将告诉你什么时候应该使用哪种嵌套类,以及这样做的原因。

嵌套类(nested class)是指定义在另一个类内部的类。嵌套类存在的目的应该只是为他的外围类(enclosing class)提供服务。如果嵌套类将来可能会用于其他的某个环境中,它就应该是顶层类(top-level class)。
嵌套类有四种:

  1. 静态成员类 (static member class)

  2. 非静态成员类 (nonstatic member class)

  3. 匿名类 (anonymous class)

  4. 局部类 (local class)

除了第一种之外,其他三种都称为内部类(inner class)

静态成员类是最简单的一种嵌套类。最好把他看作是普通类,只是被声明在另一个类的内部而已,它可以访问外围类的所有成员,包括那些声明为私有的成员。静态成员类是外围类的一个静态成员,与其静态成员一样,也遵守同样的可访问性规则。如果他被声明为私有的,它就只能在外围类的内部才可以被访问,等等。

静态成员类的一种常见用法是作为共有的辅助类,只有与它的外部类一起使用才有意义。例如,以枚举为例,它描述了计算器支持的各种操作(第34条)。Operation枚举应该是Calculator类的公有静态成员类,之后Calculator类的客户端就可以用诸如Calculator.Operation.PLUS 和 Calculator.Operation.MINUS 这样的名称来引用这些操作。

从语法上讲,静态成员类和非静态成员类之间唯一的区别是,静态成员类的声明中包含修饰符static。尽管它们的语法非常相似,但是这两种嵌套类有很大的不同。非静态成员类的每个实例都隐含地与外围类的一个外围实例(enclosing instance)相关联。在非静态成员类的实例方法内部,可以调用外围实例上的方法,或者利用修饰过的this (qualified this) 构造获得外围实例的引用[JLS, 15.8.4]。如果嵌套类的实例可以在它外围类的实例之外独立存在,这个类就必须是静态成员类:在没有外围实例的情况下,要想创建非静态成员类的实例是不可能的。

数组与泛型相比,有两个重要的不同点。首先,数组是协变的(covariant)。这个词听起来有点吓人,其实只是表示,如果Sub为Super的子类型,那么数组类型Sub[]就是Super[]子类型。相反,泛型则是可变的(invariant):对于任意两个不同的类型Type1和Type2,List既不是List的子类型,也不是List的超类型[JLS,4.10; Naftalin07, 2.5]。你可能认为,这意味着泛型是有缺陷的,但实际上可以说数组才是有缺陷的。下面的代码片段是合法的:

1
2
3
4
// Fails at runtime!
Object[] objectArray = new Long[1];
objectArray[0] = "I don't fit in"; // Throws ArrayStoreException
// Ryuu : 这竟然编译期不报错,C# 里编译期肯定报错了

但下面这段代码则不合法

1
2
3
// Won't compile!
List<Object> objectList = new ArrayList<Long>(); // Incompatible types
objectList.add("I don't fit in");

这其中无论哪一种方法,都不能将 String 放进 Long 容器中,但是使用数组,你会在运行时才发现所犯的错误;而使用列表,则可以在编译时就发现错误。

数组与泛型的第二大区别:数组是具象化的 (reified)[JLS, 4.7]。因此数组会在运行时知道和强化它们的元素类型。如上所述,将 String 保存到 Long 数组中,就会得到一个 ArrayStoreException 异常。泛型则是通过擦除(erasure)[JLS, 4.6]来实现的。泛型只会在编译时强化它们的类型信息,运行时丢弃(或者擦除)它们的元素信息。擦除就是使泛型可以与没有使用泛型的代码随意进行互用(见26条),以确保在Java5中平滑过渡到泛型。

因为以上这些根本的区别,数组和泛型不能很好地混合使用。例如,创建泛型、参数化类型或者类型参数的数组是非法的。

以下数组创建表达式没有一个是合法的

这些在编译期会产生一个泛型数组创建(generic array creation)错误。

1
2
3
4
new List<String>[];
new List<E>[1];
new E[1];
// Ryuu : 这竟然编译期报错,C# 里肯定没有错

从技术角度上来说,E、List和List这样的型应称作**不可具体化(nonreifiable)类型[JLS, 4.7]**。不可具体化类型是指其运行时表示法包含的信息比它编译时表示包含的信息比它的编译时表示法包含的信息更少的类型。唯一可具体化的(reifiable)参数化类型是无限制的通配符类型如List和Map(见26条)。创建无限制通配类型的数组是合法的。

如前文所说的,若对象包含非托管资源,那么一定要正确的清理。对于非托管型资源来说,.NET Framework 会采用一套标准的模式来完成清理工作,如果你所变写的类里也用到了非托管资源,那么该类的使用者就会认为这个类同样遵循这套模式。标准的 dispose (释放/处置) 模式会实现 IDisposable 接口,又会提供 finalizer (终结器/终止化器),以便在客户端忘记调用 IDisposable.Dispose() 的情况下也可以释放资源。这样做虽然有可能令程序的性能因执行 finalizer 而下降,但毕竟可以保证垃圾回收器能够把资源回收掉。这是处理非托管资源的正确方式,开发者应该透彻地理解该方式。实际上,.NET 中的非托管资源还可以通过System.Runtime.Interop.SafeHandle 的派生类来访问,那个类也正确地实现了这套标准的 dispose 模式。

在类的继承体系中,位于根部的那个基类应该做到如下几点

  • 实现 IDisposable 接口,以便释放资源。

  • 若本身含有非托管资源,添加 finalizer,以防客户端忘记调用 Dispose() 方法。若是没有非托管资源,则不添加 finalizer。

  • Dispose 方法与 finalizer (如果有) 都把释放资源的工作委派给虚方法,使得子类能够重写该方法,以释放它们自己的资源。

继承体系中的子类应该做到以下几点:

  • 若子类有自己的资源需要释放,那就重写由基类所定义的那个虚方法,若没有,则不重写该方法。
  • 若子类自身的某个成员字段表示非托管资源,实现 finalizer,若没有这样的字段,则不用实现 finalizer。
  • 记得调用基类的同名函数。

若类包含非托管资源,必须提供 finalizer,因为开发者不能保证使用者总是会调用 Dispose()。如果他们忘了,则会造成资源泄漏,尽管这是使用者的错误,但是受责备的是你 (因为你没有提前防范这种情况)。**(Ryuu:当然,作为使用者,应使用 Dispose(),而不是全部依赖于 finalizer (特别是在使用外部资源时)。finalizer 仅是保险手段,其确切的执行时间是不可知的 (作者下文有述)。Java 中也是如此。)**

垃圾收集器每次运行时,都会把不带 finalizer 的垃圾对象立刻从内存中移除。而带有 finalizer 的对象则会继续留在内存中,并添加到队列中。GC 会安排线程在这些对象上运行其 finalizer,运行完毕后,通常可以像不带 finalizer 的垃圾对象一样被移除。但与那些对象相比,他们属于老一代的对象,因为只有当其 finalizer 执行过一次后, GC 才会将其视为可以直接释放的对象,他们需要在内存中停留更长的时间。这也是没有办法,因为必须通过 finalizer 来保证非托管资源得到释放。尽管程序的性能可能因此有所下降,但只要客户端记得调用 Dispose(),就不会有此问题。

如果所编写的类使用了某些必须及时释放的资源,那么应按照广利实现 IDisposable 接口,以提醒此类使用者与运行系统注意。该接口只包含一个方法:

1
2
3
public interface IDisposable {
void Dispose();
}

实现 IDisposable.Dispose() 时需要注意:

  1. 释放所有的非托管资源
  2. 释放所有的托管资源 (其中包括取消事件订阅)
  3. 设定相关状态标志,表示该对象已被清理。若清理后还有对其成员的访问,可通过状态标志得知该情况,令这些操作抛出 ObjectDisposedException。
  4. 阻止垃圾回收器对该对象的重复清除 (可以通过 GC.SuppressFinalize(this) 来完成)。

正确实现 IDisposable 接口是一举两得的,因为它既提供了适当的机制使得托管资源能够及时释放,又令客户端可以通过标准的 Dispose() 来释放非托管类型的资源。如果你编写的类实现了 IDisposable 接口,并且客户端又能够记得调用其 Dispose(),那么程序将不必执行 finalizer,其性能也得到了保证,这将使得此类能顺利融入 .NET 环境中。

但此机制依然有漏洞,因为子类在清理自身的资源时必须保证基类的资源也能得到清理。若子类要重写 finalizer 或是想根据自己的需要给 IDisposable.Dispose() 添加新的逻辑,那么必须调用基类的版本。否则,基类的资源无法正确释放。此外,由于 finalizer 和 Dispose() 都有类似的任务。因此,这两个方法几乎总是包含重复的代码。直接重写接口中的函数可能无法达到预期效果,因为这些函数默认的情况下是非虚的。为此,需要再做一点工作来解决问题:把 finalizer 和 Dispose() 中重复的代码提取到 protected 级别的虚函数中,使得子类能够重写该函数,以释放他们分配的资源,而基类则应在接口方法中把核心的逻辑实现好。该辅助函数可以声明为此以供子类重写,使得其能在 Dispose() 方法或 finalizer 得以执行时把相关的资源清理干净:

1
protected virtual void Dispose(bool isDisposing)

IDisposable.Dispose() 和 finalizer 都可以调用此方法以清理相关资源。这个方法与 IDisposable.Dispose() 相互重载。由于其是虚方法,子类可以重写该方法,以便用适当的代码来清理自身的资源并调用基类版本。

  • isDisposing:true

    清理托管资源与非托管资源 (这表明该方法是在 IDisposable.Dispose() 中调用的)

  • isDisposing:false

    仅清理非托管资源 (这表明该方法是在 finalizer 中调用的)

无论是哪种情况,都要调用基类的 Dispose(bool),使得基类有机会清除其资源。

如下示例演示了该模式所用的代码框架,其中,MyResourceHog 实现了 IDisposable 接口,并创建了 Dispose(bool) 的虚方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class MyResourceHog : IDisposable
{
// Flag for already disposed
private bool alreadyDisposed = false;

// Implementation of IDisposable
// Call the virtual Dispose method
// Suppress Finalization
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose(bool isDisposing)
{
// Don't dispose more than once.
if (alreadyDisposed) return;

if (isDisposing)
{
// elided: free managed resources here.
}

// elided: free managed resources here.
// Set disposed flag:
alreadyDisposed = true;
}

public void ExampleMethod()
{
if (alreadyDisposed)
throw new ObjectDisposedException("MyResourceHog", "Called Example Method on Disposed object");
// remainder elided.
}
}

DerivedResourceHog 继承了 MyResourceHog,并重写了基类中的 protected Dispose(bool):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class DerivedResourceHog : MyResourceHog
{
// Have its own disposed flag.
private bool disposed = false;

protected override void Dispose(bool isDisposing)
{
// Don't dispose more than once.
if (disposed) return;
if (isDisposing)
{
// TODO: free managed resources here.
}
// TODO: free unmanaged resources here.
// Let the base class free its resource.
// Base class is responsible for calling
// GC.SuppressFinalize()
base.Dispose(isDisposing);

// Set derived class disposed flag:
disposed = true;
}
}

请注意,基类和子类对象采用各自的 disposed 标志来表示其资源是否得到释放。若公用一个标志,那么子类可能率先将其设置为 true,而等到基类运行 Dispose(bool) 时,则会误认为其资源已释放。

Dispose(bool) 与 finalizer 需要具备 幂等性 (idempotent),多次调用 Dispose(bool) 的效果应与调用一次相同。由于各对象的 dispose 操作之间可能没有明确的顺序,因此在执行自身的 Dispose(bool) 时,或许其中某些成员已经被释放 (dispose) 了。这并不表示程序出了问题,因为 Dispose() 本身就可能多次被调用。对于该方法以外的其他 public 方法而言,如果在此对象已被释放后还有人要调用,那么应抛出 ObjectDisposedException ( Dispose() 是个例外)。在对象释放后调用该方法应该没有任何效果。当系统执行某个对象的 finalizer 时,该对象所引用的某些资源可能已经释放过,或是没有得到初始化。对于前者来说,不用检查其是否为 null,因为他所引用的资源还可以继续引用,只是有可能被释放,甚至其 finalizer 有可能已经执行过了。

上文示例的两个类都没有 finalizer,示例代码根本不会以 false 为参数调用 Dispose(bool)。只有当该类型直接包含非托管资源时,才应实现 finalizer。否则不调用也会给该类带来负担,因为这有着较大的开销。若有,则必须添加 finalizer 才能正确的实现 dispose 模式,此时的 finalizer 应与 Dispose(bool) 相同,都可以适当地将非托管资源释放掉。

在编写 Dispose 或 finalizer 等资源清理方法时,最重要的一点是:仅释放资源,不进行其他处理。否则就会产生一些涉及对象生存期的严重问题。一般的,对象在构造时诞生,在变成垃圾并回收时死亡。若程序不在访问某个对象,可以认为该对象已 *昏迷 (comatose)*,对象中的方法也不会得到调用,实际上等于已经消亡了,然而如果他包含 finalizer,那么系统在正式宣告其死亡前,会给他机会,使其能够将非托管资源清理。此时,如果 finalizer 令该对象可以重新为程序引用,那么他将复活,但是这种从昏迷中醒来的对象有其问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// DON'T DO THIS!
public class BadClass
{
// Store a reference to a global object:
private static readonly List<BadClass> FinalizedList = new List<BadClass>();
private string msg;

public BadClass(string msg)
{
this.msg = msg;
}

~BadClass()
{
// Add this object to the list.
// This object is reachable, no longer garbage.
// It's back!
FinalizedList.Add(this);
}
}

BadClass 对象执行其 finalizer 时,会将指向自身的引用添加到全局变量表中,使得程序能够再度访问该对象,使得此对象复活。这会造成很大问题。由于 finalizer 已经执行过了,因此垃圾回收器不会再执行其 finalizer ,于是这个复活的对象将不会被系统做终结 (finalize)。其次,该对象引用的资源可能无效了。对于那些只通过 finalizer 队列中对象访问的资源来说,GC 将不会把他们从内存中移除,但这些资源的 finalizer 可能已经执行过了,这些资源基本上不能再使用了。请不要采用此写法。

应该不会有人在终结对象时故意将其复活。但此例说明,若想在 Dispose 和 finalizer 中调用其他的函数以执行一些工作,请仔细考虑,这些操作可能会导致 bug,最好是将其删除,使得 Dispose 和 finalizer 只用来释放资源。

对于运行在托管环境的程序来说,开发者不需要给自己的每一个类都编写 finalizer。只有当其中包含了非托管资源或是带有实现了 IDisposable 接口的成员,才需要添加 finalizer。注意,在只需 IDisposable 接口但不需要 finalizer 的场合下,还是应该把整套模式写出,使得子类可轻松的实现标准的 dispose 方案。

(Ryuu:作者所述许多要点,文档中都有具体实现,推荐查看 (本书作者也是 dotnet docs 的作者,Bill wagner’s github overview)。终结器 - C# 编程指南非托管类型 - C# 参考IDisposable 接口 (System)实现 Dispose 方法)

垃圾回收器可以帮你把内存管理好,并高效地移除那些用不到的对象,但这并不是在鼓励你毫无节制地创建对象,因为创建并摧毁一个基于堆 (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 对象来构建不可变的对象。

若使用 C#,那么就必须适应静态类型检查机制,该机制在很多情况下都会起到良好的作用。

静态类型检查意味着编译器会把类型不符的用法找出来,这也令应用程序在运行期能够少做一些类型检查。然而有的时候还必须在运行期检查对象的类型,比如,如果所使用的框架已经在方法签名里把参数写成了 Object,那么可能就得先将该参数转成其他类型(例如其他的类或接口),然后才能继续编写代码。有两种办法能实现转换,一是使用 as 运算符,二是通过强制类型转换 (cast) 来绕过编译器的类型检查。在这之前,可以先通过 is 判断该操作是否合理,然后再使用 as 运算符 或执行强制类型转换。

在这两种方法中,应该优先考虑第一种办法,这样做要比盲目地进行类型转换更加安全,且在运行的时候更有效率。as 及 is 运算符不会考虑由用户所定义的转换。只有当运行期的类型与要转换到的类型相符合时,该操作才能顺利地执行。这种类型转换操作很少会为了类型转换而构建新的对象(但若用 as 运算符把装箱的值类型转换成未装箱且可以为 null 的值类型,则会创建新的对象)。

下面来看一个例子。如果需要把 object 对象转换为 MyType 实例,那么可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
object o = Factory.GetObject();
// Version one:
MyType t = 0 as MyType;

if(t!= null)
{
// work with t, it's a MyType
}
else
{
// report the failure
}

此外,也可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object o = Factory.GetObject();
// Version one:
try
{
MyType t;
t = (MyType) o;
if (t != null)
{
// work with t, it's a MyType
}
}
catch(InvalidCastException)
{
// report the conversion failure
}

大家应该会觉得第一种写法比第二种更简单,而且更好理解。由于第一种写法不需要使用 try/catch 结构,因此程序的开销与代码量都比较低。如果采取第二种写法,那么不仅要捕获异常,而且还得判断t是不是 null。强制类型转换在遇到 null 的时候并不抛出异常,这导致开发者必须处理两种情况:一种是 o 本来就为 null,因此强制转换后所得的 t 也是 null;另一种是程序因 o 无法类型转换为 MyType 而抛出异常。如果采用第一种写法,那么由于 as 操作在这两种特殊情况下的结果都是 null,因此只需要用 if (t!= null) 来概括处理就可以了。

as 运算符与强制类型转换之间的最大区别在于如何对待由用户所定义的转换逻辑。as 与 is 运算符只会判断待转换的那个对象在运行期是何种类型,并据此做出相应的处理,除了必要的装箱与取消装箱操作,它们不会执行其他操作。如果待转换的对象既不属于目标类型,也不属于由目标类型所派生出来的类型,那么 as 操作就会失败。反之,强制类型转换操作则有可能使用某些类型转换逻辑来实现类型转换,这不仅包含由用户所定义的类型转换逻辑,而且还包括内置的数值类型之间的转换。例如可能发生从 long 至 short 转换,这种转换可能导致信息丢失。

继承(inheritance)是实现代码重用的有力手段,但它并非永远是完成这项工作的最佳工具。使用不当会导致软件变得很脆弱。在包的内部使用继承是非常安全的,在那里子类和超类的实现都处在同一个程序员的控制下。对于专门为了继承而设计并且具有很好的文档说明的类来说(见19条),使用继承也是非常安全的。然而,对于普通的具体类(concrete class)进行跨越包边界的继承,则是非常危险的。提示一下,本条目使用”继承”一词,含义是实现继承(当一个类扩展另一个类的时候)。本条目中讨论的问题并不指接口继承(类实现接口或接口扩展接口)。

与方法调用不同的是,继承打破了封装性[Snyder86]。子类依赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果发生了变化,子类可能会遭到破坏,即时是子类代码完全没有改变。因而,子类必须要跟着其超类的更新而演变,除非超类是专门为了扩展而设计的,并且具有很好的文档说明。

为了说明得更加具体,我们建设有一个程序使用了 HashSet。为了调优该程序的性能需要查询 HashSet,看一看自从它被创建以来添加了多少个元素(不要与它当前的元素数目混淆起来,它会随着元素的删除而递减)。为了提供这种功能,需要基于 HashSet 编写一个变体,定义记录视图插入的元素的数量 addCount,并针对该计数值导出一个访问方法。HashSet 类包含两个可以郑家元素的方法:add 和 addAll,因此这两个方法都要被覆盖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Broken - Inappropriate use of inheritance!
public class InstrumentedHashSet<E> extends HashSet<E> {
// The number of attempted element insertions
private int addCount = 0;

public int getAddCount() {
return addCount;
}

@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}

@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}

这个类看起来非常合理,但是它并不能正常工作。假设我们创建一个实例,并利用addAll方法添加了三个元素。

1
2
3
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount()); // 6

此时我我们期望 getAddCount 方法能返回3,但实际上它返回的是6。原因是,在 HashSet 的内部 addAll 方法是基于它的add 方法来实现的,即使 HashSet 的文档中并没有说明这样的实现细节。所以实际上在 addAll() 中的所有元素都使得 addCount 加了2。只需要去掉被覆盖的 addAll 方法。虽然这样可以正常工作,但其功能正确性依赖于这样的事实:HashSet 的 addAll 方法是在其 add 方法上实现的。 这种自用性(self-use)是实现细节,而不是承诺,不能保证在 java 平台的所有实现中都保持不变,不能保证随着上发行版本的不同而不发生变化。因此,InstrumentedHashSet 类将会是非常脆弱的。

导致子类脆弱的一个原因是,它们的超类在后续的发行版本中可获得新方法。假设程序的安全性依赖于这一事实:所有被插入至某个集合的元素都满足某个先决条件。下面的做法将能保证这一点:对集合进行子类化,并覆盖掉所有的能够添加元素的方法。如果在后续的发行版本中,超类中没有增加能插入元素的新方法,那么这种做法能够正常工作。然而,一旦超类增加了这样的新方法,则很有可能因为调用了这个没有被覆写的新方法,而将”非法”的元素添加到子类的实例中。这不是一个纯粹的理论问题。在把 Hashtable 和 Vector 加入到 Collections Framework 中的时候,就修正了几个这类性质的安全漏洞。

以上问题都来源于覆盖 (overriding) 方法。在扩展一个类时,增加新的方法,而不覆盖现有的方法只是相对的安全,并不是没有风险。如果超类在后续的发行版本中获得了一个新方法,并且和子类中的某一方法只是返回类型不同,这样的子类将无法通过编译 [JLS,8.4.8.3]。如果给子类提供的方法带有与新的超类方法完全相同的签名及返回类型,这就覆盖了超类中的方法。你的方法是否能遵守新超类方法,也是个问题。因为在编写子类方法时,超类新方法还并存在

幸运的是,有一种方法可以避免前文所述的所有问题。不扩展现有类,而是在新的类中增加一个私有域,引用现有类的一个实例。这种设计被称为 “复合” (composition)。因为现有类变成了新类的一个组件。新类中的每个实例方法都能调用被包含的现有实例中对应的方法,并返回其结果。这被称为转发 (forwarding),新类中的方法被称为转发方法 (forwarding method)。这样的类将会非常的稳固,它不依赖于现有类的实现细节,即使是现有类增加了新的方法,也不会影响到新的类。

如下示例用复合 / 转发方法来代替 InstrumentedHashSet 类。注意这个实现分为两部分:类本身和可重用的转发类 (forwarding class),其中包含了所有转发方法,没有任何的其他方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Wrapper class - uses composition in place of inheritance
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;

public InstrumentedSet(Set<E> s) { super(s); }

public int getAddCount() { return addCount; }

@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}

@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Reusable forwarding class
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;

public ForwardingSet(Set<E> s) { this.s = s; }

public int size() { return s.size(); }

public boolean isEmpty() { return s.isEmpty(); }

public boolean contains(Object o) { return s.contains(o); }

public Iterator<E> iterator() { return s.iterator(); }

public Object[] toArray() { return s.toArray(); }

public <T> T[] toArray(T[] a) { return s.toArray(a); }

public boolean add(E e) { return s.add(e); }

public boolean remove(Object o) { return s.remove(o); }

public boolean containsAll(Collection<?> c) { return s.containsAll(c); }

public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }

public boolean retainAll(Collection<?> c) {
return s.retainAll(c);
}

public boolean removeAll(Collection<?> c) { return s.retainAll(c); }

public void clear() { s.clear(); }

public boolean equals(Object o) { return s.equals(o); }

public int hashCode() { return s.hashCode(); }

public String toString() { return s.toString(); }
}

Set 接口的存在使得 InstrumentedSet 类的设计成为可能,因为 Set 接口保存了 HashSet 类的功能特性。前文的基于继承的方法只适用于单个具体类,并且对于超类中所支持的每个构造器都要求有一个单独的构造器,与此不同的是,这里的包装类 (wrapper class) 可以被用来包装任何 Set 实现,并且可以结合任何先前存在的构造器一起工作:

1
2
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));

因为每一个 InstrumentedSet 实例都把另一个 Set 实例包装,所以 InstrumentedSet 类被称为包装类 (wrapper class)。这也正是 Decorator (修饰者) 模式 [Gamma95] (InstrumentedSet 类对集合进行修饰,增加计数特性)。有时复合和转发的结合也被宽松的称为 “委托” (delegation)。从技术的角度而言,这不是委托,除非包装对象把自身传递给被包装的对象 [Liebermen86; Gamma95] (Ryuu:确实是有种委托的味道)。

包装类几乎没有什么缺点。需要注意的是,**包装类不适合用于回调框架 (callback framework)**;在回调框架中,对象需要把自身的引用传递给其他对象,用于后续的调用 (“回调”)。因为被包装的类并不直到它外面的包装对象,它传递一个指向自身的引用 (this),回调时避开了外面的包装对象。这被称为 SELF 问题 [Lieberman86]。有些人担心转发方法调用所带来的性能影响,或者包装对象导致的内存使用。在实践中,这两者都不会造成太大的影响。编写转发方法倒是有点繁琐,但只需给每个接口编写一次构造器,转发类则可以通过包含接口的包提供。如 Guava 就为所有的集合接口提供了转发类 [Guava]。

只有当子类真正是超类的子类型 (subtype) 时,才适合用继承。对于 A、B 两类,只有两个类有 “is-a” 的关系时,B 才应该扩展 A。若想用 B 扩展 A,就应该问问自己:每个 B 是否 is an A?如果不能肯定,那么就不应该进行扩展。如果没有 is-a 的关系,通常情况下,B 包含 A 的一个私有实例,并暴露一个较小的、较简单的 API:A 本质上不是 B 的一部分,只是它的实现细节。

在 Java 平台类库中,有许多明显违反这条原则的地方。例如,栈 (stack) 并不是向量 (vector),所以 Stack 不应扩展 Vector。同样的,属性列表也不是散列表,所以 Properties 不应扩展 Hashtable。这种情况下,复合模式才是恰当的。

若在适用复合的地方使用了继承,则会不必要地暴露实现细节。这样得到的 API 会把你限制在原始的实现上,不必要的局限该类。更为严重的是,由于暴露了内部的细节,客户端可能直接访问这些内部细节。至少会造成语义上的混淆。例如,如果 p 指向 Properties 实例,那么 p.getProperty(key) 就有可能产生与 p.get(key) 不同的结果:getProperty 考虑了默认的属性表,get 继承自 HashTable,未考虑默认的属性列表。最为严重的是,客户可能直接修改超类,从而破坏子类的约束条件。在 Properties 的情况中,设计者的目标是只允许字符串作为键 (key) 和值 (value),但直接访问底层的 HashTable 就允许违反这种约束条件。一旦违反了约束,就不能再使用 Properties API 的其他部分了。等到发现这个问题的时,已经太迟,因为客户已经依赖于使用非字符串的键和值了。

在使用继承而非复合前,考虑被扩展类的 API 是否有缺陷。是否愿意这些缺陷传播到类中 API?继承机制会把超类 API 的所有缺陷传播到子类中,而复合则允许设计新的 API 以隐藏这些缺陷。

继承违背了封装原则。只有当子类和超类有 “is-a” 的关系时,使用继承才是恰当的。并且,若子类与超类处于不同的包,并且超类并不是为继承而设计,那么继承将会导致脆弱性 (fragility)。为了避免这种脆弱性,可用复合转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装类不仅比子类更健壮,功能也更强大。

当你从手动管理内存的语言(比如C或者C++)转换到具有垃圾回收功能的语言时,工作会更加容易,因为当对象使用完毕后将会自动回收。但是,这并不代表着程序员再也不需要考虑内存管理的事情了。

考虑以下这个简单栈实现的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Can you spot the "memory leak"?
public class Stack {
private static final int DEFAULT_INITIAL_CAPACITY = 16;
private Object[] elements;
private int size = 0;

public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}

public void push(Object e) {
elements[size++] = e;
}

public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];
}

/**
* Ensure space for at least one more element, roughly doubling the capacity each time the array needs to grow.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}

这段程序(泛型版见26条)中并没有很明显的错错误。但其中隐藏着一个问题。不严格的讲,这段程序存在”内存泄漏”,随着内存的占用不断增加,程序的性能降低将会越来越明显。在极端的情况下,这种内存泄漏会导致磁盘交换(Disk Paging),甚至导致程序失败(OutOfMemoryError),虽然这种情况相对少见。

错误在于,如果一个栈先是增长,然后再收缩,那么,从栈中弹出来的对象将不会被当作垃圾回收,即时是使用栈的程序不在引用这些对象,它们也不会被回收。因为,栈内部维护着对这些对象的过期引用(obsolete reference)。所谓的过期引用,是指永远也不会在被解除的引用。在本示例中,凡是在elements数字的”活动部分(active protion)”之外的任何引用都是过期的。活动部分是指elements中下标小于size的那些元素。

在支持垃圾回收的语言中,内存泄漏是很隐蔽的(称这些内存泄漏为”无意识的对象保持(unintentional object retention)”更为恰当)。如果一个对象引用被无意识的保留起来了,也会有许许多多的对象被排除在垃圾回收机制之外,从而对性能造成潜在的重大影响。

这类问题的修复方法很简单:一旦对象引用已经过期,只需清空这些引用即可。对于上述例子中的Stack而言,只要有数据被弹出栈,指向它的引用就过期了。pop方法修改版如下:

1
2
3
4
5
6
7
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null;
return result;
}

Ryuu: elements的中的元素本身就和size不相关,仅是返回个值栈末尾的元素当然不会被回收,要将elements对其元素的引用置空,让元素没有被指针指向,该元素才会被当作垃圾回收。

以下是 Ryuu 的示例代码,您可以打断点尝试,不置空元素确实不会被回收

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import java.util.Arrays;
import java.util.EmptyStackException;

public class Rule6 {
public static void main(String[] args) {
Stack stack = new Stack();
for (int i = 0; i < 10; i++)
stack.push("1");
for (int i = 0; i < 10; i++)
stack.pop(); // break point
System.out.println("complete"); // break point
}
}

// Can you spot the "memory leak"?
class Stack {
private static final int DEFAULT_INITIAL_CAPACITY = 16;
private Object[] elements;
private int size = 0;

public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}

public void push(Object e) {
elements[size++] = e;
}

public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];
}

/**
* Ensure space for at least one more element, roughly doubling the capacity each time the array needs to grow.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}



class Stack {
private static final int DEFAULT_INITIAL_CAPACITY = 16;
private Object[] elements;
private int size = 0;

public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}

public void push(Object e) {
elements[size++] = e;
}

public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null;
return result;
}

/**
* Ensure space for at least one more element, roughly doubling the capacity each time the array needs to grow.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}

清空过期引用的另一个好处是,如果它们以后又被错误的引用,程序就会立即抛出NullPointerException异常,而不是悄悄地错误运行下去。尽快地检测出程序中的错误总是有益的。

当程序员第一次被类似这样的问题困扰的时候,它们往往会过分小心: 对于每一个对象引用,一旦程序不再用到它,就把他清空。其实这样做并没有必要,会把程序弄得很乱。清空对象引用应该是种例外,而不是一种规范行为。消除过期引用的最好的方法是让包含该引用的变量结束其生命周期。如果你是在最紧凑的作用范围内定义每一个变量(见第45条),这种情形就会自然而然地发生。

内存泄漏常见的三个来源

1. 自行管理内存类。

一般在这种情况下,程序员应警惕其可能的内存泄漏问题。一旦元素被释放掉,则该元素种包含的任何对象引用都应该被清空。

2. 缓存。

一旦将对象引用放到缓存中,很容易就会被遗忘掉,从而使得它不在有用之后很长一段时间内仍然留在缓存中。

3. 监听器和其他回调。

如果实现了一个API,客户端在这个API当中注册回调,却没有显式的取消注册 ,那么除非你采取某些行动,否则它们就会积聚。确保回调立即被当作垃圾回收的最佳方法是只保存它们的弱引用(weak reference),例如,只将它们保存成WeakHashMap中的键。

由于内存泄漏通常不会表现成明显的失败,所以它们可以在一个系统中存在很多年。往往只有通过检查代码,或者借助于Heap剖析工具(Heap Profiler)才能发现内存泄漏问题。因此,如果能够在内存泄漏发生之前就知道如何预测此类问题,并阻止它们发生,那是最好不过了。

一般来说,最好能重用对象,而不是在每次需要的时候就创造一个相同功能的对象。重用方式既快速又流行。如果对象是不可变的(immutable)(见第15条),它就始终可以被重用。

作为一个极端反面的例子,考虑下面的语句:

1
String s = new String("stringgette"); // DON'T DO THIS!

该语句每次执行的时候都创建一个新的String实例,但是这些创建对象的动作全都是不必要的。传递给String构造器的参数”stringgette”本身就是个String实例,功能方面等同于构造器创建的所有对象。如果这种用法是在一个循环中,或者是在一个被频繁调用的方法中,就会创建出成千上万不不要的String实例。

改进后的版本如下:

1
String s = "stringgette";

这个版本只使用一个String实例,而不是每次执行的时候都创建一个新的实例。而且,它可以保证,对于所有在同一台虚拟机种的代码,只要它们包含想用的字符串字面常量,该对象就会被重用 [JLS, 3.10.5]

对于同时提供了静态工厂方法(见第1条)和构造器的不可变类,通常可以使用静态工厂方法而不是构造器,以避免创建不必要的对象。例如,静态工厂方法Boolean.valueOf(String)几乎总是优先于构造器Boolean(String)。构造器在每次调用的时候都会创建一个新的对象,而静态工厂方法则从来不要求这样做,实际上也不会这样做。

除了重用不可变对象之外,也可以重用那些一直不会被修改的可变对象。下面是一个比较微妙,也比较常见的反面例子,其中涉及可变的Date对象,它们的值一旦计算出来之后就不在变化。这个类建立了一个模型,其中有一个人,并有一个isBabyBoomer方法,用来检验这个人是否为一个”baby boomer”(婴儿潮时期出生的婴儿),也就是检验这个人是否出生于1946-1964年。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// DON'T DO THIS!
public class Person {
private final Date birthDate;

public Person(Date birthDate) {
this.birthDate = birthDate;
}

// Other fields, methods and constructor omitted
public boolean isBabyBoomer() {
// Unnecessary allocation of expensive object
Calendar gmtCal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
Date boomStart = gmtCal.getTime();
gmtCal.set(1964, Calendar.JANUARY, 1, 0, 0, 0);
Date boomEnd = gmtCal.getTime();
return birthDate.compareTo(boomStart) >= 0 && birthDate.compareTo(boomEnd) < 0;
}
}

isBabyBoomer 每次别调用的时候,都会新建一个Calendar ,一个 TimeZone 和两个 Date 实例,这是不必要的。下面的版本用一个静态的初始化器(initializer)

,避免了这种效率低下的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Person {
private final Date birthDate;
private static final Date BOOM_START;
private static final Date BOOM_END;

static {
Calendar gmtCal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
BOOM_START = gmtCal.getTime();
gmtCal.set(1964, Calendar.JANUARY, 1, 0, 0, 0);
BOOM_END = gmtCal.getTime();
}

public Person(Date birthDate) {
this.birthDate = birthDate;
}

// Other fields, methods and constructor omitted

public boolean isBabyBoomer() {
return birthDate.compareTo(BOOM_START) >= 0 && birthDate.compareTo(BOOM_END) < 0;
}
}

改进后的Person类只在初始化的时候创建Calender,TimeZone和Date实例一次,而不是在每次调用isBabyBoomer的时候都创建这些实例。如果isBabyBoomer方法被频繁的调用,这种方法将会显著地提高性能 (作者: 在我的机器上,每调用一千万次,原来的版本需要32 000ms,而改进后的版本只需 130ms)。除了提高性能之外,代码的含义也更加清晰了。把boomStart和boomEnd从局部变量改为final静态域,这些如期显然是被作为常量对待,从而使得代码更易于理解。但是,这种优化带来的效果并不总是那么明显,因为Calender实例创建代价特别昂贵。

如果改进后的Person类被初始化了,isBabyBoomer方法却永远不会被调用,那就没有必要初始化BOOM_START和BOOM_END域。通过延迟初始化(lazily initializing)(见第71条),即把对这些域的初始化延迟到isBabyBoomer方法第一次被调用的时候进行,则有可能消除这些不必要的初始化工作,但是不建议这样做。正如延迟初始化中常见的情况一样,这样做会使方法的实现更加复杂,从而无法将性能显著提高到超过已经达到的水平(见55条)。

在本条目前面的例子中,所讨论的对象显然都是能够被重用的,因为它们被初始化之后不会再改变。其他的情形则并不总是这么明显了。考虑适配器(adapter)的情形 [Gamma95, p.139],有时也叫做视图 (view)。适配器是指这样一个对象:它把功能委托给一个后备对象 (backing object),从而为后备对象提供一个可以替代的接口。由于适配器除了后备对象之外,没有其他的状态信息,所以针对某个给定对象的特定适配器而言,它不需要创建多个适配器实例。

例如,Map接口的keySet方法返回该Map对象的Set视图,其中包含该Map中所有的键 (key)。看起来好像每次调用keySet都应该创建一个新的Set实例,但是,对于一个给定的Map对象,实际上每次调用keySet都返回同样的Set实例。虽然返回的Set实例一般是可改变的,但是所有返回的对象在功能上是等同的:当其中一个返回对象发生变化时,所有其他的返回对象也要发生变化,因为它们是由同一个Map实例支撑的。虽然创建KeySet视图对象的多个实例并无害处,但也无必要。

在Java 1.5发行版本中,有一种创建多余对象的新方法,称作自动装箱 (auto boxing),它允许程序员将基本类型和装箱基本类型 (Boxed Primitive Type) 混用,按需要自动装箱和拆箱。自动装箱使得基本类型和装箱基本类型之间的区别变得模糊起来,但是并没有完全消除。它们在语义上还有着微妙的差别,在性能上也有着比较明显的差别 (见第49条)。考虑下面的程序,他计算所有int正值的总和。为此,程序必须使用long,因为int不够大,无法容纳所有int正值的总和:

1
2
3
4
5
6
7
8
// Hideously slow program! Can you spot the object creation?
public static void main(String[] args) {
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
System.out.println(sum);
}

这段程序是正确的,但是要比预期的情况要更慢,因为这里打错了字符大写了L。变量sum 由 long类型变为了Long类型,意味着程序构造了231个多余的Long实例。将sum的声明从Long改为long (作者: 在我的机器上运行时间从43秒降低到了6.8秒)。结论很明显:要优先使用基本类型而不是装箱基本类型,要当心无意识的自动装箱。

以下是我自己尝试的代码 时间分别是 50 6598 ms 和 50 827 ms 性能差了约10倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private static long TIME_SPEND = System.currentTimeMillis();
private static long TIME_SPEND_TOTAL = 0;

public static void main(String[] args) {
// Avoid unnecessary boxing and unboxing.
// Long sum = 0L; // TEST_COUNT = 50 6598 ms
long sum = 0L; // TEST_COUNT = 50 827 ms
int TEST_COUNT = 10;
for (int j = 0; j < TEST_COUNT; j++) {
sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
TIME_SPEND_TOTAL += System.currentTimeMillis() - TIME_SPEND;
System.out.println("No." + (j + 1) + " : " + (System.currentTimeMillis() - TIME_SPEND) + " ms " +sum);
TIME_SPEND = System.currentTimeMillis();
}
System.out.println(TIME_SPEND_TOTAL / TEST_COUNT + " ms");
}

不要错误的认为本条目介绍的内容暗示着”创建对象的代价非常昂贵,我们应该尽可能的避免创建对象”。相反,由于小对象的构造器只做很少量的显式工作,所以,小对象的创建和回收动作是非常廉价的,特别是在现代的JVM实现上更是如此。通过创建附加的对象,提升程序的清晰性,简洁性和功能性,这通常是件好事。

反之,通过维护自己的对象池 (object pool) 来避免创建对象并不是一种好的做法,除非池中的对象是非常重量级的。真正正确使用对象池的典型对象示例就是数据库连接池。建立数据库连接的代价是非常昂贵的,因此重用这些对象非常有意义。而且,数据库的许可可能限制你只能使用一定数量的连接。但是,一般而言,维护自己的对象池必定增加代码的复杂度,同时增加内存占用 (footprint),并且还会损害性能。现代的JVM实现具有高度优化的垃圾回收器,其性能很容易就会超过轻量级对象池的性能。

与本条目对应的是第39条中有关”保护性拷贝(defensive copying)” 的内容。本条目提及”当你应该重用现有对象的时候,请不要创建新的对象”,而第39条中”当你应该创建新对象的时候,请不要重用现有的对象”。注意,在提倡使用保护性拷贝的时候,因重用对象而付出的代价要远远大于因创建重复对象而付出的代价。必要时如果没能实施保护性拷贝,将会导致潜在的错误和安全漏洞;而不必要地创建对象则只会影响程序的风格和功能。

你可能需要编写只包含静态方法和静态域的类(例如一些工具类) 。这些类的名声很不好,因为有些人在面向对象的语言中滥用这样的类来编写过程化的程序。但它们也的确有他们的用处。

这样的工具类 (utility class) 不希望被实例化,实例对它们没有意义。然而,在缺少显式构造器的情况下,编译器会自动提供一个共有的,无参的缺省构造器(default constructor)。对于用户而言,这个构造器与其他的构造器没有任何的区别,在已发行的API中常常可以看到一些被无意识地实例化的类。

企图通过将类做成抽象类来强制该类不可被实例化,这样是行不通的。该类可以被子类化,并且该子类可被实例化。这样甚至会误导用户,以为该类是特意为了继承而设计的。然而,有一些简单的习惯用法可以保证类不被实例化。由于只有当类不包含显式构造器时,编译器才会产生缺省的构造器,因此我们只要让这个类包含私有构造器,它就不能被实例化了:

1
2
3
4
5
6
7
8
// Noninstantiable utility class
public class UtilityClass {
// Suppress default constructor for noninstantiaility
private UtilityClass() {
throw new AssertionError();
}
... // Remainder omitted
}

由于显示的构造器是私有的,所以不可以在该类的外部访问它。AssertionError不是必需的,但是它可以避免不小心在该类的内部调用构造器。他保证该类在任何情况下都不会被实例化。这种习惯用法有点违背直觉,好像构造器的声明就是设计成不能被调用一样。因此,较好的做法是,在代码中增加注释(见上文)。

这种习惯用法也有其副作用,它使得一个类不能被子类化。所有的构造器都必须显示或隐式地调用超类 (superclass) 构造器,在这种情况下,子类就没有可访问的超累构造器可以调用了。

Singleton 指仅仅被实例化一次的类。Singleton 通常被用来代表那些本质上唯一的系统组件,比如窗口管理器或者文件系统。(单例模式)

在 Java 1.5 发行版本之前,实现Singleton有两种方法。这两种方法都要把构造器保持为私有的,并指定一个共有的静态成员。

  1. 在第一个方法中,共有静态成员是个 final 域:

    1
    2
    3
    4
    5
    6
    // Singleton with public final field
    public class Elvis{
    public class final Elvis INSTANCE = new Elvis();
    private Elvis(){ ... }
    public void leaveTheBuilding(){ ... }
    }

    私有的构造器仅被调用一次,用来实例化共有的静态 final 域 Elvis.INSTANCE。由于缺少共有的或者受保护的构造器,所以保证了Elvis的全局唯一性:一旦 Elvis 类被实例化,只会存在一个Elvis实例,不多也不少。

    但要提醒一点:享有特权的客户端可以借助AccessibleObject.setAccessible方法,通过反射机制调用私有构造器 (java 反射攻击) 。如果需要抵御这种攻击,可以修改构造器,让他在被要求创建第二个实例的时候抛出异常。

  2. 在第二种方法中,共有的成员是个静态工厂方法:

    1
    2
    3
    4
    5
    6
    7
    // Singleton with static factory
    public class Elvis{
    private class final Elvis INSTANCE = new Elvis();
    private Elvis(){ ... }
    public static Elvis getInstance() { return INSTANCE; }
    public void leaveTheBuilding(){ ... }
    }

    为了使利用这其中一种方法实现的Singleton类变成可序列化的(Serializable),仅仅在声明中加上”implements Serializable” 是不够的。为了维护并保证Singleton,必须声明所有实例域都是顺时的(transient) 的,并提供一个readResolve方法。否则,每次反序列化一个序列化的实例时,都会创建一个新的实例。

    在如下的例子中会导致”假的Elvis”出现。为了防止这种情况,要在Elvis类中加入下面这个readResovle方法:

    1
    2
    3
    4
    5
    // readResovle method to preserve singleton property
    private Object readResolve(){
    // Return the one true Elvis and let the garbage collector take care of the Elvis impersonator
    return INSTANCE;
    }
  3. 从 Java 1.5 发行版本起,实现Singleton还有第三种方法。只需要编写一个包含单个元素的枚举类型:(推荐方法)

    1
    2
    3
    4
    5
    // Enum singleton - the preferred approch
    public enum Elvis{
    INSTANCE;
    public void leaveTheBuilding(){ ... }
    }

    这种方法在功能上域共有域方法相近,但是他更加简洁,无偿的提供了序列化机制,绝对防止多次实例化,即使是在面对复杂序列化或者反射攻击的时候。虽然这种方法还没有广泛的采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法。

0%