1. 程式人生 > 其它 >Go入門系列(八) 函式(上)

Go入門系列(八) 函式(上)

技術標籤:gogolang

本系列文章目錄 展開/收起

函式宣告

Go中函式的基本形式

func name(parameter-list) (result-list) {

body

}

如果一個函式宣告不包括返回值列表,那麼函式體執行完畢後,不會返回任何值。如果一個函式在宣告時,包含返回值列表,該函式必須以 return語句結尾(否則會報錯),除非函式明顯無法執行到結尾處。例如函式在結尾時呼叫了panic異常或函式中存在無限迴圈。

而且如果聲明瞭返回值列表的話,我們可以直接 return 後面不接東西,它會預設返回返回值列表的變數,例如

func demo1() (r1 int, r2 int){
	r1 =1
	r2 = 2
	return
}

這裡會預設返回 r1 和 r2, 這種返回方式叫做 bare return,bare return 可以減少程式碼的重複,但是使得程式碼可讀性降低,因此不推薦多用。

但是在python或者php中,return後面不接東西會返回None

函式的型別被稱為函式的簽名。如果兩個函式形式引數列表和返回值列表中的變數型別一一對應,那麼這兩個函式被認為有相同的型別或簽名。形參和返回值的變數名不影響函式簽名,也不影響它們是否可以以省略引數型別的形式表示。

比如:

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 }

這4個函式都有相同的引數列表和返回值列表(引數的個數和型別,返回值的個數和型別都相同),那麼這4個函式都是同類型的函式。

Go語言沒有預設引數,也沒有任何方法可以通過引數名指定形參,因此形參和返回值的變數名對於函式呼叫者而言沒有意義。

沒有預設形參很好理解,這意味呼叫go的函式時,必須有幾個形參就要傳入幾個實參。後半句話的意思是,不支援按照形參的名字傳入實參,這意味著傳入的實參必須和形參的順序一一對應。相比與python的函式,例如:

def add(a, b):

return a + b

在呼叫的時候我們可以這樣:

num1 = 10

num2 = 20

print(add(b=num2, a=num1))

通過這種方式我們可以無需記住引數的順序,直接通過形參名傳參即可。

但是Go的函式不支援這個功能,go會說python盡整些花裡胡哨的東西。

Go中的函式有幾個比較重要的特性

1.引數和返回值在函式呼叫的時候就會自動宣告

這意味著下面這段程式會報錯:

func Demo2(p1, p2 string) (r1 string){
	r1 := p1 + p2
	
	return r1
}

原因是r1已經宣告,無需重複宣告。

正確的做法應該是

func Demo2(p1, p2 string) (r1 string){
	r1 = p1 + p2

	return r1
}

2.引數變數和返回值變數是函式中的區域性變數,被初始化為傳入的實參值,而且引數變數和返回值變數被儲存在相同的詞法塊中

由於是區域性變數,這意味這在函式中對引數變數操作不會影響到外層作用域的變數值,除非傳入的引數是引用的型別,例如指標、切片(如果切片新增元素時超過了其最大容量導致擴容則另當別論)、雜湊表、函式或者通道(channel)等。

3.函式傳參以值的方式傳遞,因此函式的形參是實參的拷貝而不是引用

如果傳入的是一個數組或者結構體的時候,會在底層完全開闢出一塊和實參的陣列或結構體一樣大的空間,然後拷貝陣列或者結構體的資料值到這塊新的空間裡。然後讓形參去引用這個新的空間。此時函式外和函式內的這兩個變數獨立互不影響。

但這樣的話會很浪費記憶體。因此,go官方推薦函式傳參不要傳入結構體變數本身而是傳入結構體變數的指標,用指標一樣可以操作結構體,但是這樣會影響底層的結構體的內容從而影響到函式外引用該結構體的變數。類似的,通過傳入切片代替傳入陣列,這樣做都是為了節省記憶體。

你可能會偶爾遇到沒有函式體的函式宣告,這樣的宣告定義了函式簽名。

比如:

