方法的概念和使用
方法的定义
方法就像一个工具,可以完成特定的功能,Java中也预制了很多的方法,例如我们上一节中的nextLine()
这样的后面加括号的,都是方法。
(有一些地方可能会称呼方法为函数(大部分是因为学过其他语言,然后下意识说成了函数,或者是为了适配多语言的场景),深究的话实际上这并不是同一个东西。但我们需要去了解,假如说的函数实际上想表达是什么意思,防止出现看不懂的情况)
实际上,我们也可以自定义一些方法来帮我们完成特定的功能,同时还能减少代码的冗余度,因为我们只要写一次就可以多次的调用这个方法。
那么要怎么样才能定义方法呢?语法如下
修饰符 返回值类型 方法名称([参数类型 形参 ...]){
方法体代码;
[return 返回值];
}
//[]括住的是可选的,不是一定要有 ...代表数量可变
关于标识符,我们现在的学习阶段,统一写成public static
就可以,到后面的类和对象章节详细解释
那么其他的是什么意思呢?我们通过一个例子来帮忙解释,下面定义一个求两个整型和的方法
public static int add(int x, int y) {
return x + y;
}
分别解释一下各个组成部分:
- 标识符:固定写法
- 返回值类型:我们要将两个
int
相加的和返回,所以类型肯定也是int
- 名字:方法名遵循小驼峰原则
- 形式参数:两个数相加,肯定会把两个数字传过来,那么就用形式参数接收,由于是两个数所以创建两个形式参数
- 方法体:实际执行的代码,这里可以看作是
a + b
return
:返回语句,用于将计算的最终值传回去
这里有一些定义方法的注意事项:
- 方法不能嵌套定义,即方法内嵌套定义方法
- 如果没有返回值,返回值类型为
void
- 返回值个数最多只能有一个
- 方法可以没有参数,没有参数括号中间为空即可
方法的执行过程
这里我们通过上面的加法方法来先了解一下方法的执行过程
public class Test {
public static int add(int x, int y) {
return x + y;
}
public static void main(String[] args) {
int a = add(5,3);
}
}
那么上面的代码是怎么运行的呢?
首先,将5
和3
传给add
中的x
和y
中,然后计算出x + y = 8
,最后将计算出的值返回到add(5, 3)
并将值赋值给a
实际上对于所有的方法都是类似的运行模式,被调用--->接收参数--->执行方法体--->如果有返回值就进行返回没有就直接结束
形式参数和实际参数
依旧以上面那个代码为例子
public class Test {
//x y就是形式参数,简称形参
public static int add(int x, int y) {
return x + y;
}
public static void main(String[] args) {
//5 3就是实际参数,简称实参
int a = add(5,3);
}
}
形式参数就相当于模具,而实际参数就相当于填料,两者结合才可以做出成品
在方法执行的时候,实际参数会被赋予给形式参数,并且由形式参数进行运算,注意:两者并不互通
举一个例子说明这个问题,下面拟写一个交换两个整型位置的方法
public class Test {
public static void swap(int x, int y){
int tmp = x;
x = y;
y = tmp;
}
public static void main(String[] args) {
int a = 10;
int b = 20;
System.out.println("交换前 a = " + a + " b = " + b);
swap(a, b);
System.out.println("交换后 a = " + a + " b = " + b);
}
}
会发现位置完全没有变,那么是为什么呢?方法里的代码不是已经进行交换了吗?
实际上我们在调用方法的时候,会在内存中一个区域开辟一块空间,这个区被称作栈区,而方法的这个开辟空间的操作被称为压栈。那么这块空间是用来干什么的呢,是用来存放形式参数和执行方法体的。
也就是说,形式参数使用的时候,实际上是相当于创建了一个新变量,那么我们上面的代码在执行交换的时候交换的实际上是这两个新创建的变量,而不是我们的实际参数。
那么有关于如何用方法交换变量的操作,我们将会在后面的学习中讲到,现在先留一个悬念
方法重载
上面我们写加法的方法的时候,会发现它的局限性非常高。
不仅只能算两个数的和,还只能是int
类型。那么Java允许我们对我们的方法进行重载,从而拓展它的使用范围
依旧以加法运算为例
public class Test {
public static int add(int a, int b){
return a + b;
}
//拓展了参数个数
public static int add(int a, int b, int c){
return a + b + c;
}
//拓展了参数类型
public static double add(double a , double b){
//倘若只改变返回值类型不算做方法的重载,会因为定义冲突报错
return a + b;
}
}
方法重载的几个注意点:
- 方法可以重载多次,但是数量应该合适
- 方法重载核心看的是参数列表的不同,修饰符、返回值都不影响
那么可能有人会说:你这个也不智能啊,那假如我要相加n
个数字,但我不知道n
的具体值怎么办
那么这个时候就可以用一个比较特殊的参数,可变参数
可变参数
它允许方法接受可变数量的参数,而不需要在方法定义中明确指定参数的数量
举一个定义可变参数的例子
public static int test(int...x){
}
那么我们就可以传任意个参数到test()
实际上可变参数是一个根据你输入数据个数定义的数组,但是由于涉及到了数组的知识,所以这里暂时不讲解它的使用,只是先做了解
方法的签名
在上面的方法重载中,我们写了多个名字相同的方法,那难免有人会疑惑:为什么我局部变量名字都不能一样,你在同一个类里方法名字能一样?
实际上,Java判断一个方法是否冲突,是根据方法签名来看的,方法签名由方法名称和方法参数类型组成
我们也可以通过反编译字节码文件来看一下方法签名,一下面代码为例
public class Test {
public static int add(int a, int b){
return a + b;
}
public static int add(int a, int b, int c){
return a + b + c;
}
public static double add(double a , double b){
return a + b;
}
public static void main(String[] args) {
add(1,3);
add(1.3,1.1);
}
}
反编译
在IDEA中,我们每运行完一次Java程序后,都会在out
文件夹生成字节码文件,我们先打开这个文件夹,找到里面的字节码文件,并访问它的路径
然后在该路径下打开cmd
,并输入javap -v +文件名字
,不用加.class
后缀
然后会发现蹦出来一堆的东西,翻到最末尾可以看到这里调用方法的提示
调用的分别是add:(II)I
和add:(DD)D
,其组成格式可以解释为方法名:(参数类型1参数类型2)返回值类型
虽然返回值类型在里面,但是实际上它不属于方法签名,因为只有返回值不同的时候会显示冲突
这里涉及到了一些特殊字符,下面是一些常见的特殊字符(了解即可)
特殊字符 | 数据类型 |
---|---|
V | void |
Z | boolean |
B | byte |
C | char |
S | short |
I | int |
J | long |
F | float |
D | double |
[ | 数组,涉及到多个[ ,N个就代表是N维数组 |
L | 引用类型,以L 开头; 结尾,中间是引用类型的全类名 |
方法的递归
递归,即自己调用自己,实际上是运用了将大问题拆分为小问题的思想。
例如我们要求!5
,那就求出!4
然后乘以5
,求!4
就化为!3*4
,依次类推,遇到1
的时候再停止拆分往回带就可以得到答案了
使用递归的注意点:
- 子方法和原方法的解决方法必须一致
- 要设定边界条件,并且每次递归后要会接近边界条件,防止死递归
那么就用递归实现一个求阶乘的代码
public class Test {
public static int fact(int n) {
//边界条件
if (n == 0 || n == 1) {
return 1;
} else {
//递归
return fact(n - 1) * n;
}
}
public static void main(String[] args) {
System.out.println(fact(5));
}
}
递归的优劣
我们这里先实现一个用递归求斐波那契数列的方法
public class Test {
public static int fib(int n) {
if(n == 0){
return 0;
} else if (n == 1) {
return 1;
} else {
return fib(n - 1) + fib(n - 2);
}
}
public static void main(String[] args) {
System.out.println(fib(45));
}
}
会发现,当我们让它计算第40
个以上的时候,输出就十分缓慢了(这点还要看电脑配置)
那么是为什么呢?我们不妨建立一个计数器来看看
public class Test {
//全局计数器
static int count = 0;
public static int fib(int n) {
//给执行的边界情况计数
if(n == 0){
count++;
return 0;
} else if (n == 1) {
count++;
return 1;
} else {
return fib(n - 1) + fib(n - 2);
}
}
public static void main(String[] args) {
System.out.println(fib(45));
System.out.println(count);
}
}
会发现,我们调用底层边界的次数甚至超越了我们要算的数字,我们这里甚至还没有加上中间的计算量,那么这个计算量是十分恐怖的
因此在某些时候,并不推荐使用递归来解决一些问题,而推荐使用循环
public class Test {
public static int fib(int n) {
int num1 = 0;
int num2 = 1;
int ret = 0;
for (int i = 0; i < n-1; i++) {
ret = num1 + num2;
num1 = num2;
num2 = ret;
}
return ret;
}
public static void main(String[] args) {
System.out.println(fib(45));
}
}
那么这里循环的计算量实际上就是循环的次数,也就是n-1
次,是非常低的
练习
用递归解决汉诺塔问题
每次只能移动一个盘子,并且在移动过程中三根杆上都始终保持大盘在下,小盘在上,操作过程中盘子可以置于A、B、C任一杆上
思路解析
既然是可以用递归解决的,那么我们不妨思考怎么把移动n
个盘子的问题化为移动n-1
个盘子和另外一个盘子的问题
要移动n
个盘子到B
那么肯定是要先把最底层的大盘子移动到B
上,因为移动过程中要保持大盘在下小盘在上,那么思路就出来了
我们就不考虑移动到哪里,只考虑移动n
个盘子,那无论我把n-1
个盘子移动到B
还是C
次数都应该是一样的
那既然要先把最底下的盘子移动到B
,那么就一定要先把上面的n-1
个盘子移动到C
,此时情况应该如图所示
那么这个时候我们只要把C
上面的n-1
个移动到B
上即可
那么我们移动n
个盘子的操作就被拆分为了:1. 先移动n-1
个盘子 2. 再移动一个盘子 3. 移动n-1
个盘子
用公式来表达就是F(n) = 2F(n-1) + 1
有了上面的思路,实现代码就很简单了
public class Test {
public static int hanoi(int n) {
//边界条件
if (n == 0 || n == 1) {
return n;
} else {
//递归
return 2 * hanoi(n - 1) + 1;
}
}
public static void main(String[] args) {
System.out.println(hanoi(10));
}
}