計算機程式的思維邏輯 (11) - 初識函式

swiftma發表於2016-08-09

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (11) - 初識函式

函式

前面幾節我們介紹了資料的基本型別、基本操作和流程控制,使用這些已經可以寫不少程式了。

但是如果需要經常做某一個操作,則類似的程式碼需要重複寫很多遍,比如在一個陣列中查詢某個數,第一次查詢一個數,第二次可能查詢另一個數,每查一個數,類似的程式碼都需要重寫一遍,很羅嗦。另外,有一些複雜的操作,可能分為很多個步驟,如果都放在一起,則程式碼難以理解和維護。

計算機程式使用函式這個概念來解決這個問題,即使用函式來減少重複程式碼和分解複雜操作,本節我們就來談談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程式設計及計算機技術的本質。原創文章,保留所有版權。

計算機程式的思維邏輯 (11) - 初識函式

相關文章