func Sin(x float64) float //implemented in assembly language

尤其是在定義一個介面interface的時候

遞迴

遞迴就是在函式中呼叫自己,接觸過其他語言的朋友應該也很清楚,其原理是每當函式呼叫一次自己,就會往一個函式呼叫棧中入棧這個函式的相關資訊,包括函式的上下文環境,區域性變數,函式執行到哪裡等。最先呼叫的函式最先入棧最後才出棧。遞迴的深度越大,這個棧壓入的函式資訊越多,而且是累加的形式增加,因為後入棧的函式沒執行完之前,先呼叫的函式是不可能先出棧的。因此遞迴的層數如果太多可能到導致函式呼叫棧的記憶體發生溢位(棧一般被建立的時候分配固定的64KB到2MB不等的記憶體空間,如果入棧的函式太多可能會超過這個空間大小導致棧溢位)。這也是有時候我們看到遞迴次數太多引發報錯的情況。除此之外,還會導致安全性問題。

Go語言使用可變棧,棧的大小按需增加(初始時很小)。這使得我們使用遞迴時不必考慮溢位和安全問題。

在這一節中,作者使用了一個解析html的例子來演示遞迴。

目錄結構如下

fetch 是我自定義的爬取單頁面的包

parse 是解析html的包

main.go 解析來自標準輸入的html文字

printHtml.go 爬取url並輸出其中的html

# fetch.go

package fetch

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
	"strconv"
)

// 獲取一個url的內容並且列印到標準輸出
func FetchPrint(url string) {
	content, err := Fetch(url)

	// 如果有錯誤,則將錯誤輸出到標準錯誤(其實還是輸出到螢幕上,但是相比於標準輸出的區別是,標準錯誤沒有緩衝區,所以會比標準錯誤經常會比標準輸出要更早打印出來)
	// Fprintf作用是將第三參的資料按第二參格式化之後輸入到第一參指定的流中,可以是檔案流(那就是寫入到檔案),標準輸出流,標準錯誤流等
	if err !=nil {
		fmt.Fprintf(os.Stderr, "%s", err)
	}

	fmt.Fprintf(os.Stdout, "%s", content)		// 這裡用 fmt.Printf("%s", content) 也可以,因為Printf預設就是輸出到標準輸出流,也就是列印到螢幕上
}

// 獲取一個url的內容並返回響應內容(字串)
func Fetch(url string) (string, error){
	var content string

	// 獲取一個url的響應, 返回一個Response型別變數
	resp, err := http.Get(url)

	if err != nil {
		// fmt.Errorf 返回一個error錯誤型別的變數,由於這裡要求返回一個error型別所以不能使用Sprintf
		return content, fmt.Errorf("獲取連結失敗: %v", err)
	}

	//if string(string(resp.StatusCode)[0]) != "2" {
	// strconv.Itoa(x int)的作用是將一個int型別的整型轉化為字串型的數字。而 string() 如果傳入一個整型會轉為一個對應的ASCII
	// 所以 正確的數字轉字串的方式是 strconv.Itoa() 而不是 stirng()
	// 而且假如a是字串型別, a[0]不是一個字串,而是一個uint8, 所以 判斷a[0]是否等於字元a不應該用 a[0] == "a" ,而是用 a[0] == 'a' 在Go中雙引號是字串型別,而單引號是rune型別
	if strconv.Itoa(resp.StatusCode)[0] != '2' {
		return content, fmt.Errorf("獲取連結返回狀態不正常: %s | %s | %s", resp.Status, string(string(resp.StatusCode)[0]), string(resp.StatusCode)[0])
	}

	// 如果請求成功,則獲取響應流內容並關閉連線響應流(一定是獲取了內容之後再關閉哦)
	// ioutil.ReadAll通過流的方式讀取響應中的位元組流內容返回接收到的所有位元組流([]byte位元組切片型別),需要傳入一個io.Reader型別的變數,而resp.Body是io.ReadCloser型別(繼承了Reader型別)
	b, err := ioutil.ReadAll(resp.Body)		// resp的Body成員包括一個可讀的伺服器響應流
	resp.Body.Close()		// 關閉resp的Body流(也是關閉連線),防止資源洩露

	if err != nil {
		return content, fmt.Errorf("io讀取響應資料失敗: %v", err)
	}

	content = string(b)		// 將位元組流轉為字串
	return content, nil
}

