对 java 知识点重新进行了总结与更新,点我查看

在面向对象的程序设计中,方法是一个很重要的概念,体现了面向对象三大要素中“封装”的思想。“方法”又被称为“函数”,在其他的编程语言中都有这个概念,其重要性也是不言而喻的。

在本质上,一个类描述了两件事情。⑴ 一个对象知道什么(what’s an object knows)? ⑵ 一个对象能做什么(what’san object does)?第1件事情对应于对象的属性(或状态)。第2件事情对应于对象的行为(或方法)

Person类(Person.java)

针对范例的Person类,有如下的示意图。

注意,这里的Person类仅是为了说明问题,本例的程序并不能单独运行。在Person类中,有实例变量name和age,它们描述了该类定义的对象所能感知的状态(或属性),关于类属性的使用,在前面的文章中我们已经详细的讨论了。而针对类的属性,如何操作这些属性,就是指该类定义的对象所能实施的行为,或者说,该对象所具备的方法,这里将重点讨论类中方法的使用规则。

方法的基本定义

在前面的文章范例中,我们经常需要用到某两个整数之间的随机数,有没有想过把这部分代码写成一个模块——将常用的功能封装在一起,不必再“复制和粘贴”这些代码,直接采用这个功能模块的名称,就可以达到相同的效果。其实使用Java中“方法”机制就可以解决这个问题的。

方法(method)用来实现类的行为。一个方法通常是用来完成一项具体的功能(function),所以方法在C++中也称为成员函数(member function)。英文“function”的这两层含义(函数与功能)在这里都能得到体现。

在Java语言中,每条指令的执行都是在某个特定方法的上下文中完成的。一般方法的运用原理大致如下图所示。可把方法看成完成一定功能的“黑盒”,方法的使用者(对象)只要将数据传递给方法体(要么通过方法中的参数传递,要么通过对象中的数据成员共享),就能得到结果,而无需关注方法的具体实现细节。当我们需要改变对象的属性(状态)值时,就让对象去调用对应的方法,方法通过对数据成员(实例变量)一系列的操作,然后就再将操作的结果返回。

在Java中,方法定义在类中,它和类的成员属性(数据成员)一起构建一个完整的类。构成方法有四大要素。返回值类型、方法名称、参数、方法体。这是一种标准,在大多数编程语言中都是通用的。

所有方法均在类中定义和声明。一般情况下,定义一个方法的语法如下所示。

方法包含一个方法头(method header)和一个方法体。下图以一个max方法来说明一个方法的组成部分。

方法头包括修饰符、返回值类型、方法名和参数列表等,下面一一给予解释。

修饰符(modifier):定义了该方法的访问类型。这是可选的,它告诉编译器以什么调用该方法。

返回值类型(return type):指定了方法返回的数据类型。它可以是任意有效的类型,包括构造类型(类就是一种构造类型)。如果方法没有返回值,则其返回类型必须是void。方法体中的返回值类型要与方法头中定义的返回值类型一致。

方法名(method name):方法名称的命名规则遵循Java标识符命名规范,但通常方法名以英文中的动词开头。这个名字可以是任意合法标识符。

参数列表(parameter list):参数列表是由类型、标识符组成的序列,每对之间用逗号分开。参数实际上是方法被调用时接收传递过来的参数值的变量。如果方法没有参数,那么参数表为空的,但是圆括号不能省略。参数列表可将该方法需要的一些必要的数据传给该方法。方法名和参数列表共同构成方法签名,一起来标识方法的身份信息。

方法体(body):方法体中存放的是封装在{}内部的逻辑语句,用以完成一定的功能。

方法(或称函数)在任何一种编程语言中都很重要。它们的实现方式大同小异。方法是对逻辑代码的封装,使程序结构完整条理清晰,便于后期的维护和扩展。面向对象的编程语言将这一特点进一步放大,我们可以通过对方法加以权限修饰(如private、public、protected等)来控制方法能够在何处被调用。灵活的运用方法和权限修饰符对编码的逻辑控制非常有帮助。

方法的使用

下面我们继续深化上述范例的程序,通过下面的实例讲解方法的使用。在Person类中有3个方法,在主函数中分别通过对象调用了这3方法。范例 :方法的使用(PersonTest.java)。

方法的使用(PersonTest.java)。



第05~08行定义了talk()方法,用于输出Person对象的name和age属性。

第09~12行定义了setName()方法,用于设置Person对象的name属性。

第13~16行定义了setAge()方法,用于设置Person对象的age属性。

从上面描述3个方法所用的动词“输出”、“设置”,就可以印证我们前面的论述,方法是操作对象属性(数据成员)的行为。这里的“操作”可以广义的分为两大类:读和写。读操作的主要目的是“获取”对象的属性值,这类方法可统称为Getter方法。写操作的主要目的是“设置”对象的属性值,这类方法可统称为Setter方法。因此,在Person类中,talk()方法属于Getter类方法,而setName()和setAge()方法属于Setter类方法。

代码第24行,声明了一个Person类的对象p1。第25~27行,分别通过“点”操作符调用了对象p1的setName()、setAge()及talk()方法。

事实上,由于类的属性成员name和age前并没有访问权限控制符(03~04行),由9.3.1小节讲解的知识可知,变量和方法前不加任何访问修饰符,属于默认访问控制模式。在这种模式下的方法和属性,在同一个包(package)内是可访问的。因此,在本例中,setName()、setAge()不是必需的,第25~26行的代码完全可以用下面的代码代替,而运行的结果是相同的。


