6. 函數

將一段程式碼包裝起來給個名字,日後要執行這段程式碼時只要呼叫這段程式碼的名字就可以了,這段程式碼就稱為函數,所給的名字就稱為函數名稱。使用函數的好處是降低程式碼重複性。有些程式碼在很多地方都需要執行,不斷的重複這些程式碼會降低程式的可讀性與維護性,透過函數可以解決這些問題。例如我們想要將資料儲存到硬碟中,功能完整的存檔程式碼是複雜的,加上很多地方都會需要執行存檔功能,像是選單拉下後選存檔,或是按熱鍵存檔。如果每個需要存檔的地方都要撰寫相同又複雜的程式碼,整個程式看起來就很凌亂,組織性不夠,如果將存檔的程式碼變成函數,並且給一個高可讀性的名字,例如save,這樣只要在需要存檔的時候呼叫save一行程式碼就完成。

6.1 函數宣告

函數以func保留字來宣告函數開始,後面接函數名稱。根據命名慣例,函數名稱第一個字元小寫,如果是兩個以上的單字組合起來,其他單字的第一個字元大寫。函數名稱不可以數字開頭,中間也不能有特殊符號或空白鍵,可以接受底線符號「_」。函數內容也就是真正要執行的程式碼用左右大括號框住,即使只有一行程式碼也要用大括號框住,不可省略。函數名稱後面使用小括號來描述參數,如果沒有參數的函數,小括號也不可省略,例如下面這個函數就是沒有參數的函數。

func funcName() {
    // 程式碼寫這
}

函數呼叫的方式只要寫函數名稱加上小括號即可,如下,這樣函數funcName中的程式碼就執行三次了。

funcName()
funcName()
funcName()

為了讓函數中的程式碼執行上更彈性,例如存檔函數save,我們不會每次都儲存同樣的內容以及同樣的路徑與檔名,這些都是會改變的。所以為了讓save這個函數可以適應這些改變,在呼叫函數時就可以將不同的資料傳到函數裡面,讓函數自己去調整不同的變化。傳入到函數中的值稱為函數的參數,參數由三個部分組合而成:參數標籤(argument label),參數名稱(parameter name)與資料型態(data type)。格式如下:

func funcName(argumentLabel parameterName: Int) {
    // 程式碼寫這
}

有些語言的函數在參數格式上沒有argument label這部分,例如C語言,但Swift的參數標籤設計可以讓呼叫函數時有更好的可讀性。當然參數標籤也可以省略,稍後會看到。先來看下面這個例子:

func sayHi (to someone: String) {
    print("Hi, \(someone)")
}

上述程式碼中的參數標籤為「to」,參數名稱為「someone」且資料型態為String。呼叫這個函數的語法如下:

sayHi(to: "John")
// Prints "Hi, John"

從sayHi這個函數的呼叫方式與函數設計可以發現,參數標籤這個功能可以讓我們在看函數時更易懂易讀:「say hi to someone」。從這個例子也可以發現,參數標籤是給呼叫函數時使用的,函數內部要使用的是參數名稱,也就是傳進函數的值John會放到參數名稱someone裡面。

在Swift語言中(Objective-C也一樣),函數名稱在撰寫上必須包含參數格式,這是因為語法上允許不同的參數格式的函數可以有同樣的名稱,所以為了區別起見,完整的函數名稱在撰寫上必須包含參數格式。以sayHi為例,正確的寫法應為sayHi(to:)。如果有參數的話要加上參數標籤,至於參數名稱部分不用寫,此外「:」後面的資料型態也不用寫。以下面程式碼為例,有兩個save函數,沒有參數的save,完整名稱寫成save(),兩個參數的save,寫成save(data:at:),所以這樣一看就知道呼叫的是哪一個函數了。

func save() {}
func save(data data: Data, at path: String) {}

函數設計時,參數標籤的部分可以省略,但「省略」有兩種省略方式,一種是完全不寫,另一種是以底線「_」代替。如果是完全不寫,意思是參數標籤與參數名稱相同,所以只要寫一次即可。以下例而言,參數標籤與參數名稱都是message,完整的函數名稱在撰寫上要寫成:printTwoTimes(message:)。呼叫此函數時參數標籤message不可省略。

func printTwoTimes(message: String) {
    print(message)
    print(message)
}

printTwoTimes(message: "印兩次")

另一種是參數標籤為底線的,代表呼叫時不需要加參數標籤,因為這個參數「沒有標籤名稱」。如下所示,這種函數名稱在撰寫上寫成:printMessage(_:)。

