07-Implementation-Hiding
第七章 封装
访问控制(Access control
) (或者隐藏实现(implementation hiding) )与“最初的实现不恰当”有关。
所有优秀的作者——包括那些编写软件的人——都知道一件好的作品都是经过反复打磨才变得优秀的。如果你把一段代码置于某个位置一段时间,过一会重新来看,你可能发现更好的实现方式。这是重构(refactoring)的原动力之一,重构就是重写可工作的代码,使之更加可读,易懂,因而更易维护。
但是,在修改和完善代码的愿望下,也存在巨大的压力。通常,一些用户(客户端程序员(client programmers
这个问题对于类库(library)而言尤其重要。类库的使用者必须依赖他们所使用的那部分类库,并且知道如果使用了类库的新版本,不需要改写代码。另一方面,类库的开发者必须有修改和改进类库的自由,并保证客户代码不会受这些改动影响。
这可以通过约定解决。例如,类库开发者必须同意在修改类库中的一个类时,不会移除已有的方法,因为那样将会破坏客户端程序员的代码。与之相反的情况更加复杂。在有成员属性的情况下,类库开发者如何知道哪些属性被客户端程序员使用?这同样会发生在那些只为实现类库类而创建的方法上,它们也不是设计成可供客户端程序员调用的。如果类库开发者想删除旧的实现,添加新的实现,结果会怎样呢?任何这些成员的改动都可能破环客户端程序员的代码。因此类库开发者会被束缚,不能修改任何事物。
为了解决这一问题,
然而,类库组件的概念和对类库组件访问的控制仍然不完善。其中仍然存在问题就是如何将类库组件捆绑到一个内聚的类库单元中。
包的概念
包内包含一组类,它们被组织在一个单独的命名空间(namespace)下。
例如,标准
// hiding/FullQualification.java
public class FullQualification {
public static void main(String[] args) {
java.util.ArrayList list = new java.util.ArrayList();
}
}
这种方式使得程序冗长乏味,因此你可以换一种方式,使用
// hiding/SingleImport.java
import java.util.ArrayList;
public class SingleImport {
public static void main(String[] args) {
ArrayList list = new ArrayList();
}
}
现在你就可以不加限定词,直接使用
import java.util.*
之所以使用导入,是为了提供一种管理命名空间的机制。所有类名之间都是相互隔离的。类f()
不会与类f()
冲突。但是如果类名冲突呢?假设你创建了一个
到目前为止的大部分示例都只存在单个文件,并为本地使用的,所以尚未受到包名的干扰。但是,这些示例其实已经位于包中了,叫做“未命名”包或默认包(default package
一个
代码组织
当编译一个
类库是一组类文件。每个源文件通常都含有一个
如果你使用了
package hiding;
意味着这个编译单元是一个名为
例如,假设文件名是
// hiding/mypackage/MyClass.java
package hiding.mypackage;
public class MyClass {
// ...
}
现在,如果有人想使用
// hiding/QualifiedMyClass.java
public class QualifiedMyClass {
public static void main(String[] args) {
hiding.mypackage.MyClass m = new hiding.mypackage.MyClass();
}
}
关键字
// hiding/ImportedMyClass.java
import hiding.mypackage.*;
public class ImportedMyClass {
public static void main(String[] args) {
MyClass m = new MyClass();
}
}
创建独一无二的包名
你可能注意到,一个包从未真正被打包成单一的文件,它可以由很多
将所有的文件放在一个子目录还解决了其他的两个问题:创建独一无二的包名和查找可能隐藏于目录结构某处的类。这是通过将
此技巧的第二部分是把
为了理解这点,比如说我的域名
package com.mindviewinc.simple;
这个包名可以用作下面两个文件的命名空间保护伞:
// com/mindviewinc/simple/Vector.java
// Creating a package
package com.mindviewinc.simple;
public class Vector {
public Vector() {
System.out.println("com.mindviewinc.simple.Vector");
}
}
如前所述,
// com/mindviewinc/simple/List.java
// Creating a package
package com.mindviewinc.simple;
public class List {
System.out.println("com.mindview.simple.List");
}
这两个文件都位于我机器上的子目录中,如下:
C:\DOC\Java\com\mindviewinc\simple
(注意,本书的每个文件的第一行注释都指明了文件在源代码目录树中的位置——供本书的自动代码提取工具使用
如果你回头看这个路径,会看到包名
CLASSPATH=.;D:\JAVA\LIB;C:\DOC\Java
但是在使用
CLASSPATH=.;D\JAVA\LIB;C:\flavors\grape.jar
一旦设置好类路径,下面的文件就可以放在任意目录:
// hiding/LibTest.java
// Uses the library
import com.mindviewinc.simple.*;
public class LibTest {
public static void main(String[] args) {
Vector v = new Vector();
List l = new List();
}
}
输出:
com.mindviewinc.simple.Vector
com.mindviewinc.simple.List
当编译器遇到导入
对于
冲突
如果通过
import com.mindviewinc.simple.*;
import java.util.*;
因为
现在如果要创建一个
Vector v = new Vector();
这里的
java.util.Vector v = new java.util.Vector();
这种写法完全指明了
或者,可以导入单个类以防冲突——只要不在同一个程序中使用有冲突的名字(若使用了有冲突的名字,必须明确指明全名
定制工具库
具备了以上知识,现在就可以创建自己的工具库来减少重复的程序代码了。
一般来说,我会使用反转后的域名来命名要创建的工具包,比如
比如,下面是“控制流”一章中使用到的 range()
方法,采用了
// onjava/Range.java
// Array creation methods that can be used without
// qualifiers, using static imports:
package onjava;
public class Range {
// Produce a sequence [0,n)
public static int[] range(int n) {
int[] result = new int[n];
for (int i = 0; i < n; i++) {
result[i] = i;
}
return result;
}
// Produce a sequence [start..end)
public static int[] range(int start, int end) {
int sz = end - start;
int[] result = new int[sz];
for (int i = 0; i < sz; i++) {
result[i] = start + i;
}
return result;
}
// Produce sequence [start..end) incrementing by step
public static int[] range(int start, int end, int step) {
int sz = (end - start) / step;
int[] result = new int[sz];
for (int i = 0; i < sz; i++) {
result[i] = start + (i * step);
}
return result;
}
}
这个文件的位置一定是在某个以一个
从现在开始,无论何时你创建了有用的新工具,都可以把它加入到自己的类库中。在本书中,你将会看到更多的组件加入到
使用import 改变行为
但是,条件编译还有其他的用途。调试是一个很常见的用途,调试功能在开发过程中是开启的,在发布的产品中是禁用的。可以通过改变导入的
使用包的忠告
当创建一个包时,包名就隐含了目录结构。这个包必须位于包名指定的目录中,该目录必须在以
注意,编译过的代码通常位于与源代码的不同目录中。这是很多工程的标准,而且集成开发环境(IDE)通常会自动为我们做这些。必须保证
访问权限修饰符
如果不提供访问修饰符,就意味着
包访问权限
本章之前的所有示例要么使用
包访问权限可以把相关类聚到一个包下,以便它们能轻易地相互访问。包里的类赋予了它们包访问权限的成员相互访问的权限,所以你
类控制着哪些代码有权访问自己的成员。其他包中的代码不能一上来就说
- 使成员成为
public 。那么无论是谁,无论在哪,都可以访问它。 - 赋予成员默认包访问权限,不用加任何访问修饰符,然后将其他类放在相同的包内。这样,其他类就可以访问该成员。
- 在
" 复用" 这一章你将看到,继承的类既可以访问public 成员,也可以访问 protected 成员(但不能访问 private 成员 ) 。只有当两个类处于同一个包内,它才可以访问包访问权限的成员。但现在不用担心继承和protected 。 - 提供访问器(accessor)和修改器(mutator)方法(有时也称为
"get/set" 方法) ,从而读取和改变值。
public: 接口访问权限
当你使用关键字
// hiding/dessert/Cookie.java
// Creates a library
package hiding.dessert;
public class Cookie {
public Cookie() {
System.out.println("Cookie constructor");
}
void bite() {
System.out.println("bite");
}
}
记住,
现在,使用
// hiding/Dinner.java
// Uses the library
import hiding.dessert.*;
public class Dinner {
public static void main(String[] args) {
Cookie x = new Cookie();
// -x.bite(); // Can't access
}
}
输出:
Cookie constructor
你可以创建一个bite()
方法,因为 bite()
只提供了包访问权限,因而在
默认包
你可能惊讶地发现,以下代码尽管看上去破坏了规则,但是仍然可以编译:
// hiding/Cake.java
// Accesses a class in a separate compilation unit
class Cake {
public static void main(String[] args) {
Pie x = new Pie();
x.f();
}
}
输出:
Pie.f()
同一目录下的第二个文件:
// hiding/Pie.java
// The other class
class Pie {
void f() {
System.out.println("Pie.f()");
}
}
最初看上去这两个文件毫不相关,但在f()
方法f()
具有包访问权限,因此不能被
private: 你无法访问
关键字
默认的包访问权限通常提供了足够的隐藏措施;记住,使用类的客户端程序员无法访问包访问权限成员。这样做很好,因为默认访问权限是一种我们常用的权限(同时也是一种在忘记添加任何访问权限时自动得到的权限
以下是一个使用
// hiding/IceCream.java
// Demonstrates "private" keyword
class Sundae {
private Sundae() {}
static Sundae makeASundae() {
return new Sundae();
}
}
public class IceCream {
public static void main(String[] args) {
//- Sundae x = new Sundae();
Sundae x = Sundae.makeASundae();
}
}
以上展示了makeASundae()
方法创建对象。
任何可以肯定只是该类的
对于类中的
protected: 继承访问权限
要理解
关键字
class Foo extends Bar {}
类定义的其他部分看起来是一样的。
如果你创建了一个新包,并从另一个包继承类,那么唯一能访问的就是被继承类的
回顾下先前的文件bite()
:
// hiding/ChocolateChip.java
// Can't use package-access member from another package
import hiding.dessert.*;
public class ChocolateChip extends Cookie {
public ChocolateChip() {
System.out.println("ChocolateChip constructor");
}
public void chomp() {
//- bite(); // Can't access bite
}
public static void main(String[] args) {
ChocolateChip x = new ChocolateChip();
x.chomp();
}
}
输出:
Cookie constructor
ChocolateChip constructor
如果类bite()
,那么它的任何子类中都存在 bite()
方法。但是因为 bite()
具有包访问权限并且位于另一个包中,所以我们在这个包中无法使用它。你可以把它声明为
// hiding/cookie2/Cookie.java
package hiding.cookie2;
public class Cookie {
public Cookie() {
System.out.println("Cookie constructor");
}
protected void bite() {
System.out.println("bite");
}
}
这样,bite()
对于所有继承
// hiding/ChocolateChip2.java
import hiding.cookie2.*;
public class ChocolateChip2 extends Cookie {
public ChocoalteChip2() {
System.out.println("ChocolateChip2 constructor");
}
public void chomp() {
bite(); // Protected method
}
public static void main(String[] args) {
ChocolateChip2 x = new ChocolateChip2();
x.chomp();
}
}
输出:
Cookie constructor
ChocolateChip2 constructor
bite
尽管 bite()
也具有包访问权限,但它不是
包访问权限Vs Public 构造器
当你定义一个具有包访问权限的类时,你可以在类中定义一个
// hiding/packageaccess/PublicConstructor.java
package hiding.packageaccess;
class PublicConstructor {
public PublicConstructor() {}
}
有一个
// hiding/CreatePackageAccessObject.java
// {WillNotCompile}
import hiding.packageaccess.*;
public class CreatePackageAcessObject {
public static void main(String[] args) {
new PublicConstructor();
}
}
如果你编译下这个类,会得到编译错误信息:
CreatePackageAccessObject.java:6:error:
PublicConstructor is not public in hiding.packageaccess;
cannot be accessed from outside package
new PublicConstructor();
^
1 error
因此,在一个具有包访问权限的类中定义一个
接口和实现
访问控制通常被称为隐藏实现(implementation hiding
出于两个重要的原因,访问控制在数据类型内部划定了边界。第一个原因是确立客户端程序员可以使用和不能使用的边界。可以在结构中建立自己的内部机制而不必担心客户端程序员偶尔将内部实现作为他们可以使用的接口的一部分。
这直接引出了第二个原因:将接口与实现分离。如果在一组程序中使用接口,而客户端程序员只能向
为了清晰起见,你可以采用一种创建类的风格:
// hiding/OrganizedByAccess.java
public class OrganizedByAccess {
public void pub1() {/* ... */}
public void pub2() {/* ... */}
public void pub3() {/* ... */}
private void priv1() {/* ... */}
private void priv2() {/* ... */}
private void priv3() {/* ... */}
private int i;
// ...
}
这么做只能是程序阅读起来稍微容易一些,因为实现和接口还是混合在一起。也就是说,你仍然能看到源代码——实现部分,因为它就在类中。另外,
类访问权限
访问权限修饰符也可以用于确定类库中的哪些类对于类库的使用者是可用的。如果希望某个类可以被客户端程序员使用,就把关键字
为了控制一个类的访问权限,修饰符必须出现在关键字
public class Widget {
如果你的类库名是
import hiding.Widget;
或者
import hiding.*;
这里有一些额外的限制:
- 每个编译单元(即每个文件)中只能有一个
public 类。这表示,每个编译单元有一个公共的接口用 public 类表示。该接口可以包含许多支持包访问权限的类。一旦一个编译单元中出现一个以上的 public 类,编译就会报错。 public 类的名称必须与含有该编译单元的文件名相同,包括大小写。所以对于 Widget 来说,文件名必须是 Widget.java ,不能是widget.java 或 WIDGET.java 。再次强调,如果名字不匹配,编译器会报错。- 虽然不是很常见,但是编译单元内没有
public 类也是可能的。这时可以随意命名文件(尽管随意命名会让代码的阅读者和维护者感到困惑 ) 。
如果是一个在
当你创建了一个包访问权限的类,把类中的属性声明为
注意,类既不能是
// hiding/Lunch.java
// Demonstrates class access specifiers. Make a class
// effectively private with private constructors:
class Soup1 {
private Soup1() {}
public static Soup1 makeSoup() { // [1]
return new Soup1();
}
}
class Soup2 {
private Soup2() {}
private static Soup2 ps1 = new Soup2(); // [2]
public static Soup2 access() {
return ps1;
}
public void f() {}
}
// Only one public class allowed per file:
public class Lunch {
void testPrivate() {
// Can't do this! Private constructor:
//- Soup1 soup = new Soup1();
}
void testStatic() {
Soup1 soup = Soup1.makeSoup();
}
void testSingleton() {
Soup2.access().f();
}
}
可以像
到目前为止,大部分的方法要么返回
access()
方法访问到这个对象。
本章小结
无论在什么样的关系中,划定一些供各成员共同遵守的界限是很重要的。当你创建了一个类库,也就与该类库的使用者产生了联系,他们是类库的客户端程序员,需要使用你的类库创建应用或更大的类库。
没有规则,客户端程序员就可以对类的所有成员为所欲为,即使你希望他们不要操作部分成员。这种情况下,所有事物都是公开的。
本章讨论了类库是如何通过类构建的:首先,介绍了将一组类打包到类库的方式,其次介绍了类如何控制对其成员的访问。
据估计,用
控制成员访问权限有两个原因。第一个原因是使用户不要接触他们不该接触的部分,这部分对于类内部来说是必要的,但是不属于客户端程序员所需接口的一部分。因此将方法和属性声明为
第二个也是最重要的原因是为了让类库设计者更改类内部的工作方式,而不用担心会影响到客户端程序员。比如最初以某种方式创建一个类,随后发现如果更改代码结构可以极大地提高运行速度。如果接口与实现被明确地隔离和保护,你可以实现这一目的,而不必强制客户端程序员重新编写代码。访问权限控制确保客户端程序员不会依赖某个类的底层实现的任何部分。
当你具备更改底层实现的能力时,不但可以自由地改善设计,还可能会随意地犯错。无论如何细心地计划和设计,都有可能犯错。当了解到犯错是相对安全的时候,你可以更加放心地实验,更快地学会,更快地完成项目。
类的
注意到访问权限控制关注的是类库创建者和外部使用者之间的关系,这是一种交流方式。很多情况下,事实并非如此。例如,你自己编写了所有的代码,或者在一个小组中工作,所有的东西都放在同一个包下。这些情况下,交流方式则是另外一种,此时严格地遵循访问权限规则也许不是最佳选择,默认(包)访问权限也许就足够好了。