这样看来,新的操作方法似乎更加便捷,但是上述的描述方式,违背了面向对象程序设计的一个重要原则——数据隐藏(data hiding),也就是封装性,这个概念我会在后期的文章中详细讲解。

方法中的形参与实参

如果有传递消息的需要,在定义一个方法时,参数列表中的参数个数至少为1个,有了这样的参数才有提供外部传递消息至本方法的可能。这些参数被称为形式参数,简称形参(parameter)。而在调用这个方法时,需要调用者提供与原方法定义相匹配的参数(类型、数量及顺序都一致),这些实际调用时提供的参数称为实际参数,简称实参(argument)。下图以一个方法max(int,int)为例说明了形参和实参的关系。

形参和是实参的关系如下。

⑴ 形参变量隶属于方法体,也就是说它们是方法的局部变量,只当在被调用时才被创建,才被临时性的分配内存,在调用结束后,立即释放所分配的内存单元。也就是说,当方法调用返回后,就不能再使用这些形式参数。

⑵ 在调用方法时,实参和形参在数量上、类型上、顺序上应严格保证一一对应的关系,否则就会出现参数类型不匹配的错误,从而导致调用方法失败。例如,假设t为包含max方法的一个对象,下面调用max方法时,提供的实参是不合法的。

方法的重载

假设有这样的场景,需要设计一系列方法,它们的功能相似,都是输入某基本数据类型数据,返回对应的字符串,例如,若输入整数12,则返回长度为2的字符串“12”,若输入单精度浮点数12.34,则返回长度为5的字符串“12.34”,输入布尔类型值为false,则返回字符串“false”。由于基本数据类型有8个(byte、short、int、long、char、float、double及boolean),那么就需要设计8个有着类似功能的方法。因为这些方法的功能类似,如果它们都统一叫相同的名称,例如valueOf(),就对用户非常方便——方便取名、方便调用及方便记忆,但这样编译器就会“糊涂”了,因为它不知道该如何区别这些方法。就如一个班级里有8个人重名,都叫“张三”,授课老师无法仅从姓名上区别这8个同学,为了达到区分不同同学的目的,老师需要用到这些同学的其他信息(如脸部特征、声音特征等)。

同样,编译器为了区分这些函数,除了用方法名这个特征外,还会用到方法的参数列表区分不同的方法。方法的名称及其参数列表(参数类型+参数个数)一起构成方法的签名(method signature)。就如同人们在正式文书上通过签名来区分不同人一样,编译器也可通过不同的方法签名来区分不同的方法。这种使用方法名相同但参数列表不同的方法签名机制,称之为方法的重载(method overload)。在调用的时候,编译器会根据参数的类型或个数不同来执行不同的方法体代码。下面的范例演示了String类下的重载方法valueOf使用情况。

重载方法valueOf的使用演示(OverloadValueOf.java)


代码第12~16行,分别使用了String类下静态重载方法valueOf()。这些方法虽然同名,都叫valueOf(),但它们的方法签名是不一样的,因为方法的签名不仅仅限于方法名称的区别,还包括方法参数列表的区别。第12行调用的valueOf()方法,它的形参类型是byte。第13行调用的valueOf()方法,它的形参类型是short…,第16行调用的valueOf()方法,它的形参类型是boolean。大家可以看到,使用了方法重载机制,在进行方法调用时就省了不少的麻烦事,对于相同名称的方法体,由编译器根据参数列表的不同,去区分调用哪一个方法体。

重载方法println的使用(ShowPrintlnOverload.java)


现在我们来详细解读一下“System.out.println()””的含义。System是在java.lang包中定义了一个内置类,在该类中定义了一个静态对象out,由于静态成员是属于类成员的,所以它的访问方式是“类名.成员名”——System.out。out本质上是PrintStream类的实例对象,println()则是PrintStream类中定义的方法。请大家回顾上面的一段程序,就会发现在前面章节中广泛使用的方法println()也是重载而来,因为第05~09行的“System.out.println()”可以输出不同的数据类型,相同的方法名+不同的参数列表是典型的方法重载特征。

在大家自定义设计重载方法时,大家需要注意以下3点,这些重载方法之间。

⑴ 方法名称相同。

⑵ 方法的参数列表不同(参数个数、参数类型、参数顺序,至少有一项不同)。

⑶ 方法的返回值类型和修饰符不做要求,可以相同,也可以不同。

下面以用户自定义的方法add()范例说明了方法重载的设计。

加法方法的重载(MethodOverload.java)


第04~07行,定义了方法add,其参数列表类型为“int , int ”,用于计算两个int类型数之和。第10~13行,定义了方法add,其参数列表类型为“float, float ”,用于计算两个float类型数之和。第16~19行,定义了一个同名方法add,其参数列表类型为“int, int, int”,用于计算int类型数之和。这三个同名的add方法,由于参数列表不同而构成方法重载。

第25行,实例化一个本类对象。

第28行,调用第一个add方法,计算两个整型数1和2之和。

第32行,调用第二个同名add方法,计算两个浮点数float类型数1.2和2.3之和。

第36行,调用第三个同名add方法,计算三个整型数1、2、3的和,并在下一行输出计算结果。

Java方法重载是通过方法的参数列表的不同来加以区分实现的。虽然方法名称相同,它们都叫add,但是对于add( int a, int b )、add( float a, float b )及add( inta, int b, int c )这三个方法,由于它们的方法签名不同(方法签名包括函数名及参数列表),在本质上,对于编译器而言,它们是完全不同的方法,所以可被编译器无二义性地加以区分。本例仅仅给出了三个重载方法,事实上,add( int a, float b)、add( float a, int b )、add( double a, double b )等,它们和范例中的add方法彼此之间都是重载的方法。

