# 第五章 函數
函數可以讓我們將一個語句序列打包為一個單元,然后可以從程序中其它地方多次調用。函數的機制可以讓我們將一個大的工作分解為小的任務,這樣的小任務可以讓不同程序員在不同時間、不同地方獨立完成。一個函數同時對用戶隱藏了其實現細節。由于這些因素,對于任何編程語言來說,函數都是一個至關重要的部分。
我們已經見過許多函數了。現在,讓我們多花一點時間來徹底地討論函數特性。本章的運行示例是一個網絡蜘蛛,也就是web搜索引擎中負責抓取網頁部分的組件,它們根據抓取網頁中的鏈接繼續抓取鏈接指向的頁面。一個網絡蜘蛛的例子給我們足夠的機會去探索遞歸函數、匿名函數、錯誤處理和函數其它的很多特性。
### 5.1. 函數聲明
函數聲明包括函數名、形式參數列表、返回值列表(可省略)以及函數體。
~~~
func name(parameter-list) (result-list) {
body
}
~~~
形式參數列表描述了函數的參數名以及參數類型。這些參數作為局部變量,其值由參數調用者提供。返回值列表描述了函數返回值的變量名以及類型。如果函數返回一個無名變量或者沒有返回值,返回值列表的括號是可以省略的。如果一個函數聲明不包括返回值列表,那么函數體執行完畢后,不會返回任何值。 在hypot函數中,
~~~
func hypot(x, y float64) float64 {
return math.Sqrt(x*x + y*y)
}
fmt.Println(hypot(3,4)) // "5"
~~~
x和y是形參名,3和4是調用時的傳入的實數,函數返回了一個float64類型的值。 返回值也可以像形式參數一樣被命名。在這種情況下,每個返回值被聲明成一個局部變量,并根據該返回值的類型,將其初始化為0。 如果一個函數在聲明時,包含返回值列表,該函數必須以 return語句結尾,除非函數明顯無法運行到結尾處。例如函數在結尾時調用了panic異常或函數中存在無限循環。
正如hypot一樣,如果一組形參或返回值有相同的類型,我們不必為每個形參都寫出參數類型。下面2個聲明是等價的:
~~~
func f(i, j, k int, s, t string) { /* ... */ }
func f(i int, j int, k int, s string, t string) { /* ... */ }
~~~
下面,我們給出4種方法聲明擁有2個int型參數和1個int型返回值的函數.blank identifier(譯者注:即下文的_符號)可以強調某個參數未被使用。
~~~
func add(x int, y int) int {return x + y}
func sub(x, y int) (z int) { z = x - y; return}
func first(x int, _ int) int { return x }
func zero(int, int) int { return 0 }
fmt.Printf("%T\n", add) // "func(int, int) int"
fmt.Printf("%T\n", sub) // "func(int, int) int"
fmt.Printf("%T\n", first) // "func(int, int) int"
fmt.Printf("%T\n", zero) // "func(int, int) int"
~~~
函數的類型被稱為函數的標識符。如果兩個函數形式參數列表和返回值列表中的變量類型一一對應,那么這兩個函數被認為有相同的類型和標識符。形參和返回值的變量名不影響函數標識符也不影響它們是否可以以省略參數類型的形式表示。
每一次函數調用都必須按照聲明順序為所有參數提供實參(參數值)。在函數調用時,Go語言沒有默認參數值,也沒有任何方法可以通過參數名指定形參,因此形參和返回值的變量名對于函數調用者而言沒有意義。
在函數體中,函數的形參作為局部變量,被初始化為調用者提供的值。函數的形參和有名返回值作為函數最外層的局部變量,被存儲在相同的詞法塊中。
實參通過值的方式傳遞,因此函數的形參是實參的拷貝。對形參進行修改不會影響實參。但是,如果實參包括引用類型,如指針,slice(切片)、map、function、channel等類型,實參可能會由于函數的簡介引用被修改。
你可能會偶爾遇到沒有函數體的函數聲明,這表示該函數不是以Go實現的。這樣的聲明定義了函數標識符。
~~~
package math
func Sin(x float64) float //implemented in assembly language
~~~
### 5.2. 遞歸
函數可以是遞歸的,這意味著函數可以直接或間接的調用自身。對許多問題而言,遞歸是一種強有力的技術,例如處理遞歸的數據結構。在4.4節,我們通過遍歷二叉樹來實現簡單的插入排序,在本章節,我們再次使用它來處理HTML文件。
下文的示例代碼使用了非標準包 golang.org/x/net/html ,解析HTML。golang.org/x/... 目錄下存儲了一些由Go團隊設計、維護,對網絡編程、國際化文件處理、移動平臺、圖像處理、加密解密、開發者工具提供支持的擴展包。未將這些擴展包加入到標準庫原因有二,一是部分包仍在開發中,二是對大多數Go語言的開發者而言,擴展包提供的功能很少被使用。
例子中調用golang.org/x/net/html的部分api如下所示。html.Parse函數讀入一組bytes.解析后,返回html.node類型的HTML頁面樹狀結構根節點。HTML擁有很多類型的結點如text(文本),commnets(注釋)類型,在下面的例子中,我們 只關注< name key='value' >形式的結點。
*golang.org/x/net/html*
~~~
package html
type Node struct {
Type NodeType
Data string
Attr []Attribute
FirstChild, NextSibling *Node
}
type NodeType int32
const (
ErrorNode NodeType = iota
TextNode
DocumentNode
ElementNode
CommentNode
DoctypeNode
)
type Attribute struct {
Key, Val string
}
func Parse(r io.Reader) (*Node, error)
~~~
main函數解析HTML標準輸入,通過遞歸函數visit獲得links(鏈接),并打印出這些links:
gopl.io/ch5/findlinks1
~~~
// Findlinks1 prints the links in an HTML document read from standard input.
package main
import (
"fmt"
"os"
"golang.org/x/net/html"
)
func main() {
doc, err := html.Parse(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "findlinks1: %v\n", err)
os.Exit(1)
}
for _, link := range visit(nil, doc) {
fmt.Println(link)
}
}
~~~
visit函數遍歷HTML的節點樹,從每一個anchor元素的href屬性獲得link,將這些links存入字符串數組中,并返回這個字符串數組。
~~~
// visit appends to links each link found in n and returns the result.
func visit(links []string, n *html.Node) []string {
if n.Type == html.ElementNode && n.Data == "a" {
for _, a := range n.Attr {
if a.Key == "href" {
links = append(links, a.Val)
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
links = visit(links, c)
}
return links
}
~~~
為了遍歷結點n的所有后代結點,每次遇到n的孩子結點時,visit遞歸的調用自身。這些孩子結點存放在FirstChild鏈表中。
讓我們以Go的主頁(golang.org)作為目標,運行findlinks。我們以fetch(1.5章)的輸出作為findlinks的輸入。下面的輸出做了簡化處理。
~~~
$ go build gopl.io/ch1/fetch
$ go build gopl.io/ch5/findlinks1
$ ./fetch https://golang.org | ./findlinks1
#
/doc/
/pkg/
/help/
/blog/
http://play.golang.org/
//tour.golang.org/
https://golang.org/dl/
//blog.golang.org/
/LICENSE
/doc/tos.html
http://www.google.com/intl/en/policies/privacy/
~~~
注意在頁面中出現的鏈接格式,在之后我們會介紹如何將這些鏈接,根據根路徑( https://golang.org )生成可以直接訪問的url。
在函數outline中,我們通過遞歸的方式遍歷整個HTML結點樹,并輸出樹的結構。在outline內部,每遇到一個HTML元素標簽,就將其入棧,并輸出。
*gopl.io/ch5/outline*
~~~
func main() {
doc, err := html.Parse(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "outline: %v\n", err)
os.Exit(1)
}
outline(nil, doc)
}
func outline(stack []string, n *html.Node) {
if n.Type == html.ElementNode {
stack = append(stack, n.Data) // push tag
fmt.Println(stack)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
outline(stack, c)
}
}
~~~
有一點值得注意:outline有入棧操作,但沒有相對應的出棧操作。當outline調用自身時,被調用者接收的是stack的拷貝。被調用者的入棧操作,修改的是stack的拷貝,而不是調用者的stack,因對當函數返回時,調用者的stack并未被修改。
下面是 https://golang.org 頁面的簡要結構:
~~~
$ go build gopl.io/ch5/outline
$ ./fetch https://golang.org | ./outline
[html]
[html head]
[html head meta]
[html head title]
[html head link]
[html body]
[html body div]
[html body div]
[html body div div]
[html body div div form]
[html body div div form div]
[html body div div form div a]
...
~~~
正如你在上面實驗中所見,大部分HTML頁面只需幾層遞歸就能被處理,但仍然有些頁面需要深層次的遞歸。
大部分編程語言使用固定大小的函數調用棧,常見的大小從64KB到2MB不等。固定大小棧會限制遞歸的深度,當你用遞歸處理大量數據時,需要避免棧溢出;除此之外,還會導致安全性問題。與相反,Go語言使用可變棧,棧的大小按需增加(初始時很小)。這使得我們使用遞歸時不必考慮溢出和安全問題。
**練習 5.1:** 修改findlinks代碼中遍歷n.FirstChild鏈表的部分,將循環調用visit,改成遞歸調用。
**練習 5.2:** 編寫函數,記錄在HTML樹中出現的同名元素的次數。
**練習 5.3:** 編寫函數輸出所有text結點的內容。注意不要訪問`<script>`和`<style>`元素,因為這些元素對瀏覽者是不可見的。
**練習 5.4:** 擴展vist函數,使其能夠處理其他類型的結點,如images、scripts和style sheets。
### 5.3. 多返回值
在Go中,一個函數可以返回多個值。我們已經在之前例子中看到,許多標準庫中的函數返回2個值,一個是期望得到的返回值,另一個是函數出錯時的錯誤信息。下面的例子會展示如何編寫多返回值的函數。
下面的程序是findlinks的改進版本。修改后的findlinks可以自己發起HTTP請求,這樣我們就不必再運行fetch。因為HTTP請求和解析操作可能會失敗,因此findlinks聲明了2個返回值:鏈接列表和錯誤信息。一般而言,HTML的解析器可以處理HTML頁面的錯誤結點,構造出HTML頁面結構,所以解析HTML很少失敗。這意味著如果findlinks函數失敗了,很可能是由于I/O的錯誤導致的。
*gopl.io/ch5/findlinks2*
~~~
func main() {
for _, url := range os.Args[1:] {
links, err := findLinks(url)
if err != nil {
fmt.Fprintf(os.Stderr, "findlinks2: %v\n", err)
continue
}
for _, link := range links {
fmt.Println(link)
}
}
}
// findLinks performs an HTTP GET request for url, parses the
// response as HTML, and extracts and returns the links.
func findLinks(url string) ([]string, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("getting %s: %s", url, resp.Status)
}
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("parsing %s as HTML: %v", url, err)
}
return visit(nil, doc), nil
}
~~~
在findlinks中,有4處return語句,每一處return都返回了一組值。前三處return,將http和html包中的錯誤信息傳遞給findlinks的調用者。第一處return直接返回錯誤信息,其他兩處通過fmt.Errorf(§7.8)輸出詳細的錯誤信息。如果findlinks成功結束,最后的return語句將一組解析獲得的連接返回給用戶。
在finallinks中,我們必須確保resp.Body被關閉,釋放網絡資源。雖然Go的垃圾回收機制會回收不被使用的內存,但是這不包括操作系統層面的資源,比如打開的文件、網絡連接。因此我們必須顯式的釋放這些資源。
調用多返回值函數時,返回給調用者的是一組值,調用者必須顯式的將這些值分配給變量:
~~~
links, err := findLinks(url)
~~~
如果某個值不被使用,可以將其分配給blank identifier:
~~~
links, _ := findLinks(url) // errors ignored
~~~
一個函數內部可以將另一個有多返回值的函數作為返回值,下面的例子展示了與findLinks有相同功能的函數,兩者的區別在于下面的例子先輸出參數:
~~~
func findLinksLog(url string) ([]string, error) {
log.Printf("findLinks %s", url)
return findLinks(url)
}
~~~
當你調用接受多參數的函數時,可以將一個返回多參數的函數作為該函數的參數。雖然這很少出現在實際生產代碼中,但這個特性在debug時很方便,我們只需要一條語句就可以輸出所有的返回值。下面的代碼是等價的:
~~~
log.Println(findLinks(url))
links, err := findLinks(url)
log.Println(links, err)
~~~
準確的變量名可以傳達函數返回值的含義。尤其在返回值的類型都相同時,就像下面這樣:
~~~
func Size(rect image.Rectangle) (width, height int)
func Split(path string) (dir, file string)
func HourMinSec(t time.Time) (hour, minute, second int)
~~~
雖然良好的命名很重要,但你也不必為每一個返回值都取一個適當的名字。比如,按照慣例,函數的最后一個bool類型的返回值表示函數是否運行成功,error類型的返回值代表函數的錯誤信息,對于這些類似的慣例,我們不必思考合適的命名,它們都無需解釋。
如果一個函數將所有的返回值都顯示的變量名,那么該函數的return語句可以省略操作數。這稱之為bare return。
~~~
// CountWordsAndImages does an HTTP GET request for the HTML
// document url and returns the number of words and images in it.
func CountWordsAndImages(url string) (words, images int, err error) {
resp, err := http.Get(url)
if err != nil {
return
}
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
err = fmt.Errorf("parsing HTML: %s", err)
return
}
words, images = countWordsAndImages(doc)
return
}
func countWordsAndImages(n *html.Node) (words, images int) { /* ... */ }
~~~
按照返回值列表的次序,返回所有的返回值,在上面的例子中,每一個return語句等價于:
~~~
return words, images, err
~~~
當一個函數有多處return語句以及許多返回值時,bare return 可以減少代碼的重復,但是使得代碼難以被理解。舉個例子,如果你沒有仔細的審查代碼,很難發現前2處return等價于 return 0,0,err(Go會將返回值 words和images在函數體的開始處,根據它們的類型,將其初始化為0),最后一處return等價于 return words,image,nil。基于以上原因,不宜過度使用bare return。
**練習 5.5:** 實現countWordsAndImages。(參考練習4.9如何分詞)
**練習 5.6:** 修改gopl.io/ch3/surface (§3.2) 中的corner函數,將返回值命名,并使用bare return。
### 5.4. 錯誤
在Go中有一部分函數總是能成功的運行。比如strings.Contains和strconv.FormatBool函數,對各種可能的輸入都做了良好的處理,使得運行時幾乎不會失敗,除非遇到災難性的、不可預料的情況,比如運行時的內存溢出。導致這種錯誤的原因很復雜,難以處理,從錯誤中恢復的可能性也很低。
還有一部分函數只要輸入的參數滿足一定條件,也能保證運行成功。比如time.Date函數,該函數將年月日等參數構造成time.Time對象,除非最后一個參數(時區)是nil。這種情況下會引發panic異常。panic是來自被調函數的信號,表示發生了某個已知的bug。一個良好的程序永遠不應該發生panic異常。
對于大部分函數而言,永遠無法確保能否成功運行。這是因為錯誤的原因超出了程序員的控制。舉個例子,任何進行I/O操作的函數都會面臨出現錯誤的可能,只有沒有經驗的程序員才會相信讀寫操作不會失敗,即時是簡單的讀寫。因此,當本該可信的操作出乎意料的失敗后,我們必須弄清楚導致失敗的原因。
在Go的錯誤處理中,錯誤是軟件包API和應用程序用戶界面的一個重要組成部分,程序運行失敗僅被認為是幾個預期的結果之一。
對于那些將運行失敗看作是預期結果的函數,它們會返回一個額外的返回值,通常是最后一個,來傳遞錯誤信息。如果導致失敗的原因只有一個,額外的返回值可以是一個布爾值,通常被命名為ok。比如,cache.Lookup失敗的唯一原因是key不存在,那么代碼可以按照下面的方式組織:
~~~
value, ok := cache.Lookup(key)
if !ok {
// ...cache[key] does not exist…
}
~~~
通常,導致失敗的原因不止一種,尤其是對I/O操作而言,用戶需要了解更多的錯誤信息。因此,額外的返回值不再是簡單的布爾類型,而是error類型。
內置的error是接口類型。我們將在第七章了解接口類型的含義,以及它對錯誤處理的影響。現在我們只需要明白error類型可能是nil或者non-nil。nil意味著函數運行成功,non-nil表示失敗。對于non-nil的error類型,我們可以通過調用error的Error函數或者輸出函數獲得字符串類型的錯誤信息。
~~~
fmt.Println(err)
fmt.Printf("%v", err)
~~~
通常,當函數返回non-nil的error時,其他的返回值是未定義的(undefined),這些未定義的返回值應該被忽略。然而,有少部分函數在發生錯誤時,仍然會返回一些有用的返回值。比如,當讀取文件發生錯誤時,Read函數會返回可以讀取的字節數以及錯誤信息。對于這種情況,正確的處理方式應該是先處理這些不完整的數據,再處理錯誤。因此對函數的返回值要有清晰的說明,以便于其他人使用。
在Go中,函數運行失敗時會返回錯誤信息,這些錯誤信息被認為是一種預期的值而非異常(exception),這使得Go有別于那些將函數運行失敗看作是異常的語言。雖然Go有各種異常機制,但這些機制僅被使用在處理那些未被預料到的錯誤,即bug,而不是那些在健壯程序中應該被避免的程序錯誤。對于Go的異常機制我們將在5.9介紹。
Go這樣設計的原因是由于對于某個應該在控制流程中處理的錯誤而言,將這個錯誤以異常的形式拋出會混亂對錯誤的描述,這通常會導致一些糟糕的后果。當某個程序錯誤被當作異常處理后,這個錯誤會將堆棧根據信息返回給終端用戶,這些信息復雜且無用,無法幫助定位錯誤。
正因此,Go使用控制流機制(如if和return)處理異常,這使得編碼人員能更多的關注錯誤處理。
### 5.4.1. 錯誤處理策略
當一次函數調用返回錯誤時,調用者有應該選擇何時的方式處理錯誤。根據情況的不同,有很多處理方式,讓我們來看看常用的五種方式。
首先,也是最常用的方式是傳播錯誤。這意味著函數中某個子程序的失敗,會變成該函數的失敗。下面,我們以5.3節的findLinks函數作為例子。如果findLinks對http.Get的調用失敗,findLinks會直接將這個HTTP錯誤返回給調用者:
~~~
resp, err := http.Get(url)
if err != nil{
return nill, err
}
~~~
當對html.Parse的調用失敗時,findLinks不會直接返回html.Parse的錯誤,因為缺少兩條重要信息:1、錯誤發生在解析器;2、url已經被解析。這些信息有助于錯誤的處理,findLinks會構造新的錯誤信息返回給調用者:
~~~
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("parsing %s as HTML: %v", url,err)
}
~~~
fmt.Errorf函數使用fmt.Sprintf格式化錯誤信息并返回。我們使用該函數前綴添加額外的上下文信息到原始錯誤信息。當錯誤最終由main函數處理時,錯誤信息應提供清晰的從原因到后果的因果鏈,就像美國宇航局事故調查時做的那樣:
~~~
genesis: crashed: no parachute: G-switch failed: bad relay orientation
~~~
由于錯誤信息經常是以鏈式組合在一起的,所以錯誤信息中應避免大寫和換行符。最終的錯誤信息可能很長,我們可以通過類似grep的工具處理錯誤信息(譯者注:grep是一種文本搜索工具)。
編寫錯誤信息時,我們要確保錯誤信息對問題細節的描述是詳盡的。尤其是要注意錯誤信息表達的一致性,即相同的函數或同包內的同一組函數返回的錯誤在構成和處理方式上是相似的。
以OS包為例,OS包確保文件操作(如os.Open、Read、Write、Close)返回的每個錯誤的描述不僅僅包含錯誤的原因(如無權限,文件目錄不存在)也包含文件名,這樣調用者在構造新的錯誤信息時無需再添加這些信息。
一般而言,被調函數f(x)會將調用信息和參數信息作為發生錯誤時的上下文放在錯誤信息中并返回給調用者,調用者需要添加一些錯誤信息中不包含的信息,比如添加url到html.Parse返回的錯誤中。
讓我們來看看處理錯誤的第二種策略。如果錯誤的發生是偶然性的,或由不可預知的問題導致的。一個明智的選擇是重新嘗試失敗的操作。在重試時,我們需要限制重試的時間間隔或重試的次數,防止無限制的重試。
*gopl.io/ch5/wait*
~~~
// WaitForServer attempts to contact the server of a URL.
// It tries for one minute using exponential back-off.
// It reports an error if all attempts fail.
func WaitForServer(url string) error {
const timeout = 1 * time.Minute
deadline := time.Now().Add(timeout)
for tries := 0; time.Now().Before(deadline); tries++ {
_, err := http.Head(url)
if err == nil {
return nil // success
}
log.Printf("server not responding (%s);retrying…", err)
time.Sleep(time.Second << uint(tries)) // exponential back-off
}
return fmt.Errorf("server %s failed to respond after %s", url, timeout)
}
~~~
如果錯誤發生后,程序無法繼續運行,我們就可以采用第三種策略:輸出錯誤信息并結束程序。需要注意的是,這種策略只應在main中執行。對庫函數而言,應僅向上傳播錯誤,除非該錯誤意味著程序內部包含不一致性,即遇到了bug,才能在庫函數中結束程序。
~~~
// (In function main.)
if err := WaitForServer(url); err != nil {
fmt.Fprintf(os.Stderr, "Site is down: %v\n", err)
os.Exit(1)
}
~~~
調用log.Fatalf可以更簡潔的代碼達到與上文相同的效果。log中的所有函數,都默認會在錯誤信息之前輸出時間信息。
~~~
if err := WaitForServer(url); err != nil {
log.Fatalf("Site is down: %v\n", err)
}
~~~
長時間運行的服務器常采用默認的時間格式,而交互式工具很少采用包含如此多信息的格式。
~~~
2006/01/02 15:04:05 Site is down: no such domain:
bad.gopl.io
~~~
我們可以設置log的前綴信息屏蔽時間信息,一般而言,前綴信息會被設置成命令名。
~~~
log.SetPrefix("wait: ")
log.SetFlags(0)
~~~
第四種策略:有時,我們只需要輸出錯誤信息就足夠了,不需要中斷程序的運行。我們可以通過log包提供函數
~~~
if err := Ping(); err != nil {
log.Printf("ping failed: %v; networking disabled",err)
}
~~~
或者標準錯誤流輸出錯誤信息。
~~~
if err := Ping(); err != nil {
fmt.Fprintf(os.Stderr, "ping failed: %v; networking disabled\n", err)
}
~~~
log包中的所有函數會為沒有換行符的字符串增加換行符。
第五種,也是最后一種策略:我們可以直接忽略掉錯誤。
~~~
dir, err := ioutil.TempDir("", "scratch")
if err != nil {
return fmt.Errorf("failed to create temp dir: %v",err)
}
// ...use temp dir…
os.RemoveAll(dir) // ignore errors; $TMPDIR is cleaned periodically
~~~
盡管os.RemoveAll會失敗,但上面的例子并沒有做錯誤處理。這是因為操作系統會定期的清理臨時目錄。正因如此,雖然程序沒有處理錯誤,但程序的邏輯不會因此受到影響。我們應該在每次函數調用后,都養成考慮錯誤處理的習慣,當你決定忽略某個錯誤時,你應該在清晰的記錄下你的意圖。
在Go中,錯誤處理有一套獨特的編碼風格。檢查某個子函數是否失敗后,我們通常將處理失敗的邏輯代碼放在處理成功的代碼之前。如果某個錯誤會導致函數返回,那么成功時的邏輯代碼不應放在else語句塊中,而應直接放在函數體中。Go中大部分函數的代碼結構幾乎相同,首先是一系列的初始檢查,防止錯誤發生,之后是函數的實際邏輯。
### 5.4.2. 文件結尾錯誤(EOF)
函數經常會返回多種錯誤,這對終端用戶來說可能會很有趣,但對程序而言,這使得情況變得復雜。很多時候,程序必須根據錯誤類型,作出不同的響應。讓我們考慮這樣一個例子:從文件中讀取n個字節。如果n等于文件的長度,讀取過程的任何錯誤都表示失敗。如果n小于文件的長度,調用者會重復的讀取固定大小的數據直到文件結束。這會導致調用者必須分別處理由文件結束引起的各種錯誤。基于這樣的原因,io包保證任何由文件結束引起的讀取失敗都返回同一個錯誤——io.EOF,該錯誤在io包中定義:
~~~
package io
import "errors"
// EOF is the error returned by Read when no more input is available.
var EOF = errors.New("EOF")
~~~
調用者只需通過簡單的比較,就可以檢測出這個錯誤。下面的例子展示了如何從標準輸入中讀取字符,以及判斷文件結束。(4.3的chartcount程序展示了更加復雜的代碼)
~~~
in := bufio.NewReader(os.Stdin)
for {
r, _, err := in.ReadRune()
if err == io.EOF {
break // finished reading
}
if err != nil {
return fmt.Errorf("read failed:%v", err)
}
// ...use r…
}
~~~
因為文件結束這種錯誤不需要更多的描述,所以io.EOF有固定的錯誤信息——“EOF”。對于其他錯誤,我們可能需要在錯誤信息中描述錯誤的類型和數量,這使得我們不能像io.EOF一樣采用固定的錯誤信息。在7.11節中,我們會提出更系統的方法區分某些固定的錯誤值。
### 5.5. 函數值
在Go中,函數被看作第一類值(first-class values):函數像其他值一樣,擁有類型,可以被賦值給其他變量,傳遞給函數,從函數返回。對函數值(function value)的調用類似函數調用。例子如下:
~~~
func square(n int) int { return n * n }
func negative(n int) int { return -n }
func product(m, n int) int { return m * n }
f := square
fmt.Println(f(3)) // "9"
f = negative
fmt.Println(f(3)) // "-3"
fmt.Printf("%T\n", f) // "func(int) int"
f = product // compile error: can't assign func(int, int) int to func(int) int
~~~
函數類型的零值是nil。調用值為nil的函數值會引起panic錯誤:
~~~
var f func(int) int
f(3) // 此處f的值為nil, 會引起panic錯誤
~~~
函數值可以與nil比較:
~~~
var f func(int) int
if f != nil {
f(3)
}
~~~
但是函數值之間是不可比較的,也不能用函數值作為map的key。
函數值使得我們不僅僅可以通過數據來參數化函數,亦可通過行為。標準庫中包含許多這樣的例子。下面的代碼展示了如何使用這個技巧。strings.Map對字符串中的每個字符調用add1函數,并將每個add1函數的返回值組成一個新的字符串返回給調用者。
~~~
func add1(r rune) rune { return r + 1 }
fmt.Println(strings.Map(add1, "HAL-9000")) // "IBM.:111"
fmt.Println(strings.Map(add1, "VMS")) // "WNT"
fmt.Println(strings.Map(add1, "Admix")) // "Benjy"
~~~
5.2節的findLinks函數使用了輔助函數visit,遍歷和操作了HTML頁面的所有結點。使用函數值,我們可以將遍歷結點的邏輯和操作結點的邏輯分離,使得我們可以復用遍歷的邏輯,從而對結點進行不同的操作。
*gopl.io/ch5/outline2*
~~~
// forEachNode針對每個結點x,都會調用pre(x)和post(x)。
// pre和post都是可選的。
// 遍歷孩子結點之前,pre被調用
// 遍歷孩子結點之后,post被調用
func forEachNode(n *html.Node, pre, post func(n *html.Node)) {
if pre != nil {
pre(n)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
forEachNode(c, pre, post)
}
if post != nil {
post(n)
}
}
~~~
該函數接收2個函數作為參數,分別在結點的孩子被訪問前和訪問后調用。這樣的設計給調用者更大的靈活性。舉個例子,現在我們有startElemen和endElement兩個函數用于輸出HTML元素的開始標簽和結束標簽`<b>...</b>`:
~~~
var depth int
func startElement(n *html.Node) {
if n.Type == html.ElementNode {
fmt.Printf("%*s<%s>\n", depth*2, "", n.Data)
depth++
}
}
func endElement(n *html.Node) {
if n.Type == html.ElementNode {
depth--
fmt.Printf("%*s</%s>\n", depth*2, "", n.Data)
}
}
~~~
上面的代碼利用fmt.Printf的一個小技巧控制輸出的縮進。`%*s`中的`*`會在字符串之前填充一些空格。在例子中,每次輸出會先填充`depth*2`數量的空格,再輸出"",最后再輸出HTML標簽。
如果我們像下面這樣調用forEachNode:
~~~
forEachNode(doc, startElement, endElement)
~~~
與之前的outline程序相比,我們得到了更加詳細的頁面結構:
~~~
$ go build gopl.io/ch5/outline2
$ ./outline2 http://gopl.io
<html>
<head>
<meta>
</meta>
<title>
</title>
<style>
</style>
</head>
<body>
<table>
<tbody>
<tr>
<td>
<a>
<img>
</img>
...
~~~
**練習 5.7:** 完善startElement和endElement函數,使其成為通用的HTML輸出器。要求:輸出注釋結點,文本結點以及每個元素的屬性(< a href='...'>)。使用簡略格式輸出沒有孩子結點的元素(即用`<img/>`代替`<img></img>`)。編寫測試,驗證程序輸出的格式正確。(詳見11章)
**練習 5.8:** 修改pre和post函數,使其返回布爾類型的返回值。返回false時,中止forEachNoded的遍歷。使用修改后的代碼編寫ElementByID函數,根據用戶輸入的id查找第一個擁有該id元素的HTML元素,查找成功后,停止遍歷。
~~~
func ElementByID(doc *html.Node, id string) *html.Node
~~~
**練習 5.9:** 編寫函數expand,將s中的"foo"替換為f("foo")的返回值。
~~~
func expand(s string, f func(string) string) string
~~~
### 5.6. 匿名函數
擁有函數名的函數只能在包級語法塊中被聲明,通過函數字面量(function literal),我們可繞過這一限制,在任何表達式中表示一個函數值。函數字面量的語法和函數聲明相似,區別在于func關鍵字后沒有函數名。函數值字面量是一種表達式,它的值被成為匿名函數(anonymous function)。
函數字面量允許我們在使用時函數時,再定義它。通過這種技巧,我們可以改寫之前對strings.Map的調用:
~~~
strings.Map(func(r rune) rune { return r + 1 }, "HAL-9000")
~~~
更為重要的是,通過這種方式定義的函數可以訪問完整的詞法環境(lexical environment),這意味著在函數中定義的內部函數可以引用該函數的變量,如下例所示:
*gopl.io/ch5/squares*
~~~
// squares返回一個匿名函數。
// 該匿名函數每次被調用時都會返回下一個數的平方。
func squares() func() int {
var x int
return func() int {
x++
return x * x
}
}
func main() {
f := squares()
fmt.Println(f()) // "1"
fmt.Println(f()) // "4"
fmt.Println(f()) // "9"
fmt.Println(f()) // "16"
}
~~~
函數squares返回另一個類型為 func() int 的函數。對squares的一次調用會生成一個局部變量x并返回一個匿名函數。每次調用時匿名函數時,該函數都會先使x的值加1,再返回x的平方。第二次調用squares時,會生成第二個x變量,并返回一個新的匿名函數。新匿名函數操作的是第二個x變量。
squares的例子證明,函數值不僅僅是一串代碼,還記錄了狀態。在squares中定義的匿名內部函數可以訪問和更新squares中的局部變量,這意味著匿名函數和squares中,存在變量引用。這就是函數值屬于引用類型和函數值不可比較的原因。Go使用閉包(closures)技術實現函數值,Go程序員也把函數值叫做閉包。
通過這個例子,我們看到變量的生命周期不由它的作用域決定:squares返回后,變量x仍然隱式的存在于f中。
接下來,我們討論一個有點學術性的例子,考慮這樣一個問題:給定一些計算機課程,每個課程都有前置課程,只有完成了前置課程才可以開始當前課程的學習;我們的目標是選擇出一組課程,這組課程必須確保按順序學習時,能全部被完成。每個課程的前置課程如下:
*gopl.io/ch5/toposort*
~~~
// prereqs記錄了每個課程的前置課程
var prereqs = map[string][]string{
"algorithms": {"data structures"},
"calculus": {"linear algebra"},
"compilers": {
"data structures",
"formal languages",
"computer organization",
},
"data structures": {"discrete math"},
"databases": {"data structures"},
"discrete math": {"intro to programming"},
"formal languages": {"discrete math"},
"networks": {"operating systems"},
"operating systems": {"data structures", "computer organization"},
"programming languages": {"data structures", "computer organization"},
}
~~~
這類問題被稱作拓撲排序。從概念上說,前置條件可以構成有向圖。圖中的頂點表示課程,邊表示課程間的依賴關系。顯然,圖中應該無環,這也就是說從某點出發的邊,最終不會回到該點。下面的代碼用深度優先搜索了整張圖,獲得了符合要求的課程序列。
~~~
func main() {
for i, course := range topoSort(prereqs) {
fmt.Printf("%d:\t%s\n", i+1, course)
}
}
func topoSort(m map[string][]string) []string {
var order []string
seen := make(map[string]bool)
var visitAll func(items []string)
visitAll = func(items []string) {
for _, item := range items {
if !seen[item] {
seen[item] = true
visitAll(m[item])
order = append(order, item)
}
}
}
var keys []string
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
visitAll(keys)
return order
}
~~~
當匿名函數需要被遞歸調用時,我們必須首先聲明一個變量(在上面的例子中,我們首先聲明了 visitAll),再將匿名函數賦值給這個變量。如果不分成兩部,函數字面量無法與visitAll綁定,我們也無法遞歸調用該匿名函數。
~~~
visitAll := func(items []string) {
// ...
visitAll(m[item]) // compile error: undefined: visitAll
// ...
}
~~~
在topsort中,首先對prereqs中的key排序,再調用visitAll。因為prereqs映射的是切片而不是更復雜的map,所以數據的遍歷次序是固定的,這意味著你每次運行topsort得到的輸出都是一樣的。 topsort的輸出結果如下:
~~~
1: intro to programming
2: discrete math
3: data structures
4: algorithms
5: linear algebra
6: calculus
7: formal languages
8: computer organization
9: compilers
10: databases
11: operating systems
12: networks
13: programming languages
~~~
讓我們回到findLinks這個例子。我們將代碼移動到了links包下,將函數重命名為Extract,在第八章我們會再次用到這個函數。新的匿名函數被引入,用于替換原來的visit函數。該匿名函數負責將新連接添加到切片中。在Extract中,使用forEachNode遍歷HTML頁面,由于Extract只需要在遍歷結點前操作結點,所以forEachNode的post參數被傳入nil。
*gopl.io/ch5/links*
~~~
// Package links provides a link-extraction function.
package links
import (
"fmt"
"net/http"
"golang.org/x/net/html"
)
// Extract makes an HTTP GET request to the specified URL, parses
// the response as HTML, and returns the links in the HTML document.
func Extract(url string) ([]string, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("getting %s: %s", url, resp.Status)
}
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("parsing %s as HTML: %v", url, err)
}
var links []string
visitNode := func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "a" {
for _, a := range n.Attr {
if a.Key != "href" {
continue
}
link, err := resp.Request.URL.Parse(a.Val)
if err != nil {
continue // ignore bad URLs
}
links = append(links, link.String())
}
}
}
forEachNode(doc, visitNode, nil)
return links, nil
}
~~~
上面的代碼對之前的版本做了改進,現在links中存儲的不是href屬性的原始值,而是通過resp.Request.URL解析后的值。解析后,這些連接以絕對路徑的形式存在,可以直接被http.Get訪問。
網頁抓取的核心問題就是如何遍歷圖。在topoSort的例子中,已經展示了深度優先遍歷,在網頁抓取中,我們會展示如何用廣度優先遍歷圖。在第8章,我們會介紹如何將深度優先和廣度優先結合使用。
下面的函數實現了廣度優先算法。調用者需要輸入一個初始的待訪問列表和一個函數f。待訪問列表中的每個元素被定義為string類型。廣度優先算法會為每個元素調用一次f。每次f執行完畢后,會返回一組待訪問元素。這些元素會被加入到待訪問列表中。當待訪問列表中的所有元素都被訪問后,breadthFirst函數運行結束。為了避免同一個元素被訪問兩次,代碼中維護了一個map。
*gopl.io/ch5/findlinks3*
~~~
// breadthFirst calls f for each item in the worklist.
// Any items returned by f are added to the worklist.
// f is called at most once for each item.
func breadthFirst(f func(item string) []string, worklist []string) {
seen := make(map[string]bool)
for len(worklist) > 0 {
items := worklist
worklist = nil
for _, item := range items {
if !seen[item] {
seen[item] = true
worklist = append(worklist, f(item)...)
}
}
}
}
~~~
就像我們在章節3解釋的那樣,append的參數“f(item)...”,會將f返回的一組元素一個個添加到worklist中。
在我們網頁抓取器中,元素的類型是url。crawl函數會將URL輸出,提取其中的新鏈接,并將這些新鏈接返回。我們會將crawl作為參數傳遞給breadthFirst。
~~~
func crawl(url string) []string {
fmt.Println(url)
list, err := links.Extract(url)
if err != nil {
log.Print(err)
}
return list
}
~~~
為了使抓取器開始運行,我們用命令行輸入的參數作為初始的待訪問url。
~~~
func main() {
// Crawl the web breadth-first,
// starting from the command-line arguments.
breadthFirst(crawl, os.Args[1:])
}
~~~
讓我們從 https://golang.org 開始,下面是程序的輸出結果:
~~~
$ go build gopl.io/ch5/findlinks3
$ ./findlinks3 https://golang.org
https://golang.org/
https://golang.org/doc/
https://golang.org/pkg/
https://golang.org/project/
https://code.google.com/p/go-tour/
https://golang.org/doc/code.html
https://www.youtube.com/watch?v=XCsL89YtqCs
http://research.swtch.com/gotour
~~~
當所有發現的鏈接都已經被訪問或電腦的內存耗盡時,程序運行結束。
**練習5.10:** 重寫topoSort函數,用map代替切片并移除對key的排序代碼。驗證結果的正確性(結果不唯一)。
**練習5.11:** 現在線性代數的老師把微積分設為了前置課程。完善topSort,使其能檢測有向圖中的環。
**練習5.12:** gopl.io/ch5/outline2(5.5節)的startElement和endElement共用了全局變量depth,將它們修改為匿名函數,使其共享outline中的局部變量。
**練習5.13:** 修改crawl,使其能保存發現的頁面,必要時,可以創建目錄來保存這些頁面。只保存來自原始域名下的頁面。假設初始頁面在golang.org下,就不要保存vimeo.com下的頁面。
**練習5.14:** 使用breadthFirst遍歷其他數據結構。比如,topoSort例子中的課程依賴關系(有向圖),個人計算機的文件層次結構(樹),你所在城市的公交或地鐵線路(無向圖)。
### 5.6.1. 警告:捕獲迭代變量
本節,將介紹Go詞法作用域的一個陷阱。請務必仔細的閱讀,弄清楚發生問題的原因。即使是經驗豐富的程序員也會在這個問題上犯錯誤。
考慮這個樣一個問題:你被要求首先創建一些目錄,再將目錄刪除。在下面的例子中我們用函數值來完成刪除操作。下面的示例代碼需要引入os包。為了使代碼簡單,我們忽略了所有的異常處理。
~~~
var rmdirs []func()
for _, d := range tempDirs() {
dir := d // NOTE: necessary!
os.MkdirAll(dir, 0755) // creates parent directories too
rmdirs = append(rmdirs, func() {
os.RemoveAll(dir)
})
}
// ...do some work…
for _, rmdir := range rmdirs {
rmdir() // clean up
}
~~~
你可能會感到困惑,為什么要在循環體中用循環變量d賦值一個新的局部變量,而不是像下面的代碼一樣直接使用循環變量dir。需要注意,下面的代碼是錯誤的。
~~~
var rmdirs []func()
for _, dir := range tempDirs() {
os.MkdirAll(dir, 0755)
rmdirs = append(rmdirs, func() {
os.RemoveAll(dir) // NOTE: incorrect!
})
}
~~~
問題的原因在于循環變量的作用域。在上面的程序中,for循環語句引入了新的詞法塊,循環變量dir在這個詞法塊中被聲明。在該循環中生成的所有函數值都共享相同的循環變量。需要注意,函數值中記錄的是循環變量的內存地址,而不是循環變量某一時刻的值。以dir為例,后續的迭代會不斷更新dir的值,當刪除操作執行時,for循環已完成,dir中存儲的值等于最后一次迭代的值。這意味著,每次對os.RemoveAll的調用刪除的都是相同的目錄。
通常,為了解決這個問題,我們會引入一個與循環變量同名的局部變量,作為循環變量的副本。比如下面的變量dir,雖然這看起來很奇怪,但卻很有用。
~~~
for _, dir := range tempDirs() {
dir := dir // declares inner dir, initialized to outer dir
// ...
}
~~~
這個問題不僅存在基于range的循環,在下面的例子中,對循環變量i的使用也存在同樣的問題:
~~~
var rmdirs []func()
dirs := tempDirs()
for i := 0; i < len(dirs); i++ {
os.MkdirAll(dirs[i], 0755) // OK
rmdirs = append(rmdirs, func() {
os.RemoveAll(dirs[i]) // NOTE: incorrect!
})
}
~~~
如果你使用go語句(第八章)或者defer語句(5.8節)會經常遇到此類問題。這不是go或defer本身導致的,而是因為它們都會等待循環結束后,再執行函數值。
### 5.7. 可變參數
參數數量可變的函數稱為為可變參數函數。典型的例子就是fmt.Printf和類似函數。Printf首先接收一個的必備參數,之后接收任意個數的后續參數。
在聲明可變參數函數時,需要在參數列表的最后一個參數類型之前加上省略符號“...”,這表示該函數會接收任意數量的該類型參數。
*gopl.io/ch5/sum*
~~~
func sum(vals...int) int {
total := 0
for _, val := range vals {
total += val
}
return total
}
~~~
sum函數返回任意個int型參數的和。在函數體中,vals被看作是類型為[] int的切片。sum可以接收任意數量的int型參數:
~~~
fmt.Println(sum()) // "0"
fmt.Println(sum(3)) // "3"
fmt.Println(sum(1, 2, 3, 4)) // "10"
~~~
在上面的代碼中,調用者隱式的創建一個數組,并將原始參數復制到數組中,再把數組的一個切片作為參數傳給被調函數。如果原始參數已經是切片類型,我們該如何傳遞給sum?只需在最后一個參數后加上省略符。下面的代碼功能與上個例子中最后一條語句相同。
~~~
values := []int{1, 2, 3, 4}
fmt.Println(sum(values...)) // "10"
~~~
雖然在可變參數函數內部,...int 型參數的行為看起來很像切片類型,但實際上,可變參數函數和以切片作為參數的函數是不同的。
~~~
func f(...int) {}
func g([]int) {}
fmt.Printf("%T\n", f) // "func(...int)"
fmt.Printf("%T\n", g) // "func([]int)"
~~~
可變參數函數經常被用于格式化字符串。下面的errorf函數構造了一個以行號開頭的,經過格式化的錯誤信息。函數名的后綴f是一種通用的命名規范,代表該可變參數函數可以接收Printf風格的格式化字符串。
~~~
func errorf(linenum int, format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "Line %d: ", linenum)
fmt.Fprintf(os.Stderr, format, args...)
fmt.Fprintln(os.Stderr)
}
linenum, name := 12, "count"
errorf(linenum, "undefined: %s", name) // "Line 12: undefined: count"
~~~
interfac{}表示函數的最后一個參數可以接收任意類型,我們會在第7章詳細介紹。
**練習5.15:** 編寫類似sum的可變參數函數max和min。考慮不傳參時,max和min該如何處理,再編寫至少接收1個參數的版本。
**練習5.16:**編寫多參數版本的strings.Join。
**練習5.17:**編寫多參數版本的ElementsByTagName,函數接收一個HTML結點樹以及任意數量的標簽名,返回與這些標簽名匹配的所有元素。下面給出了2個例子:
~~~
func ElementsByTagName(doc *html.Node, name...string) []*html.Node
images := ElementsByTagName(doc, "img")
headings := ElementsByTagName(doc, "h1", "h2", "h3", "h4")
~~~
### 5.8. Deferred函數
在findLinks的例子中,我們用http.Get的輸出作為html.Parse的輸入。只有url的內容的確是HTML格式的,html.Parse才可以正常工作,但實際上,url指向的內容很豐富,可能是圖片,純文本或是其他。將這些格式的內容傳遞給html.parse,會產生不良后果。
下面的例子獲取HTML頁面并輸出頁面的標題。title函數會檢查服務器返回的Content-Type字段,如果發現頁面不是HTML,將終止函數運行,返回錯誤。
*gopl.io/ch5/title1*
~~~
func title(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
// Check Content-Type is HTML (e.g., "text/html;charset=utf-8").
ct := resp.Header.Get("Content-Type")
if ct != "text/html" && !strings.HasPrefix(ct,"text/html;") {
resp.Body.Close()
return fmt.Errorf("%s has type %s, not text/html",url, ct)
}
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return fmt.Errorf("parsing %s as HTML: %v", url,err)
}
visitNode := func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "title"&&n.FirstChild != nil {
fmt.Println(n.FirstChild.Data)
}
}
forEachNode(doc, visitNode, nil)
return nil
}
~~~
下面展示了運行效果:
~~~
$ go build gopl.io/ch5/title1
$ ./title1 http://gopl.io
The Go Programming Language
$ ./title1 https://golang.org/doc/effective_go.html
Effective Go - The Go Programming Language
$ ./title1 https://golang.org/doc/gopher/frontpage.png
title: https://golang.org/doc/gopher/frontpage.png has type image/png, not text/html
~~~
resp.Body.close調用了多次,這是為了確保title在所有執行路徑下(即使函數運行失敗)都關閉了網絡連接。隨著函數變得復雜,需要處理的錯誤也變多,維護清理邏輯變得越來越困難。而Go語言獨有的defer機制可以讓事情變得簡單。
你只需要在調用普通函數或方法前加上關鍵字defer,就完成了defer所需要的語法。當defer語句被執行時,跟在defer后面的函數會被延遲執行。直到包含該defer語句的函數執行完畢時,defer后的函數才會被執行,不論包含defer語句的函數是通過return正常結束,還是由于panic導致的異常結束。你可以在一個函數中執行多條defer語句,它們的執行順序與聲明順序相反。
defer語句經常被用于處理成對的操作,如打開、關閉、連接、斷開連接、加鎖、釋放鎖。通過defer機制,不論函數邏輯多復雜,都能保證在任何執行路徑下,資源被釋放。釋放資源的defer應該直接跟在請求資源的語句后。在下面的代碼中,一條defer語句替代了之前的所有resp.Body.Close
*gopl.io/ch5/title2*
~~~
func title(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
if ct != "text/html" && !strings.HasPrefix(ct,"text/html;") {
return fmt.Errorf("%s has type %s, not text/html",url, ct)
}
doc, err := html.Parse(resp.Body)
if err != nil {
return fmt.Errorf("parsing %s as HTML: %v", url,err)
}
// ...print doc's title element…
return nil
}
~~~
在處理其他資源時,也可以采用defer機制,比如對文件的操作:
*io/ioutil*
~~~
package ioutil
func ReadFile(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
return ReadAll(f)
}
~~~
或是處理互斥鎖(9.2章)
~~~
var mu sync.Mutex
var m = make(map[string]int)
func lookup(key string) int {
mu.Lock()
defer mu.Unlock()
return m[key]
}
~~~
調試復雜程序時,defer機制也常被用于記錄何時進入和退出函數。下例中的bigSlowOperation函數,直接調用trace記錄函數的被調情況。bigSlowOperation被調時,trace會返回一個函數值,該函數值會在bigSlowOperation退出時被調用。通過這種方式, 我們可以只通過一條語句控制函數的入口和所有的出口,甚至可以記錄函數的運行時間,如例子中的start。需要注意一點:不要忘記defer語句后的圓括號,否則本該在進入時執行的操作會在退出時執行,而本該在退出時執行的,永遠不會被執行。
*gopl.io/ch5/trace*
~~~
func bigSlowOperation() {
defer trace("bigSlowOperation")() // don't forget the
extra parentheses
// ...lots of work…
time.Sleep(10 * time.Second) // simulate slow
operation by sleeping
}
func trace(msg string) func() {
start := time.Now()
log.Printf("enter %s", msg)
return func() {
log.Printf("exit %s (%s)", msg,time.Since(start))
}
}
~~~
每一次bigSlowOperation被調用,程序都會記錄函數的進入,退出,持續時間。(我們用time.Sleep模擬一個耗時的操作)
~~~
$ go build gopl.io/ch5/trace
$ ./trace
2015/11/18 09:53:26 enter bigSlowOperation
2015/11/18 09:53:36 exit bigSlowOperation (10.000589217s)
~~~
我們知道,defer語句中的函數會在return語句更新返回值變量后再執行,又因為在函數中定義的匿名函數可以訪問該函數包括返回值變量在內的所有變量,所以,對匿名函數采用defer機制,可以使其觀察函數的返回值。
以double函數為例:
~~~
func double(x int) int {
return x + x
}
~~~
我們只需要首先命名double的返回值,再增加defer語句,我們就可以在double每次被調用時,輸出參數以及返回值。
~~~
func double(x int) (result int) {
defer func() { fmt.Printf("double(%d) = %d\n", x,result) }()
return x + x
}
_ = double(4)
// Output:
// "double(4) = 8"
~~~
可能doulbe函數過于簡單,看不出這個小技巧的作用,但對于有許多return語句的函數而言,這個技巧很有用。
被延遲執行的匿名函數甚至可以修改函數返回給調用者的返回值:
~~~
func triple(x int) (result int) {
defer func() { result += x }()
return double(x)
}
fmt.Println(triple(4)) // "12"
~~~
在循環體中的defer語句需要特別注意,因為只有在函數執行完畢后,這些被延遲的函數才會執行。下面的代碼會導致系統的文件描述符耗盡,因為在所有文件都被處理之前,沒有文件會被關閉。
~~~
for _, filename := range filenames {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close() // NOTE: risky; could run out of file
descriptors
// ...process f…
}
~~~
一種解決方法是將循環體中的defer語句移至另外一個函數。在每次循環時,調用這個函數。
~~~
for _, filename := range filenames {
if err := doFile(filename); err != nil {
return err
}
}
func doFile(filename string) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
// ...process f…
}
~~~
下面的代碼是fetch(1.5節)的改進版,我們將http響應信息寫入本地文件而不是從標準輸出流輸出。我們通過path.Base提出url路徑的最后一段作為文件名。
*gopl.io/ch5/fetch*
~~~
// Fetch downloads the URL and returns the
// name and length of the local file.
func fetch(url string) (filename string, n int64, err error) {
resp, err := http.Get(url)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
local := path.Base(resp.Request.URL.Path)
if local == "/" {
local = "index.html"
}
f, err := os.Create(local)
if err != nil {
return "", 0, err
}
n, err = io.Copy(f, resp.Body)
// Close file, but prefer error from Copy, if any.
if closeErr := f.Close(); err == nil {
err = closeErr
}
return local, n, err
}
~~~
對resp.Body.Close延遲調用我們已經見過了,在此不做解釋。上例中,通過os.Create打開文件進行寫入,在關閉文件時,我們沒有對f.close采用defer機制,因為這會產生一些微妙的錯誤。許多文件系統,尤其是NFS,寫入文件時發生的錯誤會被延遲到文件關閉時反饋。如果沒有檢查文件關閉時的反饋信息,可能會導致數據丟失,而我們還誤以為寫入操作成功。如果io.Copy和f.close都失敗了,我們傾向于將io.Copy的錯誤信息反饋給調用者,因為它先于f.close發生,更有可能接近問題的本質。
**練習5.18:**不修改fetch的行為,重寫fetch函數,要求使用defer機制關閉文件。
### 5.9. Panic異常
Go的類型系統會在編譯時捕獲很多錯誤,但有些錯誤只能在運行時檢查,如數組訪問越界、空指針引用等。這些運行時錯誤會引起painc異常。
一般而言,當panic異常發生時,程序會中斷運行,并立即執行在該goroutine(可以先理解成線程,在第8章會詳細介紹)中被延遲的函數(defer 機制)。隨后,程序崩潰并輸出日志信息。日志信息包括panic value和函數調用的堆棧跟蹤信息。panic value通常是某種錯誤信息。對于每個goroutine,日志信息中都會有與之相對的,發生panic時的函數調用堆棧跟蹤信息。通常,我們不需要再次運行程序去定位問題,日志信息已經提供了足夠的診斷依據。因此,在我們填寫問題報告時,一般會將panic異常和日志信息一并記錄。
不是所有的panic異常都來自運行時,直接調用內置的panic函數也會引發panic異常;panic函數接受任何值作為參數。當某些不應該發生的場景發生時,我們就應該調用panic。比如,當程序到達了某條邏輯上不可能到達的路徑:
~~~
switch s := suit(drawCard()); s {
case "Spades": // ...
case "Hearts": // ...
case "Diamonds": // ...
case "Clubs": // ...
default:
panic(fmt.Sprintf("invalid suit %q", s)) // Joker?
}
~~~
斷言函數必須滿足的前置條件是明智的做法,但這很容易被濫用。除非你能提供更多的錯誤信息,或者能更快速的發現錯誤,否則不需要使用斷言,編譯器在運行時會幫你檢查代碼。
~~~
func Reset(x *Buffer) {
if x == nil {
panic("x is nil") // unnecessary!
}
x.elements = nil
}
~~~
雖然Go的panic機制類似于其他語言的異常,但panic的適用場景有一些不同。由于panic會引起程序的崩潰,因此panic一般用于嚴重錯誤,如程序內部的邏輯不一致。勤奮的程序員認為任何崩潰都表明代碼中存在漏洞,所以對于大部分漏洞,我們應該使用Go提供的錯誤機制,而不是panic,盡量避免程序的崩潰。在健壯的程序中,任何可以預料到的錯誤,如不正確的輸入、錯誤的配置或是失敗的I/O操作都應該被優雅的處理,最好的處理方式,就是使用Go的錯誤機制。
考慮regexp.Compile函數,該函數將正則表達式編譯成有效的可匹配格式。當輸入的正則表達式不合法時,該函數會返回一個錯誤。當調用者明確的知道正確的輸入不會引起函數錯誤時,要求調用者檢查這個錯誤是不必要和累贅的。我們應該假設函數的輸入一直合法,就如前面的斷言一樣:當調用者輸入了不應該出現的輸入時,觸發panic異常。
在程序源碼中,大多數正則表達式是字符串字面值(string literals),因此regexp包提供了包裝函數regexp.MustCompile檢查輸入的合法性。
~~~
package regexp
func Compile(expr string) (*Regexp, error) { /* ... */ }
func MustCompile(expr string) *Regexp {
re, err := Compile(expr)
if err != nil {
panic(err)
}
return re
}
~~~
包裝函數使得調用者可以便捷的用一個編譯后的正則表達式為包級別的變量賦值:
~~~
var httpSchemeRE = regexp.MustCompile(`^https?:`) //"http:" or "https:"
~~~
顯然,MustCompile不能接收不合法的輸入。函數名中的Must前綴是一種針對此類函數的命名約定,比如template.Must(4.6節)
~~~
func main() {
f(3)
}
func f(x int) {
fmt.Printf("f(%d)\n", x+0/x) // panics if x == 0
defer fmt.Printf("defer %d\n", x)
f(x - 1)
}
~~~
上例中的運行輸出如下:
~~~
f(3)
f(2)
f(1)
defer 1
defer 2
defer 3
~~~
當f(0)被調用時,發生panic異常,之前被延遲執行的的3個fmt.Printf被調用。程序中斷執行后,panic信息和堆棧信息會被輸出(下面是簡化的輸出):
~~~
panic: runtime error: integer divide by zero
main.f(0)
src/gopl.io/ch5/defer1/defer.go:14
main.f(1)
src/gopl.io/ch5/defer1/defer.go:16
main.f(2)
src/gopl.io/ch5/defer1/defer.go:16
main.f(3)
src/gopl.io/ch5/defer1/defer.go:16
main.main()
src/gopl.io/ch5/defer1/defer.go:10
~~~
我們在下一節將看到,如何使程序從panic異常中恢復,阻止程序的崩潰。
為了方便診斷問題,runtime包允許程序員輸出堆棧信息。在下面的例子中,我們通過在main函數中延遲調用printStack輸出堆棧信息。
~~~
gopl.io/ch5/defer2
func main() {
defer printStack()
f(3)
}
func printStack() {
var buf [4096]byte
n := runtime.Stack(buf[:], false)
os.Stdout.Write(buf[:n])
}
~~~
printStack的簡化輸出如下(下面只是printStack的輸出,不包括panic的日志信息):
~~~
goroutine 1 [running]:
main.printStack()
src/gopl.io/ch5/defer2/defer.go:20
main.f(0)
src/gopl.io/ch5/defer2/defer.go:27
main.f(1)
src/gopl.io/ch5/defer2/defer.go:29
main.f(2)
src/gopl.io/ch5/defer2/defer.go:29
main.f(3)
src/gopl.io/ch5/defer2/defer.go:29
main.main()
src/gopl.io/ch5/defer2/defer.go:15
~~~
將panic機制類比其他語言異常機制的讀者可能會驚訝,runtime.Stack為何能輸出已經被釋放函數的信息?在Go的panic機制中,延遲函數的調用在釋放堆棧信息之前。
### 5.10. Recover捕獲異常
通常來說,不應該對panic異常做任何處理,但有時,也許我們可以從異常中恢復,至少我們可以在程序崩潰前,做一些操作。舉個例子,當web服務器遇到不可預料的嚴重問題時,在崩潰前應該將所有的連接關閉;如果不做任何處理,會使得客戶端一直處于等待狀態。如果web服務器還在開發階段,服務器甚至可以將異常信息反饋到客戶端,幫助調試。
如果在deferred函數中調用了內置函數recover,并且定義該defer語句的函數發生了panic異常,recover會使程序從panic中恢復,并返回panic value。導致panic異常的函數不會繼續運行,但能正常返回。在未發生panic時調用recover,recover會返回nil。
讓我們以語言解析器為例,說明recover的使用場景。考慮到語言解析器的復雜性,即使某個語言解析器目前工作正常,也無法肯定它沒有漏洞。因此,當某個異常出現時,我們不會選擇讓解析器崩潰,而是會將panic異常當作普通的解析錯誤,并附加額外信息提醒用戶報告此錯誤。
~~~
func Parse(input string) (s *Syntax, err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("internal error: %v", p)
}
}()
// ...parser...
}
~~~
deferred函數幫助Parse從panic中恢復。在deferred函數內部,panic value被附加到錯誤信息中;并用err變量接收錯誤信息,返回給調用者。我們也可以通過調用runtime.Stack往錯誤信息中添加完整的堆棧調用信息。
不加區分的恢復所有的panic異常,不是可取的做法;因為在panic之后,無法保證包級變量的狀態仍然和我們預期一致。比如,對數據結構的一次重要更新沒有被完整完成、文件或者網絡連接沒有被關閉、獲得的鎖沒有被釋放。此外,如果寫日志時產生的panic被不加區分的恢復,可能會導致漏洞被忽略。
雖然把對panic的處理都集中在一個包下,有助于簡化對復雜和不可以預料問題的處理,但作為被廣泛遵守的規范,你不應該試圖去恢復其他包引起的panic。公有的API應該將函數的運行失敗作為error返回,而不是panic。同樣的,你也不應該恢復一個由他人開發的函數引起的panic,比如說調用者傳入的回調函數,因為你無法確保這樣做是安全的。
有時我們很難完全遵循規范,舉個例子,net/http包中提供了一個web服務器,將收到的請求分發給用戶提供的處理函數。很顯然,我們不能因為某個處理函數引發的panic異常,殺掉整個進程;web服務器遇到處理函數導致的panic時會調用recover,輸出堆棧信息,繼續運行。這樣的做法在實踐中很便捷,但也會引起資源泄漏,或是因為recover操作,導致其他問題。
基于以上原因,安全的做法是有選擇性的recover。換句話說,只恢復應該被恢復的panic異常,此外,這些異常所占的比例應該盡可能的低。為了標識某個panic是否應該被恢復,我們可以將panic value設置成特殊類型。在recover時對panic value進行檢查,如果發現panic value是特殊類型,就將這個panic作為errror處理,如果不是,則按照正常的panic進行處理(在下面的例子中,我們會看到這種方式)。
下面的例子是title函數的變形,如果HTML頁面包含多個`<title>`,該函數會給調用者返回一個錯誤(error)。在soleTitle內部處理時,如果檢測到有多個`<title>`,會調用panic,阻止函數繼續遞歸,并將特殊類型bailout作為panic的參數。
~~~
// soleTitle returns the text of the first non-empty title element
// in doc, and an error if there was not exactly one.
func soleTitle(doc *html.Node) (title string, err error) {
type bailout struct{}
defer func() {
switch p := recover(); p {
case nil: // no panic
case bailout{}: // "expected" panic
err = fmt.Errorf("multiple title elements")
default:
panic(p) // unexpected panic; carry on panicking
}
}()
// Bail out of recursion if we find more than one nonempty title.
forEachNode(doc, func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "title" &&
n.FirstChild != nil {
if title != "" {
panic(bailout{}) // multiple titleelements
}
title = n.FirstChild.Data
}
}, nil)
if title == "" {
return "", fmt.Errorf("no title element")
}
return title, nil
}
~~~
在上例中,deferred函數調用recover,并檢查panic value。當panic value是bailout{}類型時,deferred函數生成一個error返回給調用者。當panic value是其他non-nil值時,表示發生了未知的panic異常,deferred函數將調用panic函數并將當前的panic value作為參數傳入;此時,等同于recover沒有做任何操作。(請注意:在例子中,對可預期的錯誤采用了panic,這違反了之前的建議,我們在此只是想向讀者演示這種機制。)
有些情況下,我們無法恢復。某些致命錯誤會導致Go在運行時終止程序,如內存不足。
**練習5.19:** 使用panic和recover編寫一個不包含return語句但能返回一個非零值的函數。
- Go語言程序設計
- Go語言圣經(中文版)
- 譯者序
- 前言
- 第一章 入門
- 第二章 程序結構
- 第三章 基礎數據類型
- 第四章 復合數據類型
- 第五章 函數
- 第六章 方法
- 第七章 接口
- 7.4. flag.Value接口
- 7.6. sort.Interface接口
- 7.7. http.Handler接口
- 第八章 Goroutines和Channels
- 第九章 基于共享變量的并發
- 9.2. sync.Mutex互斥鎖
- 第十章 包和工具
- 第十一章 測試
- 第十二章 反射
- 12.2. reflect.Type和reflect.Value
- 12.5. 通過reflect.Value修改值
- 第13章 底層編程
- 13.1. unsafe.Sizeof, Alignof 和 Offsetof
- 附錄