# parseHtml.go

package parse

import (
	"fmt"
	"golang.org/x/net/html"
)

/*
我們要通過html.parse(r *io.Reader)函式將一段html字串轉化為html節點物件,parse返回的是一個*html.Node的節點指標型別,而且返回的是html文字中的第一個節點。

func Parse(r io.Reader) (*Node, error)

所以我們可以先看一下這個html.Node節點型別到底包含些什麼內容
type Node struct {
	Parent, FirstChild, LastChild, PrevSibling, NextSibling *Node

	Type      NodeType		// 節點型別
	DataAtom  atom.Atom
	Data      string		// 節點資料,如果是一個標籤節點,那麼這就是標籤名
	Namespace string
	Attr      []Attribute	// 節點屬性
}
節點型別是一個32位的非負整型,一共有7中節點型別,被存到了常量下,

Attr成員是一個html.Attribute型別的切片,Attribute結構體儲存一個屬性鍵值對。Attr []Attribute儲存的就是一個節點的所有的屬性鍵值對
type Attribute struct {
	Namespace, Key, Val string
}

 */

// 遍歷一個節點下的所有節點, 接受一個要遍歷的節點指標
func Visit(node *html.Node) {
	// 如果這個節點是標籤節點才顯示詳細資訊
	if node.Type == html.ElementNode {
		var nodeStr string = "<" + node.Data + " "

		//  遍歷該節點所有的屬性
		for _, attr := range node.Attr {
			keyValue := attr.Key + "=\"" + attr.Val + "\" "
			nodeStr += keyValue
		}

		nodeStr += ">"

		// 列印該節點
		fmt.Print(nodeStr + "\n")
	}

	// 如果這個節點有子節點(有子節點的不一定是標籤節點,也可能是錯誤節點ErrorType或者文件節點DocumentType),那麼就遞迴子節點
	if node.FirstChild != nil {
		// 遞迴遍歷列印該節點內部的節點
		Visit(node.FirstChild)
	}

	if node.Type == html.ElementNode {
		// 列印該節點的結束標籤
		fmt.Printf("</%s>\n", node.Data)
	}

	// 如果這個節點有下一個節點,就遞迴下一個節點
	if node.NextSibling != nil {
		Visit(node.NextSibling)
	}

}

# main.go

package main

import (
	"fmt"
	"golang.org/x/net/html"
	"os"
	"parse"
)

func main(){
	// 獲取螢幕輸入的html文字內容來解析
	firstNode, err := html.Parse(os.Stdin)
	//firstNode, err := html.Parse(strings.NewReader(htmlStr))
	if err != nil {
		fmt.Fprintf(os.Stdout, "%v", err)
	}

	// 遍歷列印根節點
	parse.Visit(firstNode)
}

# printHtml.go

package main

import "fetch"

func main() {
	fetch.FetchPrint("http://www.zbpblog.com")
}

在goland除錯完畢後,開始編譯

cd ./bin && go build main.go && go build printHtml.go

然後我們執行 main.go 和 printHtml.go

./printHtml.exe | ./main.exe

這裡使用了管道符,將前一個程式的輸出作為後一個程式的輸入。

在fetch函式中,我們必須確保resp.Body被關閉,釋放網路資源。雖然Go的垃圾回收機制會回收不被使用的記憶體(使用者態的記憶體),但是這不包括作業系統層面的資源,比如開啟的檔案、網路連線。因此我們必須顯式的釋放這些資源。

在後續介紹函式的其他要點時,我們會一步步的將這個程式完善。

多返回值

在go的函式中,允許返回多個值。一般而言,一個函式會返回一個期望得到的值和一個錯誤資訊,就像我們之前看到過的很多標準庫的函式的第二個返回值是error型別一樣。