提示
方法的签名仅包括方法名称和参数,因此方法重载不能根据方法的不同返回值来区分不同方法,因为返回值不属于方法签名的一部分。例如,int add(int, int)和void add(int, int)的方法签名是相同的,编译器会“认为”这两个方法完全相同而无法区分,故此它们无法达到重载的目的。

方法重载是在Java中随处可见的特性,本例中演示的是该特性的常见用法。与之类似的还有“方法覆盖”,该特性是基于“继承”的

构造方法

构造”一词来自于英文 “Constructor”,中文常译为“构造器”,又称为构造函数(C++中)或构造方法(Java中)。构造方法与普通方法的差别在于,它是专用于在构造对象时初始对象成员的,其名称和其所属类名相同。下面将会详细介绍构造方法的创建和使用。

构造方法

在讲解构造方法的概念之前,首先来回顾一下对象声明并实例化的格式。

下面分别来观察这一步的4层作用。

⑴ 类名称:表示要定义变量的类型,只是有了类之后,变量的类型是由用户自己定义的;

⑵ 对象名称:表示变量的名称,变量的命名规范与方法相同,例如:studentName;

⑶ new:是作为开辟堆内存的唯一方法,表示实例化对象;

⑷ 类名称():这就是一个构造方法。

所谓构造方法,就是在每一个类中定义的,并且是在使用关键字new实例化一个新对象的时候默认调用的方法。在Java程序里,构造方法所完成的主要工作是对新创建对象的数据成员赋初值。可将构造方法视为一种特殊的方法,其定义方式如下。

在使用构造方法的时候需注意以下几点。

⑴ 构造方法名称和其所属的类名必须保持一致。

⑵ 构造方法没有返回值,也不可以使用void。

⑶ 构造方法也可以向普通方法一样被重载。

⑷ 构造方法不能被static和final修饰。

⑸ 构造方法不能被继承,子类使用父类的构造方法需要使用super关键字

构造方法除了没有返回值,且名称必须与类的名称相同之外,它的调用时机也与普通方法有所不同。普通方法是在需要时才调用,而构造方法则是在创建对象时就自动“隐式”执行。因此,构造方法无需在程序中直接调用,而是在对象产生时自动执行一次。通常用它来对对象的数据成员进行初始化。

Java中构造方法的使用(TestConstruct.java)


第10~25行声明了一个Person类,为了简化起见,此类中只有Person的构造方法Person()和显示信息的方法show()。

第12~17行声明了一个Person类的构造方法Person(),此方法含有一个对私有变量a赋初值的语句(14行)和两个输出语句(15~16行)。事实上,输出语句并不是构造方法必需的功能,这里它们主要是为了验证构造方法是否被调用了及初始化是否成功了。

观察Person()这个方法可以发现,它的名称和类名称一致,且没有任何返回值(即使void也不被允许)。

第05行实例化了一个Person类的对象p,此时会自动调用Person类的构造方法Person(),在屏幕上打印出:“构造方法被调用…”。

第06行调用了Person中的show方法,输出指定信息。

从此程序中大家不难发现,在类中声明的构造方法,会在实例化对象时自动调用且只被调用一次。

大家可能会问,在之前的程序中用同样的方法来产生对象,但是在类中并没有声明任何构造方法,而程序不也一样可以正常运行吗?实际上,我们在执行javac编译java程序的时候,如果在程序中没有明确声明一个构造方法的话,编译器会自动为该类添加一个无参数的构造方法,类似于下表所示的代码。

这样一来,就可以保证每一个类中至少存在一个构造方法(也可以说没有构造方法的类是不存在的),所以在之前的程序之中虽然没有明确地声明构造方法,也是可以正常运行的。

提示
对于一个构造方法public Book() {}和一个普通方法public void Book() {},二者的区别在于,如果构造方法上写上void,那么其定义的形式就与普通方法一样了。构造方法是在一个对象实例化的时候只调用一次的方法,而普通方法则可通过一个实例化对象调用多次。正是因为构造方法的特殊性,它才有特殊的语法规范。

构造方法的重载

在Java里,普通方法是可以重载的,而构造方法在本质上也是方法的一种特例而已,因此它也可以重载。构造方法的名称是固定的—它们必须和类名保持一致,那么构造方法的重载,自然要体现参数列表的不同。也就是说,多个重载的构造方法彼此之间,参数个数、参数类型和参数顺序至少有一项是不同的。只要构造方法满足上述条件,便可定义多个名称相同的构造方法。这种做法在Java中是常见的,请看下面的程序。

构造方法的重载(ConstructOverload.java)



第1~21行声明了一个名为Person的类,类中有name与age两个私有属性和一个talk()方法,以及还有两个构造方法Person(),它们彼此的参数列表不同,因此所形成的方法签名也是不一致的,这2个方法名称都叫Person,故构成构造方法的重载。

前者(06-10行)只有一个整型参数,只够用来初始化一个私有属性(age),故此用默认值“kehr”来初始化另外一个私有属性(name)(08行)。后者(12-16行)的构造方法中有两个形参,刚好够用来初始化类中的两个私有属性name和age属性。但为了避免构造方法中的形参name和age与类中的两个同名私有变量,在第14-15行中,用关键字this来表明,赋值运算符(=)的左侧变量是来自于本对象的成员变量(在03-04行定义),而“=”右侧的变量则是来自于构造方法的形参,它们是作用域仅限于构造方法的局部变量。构造方法中两个this引用表示“对象自己”。在本例中,可以删除“this.”不影响运行结果。关于this详细应用我会在后面的文章详细讲到。事实上,为了避免这种同名区分上的困扰,构造方法中的参数名称可以是任何合法的标识符。

