计算机程序的思维逻辑 (11) - 初识函数

2,014 阅读15分钟

本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》(马俊昌著),由机械工业出版社华章分社出版,于2018年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,且暂不讨论这些修饰符的目的。

以上就是定义函数的语法,定义函数就是定义了一段有着明确功能的子程序,但定义函数本身不会执行任何代码,函数要被执行,需要被调用。

函数调用

Java中,任何函数都需要放在一个类中,类我们还没有介绍,我们暂时可以把类看做函数的一个容器,即函数放在类中,类中包括多个函数,Java中函数一般叫做方法,我们不特别区分函数方法,可能会交替使用。一个类里面可以定义多个函数,类里面可以定义一个叫做main的函数,形式如:

public static void main(String[] args) {
      ...
}

这个函数有特殊的含义,表示程序的入口,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。调用函数需要传递参数并处理返回值。

这里对于初学者需要注意的是,参数和返回值的名字是没有特别含义的。调用者main中的参数名字a和b,和函数定义sum中的参数名字a和b只是碰巧一样而 已,它们完全可以不一样,而且名字之间没有关系,sum函数中不能使用main函数中的名字,反之也一样。调用者main中的sum变量和sum函数中的 sum变量的名字也是碰巧一样而已,完全可以不一样。另外,变量和函数可以取一样的名字,但也是碰巧而已,名字一样不代表有特别的含义。

调用函数如果没有参数要传递,也要加括号(),如print3Lines()。

传递的参数不一定是个变量,可以是常量,也可以是某个运算表达式,可以是某个函数的返回结果。 如:System.out.println(sum(3,4)); 第一个函数调用 sum(3,4),传递的参数是常量3和4,第二个函数调用 System.out.println传递的参数是sum(3,4)的返回结果。

关于参数传递,简单总结一下,定义函数时声明参数,实际上就是定义变量,只是这些变量的值是未知的,调用函数时传递参数,实际上就是给函数中的变量赋值。

函数可以调用同一个类中的其他函数,也可以调用其他类中的函数,我们在前面几节使用过输出一个整数的二进制表示的函数,toBinaryString:

int a = 23;
System.out.println(Integer.toBinaryString(a));

toBinaryString是Integer类中修饰符为public static的函数,可以通过在前面加上类名和.直接调用。

函数基本小结

对于需要重复执行的代码,可以定义函数,然后在需要的地方调用,这样可以减少重复代码。对于复杂的操作,可以将操作分为多个函数,会使得代码更加易读。

我 们在前面介绍过,程序执行基本上只有顺序执行、条件执行和循环执行,但更完整的描述应该包括函数的调用过程。程序从main函数开始执行,碰到函数调用的时候,会跳转进函数内部,函数调用了其他函数,会接着进入其他函数,函数返回后会继续执行调用后面的语句,返回到main函数并且main函数没有要执行的语句后程序结束。下节我们会更深入的介绍执行过程细节。

在Java中,函数在程序代码中的位置和实际执行的顺序是没有关系的。

函数的定义和基本调用应该是比较容易理解的,但有很多细节可能令初学者困惑,包括参数传递、返回、函数命名、调用过程等,我们逐个讨论下。

参数传递

数组参数

数组作为参数与基本类型是不一样的,基本类型不会对调用者中的变量造成任何影响,但数组不是,在函数内修改数组中的元素会修改调用者中的数组内容。我们看个例子:

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}),使用可变长度参数主要是简化了代码书写。

返回

return的含义

对初学者,我们强调下return的含义。函数返回值类型为void且没有return的情况下,会执行到函数结尾自动返回。return用于结束函数执行,返回调用方。

return可以用于函数内的任意地方,可以在函数结尾,也可以在中间,可以在if语句内,可以在for循环内,用于提前结束函数执行,返回调用方。

函数返回值类型为void也可以使用return,即return;,不用带值,含义是返回调用方,只是没有返回值而已。

返回值的个数

函数的返回值最多只能有一个,那如果实际情况需要多个返回值呢?比如说,计算一个整数数组中的最大的前三个数,需要返回三个结果。这个可以用数组作为返回值,在函数内创建一个包含三个元素的数组,然后将前三个结果赋给对应的数组元素。

如果实际情况需要的返回值是一种复合结果呢?比如说,查找一个字符数组中,所有重复出现的字符以及重复出现的次数。这个可以用对象作为返回值,我们在后续章节介绍类和对象。

我想说的是,虽然返回值最多只能有一个,但其实一个也够了。

函数命名

每个函数都有一个名字,这个名字表示这个函数的意义,名字可以重复吗?在不同的类里,答案是肯定的,在同一个类里,要看情况。

同一个类里,函数可以重名,但是参数不能一样,一样是指参数个数相同,每个位置的参数类型也一样,但参数的名字不算,返回值类型也不算。换句话说,函数的唯一性标示是:类名_函数名_参数1类型_参数2类型_...参数n类型。

同一个类中函数名字相同但参数不同的现象,一般称为函数重载。为什么需要函数重载呢?一般是因为函数想表达的意义是一样的,但参数个数或类型不一样。比如说,求两个数的最大值,在Java的Math库中就定义了四个函数,如下所示:

public static double max(double a, double b)
public static float max(float a, float b) 
public static int max(int a, int b)
public static long max(long a, long b)

调用过程

匹配过程

在之前介绍函数调用的时候,我们没有特别说明参数的类型。这里说明一下,参数传递实际上是给参数赋值,调用者传递的数据需要与函数声明的参数类型是匹配的,但不要求完全一样。什么意思呢?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;
}

小结

函数是计算机程序的一种重要结构,通过函数来减少重复代码,分解复杂操作是计算机程序的一种重要思维方式。本节我们介绍了函数的基础概念,还有关于参数传递、返回值、重载、递归方面的一些细节。

但在Java中,函数还有大量的修饰符, 如public, private, static, final, synchronized, abstract等,本文假定函数的修饰符都是public static,在后续文章中,我们再介绍这些修饰符。函数中还可以声明异常,我们也留待后续文章介绍。

在介绍递归函数的时候,我们看到了一个系统错误,java.lang.StackOverflowError,理解这个错误,我们需要理解函数调用的实现机制,让我们下节介绍。


更多文章

计算机程序的思维逻辑 (5) - 小数计算为什么会出错?

计算机程序的思维逻辑 (6) - 如何从乱码中恢复 (上)?

计算机程序的思维逻辑 (7) - 如何从乱码中恢复 (下)?

计算机程序的思维逻辑 (8) - char的真正含义

计算机程序的思维逻辑 (9) - 条件执行的本质

计算机程序的思维逻辑 (10) - 强大的循环


未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),深入浅出,老马和你一起探索Java编程及计算机技术的本质。原创文章,保留所有版权。