12.4 只读类

12.4 只读类

12.4 只读类 尽管在一些特定的场合,由 clone()产生的本地副本能够获得我们希望的结果,但程序员(方法的作者)不得不亲自禁止别名处理的副作用。假如想制作一个库,令其具有常规用途,但却不能担保它肯定能在正确的类中得以克隆,这时又该怎么办呢?更有可能的一种情况是,假如我们想让别名发挥积极的作用——禁止不必要的对象复制——但却不希望看到由此造成的副作用,那么又该如何处理呢? 一个办法是创建“不变对象”,令其从属于只读类。可定义一个特殊的类,使其中没有任何方法能造成对象内部状态的改变。在这样的一个类中,别名处理是没有问题的。因为我们只能读取内部状态,所以当多处代码都读取相同的对象时,不会出现任何副作用。 作为“不变对象”一个简单例子,Java 的标准库包含了“封装器”(wrapper)类,可用于所有基本数据类型。大家可能已发现了这一点,如果想在一个象 Vector(只采用 Object 指针)这样的集合里保存一个 int 数值,可以将这个 int 封装到标准库的 Integer 类内部。如下所示: //: ImmutableInteger.java // The Integer class cannot be changed import java.util.*;

public class ImmutableInteger { public static void main(String[] args) { Vector v = new Vector(); for(int i = 0; i < 10; i++) v.addElement(new Integer(i)); // But how do you change the int // inside the Integer? } } ///:~

Integer 类(以及基本的“封装器”类)用简单的形式实现了“不变性”:它们没有提供可以修改对象的方法。 若确实需要一个容纳了基本数据类型的对象,并想对基本数据类型进行修改,就必须亲自创建它们。幸运的是,操作非常简单: //: MutableInteger.java // A changeable wrapper class import java.util.*;

class IntValue { int n; IntValue(int x) { n = x; } public String toString() { return Integer.toString(n); } }

public class MutableInteger { public static void main(String[] args) { Vector v = new Vector(); for(int i = 0; i < 10; i++) v.addElement(new IntValue(i)); System.out.println(v); for(int i = 0; i < v.size(); i++) ((IntValue)v.elementAt(i)).n++; System.out.println(v); } } ///:~

注意 n 在这里简化了我们的编码。 若默认的初始化为零已经足够(便不需要构造器),而且不用考虑把它打印出来(便不需要 toString),那么 IntValue 甚至还能更加简单。如下所示: class IntValue { int n; } 将元素取出来,再对其进行造型,这多少显得有些笨拙,但那是 Vector 的问题,不是 IntValue 的错。

12.4.1 创建只读类 完全可以创建自己的只读类,下面是个简单的例子: //: Immutable1.java // Objects that cannot be modified // are immune to aliasing.

public class Immutable1 { private int data; public Immutable1(int initVal) { data = initVal; } public int read() { return data; } public boolean nonzero() { return data != 0; } public Immutable1 quadruple() { return new Immutable1(data * 4); } static void f(Immutable1 i1) { Immutable1 quad = i1.quadruple(); System.out.println(“i1 = " + i1.read()); System.out.println(“quad = " + quad.read()); } public static void main(String[] args) { Immutable1 x = new Immutable1(47); System.out.println(“x = " + x.read()); f(x); System.out.println(“x = " + x.read()); } } ///:~

所有数据都设为 private,可以看到没有任何 public 方法对数据作出修改。事实上,确实需要修改一个对象的方法是 quadruple(),但它的作用是新建一个 Immutable1 对象,初始对象则是原封未动的。 方法 f()需要取得一个 Immutable1 对象,并对其采取不同的操作,而 main()的输出显示出没有对 x 作任何修改。因此,x 对象可别名处理许多次,不会造成任何伤害,因为根据 Immutable1 类的设计,它能保证对象不被改动。

12.4.2 “一成不变”的弊端 从表面看,不变类的建立似乎是一个好方案。但是,一旦真的需要那种新类型的一个修改的对象,就必须辛苦地进行新对象的创建工作,同时还有可能涉及更频繁的垃圾收集。对有些类来说,这个问题并不是很大。但对其他类来说(比如 String 类),这一方案的代价显得太高了。 为解决这个问题,我们可以创建一个“同志”类,并使其能够修改。以后只要涉及大量的修改工作,就可换为使用能修改的同志类。完事以后,再切换回不可变的类。 因此,上例可改成下面这个样子: //: Immutable2.java // A companion class for making changes // to immutable objects.

class Mutable { private int data; public Mutable(int initVal) { data = initVal; } public Mutable add(int x) { data += x; return this; } public Mutable multiply(int x) { data *= x; return this; } public Immutable2 makeImmutable2() { return new Immutable2(data); } }

public class Immutable2 { private int data; public Immutable2(int initVal) { data = initVal; } public int read() { return data; } public boolean nonzero() { return data != 0; } public Immutable2 add(int x) { return new Immutable2(data + x); } public Immutable2 multiply(int x) { return new Immutable2(data * x); } public Mutable makeMutable() { return new Mutable(data); } public static Immutable2 modify1(Immutable2 y){ Immutable2 val = y.add(12); val = val.multiply(3); val = val.add(11); val = val.multiply(2); return val; } // This produces the same result: public static Immutable2 modify2(Immutable2 y){ Mutable m = y.makeMutable(); m.add(12).multiply(3).add(11).multiply(2); return m.makeImmutable2(); } public static void main(String[] args) { Immutable2 i2 = new Immutable2(47); Immutable2 r1 = modify1(i2); Immutable2 r2 = modify2(i2); System.out.println(“i2 = " + i2.read()); System.out.println(“r1 = " + r1.read()); System.out.println(“r2 = " + r2.read()); } } ///:~

和往常一样,Immutable2 包含的方法保留了对象不可变的特征,只要涉及修改,就创建新的对象。完成这些操作的是 add()和 multiply()方法。同志类叫作 Mutable,它也含有 add()和 multiply()方法。但这些方法能够修改 Mutable 对象,而不是新建一个。除此以外,Mutable 的一个方法可用它的数据产生一个 Immutable2 对象,反之亦然。 两个静态方法 modify1()和 modify2()揭示出获得同样结果的两种不同方法。在 modify1()中,所有工作都是在 Immutable2 类中完成的,我们可看到在进程中创建了四个新的 Immutable2 对象(而且每次重新分配了 val,前一个对象就成为垃圾)。 在方法 modify2()中,可看到它的第一个行动是获取 Immutable2 y,然后从中生成一个 Mutable(类似于前面对 clone()的调用,但这一次创建了一个不同类型的对象)。随后,用 Mutable 对象进行大量修改操作,同时用不着新建许多对象。最后,它切换回 Immutable2。在这里,我们只创建了两个新对象(Mutable 和 Immutable2 的结果),而不是四个。 这一方法特别适合在下述场合应用: (1) 需要不可变的对象,而且 (2) 经常需要进行大量修改,或者 (3) 创建新的不变对象代价太高

12.4.3 不变字串 请观察下述代码: //: Stringer.java

public class Stringer { static String upcase(String s) { return s.toUpperCase(); } public static void main(String[] args) { String q = new String(“howdy”); System.out.println(q); // howdy String qq = upcase(q); System.out.println(qq); // HOWDY System.out.println(q); // howdy } } ///:~

q 传递进入 upcase()时,它实际是 q 的指针的一个副本。该指针连接的对象实际只在一个统一的物理位置处。指针四处传递的时候,它的指针会得到复制。 若观察对 upcase()的定义,会发现传递进入的指针有一个名字 s,而且该名字只有在 upcase()执行期间才会存在。upcase()完成后,本地指针 s 便会消失,而 upcase()返回结果——还是原来那个字串,只是所有字符都变成了大写。当然,它返回的实际是结果的一个指针。但它返回的指针最终是为一个新对象的,同时原来的 q 并未发生变化。所有这些是如何发生的呢?