第26行,创建一个Person类对象p1并调用Person类中含有一个参数的构造方法:Person(int age),给age初始化,name的值采用默认值。

第27行,再次创建一个Person类对象p2,调用Person类中含有两个参数的构造方法:Person (String name, int age),给name和age初始化。

第28、29行调用Person类中的talk()方法打印信息。

从本程序可以发现,构造方法的基本功能就是对类中的属性初始化,在程序产生类的实例对象时,将需要的参数由构造方法传入,之后再由构造方法为其内部的属性进行初始化,这是在一般开发中经常使用的技巧。但是有一个问题是需要大家注意,就是无参构造方法的使用,请看下面的程序。

使用无参构造方法时产生的错误(ConstructWithNoPara.java)



可以发现,在编译程序第05行时发生了错误,这个错误说找不到Person类的无参数的构造方法。在前面曾经提过,如果程序中没有声明构造方法,程序就会自动声明一个无参数的构造方法,可是现在却发生了找不到无参数构造方法的问题,这是为什么?读者可以发现第15~19行和21~25行声明了两个有参的构造方法。在Java程序中一旦用户显式声明了构造方法,那么默认的“隐式的”构造方法就不会被编译器生成。而要解决这一问题,只需要简单地修改一下Person类就可以达到目的——即在Person类中明确地声明一个无参数的构造方法,如下例所示。

正确使用无参构造方法(ConstructOverload.java)



可以看见,在程序的第15-19行声明了一无参的构造方法,此时再编译程序的话,就可以正常编译而不会出现错误了。无参构造方法由于无法从外界获取赋值信息,就用默认值初始化了类中的数据成员name和age(17-18行)。第05行,定义了一个Person类对象p,p使用了无参的构造方法Person()来初始化对象中的成员,第06行输出的结果就是默认的name和age值。

构造方法的私有化

由上面的分析可知,一个方法可根据实际需要,将其设置为不同的访问权限——public(公有访问)、private(私有访问)或默认访问(即方法前没有修饰符)。同样,构造方法也有public与private之分。到目前为止,前面的范例所使用的构造方法均属于public,它可在程序的任何地方被调用,所以新创建的对象也都可以自动调用它。如果构造方法被设为private,那么其他类中就无法调用该构造方法。换句话说,在本类之外,就不能通过new关键字调用该构造方法创建该类的实例化对象。请观察下面的代码。

构造方法的私有化(PrivateCallDemo.java)



在第04行中,由于PrivateDemo类的构造方法PrivateDemo()被声明为private(私有访问),则该构造方法在外类是不可访问的,或者说它在其他类中是不可见的。所以在第17行,试图使用PrivateDemo()方法来构造一个PrivateDemo类的对象是不可行的。所以才有上述的编译错误。

大家可能会问,如果将构造函数私有化会导致一个类不能被外类使用,从而不能实例化构造新的对象,那为什么还要将构造方法私有化呢?私有化构造方法有什么用途?事实上,构造方法虽然被私有了,但并不一定是说此类不能产生实例化对象,只是产生这个实例化对象的位置有所变化,即只能在私有构造方法所属类中产生实例化对象。例如,在该类的static void main()方法中使用new来创建。请大家观察下面的范例代码。

构造方法的私有使用范例(PrivateConstructor.java)


从此程序可以看出,第04行将构造方法声明为private类型,则此构造方法只能在本类内被调用。同时可以看出,本程序中的main方法也在PrivateConstructor类的内部(9-12行)。在同一个类中的方法,均可以相互调用,不论它们是什么访问类型。

第11行,使用new调用private访问类型的构造方法PrivateConstructor(),用来创建一个匿名对象。由此输出结果可以看出,在本类中可成功实施实例化对象。

大家可能又会疑问,如果一个类中的构造方法被私有化了,就只能在本类中使用,这岂不是大大限制了该类的使用?私有化构造方法有什么好处呢?请大家考虑下面的特定需求场景:如果要限制一个类对象产生,要求一个类只能创建一个实例化对象,该怎么办?

我们知道,实例化对象需要调用构造方法,但如果将构造方法使用private藏起来,则外部肯定无法直接调用,那么实例化该类对象就只能有一种途径——在该类内部用new关键字创建该类的实例。通过这个方式,我们就可以确保一个类只能创建一个实例化对象。在软件工程中,这种设计模式被称之为单态设计模式(SingletonDesign Pattern)。

许多时候整个系统只需要拥有一个全局对象,这样有利于协调系统整体的行为。例如,在某个Linux服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这样就大大简化了在复杂环境下的配置管理。在Windows中也有此设计的存在。例如,Windows中的回收站就是所有逻辑盘共享同一个回收站,这也是一个典型的单例模式设计。Java中的构造方法私有化就是为这种软件设计模式而服务的,请大家体会下面的范例。

构造方法的私有使用范例2(TestSingleDemo.java)



第06行声明一个Person类的对象p,但并未实例化,仅是在栈内存中为对象引用p分配了空间存储,p所指向的对象并不存在。

第08行调用Person类中的getPerson()方法,由于该方法是公有的,可以借此方法返回Person类的实例化对象,并将返回对象的引用赋值给p。

第17~20行将Person类的构造方法通过private关键字私有化,这样外部就无法通过其构造方法来产生实例化对象。

