方法参数
首先回顾一下在程序设计语言中有关参数传递给方法(或函数)的一些专业术语。值调用(call by value)表示方法接收的是调用者提供的值。而引用调用(call by reference)表示方法接收的是调用者提供的变量位置。可以想到,一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。这一点不仅仅是 java 语言,其他程序设计语言也是如此。“……调用”(call by)是一个标准的计算机科学术语,它用来描述各个程序设计语言中方法参数的传递方式。(事实上,以前还有名称调用(call by name),Algol程序设计语言—最古老的高级程序设计语言之一—使用的就是这种参数传递方式。不过,对于今天来说,这种传递方式已经成为历史。)Java程序设计语言使用值调用。也就是说,方法得到的是所有参数值的一个拷贝,特别是,方法不能修改传递给它的任何参数变量的内容。
例如,考虑下面的调用:
double percent = 10;
harry.raiseSalary( percent );
不必理睬这个方法的具体实现,在方法调用之后,percent的值还是10。
下面再仔细地研究一下这种情况。假定一个方法试图将一个参数值增加至3倍:
public static void tripleValue(double x) // doesn’t work
{
x = * x;
}
然后调用这个方法:
double percent =;
tripleValue(percent);
可以看到,无论怎样调用这个方法,执行之后,percent的值还是10。下面看一下具体的执行
过程:
1)x被初始化为percent值的一个拷贝(也就是10)。
2)x被乘以3后等于30。但是percent仍然是10(如图4-6所示)。
3)这个方法结束之后,参数变量x不再使用。
然而,方法参数共有两种类型:
• 基本数据类型(数字、布尔值)。
• 对象引用。
已经看到,一个方法不可能修改一个基本数据类型的参数。而对象引用作为参数就不同了,
我们可以很容易地利用下面这个方法实现将一个雇员的薪金提高两倍的操作:
public static void tripleSalary(Employee x) // works
{
x.raiseSalary();
}
当调用
harry = new Employee(. . .);
tripleSalary(harry);
具体的执行过程为:
1)x被初始化为harry值的拷贝,这里是一个对象的引用。
2)raiseSalary方法应用于这个对象引用。x和harry同时引用的那个Employee对象的薪金提高了200%。
3)方法结束后,参数变量x不再使用。当然,对象变量harry继续引用那个薪金增至3倍的雇员对象(如图4-7所示)。
已经看到,实现一个改变对象参数状态的方法并不是一件难事。理由很简单,方法得到的是对象引用的拷贝,对象引用及其他的拷贝同时引用同一个对象。
很多程序设计语言(特别是,C++和Pascal)提供了两种参数传递的方式:值调用和引用调用。有些程序员认为Java 程序设计语言对对象采用的是引用调用,实际上,这种理解是不对的。
由于这种误解具有一定的普遍性,所以下面给出一个反例,来详细地阐述一下这个问题。
首先,编写一个交换两个雇员对象的方法:
public static void swap(Employee x, Employee y) // doesn't work
{
Employee temp = x;
x = y;
y = temp;
}
如果Java程序设计语言对对象采用的是引用调用的话,这个方法应该能够实现交换数据的效果:
Employee a = new Employee(“Alice”, . . .);
Employee b = new Employee(“Bob”, . . .);
swap(a, b);
// does a now refer to Bob, b to Alice?
但是,方法并没有改变存储在变量a和b中的对象引用。swap方法的参数x和y被初始化为两个对象引用的拷贝,这个方法交换的是这两个拷贝。
// x refers to Alice, y to Bob
Employee temp = x;
x = y;
y = temp;
// now x refers to Bob, y to Alice
最终,白费力气。在方法结束时参数变量x和y被丢弃了。原来的变量a和b仍然引用这个方法调用之前所引用的对象(如图4-8所示)。
这个过程说明:Java 程序设计语言对对象采用的不是引用调用,实际上,对象引用进行的是值传递。
下面总结一下在Java程序设计语言中,方法参数的使用情况:
• 一个方法不能修改一个基本数据类型的参数(即数值型和布尔型值)。
• 一个方法可以改变一个对象参数的状态。
• 一个方法不能让对象参数引用一个新的对象。
例4-4中的程序给出了相应的演示。在这个程序中,首先试图将一个值参数的值提高两倍,但没有成功:
Testing tripleValue:
Before: percent=10.0
End of method: x=30.0
After : percent=10.0
随后,成功地将一个雇员的薪金提高了两倍:
Testing tripleSalary:
Before: salary=50000.0
End of method: salary=150000.0
After: salary=150000.0
方法结束之后,harry引用的对象状态发生了改变。这是因为这个方法可以通过对象引用的拷贝修改所引用的对象状态。
最后,程序演示了swap方法的失败效果:
Testing swap:
Before: a=Alice
Before: b=Bob
End of method: x=Bob
End of method: y=Alice
After: a=Alice
After: b=Bob
可以看出,参数变量x和y被交换了,但是变量a和b没有受到影响。
例4-4 ParamTest.java
对象构造
前面已经学会了编写简单的 构造器 ,以便对定义的对象进行初始化。但是,由于对象构造非常重要,所以Java提供了多种机制来编写构造器。下面将介绍这些内容。
重载
从前面可以看到,GregorianCalendar类有多个构造器。我们可以使用:
GregorianCalendar today = new GregorianCalendar( );
或者
GregorianCalendar deadline = new GregorianCalendar(2099, Calendar.DECEMBER, 31);
这种能力叫做重载(overloading)。如果多个方法(比如,GregorianCalendar构造器方法)有相同的名字、不同的参数,便产生了重载。编译器必须挑选出具体执行哪个方法,它通过用各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出正确的方法。如果编译器找不到匹配的参数,或者找出多个可能的匹配,就会产生编译时错误(这个过程被称为重载解析(overloading resolution)。)
默认域初始化
如果在构造器中没有显式地给域赋予初值,它就会被自动地赋为默认值:数值为0、布尔值为flase、对象引用为null。然而,只有缺少程序设计经验的人才会这样做。确实,如果不明确地对域进行初始化,就会影响程序代码的可读性。
默认构造器
所谓默认构造器是指没有参数的构造器。例如,Employee类的默认构造器:
public Employee( )
{
name = "";
salary =;
hireDay = new Date( );
}
如果在编写一个类时没有编写构造器,系统就会提供一个默认构造器。这个默认构造器将所有的实例域设置为默认值。于是,实例域中的数值型数据设置为0、布尔型数据设置为false、所有对象变量将设置为null。
如果类中提供了至少一个构造器,但是没有提供默认的构造器,那么在构造对象时若不提供构造参数就被视为不合法。例如,在例4-2中的Employee类提供了一个简单的构造器:
Employee(String name, double salary, int y, int m, int d)
对于这个类,构造默认的雇员属于不合法。也就是,调用
e = new Employee( );
将会产生错误。
显式域初始化
由于类的构造器方法可以重载,所以可以采用多种形式设置类的实例域的初始状态。确保不管怎样调用构造器,每个实例域都可以被设置为一个有意义的初值。这是一种很好的设想。
可以在类的定义中,简单地将一个值赋给任何域。例如:
在执行构造器之前,先执行赋值。当一个类的所有构造器都希望将相同的值赋予某个特定的实例域时,这种方式特别有用。
初始值不一定是常量。在下面的例子中,域可以调用方法来进行初始化。仔细看一下Employee类,其中每个雇员有一个id域。可以使用下列方式进行初始化:
参数名
在编写很小的构造器时(这是十分常见的),常常在参数命名上出现错误。
通常,参数用单个字符命名:
然而,这样做有一个缺陷:只有阅读代码才能够了解参数n和参数s的含义。
于是,有些程序员在每个参数前面加上一个前缀“a”:
这样很清晰。每一个读者一眼就能够看懂参数的含义。
还一种常用的技巧,它基于这样的事实:参数变量用同样的名字将实例域屏蔽起来。例如,如果将参数命名为salary,那么salary将引用这个参数,而不是实例域。但是,可以采用this.salary的形式访问实例域。回想一下,this指示隐式参数,也就是被构造的对象。下面是一个例子:
调用另一个构造器
关键字this引用方法的隐式参数。然而,这个关键字还有另外一个含义。
如果构造器的第一个语句形如this(…),那么这个构造器将调用同一个类的另一个构造器。下面是一个典型的例子:
当调用new Employee(60000) 时,Employee(double) 构造器将调用Employee(String, double) 构造器。
采用这种方式使用this关键字非常有用,这样对公共的构造器代码部分只编写一次即可。
初始化块
前面已经讲过两种初始化数据域的方法:
• 在构造器中设置值
• 在声明中赋值
实际上,Java还有第三种机制,称为初始化块。在一个类的声明中,可以包含多个代码块。
只要构造类的对象,这些块就会被执行。例如,
在这个例子中,无论使用哪个构造器构造对象,id域都在对象初始化块中被初始化。首先运行初始化块,然后才运行构造器的主体部分。
这种机制不是必须的,也不常见。通常,直接将初始化代码放置在一个构造器的内部。
由于初始化数据域有多种途径,所以列出构造过程的所有路径可能相当混乱。下面是调用构造器的具体处理步骤:
1)所有数据域被初始化为默认值(0、false或null)。
2)按照在类声明中出现的次序依次执行所有域初始化语句和初始化块。
3)如果构造器第一行调用了第二个构造器,则执行第二个构造器主体。
4)执行这个构造器的主体。
当然,应该精心地组织好初始化代码,这样有利于其他程序员的理解。例如,如果让类的构造器行为依赖于数据域声明的顺序,就会显得很奇怪并且容易引起错误。
可以通过提供一个初始化值,或者使用一个静态的初始化块来对静态域进行初始化。前面已经介绍过第一种机制:
static int nextId = 1;
如果对类的静态域进行初始化的代码比较复杂,就可以使用静态的初始化块。
将代码放置在一个块中,并标记关键字static。下面是一个例子。其功能是将雇员ID的起始值赋予一个小于10 000的随机整数。
在类第一次加载的时候,将会进行静态域的初始化。与实例域一样,静态域的默认初值是 0、false或null,除非将它们显式设置成其他值。所有的静态初始化语句以及静态初始化块都将按照类定义中出现的顺序执行。
例4-5 ConstructorTest.java
java.util.Random 1.0
• Random( )
构造一个新的随机数生成器。
• int nextInt(int n) 1.2
返回一个0~n-1之间的随机数。
对象析构与finalize方法
有些面向对象的程序设计语言,特别是C++,有显式的析构器方法,其中放置一些当对象不再使用时所需要用到的清理代码。在析构器中,最常见的操作是回收分配给对象的存储空间。
由于Java有自动的垃圾回收器,不需要人工回收内存,所以Java不支持析构器。
当然,某些对象使用了内存之外的其他资源,如文件或使用了系统资源的另一个对象的句柄。在这种情况下,当资源不再需要的时候,将其回收和再利用十分重要。
可以为任何一个类添加finalize方法。finalize方法将在垃圾回收器清除对象之前被调用。在实际应用中,不要使用finalize方法回收任何短缺的资源,这是因为很难知道这个方法什么时候才能够被调用。