Java编程思想之传递和返回对象

来源:互联网 发布:java 字符串反转递归 编辑:程序博客网 时间:2024/06/14 05:26

 对象的“传递”实际传递的只是一个句柄。一般都会问到:“Java有指针吗?”有些人认为指针的操作很困难,而且十分危险,所以一厢情愿地认为它没有好处。同时由于Java有如此好的口碑,所以应该很轻易地免除自己以前编程中的麻烦,其中不可能夹带有指针这样的“危险品”。然而准确地说,Java是有指针的!事实上,Java中每个对象(除基本数据类型以外)的标识符都属于指针的一种。但它们的使用受到了严格的限制和防范,不仅编译器对它们有“戒心”,运行期系统也不例外。或者换从另一个角度说,Java有指针,但没有传统指针的麻烦。我曾一度将这种指针叫做“句柄”,但你可以把它想像成“安全指针”。

1、传递句柄

将句柄传递进入一个方法时,指向的仍然是相同的对象。一个简单的实验可以证明这一点:

//: PassHandles.java// Passing handles aroundpackage c12;public class PassHandles {  static void f(PassHandles h) {    System.out.println("h inside f(): " + h);  }  public static void main(String[] args) {    PassHandles p = new PassHandles();    System.out.println("p inside main(): " + p);    f(p);  }} ///:~

toString方法会在打印语句里自动调用,而PassHandles直接从Object继承,没有toString的重新定义。因此,这里会采用toString的Object版本,打印出对象的类,接着是那个对象所在的位置(不是句柄,而是对象的实际存储位置)。输出结果如下:
p inside main(): PassHandles@1653748
h inside f() : PassHandles@1653748

可以看到,无论p还是h引用的都是同一个对象。这比复制一个新的PassHandles对象有效多了,使我们能将一个参数发给一个方法。但这样做也带来了另一个重要的问题。

  1.1 别名问题
“别名”意味着多个句柄都试图指向同一个对象,就象前面的例子展示的那样。若有人向那个对象里写入一点什么东西,就会产生别名问题。若其他句柄的所有者不希望那个对象改变,恐怕就要失望了。这可用下面这个简单的例子说明:

//: Alias1.java// Aliasing two handles to one objectpublic class Alias1 {  int i;  Alias1(int ii) { i = ii; }  public static void main(String[] args) {    Alias1 x = new Alias1(7);    Alias1 y = x; // Assign the handle    System.out.println("x: " + x.i);    System.out.println("y: " + y.i);    System.out.println("Incrementing x");    x.i++;    System.out.println("x: " + x.i);    System.out.println("y: " + y.i);  }} ///:~

对下面这行:
Alias1 y = x; // Assign the handle
它会新建一个Alias1句柄,但不是把它分配给由new创建的一个新鲜对象,而是分配给一个现有的句柄。所以句柄x的内容——即对象x指向的地址——被分配给y,所以无论x还是y都与相同的对象连接起来。这样一来,一旦x的i在下述语句中增值:
x.i++;
y的i值也必然受到影响。从最终的输出就可以看出:
x: 7y: 7Incrementing xx: 8y: 8

此时最直接的一个解决办法就是干脆不这样做:不要有意将多个句柄指向同一个作用域内的同一个对象。这样做可使代码更易理解和调试。然而,一旦准备将句柄作为一个自变量或参数传递——这是Java设想的正常方法——别名问题就会自动出现,因为创建的本地句柄可能修改“外部对象”(在方法作用域之外创建的对象)。下面是一个例子:

//: Alias2.java// Method calls implicitly alias their// arguments.public class Alias2 {  int i;  Alias2(int ii) { i = ii; }  static void f(Alias2 handle) {    handle.i++;  }  public static void main(String[] args) {    Alias2 x = new Alias2(7);    System.out.println("x: " + x.i);    System.out.println("Calling f(x)");    f(x);    System.out.println("x: " + x.i);  }} ///:~

输出如下:
x: 7
Calling f(x)
x: 8