第16行在类声明了一个Person类的实例化对象,此对象是在Person类的内部实例化,所以可以调用私有构造方法。此对象被标识为static类型,表示为一静态属性。另外,在声明Person对象的时候还加上了一个final关键字,此关键字表示对象PERSON不能被重新实例化。

由于Person类构造方法是private,所以如 Person p = new Person () 已经不再可行了(06行)。只能通过“p = Person.getPerson();”来获得实例,而由于这个实例PERSON是static的,全局共享一个,所以无论在Person类的外部声明多少个对象,使用多少个“p = Person.getPerson();”,最终得到的实例都是同一个。也就是说,此类只能产生一个实例对象。这种做法就是上面提到的单态设计模式。所谓设计模式也就是在大量的实践中总结和理论化之后优选的代码结构、编程风格以及解决问题的思考方式。

在方法内部调用方法

通过前面的几个范例,大家应该可以了解到,在一个Java程序中是可以通过对象去调用类中的各种方法。当然,类的内部也能互相调用彼此的方法,比如在下面的程序中,修改了以前的程序代码,新增加了一个public(公有的)say()方法,并用这个方法去调用私有的talk()方法。

在类的内部调用方法(TestPerson.java)



第09~12行,声明一个公有方法say(),此方法用于调用类内部的私有方法talk()。

在第41行,调用Person类中的公有方法say(),本质上,通过say方法调用了Person类中的私有方法talk()。如果某些方法不方便公开,就可以用这种二次包装的模式来屏蔽不想公开的实现细节(如本例的talk()方法),这在某些应用背景下是有需求的。

提示
如果强调对象本身的话,第11行也可以写成“this.talk() ;”的形式。读者也许会觉得这样写有些多余,其实this的使用方法很多,在以后会完整地介绍。读者可自行修改上面的程序试验一下,看看结果是不是与原来的相同。

方法的递归调用

在讲解递归(Recursion)这个抽象之前,让我们来温馨回顾一下,在小时候当我们在缠着长辈讲故事,可能就被尊敬的长辈们用下面的故事来“忽悠”:

从前有座山,山里有座庙,庙里有个老和尚,正在给小和尚讲故事呢!故事是什么呢?“从前有座山,山里有座庙,庙里有个老和尚,正在给小和尚讲故事呢!故事是什么呢?‘从前有座山,山里有座庙,庙里有个老和尚,正在给小和尚讲故事呢!故事是什么呢?……’”

除了讲故事的人自己停下来不讲了,这个故事可以“无限的”讲下去,原因就是“故事”嵌套的“故事”就是“故事”本身,这就是语言上的“递归”的例子。

在程序设计领域,递归是指函数(或方法)直接或间接调用自身的一种操作,如下图所示。递归调用能够大大减少代码量,将原本复杂的问题简化成一个简单的基础操作来完成。在编码过程中“递归调用”是一个非常实用的技巧。


从上图可以看出,函数(或方法)不论是直接调用自身,还是间接调用自身,都是一种无终止的过程。显然,在程序中不能出现这种无终止的递归调用。因此,在编写递归代码时,大家要特别注意,递归程序一定要有结束条件,这又被称作递归出口。如果一个递归函数缺少递归出口,执行时就会陷入死循环,其后果非常严重。递归出口可用if语句来控制,在满足某种条件时继续递归调用自身,否则就不再继续。

下面我们通过一个例子来说明方法递归的调用原理。下面以计算“1+2+3+…+n”的值为例,分别实用非递归(nonrecursion)和递归(recursion)方式实现,读者可体会二者的区别。

计算“1+2+3+…+n”,递归和非递归实现(RrecursionMethod. java)。



第04~13行,定义一个非递归的函数addNonrecursion,采用for循环的方式来计算结果。

第16~21行,定义一个递归函数addRecursion,采用递归的方式计算结果。

第25行,实例化一个本类的对象test。

第27行,调用非递归函数,计算1+2+3+…+10结果。第28行,输出计算结果。

第30行,调用递归函数,计算1+2+3+…+10结果。第31行,输出计算结果。

对于非递归的实现,用到了for循环语句,以1为基数不断循环叠加最终得出累加的结果。而在递归实现的操作中则明显不同。解决这个问题的思路是,若要计算addRecursion (n)=1+2+3+…+n结果,需要求{1+2+3+…+( n-1 )}+n;而计算1+2+3+…+( n-1 )的值,本质上就是计算addRecursion (n-1)的值。而计算addRecursion (n-1)的值,本质上就是{1+2+3+…+( n-2 )}+( n-1 ),而计算{1+2+3+…+( n-2 )}的值,本质上就是计算addRecursion (n-2),……,直到 addRecursion (1)=1,然 后 逐 级 返 回,addRecursion (2)=addRecursion (1)+2=1+2=3,addRecursion (3)= addRecursion(2)+3 = 3+ 3 =6,……,直 到 addRecursion (n)=addRecursion (n-1)+n,计算完毕。

递归通过对方法本身的压栈和弹栈的方式,将每一层的结果逐级返回,通过逐步累加求得结果。下图给出addRecursion(5)的计算过程。

由上图可以得知,递归方式求解问题分为两个阶段:(1)第1个阶段是回推,逐级推导本级计算所需的条件。例如,如果需要计算addRecursion (n),需要计算addRecursion (n-1) +n,而计算addRecursion (n-1),需要计算addRecursion(n-2)+ (n-1)……一直回推到addRecursion (1)=1,这是递归的终止条件。然后开始第2个阶段。(2)第2个阶段是递推。addRecursion(2)=addRecursion(1)+2=3, addRecursion(3) = addRecursion(2)+3=6,……,直 到addRecursion(n) =addRecursion(n-1)+n,问题得以求解。

