Go 語言中的函式和方法到底有什麼不同?
函式和方法是我們邁向程式碼複用、多人協作開發的第一步。通過函式,可以把開發任務分解成一個個小的單元,這些小單元可以被其他單元複用,進而提高開發效率、降低程式碼重合度。再加上現成的函式已經被充分測試和使用過,所以其他函式在使用這個函式時也更安全,比你自己重新寫一個相似功能的函式 Bug 率更低。
這節課,我會詳細講解 Go 語言的函式和方法,瞭解它們的宣告、使用和不同。雖然在 Go 語言中有函式和方法兩種概念,但它們的相似度非常高,只是所屬的物件不同。我們先從函式開始瞭解。
函式
函式初探
在前面的四節課中,你已經見到了 Go 語言中一個非常重要的函式:main 函式,它是一個 Go 語言程式的入口函式,我在演示程式碼示例的時候,會一遍遍地使用它。
下面的示例就是一個 main 函式:
func main() {
}
它由以下幾部分構成:
- 任何一個函式的定義,都有一個 func 關鍵字,用於宣告一個函式,就像使用 var 關鍵字宣告一個變數一樣;
- 然後緊跟的 main 是函式的名字,命名符合 Go 語言的規範即可,比如不能以數字開頭;
- main 函式名字後面的一對括號 () 是不能省略的,括號裡可以定義函式使用的引數,這裡的 main 函式沒有引數,所以是空括號 () ;
- 括號 () 後還可以有函式的返回值,因為 main 函式沒有返回值,所以這裡沒有定義;
- 最後就是大括號 {} 函式體了,你可以在函式體裡書寫程式碼,寫該函式自己的業務邏輯。
函式宣告
經過上一小節的介紹,相信你已經對 Go 語言函式的構成有一個比較清晰的瞭解了,現在讓我們一起總結出函式的宣告格式,如下面的程式碼所示:
func funcName(params) result {
body
}
這就是一個函式的簽名定義,它包含以下幾個部分:
- 關鍵字 func;
- 函式名字 funcName;
- 函式的引數 params,用來定義形參的變數名和型別,可以有一個引數,也可以有多個,也可以沒有;
- result 是返回的函式值,用於定義返回值的型別,如果沒有返回值,省略即可,也可以有多個返回值;
- body 就是函式體,可以在這裡寫函式的程式碼邏輯。
現在,我們一起根據上面的函式宣告格式,自定義一個函式,如下所示:
ch05/main.go
func sum(a int,b int) int{
return a+b
}
這是一個計算兩數之和的函式,函式的名字是 sum,它有兩個引數 a、b,引數的型別都是 int。sum 函式的返回值也是 int 型別,函式體部分就是把 a 和 b 相加,然後通過 return 關鍵字返回,如果函式沒有返回值,可以不用使用 return 關鍵字。
終於可以宣告自己的函數了,恭喜你邁出了一大步!
函式中形參的定義和我們定義變數是一樣的,都是變數名稱在前,變數型別在後,只不過在函式裡,變數名稱叫作引數名稱,也就是函式的形參,形參只能在該函式體內使用。函式形參的值由呼叫者提供,這個值也稱為函式的實參,現在我們傳遞實參給 sum 函式,演示函式的呼叫,如下面的程式碼所示:
ch05/main.go
func main() {
result:=sum(1,2)
fmt.Println(result)
}
我們自定義的 sum 函式,在 main 函式中直接呼叫,呼叫的時候需要提供真實的引數,也就是實參 1 和 2。
函式的返回值被賦值給變數 result,然後把這個結果打印出來。你可以自己執行一下,能看到結果是 3,這樣我們就通過函式 sum 達到了兩數相加的目的,如果其他業務邏輯也需要兩數相加,那麼就可以直接使用這個 sum 函式,不用再定義了。
在以上函式定義中,a 和 b 形參的型別是一樣的,這個時候我們可以省略其中一個型別的宣告,如下所示:
func sum(a, b int) int {
return a + b
}
像這樣使用逗號分隔變數,後面統一使用 int 型別,這和變數的宣告是一樣的,多個相同型別的變數都可以這麼宣告。
多值返回
同有的程式語言不一樣,Go 語言的函式可以返回多個值,也就是多值返回。在 Go 語言的標準庫中,你可以看到很多這樣的函式:第一個值返回函式的結果,第二個值返回函數出錯的資訊,這種就是多值返回的經典應用。
對於 sum 函式,假設我們不允許提供的實參是負數,可以這樣改造:在實參是負數的時候,通過多值返回,返回函式的錯誤資訊,如下面的程式碼所示:
ch05/main.go
func sum(a, b int) (int,error){
if a<0 || b<0 {
return 0,errors.New("a或者b不能是負數")
}
return a + b,nil
}
這裡需要注意的是,如果函式有多個返回值,返回值部分的型別定義需要使用小括號括起來,也就是 (int,error)。這代表函式 sum 有兩個返回值,第一個是 int 型別,第二個是 error 型別,我們在函式體中使用 return 返回結果的時候,也要符合這個型別順序。
在函式體中,可以使用 return 返回多個值,返回的多個值通過逗號分隔即可,返回多個值的型別順序要和函式宣告的返回型別順序一致,比如下面的例子:
return 0,errors.New("a或者b不能是負數")
返回的第一個值 0 是 int 型別,第二個值是 error 型別,和函式定義的返回型別完全一致。
定義好了多值返回的函式,現在我們用如下程式碼嘗試呼叫:
ch05/main.go
func main() {
result,err := sum(1, 2)
if err!=nil {
fmt.Println(err)
}else {
fmt.Println(result)
}
}
函式有多值返回的時候,需要有多個變數接收它的值,示例中使用 result 和 err 變數,使用逗號分開。
如果有的函式的返回值不需要,可以使用下劃線 _ 丟棄它,這種方式我在 for range 迴圈那節課裡也使用過,如下所示:
result,_ := sum(1, 2)
這樣即可忽略函式 sum 返回的錯誤資訊,也不用再做判斷。
提示:這裡使用的 error 是 Go 語言內建的一個介面,用於表示程式的錯誤資訊,後續課程我會詳細介紹。
命名返回引數
不止函式的引數可以有變數名稱,函式的返回值也可以,也就是說你可以為每個返回值都起一個名字,這個名字可以像引數一樣在函式體內使用。
現在我們繼續對 sum 函式的例子進行改造,為其返回值命名,如下面的程式碼所示:
ch05/main.go
func sum(a, b int) (sum int,err error){
if a<0 || b<0 {
return 0,errors.New("a或者b不能是負數")
}
sum=a+b
err=nil
return
}
返回值的命名和引數、變數都是一樣的,名稱在前,型別在後。以上示例中,命名的兩個返回值名稱,一個是 sum,一個是 err,這樣就可以在函式體中使用它們了。
通過下面示例中的這種方式直接為命名返回引數賦值,也就等於函式有了返回值,所以就可以忽略 return 的返回值了,也就是說,示例中只有一個 return,return 後沒有要返回的值。
sum=a+b
err=nil
通過命名返回引數的賦值方式,和直接使用 return 返回值的方式結果是一樣的,所以呼叫以上 sum 函式,返回的結果也一樣。
雖然 Go 語言支援函式返回值命名,但是並不是太常用,根據自己的需求情況,酌情選擇是否對函式返回值命名。
可變引數
可變引數,就是函式的引數數量是可變的,比如最常見的 fmt.Println 函式。
同樣一個函式,可以不傳引數,也可以傳遞一個引數,也可以兩個引數,也可以是多個等等,這種函式就是具有可變引數的函式,如下所示:
fmt.Println()
fmt.Println("飛雪")
fmt.Println("飛雪","無情")
下面所演示的是 Println 函式的宣告,從中可以看到,定義可變引數,只要在引數型別前加三個點 … 即可:
func Println(a ...interface{}) (n int, err error)
現在我們也可以定義自己的可變引數的函數了。還是以 sum 函式為例,在下面的程式碼中,我通過可變引數的方式,計算呼叫者傳遞的所有實參的和:
ch05/main.go
func sum1(params ...int) int {
sum := 0
for _, i := range params {
sum += i
}
return sum
}
為了便於和 sum 函式區分,我定義了函式 sum1,該函式的引數是一個可變引數,然後通過 for range 迴圈來計算這些引數之和。
講到這裡,相信你也看明白了,可變引數的型別其實就是切片,比如示例中 params 引數的型別是 []int,所以可以使用 for range 進行迴圈。
函式有了可變引數,就可以靈活地進行使用了。
如下面的呼叫者示例,傳遞幾個引數都可以,非常方便,也更靈活:
ch05/main.go
fmt.Println(sum1(1,2))
fmt.Println(sum1(1,2,3))
fmt.Println(sum1(1,2,3,4))
這裡需要注意,如果你定義的函式中既有普通引數,又有可變引數,那麼可變引數一定要放在引數列表的最後一個,比如 sum1(tip string,params …int) ,params 可變引數一定要放在最末尾。
包級函式
不管是自定義的函式 sum、sum1,還是我們使用到的函式 Println,都會從屬於一個包,也就是 package。sum 函式屬於 main 包,Println 函式屬於 fmt 包。
同一個包中的函式哪怕是私有的(函式名稱首字母小寫)也可以被呼叫。如果不同包的函式要被呼叫,那麼函式的作用域必須是公有的,也就是函式名稱的首字母要大寫,比如 Println。
在後面的包、作用域和模組化的課程中我會詳細講解,這裡可以先記住:
- 函式名稱首字母小寫代表私有函式,只有在同一個包中才可以被呼叫;
- 函式名稱首字母大寫代表公有函式,不同的包也可以呼叫;
- 任何一個函式都會從屬於一個包。
小提示:Go 語言沒有用 public、private 這樣的修飾符來修飾函式是公有還是私有,而是通過函式名稱的大小寫來代表,這樣省略了煩瑣的修飾符,更簡潔。
匿名函式和閉包
顧名思義,匿名函式就是沒有名字的函式,這是它和正常函式的主要區別。
在下面的示例中,變數 sum2 所對應的值就是一個匿名函式。需要注意的是,這裡的 sum2 只是一個函式型別的變數,並不是函式的名字。
ch05/main.go
func main() {
sum2 := func(a, b int) int {
return a + b
}
fmt.Println(sum2(1, 2))
}
通過 sum2,我們可以對匿名函式進行呼叫,以上示例算出的結果是 3,和使用正常的函式一樣。
有了匿名函式,就可以在函式中再定義函式(函式巢狀),定義的這個匿名函式,也可以稱為內部函式。更重要的是,在函式內定義的內部函式,可以使用外部函式的變數等,這種方式也稱為閉包。
我們用下面的程式碼進行演示:
ch05/main.go
func main() {
cl:=colsure()
fmt.Println(cl())
fmt.Println(cl())
fmt.Println(cl())
}
func colsure() func() int {
i:=0
return func() int {
i++
return i
}
}
執行這個程式碼,你會看到輸出列印的結果是:
1
2
3
這都得益於匿名函式閉包的能力,讓我們自定義的 colsure 函式,可以返回一個匿名函式,並且持有外部函式 colsure 的變數 i。因而在 main 函式中,每呼叫一次 cl(),i 的值就會加 1。
小提示:在 Go 語言中,函式也是一種型別,它也可以被用來宣告函式型別的變數、引數或者作為另一個函式的返回值型別。
方法
不同於函式的方法
在 Go 語言中,方法和函式是兩個概念,但又非常相似,不同點在於方法必須要有一個接收者,這個接收者是一個型別,這樣方法就和這個型別繫結在一起,稱為這個型別的方法。
在下面的示例中,type Age uint 表示定義一個新型別 Age,該型別等價於 uint,可以理解為型別 uint 的重新命名。其中 type 是 Go 語言關鍵字,表示定義一個型別,在結構體和介面的課程中我會詳細介紹。
ch05/main.go
type Age uint
func (age Age) String(){
fmt.Println("the age is",age)
}
示例中方法 String() 就是型別 Age 的方法,型別 Age 是方法 String() 的接收者。
和函式不同,定義方法時會在關鍵字 func 和方法名 String 之間加一個接收者 (age Age) ,接收者使用小括號包圍。
接收者的定義和普通變數、函式引數等一樣,前面是變數名,後面是接收者型別。
現在方法 String() 就和型別 Age 繫結在一起了,String() 是型別 Age 的方法。
定義了接收者的方法後,就可以通過點操作符呼叫方法,如下面的程式碼所示:
ch05/main.go
func main() {
age:=Age(25)
age.String()
}
執行這段程式碼,可以看到如下輸出:
the age is 25
接收者就是函式和方法的最大不同,此外,上面所講到的函式具備的能力,方法也都具備。
提示:因為 25 也是 unit 型別,unit 型別等價於我定義的 Age 型別,所以 25 可以強制轉換為 Age 型別。
值型別接收者和指標型別接收者
方法的接收者除了可以是值型別(比如上一小節的示例),也可以是指標型別。
定義的方法的接收者型別是指標,所以我們對指標的修改是有效的,如果不是指標,修改就沒有效果,如下所示:
ch05/main.go
func (age *Age) Modify(){
*age = Age(30)
}
呼叫一次 Modify 方法後,再呼叫 String 方法檢視結果,會發現已經變成了 30,說明基於指標的修改有效,如下所示:
age:=Age(25)
age.String()
age.Modify()
age.String()
提示:在呼叫方法的時候,傳遞的接收者本質上都是副本,只不過一個是這個值副本,一是指向這個值指標的副本。指標具有指向原有值的特性,所以修改了指標指向的值,也就修改了原有的值。我們可以簡單地理解為值接收者使用的是值的副本來呼叫方法,而指標接收者使用實際的值來呼叫方法。
示例中呼叫指標接收者方法的時候,使用的是一個值型別的變數,並不是一個指標型別,其實這裡使用指標變數呼叫也是可以的,如下面的程式碼所示:
(&age).Modify()
這就是 Go 語言編譯器幫我們自動做的事情:
- 如果使用一個值型別變數呼叫指標型別接收者的方法,Go 語言編譯器會自動幫我們取指標呼叫,以滿足指標接收者的要求。
- 同樣的原理,如果使用一個指標型別變數呼叫值型別接收者的方法,Go 語言編譯器會自動幫我們解引用呼叫,以滿足值型別接收者的要求。
總之,方法的呼叫者,既可以是值也可以是指標,不用太關注這些,Go 語言會幫我們自動轉義,大大提高開發效率,同時避免因不小心造成的 Bug。
不管是使用值型別接收者,還是指標型別接收者,要先確定你的需求:在對型別進行操作的時候是要改變當前接收者的值,還是要建立一個新值進行返回?這些就可以決定使用哪種接收者。
總結
在 Go 語言中,雖然存在函式和方法兩個概念,但是它們基本相同,不同的是所屬的物件。函式屬於一個包,方法屬於一個型別,所以方法也可以簡單地理解為和一個型別關聯的函式。
不管是函式還是方法,它們都是程式碼複用的第一步,也是程式碼職責分離的基礎。掌握好函式和方法,可以讓你寫出職責清晰、任務明確、可複用的程式碼,提高開發效率、降低 Bug 率。
本節課給你留的思考題是:方法是否可以作為表示式賦值給一個變數?如果可以的話,如何通過這個變數呼叫方法?