  1. 隐式常数 若使用下述语句: String s = “asdf”; String x = Stringer.upcase(s); 那么真的希望 upcase()方法改变自变量或者参数吗?我们通常是不愿意的,因为作为提供给方法的一种信息,自变量一般是拿给代码的读者看的,而不是让他们修改。这是一个相当重要的保证,因为它使代码更易编写和理解。 为了在 C++中实现这一保证,需要一个特殊关键字的帮助:const。利用这个关键字,程序员可以保证一个指针(C++叫“指针”或者“引用”)不会被用来修改原始的对象。但这样一来,C++程序员需要用心记住在所有地方都使用 const。这显然易使人混淆,也不容易记住。

  2. 覆盖”+“和 StringBuffer 利用前面提到的技术,String 类的对象被设计成“不可变”。若查阅联机文档中关于 String 类的内容(本章稍后还要总结它),就会发现类中能够修改 String 的每个方法实际都创建和返回了一个崭新的 String 对象,新对象里包含了修改过的信息——原来的 String 是原封未动的。因此,Java 里没有与 C++的 const 对应的特性可用来让编译器支持对象的不可变能力。若想获得这一能力,可以自行设置,就象 String 那样。 由于 String 对象是不可变的,所以能够根据情况对一个特定的 String 进行多次别名处理。因为它是只读的,所以一个指针不可能会改变一些会影响其他指针的东西。因此,只读对象可以很好地解决别名问题。 通过修改产生对象的一个崭新版本,似乎可以解决修改对象时的所有问题,就象 String 那样。但对某些操作来讲,这种方法的效率并不高。一个典型的例子便是为 String 对象覆盖的运算符“+”。“覆盖”意味着在与一个特定的类使用时,它的含义已发生了变化(用于 String 的“+”和“+=”是 Java 中能被覆盖的唯一运算符,Java 不允许程序员覆盖其他任何运算符——注释 ④)。

④:C++允许程序员随意覆盖运算符。由于这通常是一个复杂的过程(参见《Thinking in C++》,Prentice-Hall 于 1995 年出版),所以 Java 的设计者认定它是一种“糟糕”的特性,决定不在 Java 中采用。但具有讽剌意味的是,运算符的覆盖在 Java 中要比在 C++中容易得多。

针对 String 对象使用时,“+”允许我们将不同的字串连接起来: String s = “abc” + foo + “def” + Integer.toString(47);

可以想象出它“可能”是如何工作的:字串"abc"可以有一个方法 append(),它新建了一个字串,其中包含"abc"以及 foo 的内容;这个新字串然后再创建另一个新字串,在其中添加"def”;以此类推。 这一设想是行得通的,但它要求创建大量字串对象。尽管最终的目的只是获得包含了所有内容的一个新字串,但中间却要用到大量字串对象,而且要不断地进行垃圾收集。我怀疑 Java 的设计者是否先试过种方法(这是软件开发的一个教训——除非自己试试代码,并让某些东西运行起来,否则不可能真正了解系统)。我还怀疑他们是否早就发现这样做获得的性能是不能接受的。 解决的方法是象前面介绍的那样制作一个可变的同志类。对字串来说,这个同志类叫作 StringBuffer,编译器可以自动创建一个 StringBuffer,以便计算特定的表达式,特别是面向 String 对象应用覆盖过的运算符+和+=时。下面这个例子可以解决这个问题: //: ImmutableStrings.java // Demonstrating StringBuffer

public class ImmutableStrings { public static void main(String[] args) { String foo = “foo”; String s = “abc” + foo + “def” + Integer.toString(47); System.out.println(s); // The “equivalent” using StringBuffer: StringBuffer sb = new StringBuffer(“abc”); // Creates String! sb.append(foo); sb.append(“def”); // Creates String! sb.append(Integer.toString(47)); System.out.println(sb); } } ///:~

创建字串 s 时,编译器做的工作大致等价于后面使用 sb 的代码——创建一个 StringBuffer,并用 append()将新字符直接加入 StringBuffer 对象(而不是每次都产生新对象)。尽管这样做更有效,但不值得每次都创建象"abc"和"def"这样的引号字串,编译器会把它们都转换成 String 对象。所以尽管 StringBuffer 提供了更高的效率,但会产生比我们希望的多得多的对象。

12.4.4 String 和 StringBuffer 类 这里总结一下同时适用于 String 和 StringBuffer 的方法,以便对它们相互间的沟通方式有一个印象。这些表格并未把每个单独的方法都包括进去,而是包含了与本次讨论有重要关系的方法。那些已被覆盖的方法用单独一行总结。 首先总结 String 类的各种方法:

方法 自变量,覆盖 用途

构造器 已被覆盖:默认,String,StringBuffer,char 数组,byte 数组 创建 String 对象 length() 无 String 中的字符数量 charAt() int Index 位于 String 内某个位置的 char getChars(),getBytes 开始复制的起点和终点,要向其中复制内容的数组,对目标数组的一个索引 将 char 或 byte 复制到外部数组内部 toCharArray() 无 产生一个 char[],其中包含了 String 内部的字符 equals(),equalsIgnoreCase() 用于对比的一个 String 对两个字串的内容进行等价性检查 compareTo() 用于对比的一个 String 结果为负、零或正,具体取决于 String 和自变量的字典顺序。注意大写和小写不是相等的! regionMatches() 这个 String 以及其他 String 的位置偏移,以及要比较的区域长度。覆盖加入了“忽略大小写”的特性 一个布尔结果,指出要对比的区域是否相同 startsWith() 可能以它开头的 String。覆盖在自变量里加入了偏移 一个布尔结果,指出 String 是否以那个自变量开头 endsWith() 可能是这个 String 后缀的一个 String 一个布尔结果,指出自变量是不是一个后缀 indexOf(),lastIndexOf() 已覆盖:char,char 和起始索引,String,String 和起始索引 若自变量未在这个 String 里找到,则返回-1;否则返回自变量开始处的位置索引。lastIndexOf()可从终点开始回溯搜索 substring() 已覆盖:起始索引,起始索引和结束索引 返回一个新的 String 对象,其中包含了指定的字符子集 concat() 想连结的 String 返回一个新 String 对象,其中包含了原始 String 的字符,并在后面加上由自变量提供的字符 relpace() 要查找的老字符,要用它替换的新字符 返回一个新 String 对象,其中已完成了替换工作。若没有找到相符的搜索项,就沿用老字串 toLowerCase(),toUpperCase() 无 返回一个新 String 对象,其中所有字符的大小写形式都进行了统一。若不必修改,则沿用老字串 trim() 无 返回一个新的 String 对象,头尾空白均已删除。若毋需改动,则沿用老字串 valueOf() 已覆盖:object,char[],char[]和偏移以及计数,boolean,char,int,long,float,double 返回一个 String,其中包含自变量的一个字符表现形式 Intern() 无 为每个独一无二的字符顺序都产生一个(而且只有一个)String 指针

可以看到,一旦有必要改变原来的内容,每个 String 方法都小心地返回了一个新的 String 对象。另外要注意的一个问题是,若内容不需要改变,则方法只返回指向原来那个 String 的一个指针。这样做可以节省存储空间和系统开销。 下面列出有关 StringBuffer(字串缓冲)类的方法:

方法 自变量,覆盖 用途

构造器 已覆盖:默认,要创建的缓冲区长度,要根据它创建的 String 新建一个 StringBuffer 对象 toString() 无 根据这个 StringBuffer 创建一个 String length() 无 StringBuffer 中的字符数量 capacity() 无 返回目前分配的空间大小 ensureCapacity() 用于表示希望容量的一个整数 使 StringBuffer 容纳至少希望的空间大小 setLength() 用于指示缓冲区内字串新长度的一个整数 缩短或扩充前一个字符串。如果是扩充,则用 null 值填充空隙 charAt() 表示目标元素所在位置的一个整数 返回位于缓冲区指定位置处的 char setCharAt() 代表目标元素位置的一个整数以及元素的一个新 char 值 修改指定位置处的值 getChars() 复制的起点和终点,要在其中复制的数组以及目标数组的一个索引 将 char 复制到一个外部数组。和 String 不同,这里没有 getBytes()可供使用 append() 已覆盖:Object,String,char[],特定偏移和长度的 char[],boolean,char,int,long,float,double 将自变量转换成一个字串,并将其追加到当前缓冲区的末尾。若有必要,同时增大缓冲区的长度 insert() 已覆盖,第一个自变量代表开始插入的位置:Object,String,char[],boolean,char,int,long,float,double 第二个自变量转换成一个字串,并插入当前缓冲区。插入位置在偏移区域的起点处。若有必要,同时会增大缓冲区的长度 reverse() 无 反转缓冲内的字符顺序

最常用的一个方法是 append()。在计算包含了+和+=运算符的 String 表达式时,编译器便会用到这个方法。insert()方法采用类似的形式。这两个方法都能对缓冲区进行重要的操作,不需要另建新对象。

12.4.5 字串的特殊性 现在,大家已知道 String 类并非仅仅是 Java 提供的另一个类。String 里含有大量特殊的类。通过编译器和特殊的覆盖或重载运算符+和+=,可将引号字符串转换成一个 String。在本章中,大家已见识了剩下的一种特殊情况:用同志 StringBuffer 精心构造的“不可变”能力,以及编译器中出现的一些有趣现象。

上一页
下一页