提示
虽然递归操作有许多的优点,但是缺点也很明显。使用递归实现需要函数压栈和弹栈的操作,所以程序的运行速度比不用递归实现要慢的多。如果操作不慎还极易出现死循环,读者编写代码过程中需要多加注意,一定要设置递归操作的终止条件。

代码块

代码块是一种常见的代码形式。它用大括号“{ }”将多行代码封装在一起,形成一个独立的代码区域,这就构成了代码块。代码块的格式如下。

代码块有四种。

⑴ 普通代码块。

⑵ 构造代码块。

⑶ 静态代码块。

⑷ 同步代码块。

代码块不能够独立运行,须要依赖于其他配置。下面分别给予解释。

普通代码块

普通代码块是最常见的代码块。在方法名后(或方法体内)用一对“{ }”括起来的数据块就是普通代码块。它不能够单独存在于类中,需要紧跟在方法名后面,并通过方法调用。

普通代码块演示(NormalCodeBlock.java)


本例中,有两个普通代码块,第一个普通代码块是04~13行之间,是整个main方法的主体部分。第二个普通代码块是从06~09行之间,被左右花括号{}包括的部分。

在普通代码块内,变量的作用范围自左花括号“{”内定义处开始,到右花括号“}”结束。因此,第二代码块中,在第07行定义变量x,其生命周期在第09行就结束了。而在第11行需重新定义整型变量x,这个变量x和第07行的变量x互不影响的,不过它们“碰巧”同名罢了。故第08行输出x =10,而第12行输出x = 100。

但如果分别将第06行和第09行“看似无用途的”左右花括号“{}”删除掉,那么这个程序就无法编译通过,这是因为同一个main方法块内,同一个变量x被重复定义两次。第一次在第07行处定义,而第二次在第11行定义,重复定义编译无法通过,如下图所示。

构造代码块

构造代码块就是在类中直接定义的,且没有任何前缀、后缀以及修饰符的代码块。通过前面的学习,我们应该知道,在一个类中,至少需要有一个构造方法(如果用户自己不显式定义,编译器会“隐式”地配备一个),它在生成对象时被调用。构造代码块和构造方法一样是在对象生成时被调用,但是它的调用时机比构造方法还要早。

由于这种特性,构造代码块可用来初始化成员变量。如果一个类中有多个构造方法,这些构造方法都需要初始化成员变量,那么就可以把每个构造方法中相同的代码部分抽取出来,集中一起放在构造代码块中。这样利用构造代码块来初始化共有的成员变量,可大大减少不同构造方法中重复的代码,提高代码的复用性。

构造代码块演示(ConsCodeBlock.java)



我们首先分析类Person。第14~17行就是构造代码块,在第16行,对类的数据成员x进行初始化。如果语句“x = 100;”不放置于代码块中,要达到相同的效果,此句语句就需要分别出现在构造方法Person()和Person(String name)中。如果使用默认值来初始化类中的成员变量比较多,很明显,使用构造代码块能节省很多代码空间。第17行语句来显示构造代码块被调用了,实际的代码开发中,此类输出语句是不需要的。

第19~24行,定义了一个无参的构造方法。第22行是对name数据成员变量赋初值“Guangzi”,然后调用了show()方法来输出name的值和x的值(x的初始值已经在构造代码块中初始化)。

第26~31行,定义了一个有参构造方法。该构造方法有一个形式参数name,来接收外界输入,从而初始化类中的数据成员变量name,为了区分形参name和类中成员变量name,赋值运算符“=”左侧的变量,加上“this.”来表明其是来自类成员。

思考一下,为什么构造方法Person()和Person(String name)中的name的初始化不放到构造代码块中呢?这是因为二者对name初始化的方式不同,前者是采用默认值的方法来初始化name,而后者是采纳外界输入的方法来初始化name。由此读者可以知道,构造代码块中的初始化是一个类的所有构造方法都共有的“交集”部分,具有个性化特征的初始化还是要放在各自的构造方法中。

第33~37行,定义了show()方法,用来输出私有数据成员name和x。

在分析完毕Person类,回到使用Person类的ConsCodeBlock类。在这个类中,仅有一个main方法。在main方法中,先定义了一个Person类对象p1(第05行),它调用Person类的无参构造方法来生成这个对象,从运行结果可以看出,构造代码块先执行,然后再执行构造方法,在构造代码块中,x的值被成功赋值为100。而在构造方法中,name被赋予默认值为“Guangzi”。

然后,定义了一个Person类对象p2 (第07行),它调用Person类的有参构造方法来生成这个对象,从运行结果可以看出,依然是构造代码块先执行,然后再执行构造方法,在构造代码块中,x的值被赋值为100,而name的值被构造方法的外界输入(通过实参)初始化为“Zhang”。

构造代码块不在任何方法之内,仅位于类的范围内,它的地位和其他方法体是对等的,可以理解为构造代码块是没有名称的方法体,但仅限用于对类数据成员的初始化,且仅运行一次。

从上面的案例结果不难看出,在类被实例化的过程中,构造代码块内的代码比构造方法先执行。构造代码不仅可以减少代码量,也提高了代码的可读性,善用构造代码块能够给编码带来许多便利。

静态代码块

使用static关键字加以修饰并用大括号“{ }”括起来的代码块称为静态代码块,其主要用来初始化静态成员变量。它是最早执行的代码块。参见下面的范例。

静态代码块演示(StaticCodeBlock.java)



第04~07行,用static标识,用左右花括号“{}”括起来的区域就是一个静态代码块。

