3.5 数据隐藏和封装
本章开头说过,类由一些数据和方法组成。目前,我们尚未说明的最重要的面向对象技术之一是,把数据隐藏在类中,只能通过方法获取。这种技术叫作封装(encapsulation),因为它把数据(和内部方法)安全地密封在类这个“容器”中,只能由可信的用户(即这个类中的方法)访问。
为什么要这么做呢?最重要的原因是,隐藏类的内部实现细节。如果避免让程序员依赖这些细节,你就可以放心地修改实现,而无需担心会破坏使用这个类的现有代码。
你应该始终封装自己的代码。如果没有封装好,那么几乎无法推知并最终确认代码是否正确,尤其是在多线程环境中(而基本上所有 Java 程序都运行在多线程环境中)。
使用封装的另一个原因是保护类,避免有意或无意做了糊涂事。类中经常包含一些相互依赖的字段,而且这些字段的状态必须始终如一。如果允许程序员(包括你自己)直接操作这些字段,修改某个字段后可能不会修改重要的相关字段,那么类的状态就前后不一致了。然而,如果必须调用方法才能修改字段,那么这个方法可以做一切所需的措施,确保状态一致。类似地,如果类中定义的某些方法仅供内部使用,隐藏这些方法能避免这个类的用户调用这些方法。
封装还可以这样理解:把类的数据都隐藏后,方法就是在这个类的对象上能执行的唯一一种可能的操作。
只要小心测试和调试方法,就可以认为类能按预期的方式运行。然而,如果类的所有字段都可以直接操作,那么要测试的可能性根本数不完。
这种想法可以得到一个非常重要的推论,5.5 节介绍 Java 程序的安全性时会说明(Java 程序的安全和 Java 编程语言的类型安全不是同一个概念)。
隐藏类的字段和方法还有一些次要的原因。
如果内部字段和方法在外部可见,会弄乱类的 API。让可见的字段尽量少,可以保持类的整洁,从而更易于使用和理解。
如果方法对类的使用者可见,就必须为其编写文档。把方法隐藏起来,可以节省时间和精力。
3.5.1 访问控制
Java 定义了一些访问控制规则,可以禁止类的成员在类外部使用。在本章的一些示例中,你已经见过字段和方法声明中使用的 public 修饰符。这个 public 关键字,连同 protected 和 private(还有一个特殊的),是访问控制修饰符,为字段或方法指定访问规则。
1. 访问包
Java 语言不直接支持包的访问控制。访问控制一般在类和类的成员这些层级完成。
已经加载的包始终可以被同一个包中的代码访问。一个包在其他包中是否能访问,取决于这个包在宿主系统中的部署方式。例如,如果组成包的类文件存储在一个目录中,那么用户必须能访问这个目录和其中的文件才能访问包。
2. 访问类
默认情况下,顶层类在定义它的包中可以访问。不过,如果顶层类声明为 public,那么在任何地方都能访问。
第 4 章会介绍嵌套类。嵌套类是定义为其他类的成员的类。因为这种内部类是某个类的成员,因此也遵守成员的访问控制规则。
3. 访问成员
类的成员在类的主体里始终可以访问。默认情况下,在定义这个类的包中也可以访问成员。这种默认的访问等级一般叫作包访问。这只是四个可用的访问等级中的一个。其他三个等级使用 public、protected 和 private 修饰符定义。下面是使用这三个修饰符的示 例代码:
public class Laundromat { // 所有人都可以使用这个类private Laundry[] dirty; // 不能使用这个内部字段public void wash() { ... } // 但能使用这两个公开的方法public void dry() { ... } // 处理内部字段// 子类可能会想调整这个字段protected int temperature;}
下述访问规则适用于类的成员。
类中的所有字段和方法在类的主体里始终可以使用。
如果类的成员使用
public修饰符声明,那么可以在能访问这个类的任何地方访问这个成员。这是限制最松的访问控制类型。如果类的成员声明为
private,那么除了在类内部之外,其他地方都不能访问这个成员。这是限制最严的访问控制类型。如果类的成员声明为
protected,那么包里的所有类都能访问这个成员(等同于默认的包访问规则),而且在这个类的任何子类的主体中也能访问这个成员,而不管子类在哪个包中定义。如果声明类的成员时没使用任何修饰符,那么使用默认的访问规则(有时叫包访问),包中的所有类都能访问这个成员,但在包外部不能访问。
默认的访问规则比
protected严格,因为默认规则不允许在包外部的子类中访问成员。
使用 protected 修饰的成员时要格外小心。假设 A 类使用 protected 声明了一个字段 x,而且在另一个包中定义的 B 类继承 A 类(重点是 B 类在另一包中定义)。因此,B 类继承了这个 protected 声明的字段 x,那么,在 B 类的代码中可以访问当前实例的这个字段,而且引用 B 类实例的代码也能访问这个字段。但是,这并不意味着在 B 类的代码中能读取任何一个 A 类实例的受保护字段。
下面通过代码讲解这个语言细节。A 类的定义如下:
package javanut6.ch03;public class A {protected final String name;public A(String named) {name = named;}public String getName() {return name;}}
B 类的定义如下:
package javanut6.ch03.different;import javanut6.ch03.A;public class B extends A {public B(String named) {super(named);}@Overridepublic String getName() {return "B: " + name;}}
Java 的包不能“嵌套”,所以
javanut6.ch03.different和javanut6.ch03是不同的包。javanut6.ch03.different不以任何方式包含在javanut6.ch03中,也和javanut6.ch03没有任何关系。
可是,如果我们试图把下面这个新方法添加到 B 类中,会导致编译出错,因为 B 类的实例无法访问任何一个 A 类的实例:
public String examine(A a) {return "B sees: " + a.name;}
如果把这个方法改成:
public String examine(B b) {return "B sees another B: " + b.name;}
就能编译通过,因为同一类型的多个实例可以访问各自的 protected 字段。当然,如果 B 类和 A 类在同一包中,那么任何一个 B 类的实例都能访问任何一个 A 类实例的全部受保护字段,因为使用 protected 声明的字段对同一个包中的每个类都可见。
4. 访问控制和继承
Java 规范规定:
子类继承超类中所有可以访问的实例字段和实例方法;
如果子类和超类在同一个包中定义,那么子类继承所有没使用
private声明的实例字段和方法;如果子类在其他包中定义,那么它继承所有使用
protected和public声明的实例字段和方法;使用
private声明的字段和方法绝不会被继承;类字段和类方法也一样;构造方法不会被继承(而是链在一起调用,本章前面已经说过)。
不过,有些程序员会对“子类不继承超类中不可访问的字段和方法”感到困惑。这似乎暗示了,创建子类的实例时不会为超类中使用 private 声明的字段分配内存。然而,这不是上述规定想表述的。
其实,子类的每个实例都包含一个完整的超类实例,其中包括所有不可访问的字段和方法。
某些成员可能无法访问,这似乎和类的成员在类的主体中始终可以访问相矛盾。为了避免误解,我们要使用“继承的成员”表示那些可以访问的超类成员。
那么,关于成员访问性的正确表述应该是:“所有继承的成员和所有在类中定义的成员都是可以访问的。”这句话还可以换种方式说:
类继承超类的所有实例字段和实例方法(但不继承构造方法);
在类的主体中始终可以访问这个类定义的所有字段和方法,而且还可以访问继承自超类的可访问的字段和方法。
5. 成员访问规则总结
表 3-1 总结了成员的访问规则。
表3-1:类中成员的可访问性
| 能否访问 | 成员可见性 | |||
|---|---|---|---|---|
| 公开 | 受保护 | 默认 | 私有 | |
| 定义成员的类 | 是 | 是 | 是 | 是 |
| 同一个包中的类 | 是 | 是 | 是 | 否 |
| 不同包中的子类 | 是 | 是 | 否 | 否 |
| 不同的包,也不是子类 | 是 | 否 | 否 | 否 |
下面是一些使用可见性修饰符的经验法则。
只使用
public声明组成类的公开 API 的方法和常量。使用public声明的字段只能是常量和不能修改的对象,而且必须同时使用final声明。使用
protected声明大多数使用这个类的程序员不会用到的字段和方法,但在其他包中定义子类时可能会用到。
严格来说,使用
protected声明的成员是类公开 API 的一部分,必须为其编写文档,而且不能轻易修改,以防破坏依赖这些成员的代码。
如果字段和方法供类的内部实现细节使用,但是同一个包中协作的类也要使用,那么就使用默认的包可见性。
使用
private声明只在类内部使用,在其他地方都要隐藏的字段和方法。
如果不确定该使用 protected、包还是 private 可见性,那么先使用 private。如果太过严格,可以稍微放松访问限制(如果是字段的话,还可以提供访问器方法)。
设计 API 时这么做尤其重要,因为提高访问限制是不向后兼容的改动,可能会破坏依赖成员访问性的代码。
3.5.2 数据访问器方法
在 Circle 类那个示例中,我们使用 public 声明表示圆半径的字段。Circle 类可能有很好的理由让这个字段可以公开访问;这个类很简单,字段之间不相互依赖。但是,当前实现的 Circle 类允许对象的半径为负数,而半径为负数的圆肯定不存在。可是,只要半径存储在声明为 public 的字段中,任何程序员都能把这个字段的值设为任何想要的值,而不管这个值有多么不合理。唯一的办法是限制程序员,不让他们直接访问这个字段,然后定义 public 方法,间接访问这个字段。提供 public 方法读写字段和把字段本身声明为 public 不是一回事。目前而言,二者的区别是,方法可以检查错误。
例如,我们或许不想让 Circle 对象的半径使用负数——负数显然不合理,但目前的实现没有阻止这么做。示例 3-4 展示了如何修改 Circle 类的定义,避免把半径设为负数。
Circle 类的这个版本使用 protected 声明 r 字段,还定义了访问器方法 getRadius() 和 setRadius(),用于读写这个字段的值,而且限制半径不能为负数。r 字段使用 protected 声明,所以可以在子类中直接(且高效地)访问。
示例 3-4:使用数据隐藏和封装技术定义的 Circle 类
package shapes; // 为这个类指定一个包public class Circle { // 这个类还使用public声明// 这是通用的常量,所以要保证声明为publicpublic static final double PI = 3.14159;protected double r; // 半径被隐藏了,但在子类中可见// 限制半径取值的方法// 这是子类可能感兴趣的实现细节protected void checkRadius(double radius) {if (radius < 0.0)throw new IllegalArgumentException("radius may not be negative.");}// 非默认的构造方法public Circle(double r) {checkRadius(r);this.r = r;}// 公开的数据访问器方法public double getRadius() { return r; }public void setRadius(double r) {checkRadius(r);this.r = r;}// 操作实例字段的方法public double area() { return PI * r * r; }public double circumference() { return 2 * PI * r; }}
我们在一个名为 shapes 的包中定义 Circle 类。因为 r 字段使用 protected 声明,所以 shapes 包中的任何其他类都能直接访问这个字段,而且能把它设为任何值。这里假设 shapes 包中的所有类都由同一个作者或者协作的多个作者编写,而且包中的类相互信任,不会滥用拥有的访问权限影响彼此的实现细节。
最后,限制半径不能使用负数的代码在一个使用 protected 声明的方法中,这个方法是 checkRadius()。虽然 Circle 类的用户无法调用这个方法,但这个类的子类可以调用,而且如果想修改对半径的限制,还可以覆盖这个方法。
在 Java 中,数据访问器方法的命名有个通用约定,即以“get”和“set”开头。但是,如果要访问的字段是
boolean类型,那么读取字段的方法使用的名称可能会以“is”开头。例如,名为readable的boolean类型字段对应的访问器方法是isReadable()而不是getReadable()。
你应该始终封装自己的代码。如果没有封装好,那么几乎无法推知并最终确认代码是否正确,尤其是在多线程环境中(而基本上所有 Java 程序都运行在多线程环境中)。