返回多個值只需用逗號隔開即可。

呼叫函式的時候,如果函式返回多個值就必須用多個變數接收,而是接收的變數個數一定要和函式返回的值的個數相同。這一點和python不同,python也支援多返回值的函式,但是python接受函式的多返回值時會顯得更靈活,開發者可以接收任意多個返回值,比如python的demo()返回3個返回值,但是可以只接收1個,而Go必須3個都接收。

如果Go也想忽略函式的某些返回值,那麼可以用_來佔位(但其實這裡還是接受了3個返回值,只是忽略了後兩個而已):

r1, _, _ = demo()

錯誤

這裡介紹一下go的錯誤處理,在之後我會還會詳細的說錯誤和異常,但是在這裡提到是因為Go的函式總是會大量的處理可能出現的異常,以及Go的函式總是會將error作為返回值返回給呼叫方。

對於大部分函式而言,永遠無法確保能否成功執行。這是因為錯誤的原因超出了程式設計師的控制。舉個例子,任何進行I/O操作的函式都會面臨出現錯誤的可能,只有沒有經驗的程式設計師才會相信讀寫操作不會失敗,即使是簡單的讀寫。因此,當本該可信的操作出乎意料的失敗後,我們必須弄清楚導致失敗的原因。

如果一個函式可能會發生錯誤,那麼這個函式一般會將錯誤資訊作為最後一個返回值返回(而實際上大部分函式都會這樣做,因為大部分函式都不能保證執行函式時不會發生錯誤)。

如果導致失敗的原因只有一個,額外的返回值可以是一個布林值,通常被命名為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時,其他的返回值一般會返回零值。然而,有少部分函式在發生錯誤時,仍然會返回一些有用的返回值。比如,當讀取檔案發生錯誤時,Read函式會返回可以讀取的位元組數以及錯誤資訊。對於這種情況,正確的處理方式應該是先處理這些不完整的資料,再處理錯誤。

函式中出現的錯誤一般不要以異常的形式丟擲(不要隨意使用log.Fatal這樣的丟擲錯誤並終止程式的語句,除非這個錯誤足夠嚴重到要終止程式),而是使用return返回給呼叫方,否則會混亂對錯誤的描述,這通常會導致一些糟糕的後果,比如會增加異常資訊的複雜性增加定位錯誤的難度。

Go中的錯誤處理一般有以下幾種

1.直接返回給呼叫方

這種處理方式佔所有處理方式的80%,如果把錯誤直接返回給錯誤方,我們會返回一個error型別而不是string型別的錯誤。

Go提供了fmt.Errorf(format string, a ...interface{}) 這個函式,他會將錯誤資訊用fmt.Sprintf格式化之後,再封裝為error型別返回。

如果我們直接返回一個呼叫標準庫返回的err錯誤,就不用Errorf這個函式。但是如果我們想除了返回這個err錯誤之外還附加一些其他資訊到這個錯誤中,我們就可以用Errorf。

例如:

doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
    return nil, fmt.Errorf("parsing %s as HTML: %v", url, err)
}

這裡就用Errorf將url這個資訊伴隨著err一起返回給客戶端了。

凡是像map,陣列,結構體這樣的複合型別甚至時error型別和其他自定義的複雜型別都可以用 %v 來格式化,用%#v格式化可以顯示出更多的資訊。

2.重試

如果錯誤的發生是偶然性的,或由不可預知的問題導致的,可以選擇重新嘗試失敗的操作

在重試時,我們需要限制重試的時間間隔或重試的次數,防止無限制的重試。

例如我想請求1個國外的網頁,這個網頁不是很穩定,有時候會請求失敗,因此如果請求失敗就要重複請求。

package funcExample

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"time"
)

