46.java编程思想——传递和返回对象 只读类

46.java编程思想——传递和返回对象 只读类

尽管在一些特定的场合,由clone()产生的本地副本能够获得我们希望的结果,但程序员(方法的作者)不得不亲自禁止别名处理的副作用。假如想制作一个库,令其具有常规用途,但却不能担保它肯定能在正确的类中得以克隆,这时又该怎么办呢?更有可能的一种情况是,假如我们想让别名发挥积极的作用——禁止不必要的对象复制——但却不希望看到由此造成的副作用,那么又该如何处理呢?

一个办法是创建“不变对象”,令其从属于只读类。可定义一个特殊的类,使其中没有任何方法能造成对象内部状态的改变。在这样的一个类中,别名处理是没有问题的。因为我们只能读取内部状态,所以当多处代码都读取相同的对象时,不会出现任何副作用。

作为“不变对象”一个简单例子,Java 的标准库包含了“封装器”(wrapper)类,可用于所有基本数据类型。大家可能已发现了这一点,如果想在一个象Vector(只采用Object 句柄)这样的集合里保存一个int数值,可以将这个int 封装到标准库的Integer 类内部。如下所示:

import java.util.*;

public class ImmutableInteger {

    public staticvoidmain(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 类(以及基本的“封装器”类)用简单的形式实现了“不变性”:它们没有提供可以修改对象的方法。

若确实需要一个容纳了基本数据类型的对象,并想对基本数据类型进行修改,就必须亲自创建它们。幸运的是,操作非常简单:

import java.util.*;

class IntValue {

    int n;

    IntValue(int x) {

        n = x;

    }

    public String toString() {

        return Integer.toString(n);

    }

}

public class MutableInteger {

    public staticvoidmain(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);

    }

} /// :~

输出:

[0,1, 2, 3, 4, 5, 6, 7, 8, 9]

[1,2, 3, 4, 5, 6, 7, 8, 9, 10]

注意n 在这里简化了我们的编码。

若默认的初始化为零已经足够(便不需要构建器),而且不用考虑把它打印出来(便不需要toString ),那么IntValue 甚至还能更加简单。如下所示:

class IntValue { int n; }

将元素取出来,再对其进行造型,这多少显得有些笨拙,但那是Vector 的问题,不是IntValue 的错。

1     创建只读类

1.1     代码

public class Immutable1 {

    private intdata;

    public Immutable1(int initVal){

        data = initVal;

    }

    public int read() {

        returndata;

    }

    public booleannonzero() {

        return data != 0;

    }

    public Immutable1 quadruple() {

        return new Immutable1(data * 4);

    }

    static voidf(Immutable1 i1){

        Immutable1 quad = i1.quadruple();

        System.out.println("i1 = " + i1.read());

        System.out.println("quad = " + quad.read());

    }

    public staticvoidmain(String[] args){

        Immutable1 x = new Immutable1(47);

        System.out.println("x = " + x.read());

        f(x);

        System.out.println("x = " + x.read());

    }

} /// :~

1.2     执行

x= 47

i1= 47

quad= 188

x= 47

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

2     “一成不变”的弊端

从表面看,不变类的建立似乎是一个好方案。但是,一旦真的需要那种新类型的一个修改的对象,就必须辛苦地进行新对象的创建工作,同时还有可能涉及更频繁的垃圾收集。对有些类来说,这个问题并不是很大。

但对其他类来说(比如String 类),这一方案的代价显得太高了。

为解决这个问题,我们可以创建一个“同志”类,并使其能够修改。以后只要涉及大量的修改工作,就可换为使用能修改的同志类。完事以后,再切换回不可变的类。

可改成下面这个样子:

2.1     代码

class Mutable {

    private intdata;