第09~12行,是一个无参的构造方法,方法的名称与类同名,均为StaticCodeBlock。

第14~16行,没有任何标识,用左右花括号“{}”括起来的区域就是前面讲到的构造代码块。

在主方法(第18~27行)中,使用new关键字,分别创建了三个无名对象(分别在第22、24、26行)。据此来验证静态代码块执行多少次。

从结果可以看出,静态代码块的执行时间主方法main()方法都要早。静态块还优先于构造方法的执行,而且不管有多少个实例化对象产生(本例中创建了3个对象),静态块都只执行一次。利用这种特性,静态代码块可以被用来初始化类中的静态成员变量。静态成员变量是属于所有类对象共享的,故此不会受到创建对象个数的影响。

上面的案例可得出初步结论。在执行时机上,静态代码块在类加载时就会执行,因此早于构造代码块、构造方法。当静态代码块和main方法属于一个类时,静态代码块比main方法执行早。静态块的执行级别是最高的。

方法与数组

数组引用传递

让数组b直接指向数组a(即b = a;),这样做的目的是为了提高程序运行的效率。试想一下,假如数组中有上万个元素,在拷贝数组时,如果将数组a的所有元素都一一拷贝至数组b,时间开销很大,有时候也不是必需的。所以,在Java语言中, b = a(a和b都是引用名)的含义就是将a起个别名"b"。之后,a和b其实就是指向的是同一个对象。在Java中,这种给变量取别名的机制称之为引用(reference)。

抽象的概念都源于具体的表象。在现实生活中,“引用”的例子也很多,例如,周树人的“笔名”是鲁迅。一般来说,人们都会有一个正式的学名,同时也有个亲切的“乳名(小名)”。这些表象有不同的“名称”,在本质上,指向的事物都是同一个。我们说鲁迅先生写了很多脍炙人口的作品,实际上也是说周树人先生写了很多脍炙人口的作品。中国自主研制的CPU——龙芯,小名狗剩,外文名GodSon,它们三者名称虽然不同,但指向的都是中国自主研制的CPU。

一个程序若想运行,必须驻入内存,而在内存中必然有其存储地址,通过这些内存地址,就可以找到我们想的数据。这些内存地址通常都很长(具体长度取决于JVM的类型),因为不容易记住,所以就给这些地址取个名称,这就是引用变量,这些引用变量存储在一块名叫“堆内存”的区域。

那么所谓“引用”,就是Java对象在堆内存的地址赋给了多个“栈内存”的变量,由于Java禁止用户直接操作“堆整型、浮点型、布尔型等基本数据类内存”中对象的地址,所以只能用这些“栈内存”的多个引用名来间接操作它们对应的“堆内存”数据。所以,Java中的“引用”更类似于C/C++中的“指针”概念,所不同的是,C/C++中的“指针”可以被用户直接修改,而在Java中对内存的直接修改是被屏蔽的。

在Java中,所有对象都是通过引用进行操作的。而数组也是一种对象。当将数组作为参数传递给方法时,传递的实际上就是该数组对象的引用。在方法中对数组的所有操作,都会映射到原数组中,这一点也是Java面向对象的一个重要特点。

所谓的数组引用传递,就是将一块数组的堆内存空间交由多个栈内存所指向。这包括了方法通过参数列表接收数组和方法计算完毕后返回数组等两种情况,但不管数组操作形式如何改变,最终都属于引用传递。请注意,除了对象有这种特性外,整型、浮点型、布尔型等基本数据类型都不具备该特性。

演示数组的引用传递 (ArrayReference.java)



第3~9行,定义静态方法changeReferValue,分别对传入参数的值进行修改。

第12~19行,定义静态方法printArr,用于将数组在终端打印出来。请读者注意第14行的for循环方式。在Java1.5以后的版本中,对于数组和集合框架(Collection)等类型的对象,提供了一种新的遍历方式,称为for-each

Java集合框架是由一套设计优良的接口和类组成的,可使程序员成批地操作数据或对象元素,第14~17行代码,完全可以替换为传统的for循环方式。

第21~26行,定义静态方法print,用于打印所有变量。

第30~31行,在主方法中分别声明了整型、数组等2种不同类型的变量,并赋予初值。整型属于基本数据类型,而数组则属于引用数据类型。

第34行,调用print方法,打印出没有调用changeReferValue方法前的各个变量值。

第36行,调用changeReferValue函数,试图改变参数的值。

第37行,再次调用print方法,用以查看调用changeReferValue方法之后,各个变量值的值是否得以改变。

因为数组是对象,所以在changeReferValue方法的参数列表中,最后一个参数传递形式为“传引用”。换句话说,main方法中实参arr和changeReferValue方法中的形参myAr指向同一块内存空间。因此,在changeReferValue方法中,对形参arr所指向的数组数据的任何修改,都会同步影响到main方法中的实参arr所指向的数组数据(myAr和arr本质上就上一块内存区域)。在changeReferValue方法中,由于对于整型形参a和实参in之间是“传值”关系。在实参in将值赋值给形参a后,形参和实参二者之间再没有任何关联,所以在方法changeReferValue中对a的+1操作,并没有影响实参in的值,在本质上,形参a和实参in所指向的完全是不同的内存空间。

在方法中实现排序

在方法中对数组进行排序 (ArraySort.java)



3~18行的sort方法是一个冒泡排序算法,对数组arr的元素从大到小排序。

20~29行的printArr方法是将数组的元素输出。其中数组的输出用了for-each语法。

34和36行,在main方法中分别输出排序前和排序后的数组元素。