方法改变了自己的参数——外部对象。一旦遇到这种情况,必须判断它是否合理,用户是否愿意这样,以及是不是会造成问题。
通常,我们调用一个方法是为了产生返回值,或者用它改变为其调用方法的那个对象的状态(方法其实就是我们向那个对象“发一条消息”的方式)。很少需要调用一个方法来处理它的参数;这叫作利用方法的“副作用”(Side Effect)。所以倘若创建一个会修改自己参数的方法,必须向用户明确地指出这一情况,并警告使用那个方法可能会有的后果以及它的潜在威胁。由于存在这些混淆和缺陷,所以应该尽量避免改变参数。
若需在一个方法调用期间修改一个参数,且不打算修改外部参数,就应在自己的方法内部制作一个副本,从而保护那个参数。
2、制作本地副本
稍微总结一下:Java中的所有自变量或参数传递都是通过传递句柄进行的。也就是说,当我们传递“一个对象”时,实际传递的只是指向位于方法外部的那个对象的“一个句柄”。所以一旦要对那个句柄进行任何修改,便相当于修改外部对象。此外:
■参数传递过程中会自动产生别名问题
不存在本地对象,只有本地句柄
■句柄有自己的作用域,而对象没有
■对象的“存在时间”在Java里不是个问题
■没有语言上的支持(如常量)可防止对象被修改(以避免别名的副作用)
若只是从对象中读取信息,而不修改它,传递句柄便是自变量传递中最有效的一种形式。这种做非常恰当;默认的方法一般也是最有效的方法。然而,有时仍需将对象当作“本地的”对待,使我们作出的改变只影响一个本地副本,不会对外面的对象造成影响。许多程序设计语言都支持在方法内自动生成外部对象的一个本地副本(注释)。尽管Java不具备这种能力,但允许我们达到同样的效果。

:在C语言中,通常控制的是少量数据位,默认操作是按值传递。C++也必须遵照这一形式,但按值传递对象并非肯定是一种有效的方式。此外,在C++中用于支持按值传递的代码也较难编写,是件让人头痛的事情。

  2.1 按值传递
首先要解决术语的问题,最适合“按值传递”的看起来是自变量。“按值传递”以及它的含义取决于如何理解程序的运行方式。最常见的意思是获得要传递的任何东西的一个本地副本,但这里真正的问题是如何看待自己准备传递的东西。对于“按值传递”的含义,目前存在两种存在明显区别的见解:
(1) Java按值传递任何东西。若将基本数据类型传递进入一个方法,会明确得到基本数据类型的一个副本。但若将一个句柄传递进入方法,得到的是句柄的副本。所以人们认为“一切”都按值传递。当然,这种说法也有一个前提:句柄肯定也会被传递。但Java的设计方案似乎有些超前,允许我们忽略(大多数时候)自己处理的是一个句柄。也就是说,它允许我们将句柄假想成“对象”,因为在发出方法调用时,系统会自动照管两者间的差异。
(2) Java主要按值传递(无自变量),但对象却是按引用传递的。得到这个结论的前提是句柄只是对象的一个“别名”,所以不考虑传递句柄的问题,而是直接指出“我准备传递对象”。由于将其传递进入一个方法时没有获得对象的一个本地副本,所以对象显然不是按值传递的。Sun公司似乎在某种程度上支持这一见解,因为它“保留但未实现”的关键字之一便是byvalue(按值)。但没人知道那个关键字什么时候可以发挥作用。

   2.2 克隆对象
若需修改一个对象,同时不想改变调用者的对象,就要制作该对象的一个本地副本。这也是本地副本最常见的一种用途。若决定制作一个本地副本,只需简单地使用clone()方法即可。Clone是“克隆”的意思,即制作完全一模一样的副本。这个方法在基础类Object中定义成“protected”(受保护)模式。但在希望克隆的任何衍生类中,必须将其覆盖为“public”模式。
     