    public Mutable(intinitVal){

        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 intdata;

 

    public Immutable2(int initVal){

        data = initVal;

    }

    public intread() {

        return data;

    }

    public booleannonzero() {

        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 staticImmutable2 modify1(Immutable2 y) {

        Immutable2 val = y.add(12);

        val = val.multiply(3);

        val = val.add(11);

        val = val.multiply(2);

        return val;

    }

    // Thisproduces the same result:

    public staticImmutable2 modify2(Immutable2 y) {

        Mutable m = y.makeMutable();

        m.add(12).multiply(3).add(11).multiply(2);

        return m.makeImmutable2();

    }

    public staticvoidmain(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());

    }

} /// :~

2.2     执行

i2= 47

r1= 376

r2= 376

和往常一样,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) 创建新的不变对象代价太高

2.3     代码2-不变字串

public class Stringer {

    static String upcase(String s) {

        return s.toUpperCase();

    }

    public staticvoidmain(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

    }

} /// :~

2.4     执行

howdy

HOWDY

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 对象应用覆盖过的运算符+和+=时。

2.5     代码3

public class ImmutableStrings {

    public staticvoidmain(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);

    }

} /// :~

2.6     执行

abcfoodef47

abcfoodef47

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

3     S t r i n g 和S t r i n g B u f f e r 类

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

3.1     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的一个句柄。这样做可以节省存储空间和系统开销。

3.2     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()方法采用类似的形式。这两个方法都能对缓冲区进行重要的操作,不需要另建新对象。

4     字串的特殊性

String 类并非仅仅是Java 提供的另一个类。String 里含有大量特殊的类。通过编译器和

特殊的覆盖或过载运算符+和+=,可将引号字符串转换成一个String。用同志StringBuffer 精心构造的“不可变”能力,以及编译器中出现的一些有趣现象。

5     总结

由于Java 中的所有东西都是句柄,而且由于每个对象都是在内存堆中创建的——只有不再需要的时候,才会当作垃圾收集掉,所以对象的操作方式发生了变化,特别是在传递和返回对象的时候。举个例子来说,在C和C++中,如果想在一个方法里初始化一些存储空间,可能需要请求用户将那片存储区域的地址传递进入方法。否则就必须考虑由谁负责清除那片区域。因此,这些方法的接口和对它们的理解就显得要复杂一些。但在Java 中,根本不必关心由谁负责清除,也不必关心在需要一个对象的时候它是否仍然存在。因为系统会照料一切。我们的程序可在需要的时候创建一个对象。而且更进一步地,根本不必担心那个对象的传输机制的细节:只需简单地传递句柄即可。有些时候,这种简化非常有价值,但另一些时候却显得有些多余。

可从两个方面认识这一机制的缺点:

(1) 肯定要为额外的内存管理付出效率上的损失(尽管损失不大),而且对于运行所需的时间,总是存在一丝不确定的因素(因为在内存不够时,垃圾收集器可能会被强制采取行动)。对大多数应用来说,优点显得比缺点重要,而且部分对时间要求非常苛刻的段落可以用native 方法写成。

(2) 别名处理:有时会不慎获得指向同一个对象的两个句柄。只有在这两个句柄都假定指向一个“明确”的对象时,才有可能产生问题。对这个问题,必须加以足够的重视。而且应该尽可能地“克隆”一个对象,以防止另一个句柄被不希望的改动影响。除此以外,可考虑创建“不可变”对象,使它的操作能返回同种类型或不同种类型的一个新对象,从而提高程序的执行效率。但千万不要改变原始对象,使对那个对象别名的其他任何方面都感觉不出变化。

有些人认为Java 的克隆是一个笨拙的家伙,所以他们实现了自己的克隆方案,永远杜绝调用Object.clone()方法,从而消除了实现Cloneable 和捕获CloneNotSupportException 违例的需要。这一做法是合理的,而且由于clone()在Java 标准库中很少得以支持,所以这显然也是一种“安全”的方法。只要不调用Object.clone(),就不必实现Cloneable 或者捕获违例,所以那看起来也是能够接受的。Doug Lea 特别重视这个问题,他说只需为每个类都创建一个名为duplicate()的函数即可。

Java 中一个有趣的关键字是byvalue(按值),它属于那些“保留但未实现”的关键字之一。在理解了别名和克隆问题以后,大家可以想象byvalue 最终有一天会在Java 中用于实现一种自动化的本地副本。这样做可以解决更多复杂的克隆问题,并使这种情况下的编写的代码变得更加简单和健壮。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值