func Request(url string) (content string, err error){
	// 發生錯誤時重試
	resp, err := http.Get(url)

	// 正常情況下,如果err不為空而且還要判定具體是哪種錯誤才能夠進行重試,這裡只是為了演示,因此只要發生錯誤就重試
	if err != nil {
		// 定義重試的時間範圍是1分鐘,1分鐘內請求失敗會重複請求
		scope := time.Minute		// 是一個Duration物件,單位是納秒
		dealine := time.Now().Add(scope)	// 這是1分鐘後的Time時間物件

		// 如果超過一分鐘就會結束迴圈
		for tries := 0; time.Now().Before(dealine); tries++ {
			fmt.Printf("重試請求 %s \n", url)
			resp, err = http.Get(url)		// 不要用 :=,因為這裡的err要覆蓋上面的err才行

			// 重試失敗就再重試
			if err != nil {
				// 睡一段時間,睡的時間隨著重試次數增加而加長
				time.Sleep(time.Second << tries)
				continue
			}

			// 重試成功
			break
		}
	}

	//  如果重試1分鐘還是失敗則返回錯誤
	if err != nil {
		return content, fmt.Errorf("請求url %s 失敗: %v", url, err)
	}

	// 讀取失敗
	var res_bytes []byte
	res_bytes, err = ioutil.ReadAll(resp.Body)
	if err != nil{
		return string(res_bytes), fmt.Errorf("讀取url %s 的響應失敗: %v", url, err)
	}

	return string(res_bytes), err
}

3.輸出錯誤資訊並結束程式

如果錯誤發生後,程式無法繼續執行,我們就可以採用第三種策略:輸出錯誤資訊並結束程式

需要注意的是,這種策略只應在main中執行。對庫函式而言,應僅向上傳播錯誤(即直接return錯誤給呼叫方),除非該錯誤意味著程式內部包含不一致性,即遇到了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中的所有函式,都預設會在列印資訊之前輸出時間資訊。

log.Fatalf("Site is down: %v\n", err)

我們可以設定log的字首資訊遮蔽時間資訊,一般而言,字首資訊會被設定成命令名

log.SetPrefix("wait: ")
log.SetFlags(0)

4.只列印錯誤資訊

可以使用log.Printf 或者 fmt.Printf 或者 fmt.Fprintf

log.Printf("ping failed: %v; networking disabled",err)
fmt.Printf("ping failed: %v; networking disabled",err)
fmt.Fprintf(os.Stderr, "ping failed: %v; networking disabled\n", err)

其中前二者的區別是:log包中的所有函式會為沒有換行符的字串增加換行符。

5.直接忽略掉錯誤

==========================

在Go中,錯誤處理有一套獨特的編碼風格。檢查某個子函式是否失敗後,我們通常將處理失敗的邏輯程式碼放在處理成功的程式碼之前(而且一般錯誤的處理會放在if中並返回,而成功的處理不會放在else中而是在if的程式碼塊之後和之外)。如果某個錯誤會導致函式返回,那麼成功時的邏輯程式碼不應放在else語句塊中,而應直接放在函式體中。

例如:

res, err := http.Get(url)

if err != nil {
    // 錯誤邏輯的處理
    return fmt.Errorf(...)
}

// 沒有錯誤時的邏輯處理
// ...

檔案結尾錯誤(EOF)

當我們從檔案中讀取n個位元組。如果n等於檔案的長度,讀取過程的任何錯誤都表示失敗。如果n小於檔案的長度,呼叫者會重複的讀取固定大小的資料直到檔案結束。這會導致呼叫者必須分別處理由檔案結束引起的各種錯誤。基於這樣的原因,io包保證任何由檔案結束引起的讀取失敗就是指標指到文字尾部都返回同一個錯誤——io.EOF

in := bufio.NewReader(os.Stdin)		// 建立一個標準輸入的檔案流物件
for {		// 死迴圈
    r, _, err := in.ReadRune()	// 每次從標準輸入中讀取1個字元
    if err == io.EOF {		// 如果檔案指標到達最後一個字元則跳出迴圈
        break // finished reading
    }
    if err != nil {
        return fmt.Errorf("read failed:%v", err)
    }
    // ...use r…
}

本文轉載自: 張柏沛IT技術部落格 > Go入門系列(八) 函式(上)