import java.util.*;class Int {  private int i;  public Int(int ii) { i = ii; }  public void increment() { i++; }  public String toString() {     return Integer.toString(i);   }}public class Cloning {  public static void main(String[] args) {    Vector v = new Vector();    for(int i = 0; i < 10; i++ )      v.addElement(new Int(i));    System.out.println("v: " + v);    Vector v2 = (Vector)v.clone();    // Increment all v2's elements:    for(Enumeration e = v2.elements();        e.hasMoreElements(); )      ((Int)e.nextElement()).increment();    // See if it changed v's elements:    System.out.println("v: " + v);  }} ///:~

clone()方法产生了一个Object,后者必须立即重新造型为正确类型。这个例子指出Vector的clone()方法不能自动尝试克隆Vector内包含的每个对象——由于别名问题,老的Vector和克隆的Vector都包含了相同的对象。我们通常把这种情况叫作“简单复制”或者“浅层复制”,因为它只复制了一个对象的“表面”部分。实际对象除包含这个“表面”以外,还包括句柄指向的所有对象,以及那些对象又指向的其他所有对象,由此类推。这便是“对象网”或“对象关系网”的由来。若能复制下所有这张网,便叫作“全面复制”或者“深层复制”。
在输出中可看到浅层复制的结果,注意对v2采取的行动也会影响到v:
v: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]v: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

一般来说,由于不敢保证Vector里包含的对象是“可以克隆”(注释②)的,所以最好不要试图克隆那些对象。

②:“可以克隆”用英语讲是cloneable,请留意Java库中专门保留了这样的一个关键字。

    2.3 用Vector进行深层复制
下面让我们复习一下本章早些时候提出的Vector例子。这一次Int2类是可以克隆的,所以能对Vector进行深层复制:
//: AddingClone.java// You must go through a few gyrations to// add cloning to your own class.import java.util.*;class Int2 implements Cloneable {  private int i;  public Int2(int ii) { i = ii; }  public void increment() { i++; }  public String toString() {    return Integer.toString(i);  }  public Object clone() {    Object o = null;    try {      o = super.clone();    } catch (CloneNotSupportedException e) {      System.out.println("Int2 can't clone");    }    return o;  }}// Once it's cloneable, inheritance// doesn't remove cloneability:class Int3 extends Int2 {  private int j; // Automatically duplicated  public Int3(int i) { super(i); }}public class AddingClone {  public static void main(String[] args) {    Int2 x = new Int2(10);    Int2 x2 = (Int2)x.clone();    x2.increment();    System.out.println(      "x = " + x + ", x2 = " + x2);    // Anything inherited is also cloneable:    Int3 x3 = new Int3(7);    x3 = (Int3)x3.clone();    Vector v = new Vector();    for(int i = 0; i < 10; i++ )      v.addElement(new Int2(i));    System.out.println("v: " + v);    Vector v2 = (Vector)v.clone();    // Now clone each element:    for(int i = 0; i < v.size(); i++)      v2.setElementAt(        ((Int2)v2.elementAt(i)).clone(), i);    // Increment all v2's elements:    for(Enumeration e = v2.elements();        e.hasMoreElements(); )      ((Int2)e.nextElement()).increment();    // See if it changed v's elements:    System.out.println("v: " + v);    System.out.println("v2: " + v2);  }} ///:~

Int3自Int2继承而来,并添加了一个新的基本类型成员int j。大家也许认为自己需要再次覆盖clone(),以确保j得到复制,但实情并非如此。将Int2的clone()当作Int3的clone()调用时,它会调用Object.clone(),判断出当前操作的是Int3,并复制Int3内的所有二进制位。只要没有新增需要克隆的句柄,对Object.clone()的一个调用就能完成所有必要的复制——无论clone()是在层次结构多深的一级定义的。
至此,大家可以总结出对Vector进行深层复制的先决条件:在克隆了Vector后,必须在其中遍历,并克隆由Vector指向的每个对象。为了对Hashtable(散列表)进行深层复制,也必须采取类似的处理。
这个例子剩余的部分显示出克隆已实际进行——证据就是在克隆了对象以后,可以自由改变它,而原来那个对象不受任何影响。
  
3、 不变字串
请观察下述代码:
//: Stringer.javapublic 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并未发生变化。所有这些是如何发生的呢?

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

  3.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";以此类推。
这一设想是行得通的,但它要求创建大量字串对象。尽管最终的目的只是获得包含了所有内容的一个新字串,但中间却要用到大量字串对象,而且要不断地进行垃圾收集。
解决的方法是象前面介绍的那样制作一个可变的同志类。对字串来说,这个同志类叫作StringBuffer,编译器可以自动创建一个StringBuffer,以便计算特定的表达式,特别是面向String对象应用覆盖过的运算符+和+=时。下面这个例子可以解决这个问题:
//: ImmutableStrings.java// Demonstrating StringBufferpublic 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提供了更高的效率,但会产生比我们希望的多得多的对象。

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

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










0 0
原创粉丝点击