func printMessage(_ message: String) {
    print(message)
}

printMessage("警告訊息")

6.2 有傳回值函數

函數的傳回值代表呼叫這個函數後,該函數會傳回資料給呼叫者,因此呼叫者需要使用變數或常數來接收傳回來的值。如果函數有傳回值,必須在函數後方加上「->」並且寫出傳回值型態。當函數中有值需要傳回時用保留字return就可以將值傳回,並且該函數就會立刻結束,也就是return之後的程式碼都不會再執行。

func getPlanet() -> String {
    return "地球"
}
let earth = getPlanet()
// earth == "地球"

如果函數中只有一行以return開頭的程式碼,return可以省略不寫,稱為Implicit Return,如下:

func getPlanet() -> String {
    "地球"
}

如果傳回值有兩個以上,可以將這些值用tuple包裝起來後傳回,如下:

func getPlanet() -> (String, String) {
    ("太陽系", "地球")
}
let (solar, earth) = getPlanet()
// solar == "太陽系", earth == "地球"

沒有傳回值函數在傳回值型態的位置可以加上「-> Void」,只是通常我們省略不寫,其中Void代表沒有傳回值的意思,例如:

func draw() -> Void {

}

呼叫一個有傳回值的函數,有幾種方式呼叫。一種是宣告一個變數或常數來儲存傳回來的值,以下面的例子而言,常數length的內容就是呼叫printMessage(_:)後該函數的傳回值,以這例子而言length的內容是數字11。

func printMessage(_ message: String) -> Int {
    print(message)
    return message.count
}

let length = printMessage("hello world")

另一種呼叫方式是,我們對傳回值沒有興趣,其值不需要保留起來,這時我們可以宣告一個名稱為底線「_」的常數,這代表我們不在乎傳回值,例如:

let _ = printMessage("hello world")

「let _」讓我們知道函數有傳回值,但是沒有打算要使用他。當然,我們也可以完全省略掉這部分不寫,就將有傳回值的函數當成沒有傳回值的函數呼叫即可,只是在程式碼閱讀上就不知道這個函數其實是有傳回值的。

在傳回值型態後方加上問號,代表這個函數除了正常的值外還可以傳回nil。例如一個除法運算的函數,如果分母為零的話,計算結果為nil。

func divided(_ a: Double, _ b: Double) -> Double? {
    (b == 0) ? nil : a / b
}

~補充說明~
? 與 : 合在一起使用稱為三元運算子。問號左邊是一個布林運算式,如果運算結果為true,執行冒號左邊的程式碼,如果為false,執行冒號右邊的程式碼。因為函數中只有一行,所以return省略了。

6.3 參數預設值

參數可以給預設值,如果函數呼叫時該參數有傳值進去,傳進去的值就會取代預設值,否則就是預設值。換句話說,有預設值的參數在呼叫時可以不傳參數值進去。參數預設值的設定語法就是用等號「=」將值放在型態後方即可,例如:

func sayHi(to someone: String="Sir") {
    print("Hi, \(someone)")
}

sayHi()
// Prints "Hi, Sir"
sayHi(to: "John")
// Prints "Hi, John"

6.4 數量可變動參數

數量可變動參數代表該函數可以不傳參數進去,或是傳遞一個參數、兩個參數…n個參數,不管幾個參數都可以。因為無法事先確定到底有多少個參數,因此這樣的參數設計就稱為數量可變動參數。數量可變動參數只要在型態後方加上「…」即可,該參數會以陣列形式儲存傳進去的參數值。例如設計一個sum(_:)函數,用來將傳進去的數字加總起來後傳回。

func sum(_ numbers: Double...) -> Double {
    var total = 0.0
    for p in numbers {
        total += p
    }
    return total
}

print(sum(1, 2))
// Prints "3.0"
print(sum(1, 3.7, 6.9, 5))
// Prints "16.6"
print(sum())
// Prints "0.0"

一個函數最若包含兩個以上的參數,並且其中一個參數是數量可變動參數,這時必須使用參數標籤來得知哪些參數值屬於數量可變動參數,例如:

func sum(base: Double=0.0, _ numbers: Double...) -> Double {
    var total = base
    for p in numbers {
        total += p
    }
    return total
}

呼叫時的範例如下,第一行程式碼的參數值2, 3, 6會放入numbers中,第二行程式碼的參數值6會放入base中,而2, 3, 6會放入numbers中。

print(sum(2, 3, 6))
print(sum(base: 10, 2, 3, 6))

