文章目录
声明:
本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。
本博客已标明出处,如有侵权请告知,马上删除。
1.6 函数的用法
1.6.1 定义函数
如果需要经常做某一个操作,则类似的代码需要重复写很多遍。比如在一个数组中查找某个数,第一次查找一个数,第二次可能查找另一个数,每查一个数,类似的代码都需要重写一遍,很罗嗦。另外,有一些复杂的操作,可能分为很多个步骤,如果都放在一起,则代码难以理解和维护。
计算机程序使用函数这个概念来解决这个问题,即使用函数来减少重复代码和分解复杂操作,本节我们就来谈谈 Java 中的函数,包括函数的基础和一些细节。
函数这个概念,我们学数学的时候都接触过,其基本格式是 y = f(x),表示的是 x 到 y 的对应关系,给定输入 x,经过函数变换 f,输出 y。程序中的函数概念与其类似,也有输入、操作、和输出组成,但它表示的一段子程序,这个子程序有一个名字,表示它的目的(类比 f),有零个或多个参数(类比 x),有可能返回一个结果(类比 y)。我们来看两个简单的例子:
public static int sum(int a, int b){
int sum = a + b;
return sum;
}
public static void print3Lines(){
for(int i=0;i<3;i++){
System.out.println();
}
}
第一个函数名字叫做 sum,它的目的是对输入的两个数求和,有两个输入参数,分别是 int 整数 a 和 b,它的操作是对两个数求和,求和结果放在变量 sum 中(这个 sum 和函数名字的 sum 没有任何关系),然后使用 return 语句将结果返回,最开始的 public static 是函数的修饰符,我们后续介绍。
第二个函数名字叫做 print3Lines,它的目的是在屏幕上输出三个空行,它没有输入参数,操作是使用一个循环输出三个空行,它没有返回值。
以上代码都比较简单,主要是演示函数的基本语法结构,即:
修饰符 返回值类型 函数名字(参数类型 参数名字, ...) {
操作 ...
return 返回值;
}
函数的主要组成部分有:
- 函数名字:名字是不可或缺的,表示函数的功能。
- 参数:参数有 0 个到多个,每个参数有参数的数据类型和参数名字组成。
- 操作:函数的具体操作代码。
- 返回值:函数可以没有返回值,没有的话返回值类型写成 void,有的话在函数代码中必须要使用 return 语句返回一个值,这个值的类型需要和声明的返回值类型一致。
- 修饰符:Java 中函数有很多修饰符,分别表示不同的目的,在本节我们假定修饰符为 public static,且暂不讨论这些修饰符的目的。
以上就是定义函数的语法,定义函数就是定义了一段有着明确功能的子程序,但定义函数本身不会执行任何代码,函数要被执行,需要被调用。
1.6.2 函数调用
Java 中,任何函数都需要放在一个类中,类我们还没有介绍,我们暂时可以把类看做函数的一个容器,即函数放在类中,类中包括多个函数。Java 中函数一般叫做方法,我们不特别区分函数和方法,可能会交替使用。一个类里面可以定义多个函数,类里面可以定义一个叫做 main 的函数,形式如:
public static void main(String[] args) {
...
}
main 函数有特殊的含义,表示程序的入口,String[] args 表示从控制台接收到的参数,我们暂时可以忽略它。Java 中运行一个程序的时候,需要指定一个定义了 main 函数的类,Java 会寻找 main 函数,并从 main 函数开始执行。
刚开始学编程的人可能会误以为程序从代码的第一行开始执行,这是错误的,不管 main 函数定义在哪里,Java 函数都会先找到它,然后从它的第一行开始执行。
main 函数中除了可以定义变量,操作数据,还可以调用其它函数,如下所示:
public static void main(String[] args) {
int a = 2;
int b = 3;
int sum = sum(a, b);
System.out.println(sum);
print3Lines();
System.out.println(sum(3,4));
}
main 函数首先定义了两个变量 a 和 b,接着调用了函数 sum,并将 a 和 b 传递给了 sum 函数,然后将 sum 的结果赋值给了变量 sum。调用函数需要传递参数并处理返回值。
函数可以调用同一个类中的其他函数,也可以调用其他类中的函数,比如:
int a = 23;
System.out.println(Integer.toBinaryString(a));
toBinaryString 是 Integer 类中修饰符为 public static 的函数,可以通过在前面加上类名和 . 直接调用。
1.6.3 进一步理解函数
函数的定义和基本调用应该是比较容易理解的,但有很多细节可能令初学者困惑,包括参数传递、返回、函数命名、调用过程等,我们逐个介绍。
1.6.3.1 参数传递
有两类特殊类型的参数:数组和可变长度的参数
数组
数组作为参数与基本类型是不一样的,基本类型不会对调用者中的变量造成任何影响,但数组不是,在函数内修改数组中的元素会修改调用者中的数组内容。我们看个例子:
public static void reset(int[] arr){
for(int i=0;i<arr.length;i++){
arr[i] = i;
}
}
public static void main(String[] args) {
int[] arr = {10,20,30,40};
reset(arr);
for(int i=0;i<arr.length;i++){
System.out.println(arr[i]);
}
}
在 reset 函数内给参数数组元素赋值,在 main 函数中数组 arr 的值也会变。
在上例中,函数参数中的数组变量 arr 和 main 函数中的数组变量 arr 存储的都是相同的位置,而数组内容本身只有一份数据,所以,在 reset 中修改数组元素内容和在 main 中修改是完全一样的。
可变长度的参数
上面介绍的函数,参数个数都是固定的,但有的时候可能希望参数个数不是固定的,比如说求若干个数的最大值,可能是两个,也可能是多个,Java 支持可变长度的参数,如下例所示:
public static int max(int min, int ... a){
int max = min;
for(int i=0;i<a.length;i++){
if(max<a[i]){
max = a[i];
}
}
return max;
}
public static void main(String[] args) {
System.out.println(max(0));
System.out.println(max(0,2));
System.out.println(max(0,2,4));
System.out.println(max(0,2,4,5));
}
这个 max 函数接受一个最小值,以及可变长度的若干参数,返回其中的最大值。可变长度参数的语法是在数据类型后面加三个点 …,在函数内,可变长度参数可以看做就是数组,可变长度参数必须是参数列表中的最后一个参数,一个函数也只能有一个可变长度的参数。
可变长度参数实际上会转换为数组参数。也就是说,函数声明 max(int min, int… a) 实际上会转换为 max(int min, int[] a),在 main 函数调用 max(0,2,4,5) 的时候,实际上会转换为调用 max(0, new int[]{2,4,5}),使用可变长度参数主要是简化了代码书写。
1.6.3.2 返回
return 的含义
对初学者,我们强调下 return 的含义。函数返回值类型为 void 且没有 return 的情况下,会执行到函数结尾自动返回。return 用于结束函数执行,返回调用方。
return 可以用于函数内的任意地方,可以在函数结尾,也可以在中间,可以在 if 语句内,可以在 for 循环内,用于提前结束函数执行,返回调用方。
函数返回值类型为 void 也可以使用 return,即 “return;”,不用带值,含义是返回调用方,只是没有返回值而已。
返回值的个数
函数的返回值最多只能有一个,那如果实际情况需要多个返回值呢?比如说,计算一个整数数组中的最大的前三个数,需要返回三个结果。这个可以用数组作为返回值,在函数内创建一个包含三个元素的数组,然后将前三个结果赋给对应的数组元素。
如果实际情况需要的返回值是一种复合结果呢?比如说,查找一个字符数组中,所有重复出现的字符以及重复出现的次数。这个可以用对象作为返回值,我们在后续章节介绍类和对象。
我想说的是,虽然返回值最多只能有一个,但其实一个也够了。
1.6.3.3 函数命名
每个函数都有一个名字,这个名字表示这个函数的意义,名字可以重复吗?在不同的类里,答案是肯定的,在同一个类里,要看情况。
同一个类里,函数可以重名,但是参数不能一样。即要么参数个数不同,要么参数个数相同但至少有一个参数类型不一样。
同一个类中函数名字相同但参数不同的现象,一般称为函数重载。为什么需要函数重载呢?一般是因为函数想表达的意义是一样的,但参数个数或类型不一样。比如说,求两个数的最大值,在 Java 的 Math 库中就定义了四个函数,如下图所示:
1.6.3.4 调用过程
匹配过程
在之前介绍函数调用的时候,我们没有特别说明参数的类型。这里说明一下,参数传递实际上是给参数赋值,调用者传递的数据需要与函数声明的参数类型是匹配的,但不要求完全一样。
什么意思呢?Java 编译器会自动进行类型转换,并寻找最匹配的函数。比如说:
char a = 'a';
char b = 'b';
System.out.println(Math.max(a,b));
参数是字符类型的,但 Math 并没有定义针对字符类型的 max 函数,我们之前说明,char 其实是一个整数,Java 会自动将 char 转换为 int,然后调用 Math.max(int a, int b),屏幕会输出整数结果 98。
如果 Math 中没有定义针对 int 类型的 max 函数呢?调用也会成功,会调用 long 类型的 max 函数,如果 long 也没有呢?会调用 float 型的 max 函数,如果 float 也没有,会调用 double 型的。Java 编译器会自动寻找最匹配的。
在只有一个函数的情况下(即没有重载),只要可以进行类型转换,就会调用该函数,在有函数重载的情况下,会调用最匹配的函数。
递归
函数大部分情况下都是被别的函数调用,但其实函数也可以调用它自己,调用自己的函数就叫递归函数。
为什么需要自己调用自己呢?我们来看一个例子,求一个数的阶乘,数学中一个数 n 的阶乘,表示为 n!,它的值定义是这样的:
0!=1
n!=(n-1)!×n
0 的阶乘是 1,n 的阶乘的值是 n-1 的阶乘的值乘以 n,这个定义是一个递归的定义,为求 n 的值,需先求 n-1 的值,直到 0,然后依次往回退。用递归表达的计算用递归函数容易实现,代码如下:
public static long factorial(int n){
if(n==0){
return 1;
}else{
return n*factorial(n-1);
}
}
递归函数形式上往往比较简单,但递归其实是有开销的,而且使用不当,可能会出现意外的结果,比如说这个调用:
System.out.println(factorial(10000));
系统并不会给出任何结果,而会抛出异常,异常类型为:java.lang.StackOverflowError。这表示栈溢出错误,要理解这个错误,我们需要理解函数调用的实现原理(下节介绍)。
那如果递归不行怎么办呢?递归函数经常可以转换为非递归的形式,通过一些数据结构(后续章节介绍)以及循环来实现。比如,求阶乘的例子,其非递归形式的定义是:
n!=1×2×3×…×n
这个可以用循环来实现,代码如下:
public static long factorial(int n){
long result = 1;
for(int i=1; i<=n; i++){
result*=i;
}
return result;
}
1.7 函数调用的基本原理
在介绍递归函数的时候,我们看到一个系统错误:java.lang.StackOverflowError,栈溢出错误。要理解这个错误,我们需要理解函数调用的实现机制。
下面,我们先来了解一个重要的概念:栈,然后再通过一些例子来仔细分析函数调用的过程。
1.7.1 栈的概念
我们之前谈过程序执行的基本原理:CPU 有一个指令指示器,指向下一条要执行的指令,要么顺序执行,要么进行跳转(条件跳转或无条件跳转)。
基本上,这依然是成立的,程序从 main 函数开始顺序执行,函数调用可以看做是一个无条件跳转,跳转到对应函数的指令处开始执行,碰到 return 语句或者函数结尾的时候,再执行一次无条件跳转,跳转回调用方,执行调用函数后的下一条指令。
但这里面有几个问题:
- 参数如何传递?
- 函数如何知道返回到什么地方?在 if/else, for 中,跳转的地址都是确定的,但函数自己并不知道会被谁调用,而且可能会被很多地方调用,它并不能提前知道执行结束后返回哪里。
- 函数结果如何传给调用方?
解决思路是使用内存来存放这些数据,函数调用方和函数自己就如何存放和使用这些数据达成一个一致的协议或约定。这个约定在各种计算机系统中都是类似的,存放这些数据的内存有一个相同的名字,叫栈。
栈是一块内存,但它的使用有特别的约定,一般是先进后出,类似于一个桶,往栈里放数据,我们称为入栈,最下面的我们称为栈底,最上面的我们称为栈顶,从栈顶拿出数据,通常称为出栈。栈一般是从高位地址向低位地址扩展,换句话说,栈底的内存地址是最高的,栈顶的是最小的。
计算机系统主要使用栈来存放函数调用过程中需要的数据,包括参数、返回地址,函数内定义的局部变量也放在栈中。计算机系统就如何在栈中存放这些数据,调用者和函数如何协作做了约定。返回值不太一样,它可能放在栈中,但它使用的栈和局部变量不完全一样,有的系统使用 CPU 内的一个存储器存储返回值,我们可以简单认为存在一个专门的返回值存储器。main 函数的相关数据放在栈的最下面,每调用一次函数,都会将相关函数的数据入栈,调用结束会出栈。
1.7.2 函数执行的基本原理
以上描述可能有点抽象,我们通过一个例子来具体说明函数执行的过程,看个简单的例子:
public class Sum {
public static int sum(int a, int b) {
int c = a + b;
return c;
}
public static void main(String[] args) {
int d = Sum.sum(1, 2);
System.out.println(d);
}
}
这是一个简单的例子,main 函数调用了 sum 函数,计算 1 和 2 的和,然后输出计算结果,从概念上,这是容易理解的,让我们从栈的角度来讨论下。
当程序在 main 函数调用 Sum.sum 之前,栈的情况大概如图 1-1 所示。
主要存放了两个变量 args 和 d。在程序执行到 Sum.sum 的函数内部,准备返回之前,即第 5 行,栈的情况大概如图 1-2 所示。
我们解释下,在 main 函数调用 Sum.sum 时,首先将参数 1 和 2 入栈,然后将返回地址(也就是调用函数结束后要执行的指令地址)入栈,接着跳转到 sum 函数,在 sum 函数内部,需要为局部变量 c 分配一个空间,而参数变量 a 和 b 则直接对应于入栈的数据 1 和 2,在返回之前,返回值保存到了专门的返回值存储器中。
在调用 return 后,程序会跳转到栈中保存的返回地址,即 main 的下一条指令地址,而 sum 函数相关的数据会出栈,从而又变回图 1-1 的样子。
main 的下一条指令是根据函数返回值给变量 d 赋值,返回值从专门的返回值存储器中获得。
函数执行的基本原理,简单来说就是这样。但有一些需要介绍的点,我们讨论一下。
我们在 1.1 节的时候说过,定义一个变量就会分配一块内存,但我们并没有具体谈什么时候分配内存,具体分配在哪里,什么时候释放内存。
从以上关于栈的描述我们可以看出,函数中的参数和函数内定义的变量,都分配在栈中,这些变量只有在函数被调用的时候才分配,而且在调用结束后就被释放了。但这个说法主要针对基本数据类型,接下来我们谈数组和对象。
1.7.3 数组和对象的内存分配
对于数组和对象类型,我们介绍过,它们都有两块内存,一块存放实际的内容,一块存放实际内容的地址,实际的内容空间一般不是分配在栈上的,而是分配在堆(也是内存的一部分,后续文章介绍)中,但存放地址的空间是分配在栈上的。
我们来看个例子,下面是代码:
public class ArrayMax {
public static int max(int min, int[] arr) {
int max = min;
for(int a : arr){
if(a>max){
max = a;
}
}
return max;
}
public static void main(String[] args) {
int[] arr = new int[]{2,3,4};
int ret = max(0, arr);
System.out.println(ret);
}
}
这个程序也很简单,main 函数新建了一个数组,然后调用函数 max 计算 0 和数组中元素的最大值,在程序执行到 max 函数的 return 语句之前的时候,内存中栈和堆的情况如图 1-3 所示。
对于数组 arr,在栈中存放的是实际内容的地址 0x1000,存放地址的栈空间会随着入栈分配,出栈释放,但存放实际内容的堆空间不受影响。
但说堆空间完全不受影响是不正确的,在这个例子中,当 main 函数执行结束,栈空间没有变量指向它的时候,Java 系统会自动进行垃圾回收,从而释放这块空间。
1.7.4 递归调用的原理
我们再通过栈的角度来理解一下递归函数的调用过程,代码如下:
public static int factorial(int n) {
if(n==0){
return 1;
}else{
return n*factorial(n-1);
}
}
public static void main(String[] args) {
int ret = factorial(4);
System.out.println(ret);
}
在 factorial 第一次被调用的时候,n 是 4,在执行到 n * factorial(n-1),即 4 * factorial(3) 之前的时候,栈的情况大概如图 1-4 所示。
注意返回值存储器是没有值的,在调用 factorial(3) 后,栈的情况变为了如图 1-5 所示。
栈的深度增加了,返回值存储器依然为空,就这样,每递归调用一次,栈的深度就增加一层,每次调用都会分配对应的参数和局部变量,也都会保存调用的返回地址,在调用到 n 等于 0 的时候,栈的情况如图 1-6 所示。
这个时候,终于有返回值了,我们将 factorial 简写为 f。
- f(0) 的返回值为 1,f(0) 返回到 f(1)
- f(1) 执行 1 * f(0),结果也是 1,然后返回到 f(2)
- f(2) 执行 2 * f(1),结果是 2,然后接着返回到 f(3)
- f(3) 执行 3 * f(2),结果是 6,然后返回到 f(4)
- f(4) 执行 4 * f(3),结果是 24
以上就是递归函数的执行过程,函数代码虽然只有一份,但在执行的过程中,每调用一次,就会有一次入栈,生成一份不同的参数、局部变量和返回地址。
函数调用的成本
从函数调用的过程我们可以看出,调用是有成本的,每一次调用都需要分配额外的栈空间用于存储参数、局部变量以及返回地址,需要进行额外的入栈和出栈操作。
在递归调用的情况下,如果递归的次数比较多,这个成本是比较可观的,所以,如果程序可以比较容易的改为别的方式,应该考虑别的方式。
另外,栈的空间不是无限的,一般正常调用都是没有问题的,但像上节介绍的例子,栈空间过深,系统就会抛出错误,java.lang.StackOverflowError,即栈溢出。
1.7.5 小结
本节介绍了函数调用的基本原理,函数调用主要是通过栈来存储相关数据的,系统就函数调用者和函数如何使用栈做了约定,返回值我们简化认为是通过一个专门的返回值存储器存储的。