由于sort方法的参数传递方式数组对象的引用,故此,在sort方法体对数组arr的所有修改,都会在主函数定义的数组对象arr中体现出来,所以sort函数不需要返回值。在sort方法中,数组用使用“传引用”方式,保证了sort内的数组arr和main中的数组arr本质上同一个数组(它们的引用指向的是同一块内存空间),这样在sort方法中完成排序后,自然不用“多此一举”的将其排序的结果返回main方法了。

让方法返回数组

方法的返回值可以是Java所支持的任意一种类型。数组作为对象同样也可以成为方法的返回值。修改上述范例的排序函数,改变其返回值类型为整型数组(int[]),如下所示。

演示方法返回数组 (ArrReturn.java)。



04~20行的sort方法是一个冒泡排序算法,对数组arr的元素从大到小排序。与之前范例中的ArraySort不同的是,此处的sort方法返回的是一个整型数组int[]。

21~30行的printArr方法是将数组的元素输出。使用的是for-each语法。

第39行,调用sort()方法来实施对数组arr的排序。第37行和40行,在main方法中分别输出排序前和排序后的数组元素。

在第36行中,声明一个整型数组arrnew,此时还是一个空引用。在第39行,arrnew用来接收sort方法返回的数组引用。结合上一个范例分析可知,在34行定义的数组arr和sort方法中的形参arr构成“引用”关系,即二者本质上指向的是同一块内存空间。而sort方法完成排序后,返回的arr数组对象的引用,又被arrnew所接收(39行),故此arrnew和arr指向的也是同一块内存空间,也就是它们有相同的“引用值”。所以,在sort方法中对arr的排序,也可以说对arrnew做了排序。

以上的范例程序,不管如何的改变,实际上可以发现,数组对象引用的核心功能没有改变,还是一块堆内存设置了多个栈内存指向——一个数组对象(存储于堆内存中)对应多个别名(引用,存在于堆内存中)。

与数组有关的操作方法

Java对系统开发支持地非常好。一般来说,对于一些常用的功能,都会有相应的开发包支持,对于用户而言,比较麻烦的是要为这些开发包实施单独的配置,了解这些包提供的应用程序接口。Java对数组进行了封装和抽象,实现了Array接口。一些框架集合(collection)继承了接口,对数组的功能进行了扩展,形成了一类功能强大的工具集。在本书的第20章 Java类集框架,将会介绍这方面的知识点。在Java中,针对数组操作的支持主要有两个,数组的克隆和数组的排序。下面分别一一给予简介。

数组的克隆

由基本数据类型构建的数组,如:int []、 double[] 等,Java提供的内置方法并不是很多,最常用的方法是clone()方法,它会将数组复制一份,返回一个新的引用,常用来复制数组。数组对象提供的length属性用于记录数组的长度,即数组中包含元素的个数。

数组有关的操作方法(ArrayMethod.java)


第03~10行所示的printArr方法,其功能是将数组的元素输出。

在main方法中,第13行声明一个数组arr。在第14行,使用数组clone方法,新的数组arrnew克隆原数组的元素。第16行输出原来数组arr的元素。代码第20行,输出克隆数组arrnew的元素。

代码第23~26行,判断数组对象arr和克隆数组arrnew的引用值是否相同。

第14行声明一个新的数组arrnew,并将数组的值“克隆”(复制)给arrnew。从输出结果可以看出,此时数组arrnew和arr的元素是相同的,但是二者的引用值是不同的,也就是说,它们分别指向不同的堆内存地址。

如果将13~14行改为如下代码:

那么,这时arrnew和arr指向的是相同的地址空间,即一个堆内存地址对应两个引用名。arrnew和arr是“别名”关系,它们的“外表”不一样,但指向的本质东西是相同的,这样,数组arrnew和数组arr本质上就是一个数组,它们包括的元素自然也是相同的。如果将arrnew指向的元素值实施操作, arr中的元素值自然也会相应发生改变。如下图所示。

而范例中使用数组的克隆,其机理和上面的数组名引用是不同的。通过“克隆”机制,数组arrnew在堆内存中另辟一块和数组arr等大的内存,然后一一复制arr中的元素,克隆完毕后,两个数组中的元素值是一一对应相同的。由于arr和newarr对应两块不同的内存空间,克隆之后,它们对各自元素的修改,不会对另外一个数组产生任何影响,如下图所示。

Java的所有类都默认继承java.lang.Object类,在java.lang.Object类中有一个方法clone()。这个方法将返回Object对象的一个拷贝。这里有两点需要特别说明。

(1) “克隆”(拷贝)对象返回的是一个新对象,而不是一个已有对象的引用。

⑵ “克隆”(拷贝)对象与用 new操作符返回的新对象是有区别的,克隆对象是拷贝某个对象的当前信息,而不是对象的初始信息。打个比方说,如果把new构造出来的对象比喻成刚出生的“小羊羔”,过了一段时间,“小羊羔”长成一个“小山羊”了。这时,如果通过克隆操作,得到的是另外一个一模一样的“小山羊”,而不是当初new构造出来的“小羊羔”。

数组的排序

使用Java的包库对数组进行排序。


代码03-12行,是一个输出数组元素的printArr方法。

代码16行,定义了一个数组arr,并做了元素的初始化

代码18行利用Java的包库提供的方法来排序,代码19行输出了排序后的数组元素。

Java对数组的排序方法sort(),存在于util. Arrays的包中,我们要么使用本范例中的语法,明确的指定sort方法的来源。

要么在代码的第一行,导入java.util库包。

然后,再使用简洁的调用方式。

Java—快速归类整型常数—枚举

更多推荐

Java—重复调用的代码块—方法