當然我們也可以為numbers加上參數標籤,這樣呼叫時就可以更明確看出值屬於哪個參數了,例如:

func sum(base: Double=0.0, numbers: Double...) -> Double {
    .
    .
    .
}

print(sum(numbers: 2, 3, 6))
print(sum(base: 10, numbers: 2, 3, 6))

透過參數標籤,我們也可以讓函數中的數量可變動參數超過一個以上,例如:

func f(labelA: Int..., labelB: Int...) {
    print("labelA: \(labelA)")
    print("labelB: \(labelB)")
}

f(labelA: 1, 2, 3, labelB: 4, 5)
// Prints
// labelA: [1, 2, 3]
// labelB: [4, 5]

6.5 In-Out類型參數

在Swift中寫出下面這段程式碼會得到一個錯誤訊息,大意是increment(_:)的參數n是用let宣告的常數,因此n += 1這個語法不正確。熟悉C語言的讀者會知道,這段程式碼在語法上是正確的,但是呼叫完increment(_:)之後,n的值還是一樣10,並沒有變成11。雖然大部分的程式設計師不會犯這樣的錯誤,但有的時候還是會不小心產生這個非常難發現的bug,所以Swift直接禁止了這樣的語法。

var n = 10
func increment(_ n: Int) {
    n += 1
}
increment(n)
print(n)

若希望在函數中可以修改n的值,必須在參數上加上inout保留字,用來告訴函數這個參數內容是可以修改的。除此之外,inout也代表呼叫函數時傳進去的變數n與函數的參數n實際上是同一個記憶體位置,因此在函數中修改了n的值實際也會影響函數外面的變數n。在呼叫具有inout參數的函數時,傳進去的變數名稱前必須加上&符號,如下程式碼所示。

var n = 10
func increment(_ n: inout Int) {
    n += 1
}
increment(&n)
print(n)
// Prints "11"

~補充說明~
沒有inout的參數傳遞方式一般稱為傳值呼叫(call by value),有加上inout的參數傳遞方式稱為傳址呼叫(call by address或call by reference)。Swift內建的函數很少使用到inout傳遞,Swift也不讓我們在沒有inout情況下允許函數內部程式碼修改傳進去的參數值,通常只有在C寫成的函數讓Swift呼叫時才有機會使用傳址呼叫。

6.6 函數型態

資料有資料型態,函數也有函數型態。函數型態既不是指函數的參數型態,也不是指傳回值型態,而是指整個函數的樣式。以下面這個函數為例,該函數型態為「(Int, Int) -> Int」。

func plus(_ a: Int, _ b: Int) -> Int {
    a + b
}

沒有傳回值的函數型態,如下面程式碼中的draw()函數,函數型態為「() -> Void」,這裡的Void不可以省略。

func draw() {

}

當函數有了型態後,我們就可以將函數放到某個變數或常數中,只要宣告這個變數或常數的資料型態為函數型態就可以了,例如以下範例。

func plus(_ a: Int, _ b: Int) -> Int {
    a + b
}

let addTwoInts: (Int, Int) -> Int = plus
let result = addTwoInts(5, 3)

我們也可讓函數傳回函數,例如函數calculate()傳回值型態是 (Int, Int) -> Int,代表calculate()傳回一個函數,該函數型態為 (Int, Int) -> Int。因此,可以在calculate()中傳回plus()函數。

func calculate() -> (Int, Int) -> Int {
    plus
}

let answer  = calculate()(5, 3)
// answer == 8

函數可以儲存在變數中,當然函數也就可以儲存在參數中。換句話說,函數可以當成參數傳給另外一個函數,這個特性成為這個語言非常強大的一個功能。例如下面這段程式碼中的execute函數,第三個參數型態為 (Int, Int) -> Int 表示是一個函數,因此在呼叫execute(_:_:_:)時可以將一個符合該型態的函數當成參數傳遞進去。其他更多的應用請參考第7章Closure。

func execute(_ a: Int, _ b: Int, _ exec: (Int, Int) -> Int) -> Int {
    exec(a, b)
}

print(execute(5, 3, plus))
// Prints "8"

6.7 巢狀函數

函數中可以包含另外一個函數,形成函數中的函數。函數中的函數有效範圍僅止於包含他的函數,除非函數中的函數被傳出去,否則最外面是無法直接呼叫到函數中的函數。下列函數createAndDrawRectangle(width:height:)中包含了create()與draw()這兩個函數,create()負責產生要畫出的形狀,draw()負責畫出來。這兩個函數只有在createAndDrawRectangle(width:height:)內部可以呼叫,外面是看不到的。變數shape相當於createAndDrawRectangle(width:height:)的區域變數,但對於create()與draw()而言則是全域變數。

func createAndDrawRectantle(width: Int, height: Int) {
    var shape: [String] = []
    func create() {
        let line = String(repeating: "*", count: width)
        for _ in 0..<height {
            shape.append(line)
        }
    }
    func draw() {
        for line in shape {
            print(line)
        }
    }
    create()
    draw()
}

呼叫後的結果如下:

createAndDrawRectantle(width: 6, height: 3)
// Prints:
// ******
// ******
// ******

若函數將內部的函數傳到外面時,會產生一個特殊的現象,傳出去的函數可以存取原本所在函數內部所宣告的變數或常數,並且將他們視為整體變數,這個特性稱為capture value。下面的例子設計了一個紀錄產品庫存的函數products(_:),該函數傳進目前庫存數量,然後傳出一個內部函數。在products(_:)中宣告了一個記錄庫存數量的變數number,初始值為傳進該函數的參數值。Products(_:)內部宣告另外一個函數take(),每呼叫一次庫存數量就少一個,直到庫存零為止,然後傳回目前的庫存量。最後products(_:)將take()函數傳出去。

func products(_ quantity: Int) -> () -> Int {
    var number = quantity
    func take() -> Int {
        if number > 0 {
            number = number - 1
        } else {
            number = 0
        }
        return number
    }
    return take
}

現在進貨啤酒五罐,然後我們喝了兩罐,所以剩餘的啤酒數量為三罐,程式如下:

let beer = products(5)

var rest: Int
rest = beer()
// rest == 4
rest = beer()
// rest == 3

如果同時進了果汁四罐,這時果汁的剩餘數量並不會與啤酒互相衝突。

let juice = products(4)
rest = juice()
// rest == 3

產品的剩餘數量是記錄在products(_:)函數中的變數number中,對products(_:)而言,number相當於區域變數,但對take()而言相當於全域變數,但又不是真正的全域變數,因為只要呼叫products(_:)函數一次,number就是全新獨立的。

6.8 遞迴

遞迴函數表示函數中的程式碼會再呼叫自己一次,在很多問題上的解法上可以看到使用遞迴技巧,例如搜尋、排序、或是有名的河內塔問題、八個皇后問題、老鼠走迷宮、各種棋類問題…等。

先來看著名的河內塔問題,據說這是一個古印度神廟裡的一個故事。在這神廟中有三根柱子,其中一根柱子上有64個從大到小的圓盤。廟中僧侶要把這64個圓盤搬到另外一根柱子去,但有兩個規則,一個是一次只能將一個圓盤搬動到另一個柱子上,第二個規則是小圓盤一定要在大圓盤上面。當64個圓盤都移動到另外一個柱子時,萬物都將至極樂世界。

第一次搬動的時候,A的圓盤可以搬到B或C,假設搬到C。

第二次搬動的時候只能A搬到B,因為大的圓盤不能在小的圓盤上面

第三次搬動時,將C搬到B。然後第四次時,將A搬到C,以此類推直到所有圓盤都搬到另外一根柱子為止。

如果有64個圓盤要移動幾次呢?答案是18,446,744,073,709,551,615次,若移動一次需要費時一秒鐘,全部移動完差不多是6000億年,還是讓電腦來搬吧,程式碼如下。

var i = 0
func hanoi(_ n: Int, _ a: String, _ b: String, _ c: String) {
    if n == 1 {
        i += 1
        print("\(i): 從 \(a) 移到 \(c)")
    } else {
        hanoi(n - 1, a, c, b)
        hanoi(1, a, b, c)
        hanoi(n - 1, b, a, c)
    }
}

hanoi(3, "A", "B", "C")
// 1: 從 A 移到 C
// 2: 從 A 移到 B
// 3: 從 C 移到 B
// 4: 從 A 移到 C
// 5: 從 B 移到 A
// 6: 從 B 移到 C
// 7: 從 A 移到 C

另外一個範例是計算階乘,例如1 x 2 x 3 x 4 x 5,這在許多數學公式中會用到,例如計算排列組合數,也一樣是用遞迴來計算,相較於河內塔的遞迴解法簡單許多。

func factorial(_ n: Int) -> Int {
    if n == 0 {
        return 1
    } else {
        return n * factorial(n - 1)
    }
}

let result = factorial(5)
// result == 120

發表迴響