讀古今文學網 > Go語言程序設計 > 第1章 5個例子 >

第1章 5個例子

本章總共有5個比較小的示例程序。這些示例程序概覽了Go語言的一些關鍵特性和核心包(在其他語言裡也叫模塊或者庫,在Go語言裡叫做包(package),這些官方提供的包統稱為Go語言標準庫),從而讓讀者對學習Go語言編程有一個初步的認識。如果有些語法或者專業術語沒法立即理解,不用擔心,本章所有提到的知識點在後面的章節中都有詳細的描述。

要使用Go語言寫出Go味道的程序需要一定的時間和實踐。如果你想將C、C++、Java、Python以及其他語言實現的程序移植到Go語言,花些時間學習Go語言特別是面向對像和並發編程的知識將會讓你事半功倍。而如果你想使用 Go語言來從頭創建新的應用,那就更要好好掌握 Go語言提供的功能了,所以說前期投入足夠的學習時間非常重要,前期付出的越多,後期節省的時間也將越多。

1.1 開始

為了盡可能獲得最佳的運行性能,Go語言被設計成一門靜態編譯型的語言,而不是動態解釋型的。Go語言的編譯速度非常塊,明顯要快過其他同類的語言,比如C和C++。

Go語言的官方編譯器被稱為gc,包括編譯工具5g、6g和8g,鏈接工具5l、6l和8l,以及文檔查看工具godoc(在Windows下分別是5g.exe、6l.exe等)。這些古怪的命名習慣源自於Plan 9操作系統,例如用數字來表示處理器的架構(5代表ARM,6代表包括Intel 64位處理器在內的AMD64架構,而8則代表Intel 386)。幸好,我們不必擔心如何挑選這些工具,因為Go語言提供了名字為go的高級構建工具,會幫我們處理編譯和鏈接的事情。

Go語言官方文檔

Go語言的官方網站是golang.org,包含了最新的Go語言文檔。其中Packages鏈接對 Go 標準庫裡的包做了詳細的介紹,還提供了所有包的源碼,在文檔不足的情況下是非常有用的。Commands 頁面介紹了 Go語言的命令行程序,包括 Go 編譯器和構建工具等。Specification鏈接主要非正式、全面地描述了Go語言的語法規格。最後,Effective Go鏈接包含了大量Go語言的最佳實踐。

Go語言官網還特地為讀者準備了一個沙盒,你可以在這個沙盒中在線編寫、編譯以及運行Go小程序(有一些功能限制)。這個沙盒對於初學者而言非常有用,可以用來熟悉Go語法的某些特殊之處,甚至可以用來學習fmt包中複雜的文本格式化功能或者regexp包中的正則表達式引擎等。官網的搜索功能只搜索官方文檔。如果需要更多其他的Go語言資源,你可以訪問go-lang.cat-v.org/go-search。

讀者也可以在本地直接查看Go語言官方文檔。要在本地查看,讀者需要運行godoc工具,運行時需要提供一個參數以使godoc運行為Web服務器。下面演示了如何在一個Unix終端(xterm、gnome-terminal、onsole、Terminal.app或者類似的程序)中運行

$ godoc -http=:8000

或者在Windows的終端中(也就是命令提示符或MS-DOS的命令窗口):

C:\>godoc -http=:8000

其中端口號可任意指定,只要不跟已經運行的服務器端口號衝突就行。假設 godoc 命令的執行路徑已經包含在你的PATH環境變量中。

運行 godoc 後,你只需用瀏覽器打開 http://localhost:8000 即可在本地查看 Go語言官方文檔。你會發現本地的文檔看起來跟golang.org的首頁非常相似。Packages鏈接會顯示Go語言的官方標準庫和所有安裝在GOROOT下的第三方包的文檔。如果GOPATH變量已經定義(指向某些本地程序和包的路徑),Packages 鏈接旁邊會出現另一個鏈接。你可以通過這個鏈接訪問相應的文檔(環境變量GOROOT和GOPATH將在本章後面小節和第9章中討論)。

讀者也可以在終端中使用godoc命令來查看整個包或者包中某個特定功能的文檔。例如,在終端中執行godoc image NewRGBA命令將會輸出關於函數image.NewRGBA的文檔。執行godoc image/png命令會輸出關於整個image/png包的文檔。

本書中的所有示例(可以從www.qtrac.eu/gobook.html獲得)已經在Linux、Mac OS X和Windows平台上用Go 1中的gc編譯器測試通過。Go語言的開發團隊會讓所有後續的Go 1.x版本都向後兼容Go 1,因此本書所述文字及示例都適用於整個1.x系列的Go。(如果發生不兼容的情況,我們也會及時更新書中的示例以與最新的Go語言發佈版兼容。因此,隨著時間的推移,網站上的示例程序可能跟本書中所展示的代碼不完全相同。)

要下載和安裝Go,請訪問golang.org/doc/install.html,那裡有安裝指南和下載鏈接。在撰寫本書時,Go 1已經發佈了適用於FreeBSD 7+、Linux 2.6+、Mac OS X(Snow Leopard和Lion)以及Windows 2000+平台的源代碼和二進製版本,並且同時支持這些平台的Intel 32位和AMD 64位處理器架構。另外Go 1還在Linux平台上支持ARM架構。預編譯的Go安裝包已經包含在Ubuntu Linux的發行版中,而在你閱讀本書時可能更多的其他Linux發行版也包含Go安裝包。如果只為了學習Go語言編程,從Go安裝包安裝要比從頭編譯和安裝Go環境簡單得多。

用gc構建的程序使用一種特定的調用約定。這意味著用gc構建的程序只能鏈接到使用相同調用約定的外部包,除非出現合適的橋接工具。Go語言支持在程序中以 cgo 工具(golang.org/cmd/cgo)的形式調用外部的C語言代碼。而且目前至少在Linux和BSD系統中已經可以通過SWIG工具 (www.swig.org)在Go程序中調用C和C++語言的代碼。

除了gc之外還有一個名為gccgo的Go編譯器。這是一個針對Go語言的gcc(GNU編譯工具集)前端工具。4.6以上版本的gcc都包含這個工具。像gc一樣,gccgo也已經在部分Linux發行版中預裝。編譯和安裝gccgo的指南請查看這個網址:golang.org/doc/gccgo_install.html。

1.2 編輯、編譯和運行

Go程序使用UTF-8編碼[1]的純Unicode文本編寫。大部分現代編輯器都能夠自動處理編碼,並且某些最流行的編輯器還支持Go語言的語法高亮和自動縮進。如果你用的編輯器不支持Go語言,可以在Go語言官網的搜索框中輸入編輯器的名字,看看是否有合適的插件可用。為了編輯方便,所有的Go語言關鍵字和操作符都使用ASCII編碼字符,但是Go語言中的標識符可以是任一Unicode編碼字符後跟若干Unicode字符或數字,這樣Go語言開發者可以在代碼中自由地使用他們的母語。

Go語言版Shebang腳本

因為Go的編譯速度非常快,Go程序可以作為類Unix系統上的shebang #! 腳本使用。我們需要安裝一個合適的工具來實現腳本效果。在撰寫本書的時候已經有兩個能提供所需功能的工具:gonow(github.com/kison/gonow)和gorun(wiki.ubuntu.com/gorun)

在安裝完gonow或者gorun後,我們就可以通過簡單的兩個步驟將任意Go程序當做shebang腳本使用。首先,將#!/usr/bin/env gonow或者#!/usr/bin/env gorun添加到包含main函數(在main包裡)的.go文件開始處。然後,將文件設置成可執行(如用chmod +x命令)。這些文件只能夠用gonow或者 gorun來編譯,而不能用普通的編譯方式來編譯,因為文件中的#!在Go語言中是非法的。

當gonow或者gorun首次執行一個.go文件時,它會編譯該文件(當然,非常快),然後運行。在隨後的使用過程中,只有當這個.go文件自上次編譯後又被修改過後才會被再次編譯這使得用Go語言來快速而方便地創建各種實用工具成為可能,比如創建系統管理任務。

為了感受一下如何編輯、編譯和運行Go程序,我將從經典的「Hello World」程序開始(雖然我們會將其設計得稍微複雜些)。我們首先討論編譯與運行,然後在下一節中詳細解讀文件hello/hello.go中的源代碼,因為它包含了一些Go語言的基本思想和特性。

我們可以從www.qtrac.eu/gobook.html得到本書中的所有源碼,源代碼包解壓後將是一個goeg文件夾。所以如果我們在$HOME文件夾下解壓縮,源文件hello.go的路徑將會是$HOME/goeg/src/hello/hello.go。如無特別說明,我們在提到程序的源文件路徑時將默認忽略$HOME/goeg/src 部分,比如在這個例子裡 hello 程序的源文件路徑被描述為hello/hello.go(當然,Windows用戶必須將「/」替換成「\」,同時使用它們自己解壓的路徑,如C:\goeg或者%HOME-PATH%\goeg等)。

如果你直接從預編譯Go安裝包安裝,或從源碼編譯並以root或Administrator的身份安裝,那麼你的系統中應該至少有一個環境變量GOROOT,它包含了Go安裝目錄的路徑,同時你系統中的環境變量PATH現在應該已經包含$GOROOT/bin或%GOROOT%\bin。要查看Go是否安裝正確,在終端(xterm、gnome-terminal、konsole、Terminal.app或者類似的工具)裡鍵入以下命令即可:

$ go version

或者在Windows系統的MS-DOS命令提示符窗口裡鍵入:

C:\>go version

如果返回的是「command not found」或者「『go』is not recognized...」這樣的錯誤信息,意味著Go不在環境變量PATH中。如果你用的是類Unix系統(包括Mac OS X),有一個很簡單的解決辦法,就是將該環境變量加入.bashrc(或者其他shell程序的類似文件)中。例如,作者的.bashrc文件包含這幾行:

export GOROOT=$HOME/opt/go

export PATH=$PATH:$GOROOT/bin

通常情況下,你必須調整這些值來匹配你自己的系統(當然這只有在 go version 命令返回失敗時才需要這樣做)。

如果你用的是Windows系統,可以寫一個批處理文件來設置Go語言的環境變量,每次打開命令提示符窗口執行Go命令時先運行這個批處理文件即可。不過最好還是在控制面板裡設置Go語言的環境變量,一勞永逸。步驟如下,依次點擊「開始菜單」(那個Windows圖標)、「控制面板」、「系統和安全」、「系統」、「高級系統設置」,在系統屬性對話框中點擊「環境變量」按鈕,然後點擊「新建...」按鈕,在其中加入一個以GOROOT命名的變量以及一個適當的值,如C:\Go。在相同的對話框中,編輯PATH環境變量,並在尾部加入文字;C:\Go\bin——文字開頭的分號至關重要!在以上兩者中,用你系統上實際安裝的Go 路徑來替代 C:\Go,如果你實際安裝的Go 路徑不是C:\Go的話。(再次聲明,只有在go version命令返回失敗時才需要這樣做。)

現在我們假設Go在你機器上安裝正確,並且Go bin目錄包含PATH中所有的Go構建工具。(為了讓新設置生效,可能有必要重新打開一個終端或命令行窗口。)

構建Go程序,有兩步是必須的:編譯和鏈接。[2]所有這兩步都由go構建工具處理。go構建工具不僅可以構建本地程序和本地包,並且可以抓取、構建和安裝第三方程序和第三方包。

讓 go的構建工具能夠構建本地程序和本地包需滿足三個條件。首先,Go的bin 目錄($GOROOT/bin或者 %GOROOT%\bin)必須在環境變量中。其次,必須有一個包含src目錄的目錄樹,其中包含了本地程序和本地包的源代碼。例如,本書的示例代碼被解壓到goeg/src/hello和goeg/src/bigdigits等目錄。最後,src目錄的上一級目錄必須在環境變量GOPATH中。例如,為了使用go的構建工具構建本書的hello示例程序,我們必須這樣做:

$ export GOPATH=$HOME/goeg

$ cd $GOPATH/src/hello

$ go build

相應地,在Windows上也可以這樣做:

C:\>set GOPATH=C:\goeg

C:\>cd %gopath%\src\hello

C:\goeg\src\hello>go build

以上兩種情況都假設PATH環境變量中已經包含$GOROOT/bin或者%GOROOT%\bin。在go構建工具構建好了程序後,我們就可以嘗試運行它。可執行文件的默認文件名跟它所位於的目錄名稱一致(例如,在類Unix系統中是hello,在Windows系統中是hello.exe),一旦構建完成,我們就可以運行這個程序了。

$./hello

Hello World!

或者

$./hello Go Programmers!

Hello Go Programmers!

在Windows上也類似:

C:\goeg\src\hello>hello Windows Go Programmers!

Hello Windows Go Programmers!

我們用加粗代碼字體的形式顯示需要你在終端輸入的文字,並以羅馬字體的形式顯示終端的輸出。我們也假設命令提示符是$,但其實是什麼都沒關係(如Windows下的C:\>)。

有一點可以注意到的是,我們無需編譯或者顯式鏈接任何其他的包(即使我們將看到hello.go使用了3個標準庫中的包)。這是為什麼Go程序構建得如此快的原因。

如果我們有好幾個 Go 程序,如果它們的可執行程序都可以保存在同一個目錄下,由於我們可以一次性將這個目錄加入到PATH中,這將會非常的方便。幸運的是,go構建工具可以用以下方式來支持這樣的特性:

$ export GOPATH=$HOME/goeg

$ cd $GOPATH/src/hello

$ go install

同樣地,我們可以在Windows上這樣做:

C:\>set GOPATH=C:\goeg

C:\>cd %gopath%\src\hello

C:\goeg\src\hello>go install

go install 命令跟 go build 所做的工作是一樣的,唯一不同的是,它將可執行文件放入一個標準路徑中($GOPATH/bin或者 %GOPATH%\bin)。這意味著,只需在PATH中加上一個統一路徑($GOPATH/bin 或者 %GOPATH%\bin),我們所安裝的所有 Go 程序都會包含在PATH中從而可以在任一路徑下直接運行。

除了本書中的示例程序之外,我們可能會想在自己的一個目錄下開發自己的Go程序和包。要達到這個目的,我們可以將 GOPATH 環境變量設置成兩個或者多個以冒號分隔的路徑(在Windows中是以分號分隔)。例如,export GOPATH=$HOME/app/go:$HOME/goeg或者SET GOPATH=C:\app\go;C:\goeg。[3]在這個情況下我們必須將所有的程序和包的源代碼都放入$HOME/app/go/src或者C:\app\go\src中。因此,如果我們開發了一個叫myapp的程序,它的.go源文件將位於$HOME/app/go/src/myapp或者C:\app\go\src\myapp。如果我們使用go install在一個GOPATH路徑下構建程序,而且GOPATH環境變量包含了兩個或者更多個路徑,那麼可執行文件將被放入相對應源代碼目錄的bin文件夾中。

通常,每次構建Go程序時export或者設置GOPATH環境變量可能很費勁,因此最好是永久性地設置好這個環境變量。前面我們已經提到過,類Unix系統可修改.bashrc文件(或類似的文件)以設置GOPATH環境變量(參見本書示例中的gopath.sh文件),Windows上可通過編寫一個批處理文件(參見本書示例中的gopath.bat文件)或添加GOPATH到系統的環境變量:依次點擊「開始菜單」(那個Windows圖標)、「控制面板」、「系統和安全」、「系統」、「高級系統設置」,在系統屬性對話框中點擊「環境變量」按鈕,然後點擊「新建...」按鈕,在其中加入一個以GOPATH命名的變量以及一個適當的值,如C:\goeg或C:\app\go;C:\goeg。

雖然Go語言的推薦構建工具是go命令行工具,我們完全可以使用make或者其他現代構建工具,或者使用別的針對Go語言的構建工具,或者給流行集成開發環境如Eclipse和Visual Studio安裝合適的插件來進行Go工程的構建。

1.3 Hello Who?

現在我們已經知道怎麼編譯一個 hello 程序,讓我們看看它的代碼。不要擔心細節,本章所提及的一切(以及更多的內容)在後面的章節中都有詳細描述。下面是完整的hello程序(在文件hello/hello.go中):

// hello.go

package main

import (1

〞fmt〞

〞os〞

〞strings〞

)

func main {

who := 〞World!〞 2

if len(os.Args) > 1 { /* os.Args[0]是〞hello〞或者〞hello.exe〞 */ 3

who = strings.Join(os.Args[1:], 〞 〞) 4

}

fmt.Println(〞Hello〞, who) 5

}

Go語言使用 C++風格的註釋://表示單行註釋,到行尾結束,/…/ 表示多行註釋。Go語言中的慣例是使用單行註釋,而多行註釋則往往用於在開發過程中註釋掉若干行代碼。[4]

所有的Go語言代碼都只能放置於一個包中,每一個Go程序都必須包含一個main包以及一個 main函數。main函數作為整個程序的入口,在程序運行時最先被執行。實際上,Go語言中的包還可能包含init函數,它先於main函數被執行,我們將在1.7節瞭解到,關於init函數的完全介紹在5.6.2節。需要注意的是,包名和函數名之間不會發生命名衝突情況。

Go語言針對的處理單元是包而非文件,這意味著我們可以將包拆分成任意數量的文件。在Go編譯器看來,如果所有這些文件的包聲明都是一樣的,那麼它們就同樣屬於一個包,這跟把所有內容放在一個單一的文件裡是一樣的。通常,我們也可以根據應用程序的功能將其拆分成盡可能多的包,以保持一切模塊化,我們將在第9章看到相關內容。

代碼中的import語句(標注為1的地方)導入了3個標準庫中的包。fmt包提供來格式化文本和讀入格式文本的函數(參見 3.5 節),os 包提供了跨平台的操作系統層面變量及函數,而strings包則提供了處理字符串的函數(參見3.6.1節)。

Go語言的基本類型支持常用的操作符(如+操作符可用於數字加法運算和字符串連接運算),同時Go語言的標準庫也提供了擁有各種功能的包來對這些操作進行補充,如這裡引入的strings包。你也可以基於這些基本類型創建自己的類型或者為這些類型添加自定義方法(我們將在1.5節提及,並在第6章詳細闡述)。

讀者可能也已經注意到程序中沒有分號,那些 import 語句也不用逗號分隔,if 語句的條件也不用圓括號括起來。在Go語言中,包含函數體以及控制結構體(例如if語句和for循環語句)在內的代碼塊均使用花括號作為邊界符。使用代碼縮進僅僅是為了提高代碼可讀性。從技術層面講,Go語言的語句是以分號分隔的,但這些是由編譯器自動添加的,我們不用手動輸入,除非我們需要在同一行中寫入多個語句。沒有分號及只需要少量的逗號和圓括號,使得Go語言的程序更容易閱讀,並且可以大幅降低編寫代碼時的鍵盤敲擊次數。

Go語言的函數和方法以關鍵字func定義。但main包裡的main函數比較特別,它既沒有參數,也沒有返回值。當main.main運行完畢,程序會自動終止並向操作系統返回0。通常我們可以隨時選擇退出程序,並返回一個自己選擇的返回值,這點我們隨後將詳細講解(參見1.4節)。

main函數中的第一行(標注2)使用了 := 操作符,在Go語言中叫做快速變量聲明。這條語句同時聲明並初始化了一個變量,也就是說我們不必聲明一個具體類型的變量,因為Go語言可以從其初始化值中推導出其類型。所以這裡我們相當於聲明了一個string類型的變量who,而且由於go是強類型的語言,也就只能將string類型的值賦值給who。

就像大多數語言使用if語句檢測一個條件是否成立一樣,在這個例子裡if語句用來判斷命令行中是否輸入了一個字符串,如果條件成立就執行相應大括號中的代碼塊。我們將在本章末尾(參見1.6節)及後面的章節(參見5.2.1節)中看到一些更加複雜的if語句。

代碼中的os.Args變量是一個string類型的切片(標注3)。數組、切片和其他容器類型將在第4章中詳細闡述(參見4.2節)。現在我們只需要知道可以使用語言內置的len函數來獲得切片的長度即可,而切片的元素則可以通過索引操作來獲得,其語法是一個 Python 語法子集。具體而言,slice[n]返回切片的第n個元素(從0開始計數),而slice[n:]則返回另一個包含從第n個元素到最後一個元素的切片。在數據集合那一章節,我們將會看到Go語言在這方面的詳細語法。對於os.Args,這個切片總是至少包含一個string(程序本身的名字),其在切片中的位置索引為0(Go語言中的所有索引都是從0開始的)。

只要用戶輸入一個或多個命令行參數,if 語句的條件就成立了,我們將從命令行輸入的所有參數連接成一個字符串並賦值給 who 變量(標注4)。在這裡我們使用賦值操作符(=),因為如果我們使用快速聲明操作符(:=)的話,只能得到另一個生命週期僅限於當前 if代碼塊的新局部變量who。strings.Join函數的輸入參數為以一個string類型的切片和一個分隔符(可以是一個空字符,如〞〞)作為輸入,返回一個由分隔符將切片中的所有字符串連接在一起的新字符串。在這個示例裡我們用空格作為連接符來連接所有輸入的字符串參數。

最後,在最後一個語句(標注5)中,我們打印Hello和一個空格,以及who變量中的字符串,並添加一個換行符。fmt 包提供了許多不同的打印函數變體,比如像 fmt.Println會整潔地打印任何輸入的內容,而像 fmt.Printf 則使用佔位符來提供良好的格式化輸出控制能力。打印函數將在第3章(參見3.5節)詳細闡述。

本節的hello 程序展示了很多超出這類程序一般所做事情之外的語言特性。接下來的示例也會這樣做,在保持程序盡量簡短的情況下盡量覆蓋更多的高級特性。這樣做的主要目的是,通過熟悉簡單的語言基礎,讓讀者在構建、運行和體驗簡單的Go程序的同時體驗一下Go語言的強大與獨特。當然,本章提及的所有內容都將在後面章節中更詳細地闡述。

1.4 大數字——二維切片

示例程序bigdigits(源文件是bigdigits/bigdigits.go)從命令行接收一個數字(作為一個字符串輸入),然後用大數字的格式將這個數字輸出到命令行窗口。回溯到20世紀,在一些多個用戶共用一台高速行式打印機的地方,通常都會習慣性地為每個用戶的打印任務添加一個封面頁以顯示該用戶的一些標識信息,比如他們的用戶名和打印的文件名等。那時候採取的就是類似於這個例子中演示的大數字技術。

我們將分3部分瞭解這個示例程序:首先介紹import部分,然後是靜態數據,再之後是程序處理過程。為了讓大家對整個過程有個大致的印象,我們先來看看程序的運行結果,如下:

$./bigdigits 290175493

222  9999  000  1 77777 55555  4  9999  333

2  2 9  9  0  0  11   7 5    44  9  9  3  3

2  9  9 0  0  1   7  5   4 4  9  9    3

2  9999 0  0  1  7  555  4  4  9999   33

2     9 0  0  1  7    5 444444   9    3

2     9  0  0  1  7   5  5   4    9  3  3

22222   9  000  111 7   555   4    9  333

從這個例子可以看出,每個數字都由一個字符串類型的切片來表示,所有的數字可以用一個二維的字符串類型切片來表示。在查看數據之前,我們先來瞭解如何聲明和初始化一維的字符串類型以及數字類型的切片。

longWeekend := string{〞Friday〞, 〞Saturday〞, 〞Sunday〞, 〞Monday〞}

var lowPrimes = int{2, 3, 5, 7, 11, 13, 17, 19}

切片的表達方式為Type,如果我們希望同時完成初始化的話,可以在後面直接跟一個花括號,括號內是一個對應類型的元素列表,並在元素之間用逗號分隔。本來對於這兩個切片我們可以用同樣的變量聲明語法,但我們刻意地對 LowPrimes 切片的聲明採用了相對較長的聲明方式。採取這個方式的原因我們很快會給出說明。因為一個切片的類型本身可以是另一個切片,所以我們可以很容易地創建多維的集合(例如元素類型為切片的切片等)。

bigdigits程序只需要引入四個包:

import (

〞fmt〞

〞log〞

〞os〞

〞path/filepath〞

)

fmt包提供了格式化文本和讀取格式化文本的相關函數(參見3.5節)。log包提供了日誌功能。os 包提供的是平台無關的操作系統級別變量和函數,包括用於保存命令行參數的類型為string的os.Args變量(即字符串類型的切片)。而path包中的filepath子包則提供了一系列可跨平台的對文件名和路徑操作的函數。需要注意的是,對於位於其他包內的子包,在我們的代碼中用到時只需要指定其包名稱的最後一部分即可(對於此例而言就是filepath)。

對於bigdigits程序而言,我們需要二維數據(字符串類型的二維切片)。下面我們示範一下如何創建這樣的數據,通過將數字0排列好以展示數字對應的字符串如何對應到輸出裡的行,不過省略了數字3到8的對應字符串。

var bigDigits = string{

{〞 000 〞,

〞 0  0 〞,

〞0   0〞,

〞0   0〞,

〞0   0〞,

〞 0  0 〞,

〞 000 〞},

{〞 1 〞, 〞11 〞, 〞 1 〞, 〞 1 〞, 〞 1 〞, 〞 1 〞, 〞111〞},

{〞 222 〞, 〞2  2〞, 〞  2 〞, 〞  2 〞, 〞 2 〞,  〞2 〞, 〞22222〞},

//...3至8...

{〞 9999〞, 〞9  9〞, 〞9  9〞, 〞 9999〞, 〞  9〞, 〞  9〞, 〞  9〞},

}

雖然在函數和方法之外聲明的變量不能使用 := 操作符,但我們可以通過使用關鍵字var和賦值運算符 =的長聲明方式來達到同樣的效果,例如本例中我們為 bigDigits 變量所做的。其實之前我們在聲明 lowPrimes 變量時已經使用過了。不過我們仍然不需要指定bigDigits的數據類型,因為Go語言能夠從賦值動作中推導出相應的類型信息。

我們把計數工作丟給了Go編譯器,因此不需要明確指定切片的維度。Go語言的眾多便利之一就是支持像大括號這樣的復合文面量語法,因此我們不必在一個地方聲明這個變量,又在別的地方將相應的值賦值給它,當然,這麼做也是可以的。

main函數總共只有20行代碼,從命令行讀取輸入然後生成輸出結果。

func main {

if len(os.Args) == 1 { 1

fmt.Printf(〞usage: %s <whole-number>\n〞, filepath.Base(os.Args[0]))

os.Exit(1)

}

stringOfDigits := os.Args[1]

for row := range bigDigits[0] { 2

line := 〞〞

for column := range stringOfDigits { 3

digit := stringOfDigits[column] - '0' 4

if 0 <= digit && digit <= 9 { 5

line += bigDigits[digit][row] + 〞 〞 6

} else {

log.Fatal(〞invalid whole number〞)

}

}

fmt.Println(line)

}

}

程序先檢查啟動時是否帶有命令行參數。如果沒有,則len(os.Args)的值為1(回憶一下,os.Args[0]存放的是程序名字,因此這個切片的長度通常至少為1),然後if條件成立,調用 fmt.Printf函數打印一條用法信息,fmt.Printf接收%佔位符,類似於 C/C++中printf函數的支持方式,以及Python的%操作符(更詳細的用法可參見3.5節)。

path/filepath包提供了路徑操作函數。比如,filepath.Base函數會返回傳入路徑的基礎名(其實就是文件名)。輸出消息後,程序通過調用os.Exit函數退出,返回1給操作系統。在類Unix系統中,程序返回0表示成功,非零值表示用法問題或執行失敗。

filepath.Base函數的用法演示了 Go語言的一個很酷的功能:在導入一個包時,無論這是一個頂級包還是屬於其他包(如path/filepath),我們只需要使用包名裡的最後一部分來引用它(如filepath)。而且我們還可以在引入包時給這個包分配一個別名以避免名字衝突。本書第9章會詳細介紹相關的用法。

假如用戶傳入了至少一個命令行參數,我們會將第一個命令行參數複製到stringOfDigits字符串變量中。為了能夠將用戶輸入的數字轉換為大數字,我們需要遍歷 bigDigits 切片中的每一行,也就是說,先生成每個數字的第一行,然後再生成第二行,等等。我們假設所有的bigDigits 切片都包含了同行的行數,因此我們直接使用了第一個切片的行數。Go語言的for 循環有若干種不同的語法以滿足不同的需求;本例標注2和3的地方我們使用了for...range循環來返回切片中每個元素的索引位置。

行列循環部分的代碼可以用如下方式實現:

for row := 0; row < len(bigDigits[0]); row++ {

line := 〞〞

for column := 0; column < len(stringOfDigits); column++ {

...

這是C、C++、Java程序員所熟悉的方式,當然Go語言也支持[5]。但是for...range語法可以實現得更短且更方便(我會在5.3節中討論Go語言中for循環的各種詳細用法)。

在每次遍歷行之前我們會將行的line變量設置為一個空字符串。然後我們再遍歷從用戶那裡接受到的stringOfDigits字符串中的每一列(其實就是字符)。Go語言中的字符串採用的是UTF-8編碼,因此一個字符有可能佔用兩個或者更多字節。不過這在本例中並不是個問題,因為我們只需要考慮如何處理0到9的數字,而這些數字在UTF-8中都是用一個字節表示。它們的表示方法與7位的ASCII標準完全一致。(之後在第3章中我們將學習如何一個字符一個字符地遍歷一個字符串,無論其中的字符是單字節還是多字節。)

當我們按索引位置查詢一個字符串的內容時,我們將得到索引位置對應的一個byte類型的值(在Go語言中,byte類型等同於uint8類型)。所以,我們可以對命令行傳入的參數按索引位置取相應的byte類型值,然後將該值和數字0對應的byte類型值相減,以得知對應的數字。在UTF-8和ASCII中,字符『0』對應的是48,字符『1』對應的是49,以此類推。因此,假如我們得到的是一個字符『3』(對應數值為51),那麼我們可以通過運算『3』-『0』(也就是51-48)來獲取相應的整型值,也就是一個byte類型的整型數,值為3。

Go語言採用單引號來表達字符,而一個字符其實就是一個與Go語言所有其他整型類型兼容的整型數。Go語言的強類型特徵意味著我們不能在不做強制類型轉換的前提下將一個int32類型和一個int16類型直接相加,但Go語言的數值類型常量適應到它們的上下文,因此在這個上下文裡,『0』將會被當做是一個byte類型。

假如對應的數字在範圍之內,我們可以添加合適的字符串到該行中(在if語句中常量0和9被認為是byte類型,因為digit的類型就是byte,但如果digit是其他的一個類型,比如是int,那麼它們也自然會被認為是相應的類型)。雖然Go語言的字符串是不可變的,但 += 這種語法在Go語言裡也是支持的,主要是易於使用,實質上是暗地裡將原字符串替換掉了,另外 + 連接運算符也是支持的,返回一個將兩個字符串連接起來的新字符串(第3章將對字符串進行詳細描述)。

為了獲得對應的字符串,我們先訪問對應於數字的bigDigits切片中的相應行。

如果數字超過了範圍(比如包含了非數字的字符),我們調用log.Fatal函數記錄一條錯誤信息,包括日期、時間和錯誤信息,如果沒有顯式指定記錄到哪裡,那麼默認是打印到os.Stderr,並調用os.Exit(1)終止程序的執行。另外還有一個log.FatalF函數可以接受%格式的佔位符。在第一個if語句裡我們沒有使用log.Fatal函數,因為我們只需要輸出程序的幫助信息,而不需要日期和時間這些通常log.Fatal函數的輸出會包含的信息。

當每個數字對應行的字符串準備就緒後,這一行將被打印。在這個例子裡,總共有7行被打印,因為每個bigDigits字符串切片中的數字都用七個字符串來表示。

最後一點,通常情況下聲明和定義的順序並不會帶來影響。因此在 bigdigits/bigdigits.go文件中,我們可以在main函數前後聲明bigDigits變量。在這個例子裡,我們將main函數放在前面,因為本書所有的例子我們都趨向於用自上而下的方式來組織內容。

這兩個例子中我們已經接觸到不少東西,但也僅僅是介紹了 Go語言與其他主流語言類似的一些功能,除了語法上略有區別外。接下來的3個例子將把我們帶離舒適地帶,開始展示Go語言的一些特有功能,比如特有的Go語言類型,文件處理(包括錯誤處理)和以值方式傳遞函數,以及使用goroutine和通道(channel)進行並行編程等。

1.5 棧——自定義類型及其方法

雖然Go語言支持面向對像編程,但它既沒有類也沒有繼承(is-a關係)這樣的概念。但是Go語言支持創建自定義類型,而且很容易創建聚合(has-a關係)結構。Go語言也支持將其數據和行為完全分離,同時也支持鴨子類型。鴨子類型是一種強有力的抽像機制,它意味著數據的值(比如傳入函數的數據)可以根據該數據提供的方法來被處理,而不管其實際的類型。這個術語是從這條語句演化而來的:「如果它走起來像鴨子,叫起來像鴨子,它就是一隻鴨子。」所有這些一起,提供了一種游離於類和繼承之外的更加靈活強大的選擇。但如果要從 Go語言的面向對像特性中獲益,習慣於傳統方法的我們必須在概念上做一些重大調整。

Go語言使用內置的基礎類型如 bool、int和string 等類型來表示數據,或者使用struct來對基本類型進行聚合。[6]Go語言的自定義類型建立在基本類型、struct或者其他自定義類型之上。(我們會在本章後面看到一些簡單的例子,參見1.7節。)

Go語言同時支持命名和匿名的自定義類型。相同結構的匿名類型等價,可以相互替換,但是不能有任何方法(這點我們會在6.4節詳細闡述)。任何命名的自定義類型都可以有方法,並且這些方法一起構成該類型的接口。命名的自定義類型即使結構完全相同,也不能相互替換(除特別聲明之外,本書所指的「自定義類型」都是指命名的自定義類型)。

接口也是一種類型,可以通過指定一組方法的方式定義。接口是抽像的,因此不可以實例化。如果某個具體類型實現了某個接口所有的方法,那麼這個類型就被認為實現了該接口。也就是說,這個具體類型的值既可以當做該接口類型的值來使用,也可以當做該具體類型的值來使用。然而,不需要在接口和實現該接口的具體類型之間建立形式上的聯接。一個自定義的類型只要實現了某個接口定義的所有方法就是實現了該接口。當然,一個類型可以實現多個接口,只要這個類型同時實現多個接口所定義的所有方法。

空接口(沒有定義方法的接口)用interfae{}來表示。[7]由於空接口沒有做任何要求(因為它不需要任何方法),它可以用來表示任意值(效果上相當於一個指向任意類型值的指針),無論這個值是一個內置類型的值還是一個自定義類型的值(Go語言的指針和引用將在4.1節介紹)。順便提一句,在Go語言中我們只講類型和值,而非類和對像或者實例(因為Go語言沒有類的概念)。

函數和方法的參數類型可以是任意內置類型或者自定義類型,甚至是接口。後一種情況表示,一個函數可能接收這樣一個參數,例如「傳入一個可以讀取數據的值」,而不管該值的實際類型是什麼(我們馬上會在實踐中看到這個,參見1.6節)。

第6章詳細闡述了這些,並提供了許多例子來保證讀者理解這些想法。現在,就讓我們來看一個非常簡單的自定義棧類型如何被創建和使用,然後看看該自定義類型是如何實現的。

我們從程序的運行結果分析開始:

$./stacker

81.52

[pin clip needle]

-15

hay

上述結果中的每一項都從該自定義棧中彈出,並各自在單獨一行中打印出來。

這個程序的源碼是stacker/stacker.go。這裡是該程序的包導入語句:

import (

〞fmt〞

〞stacker/stack〞

)

fmt包是Go語言標準庫的一部分,而stack包則是為我們的stacker程序特意創建的一個本地包。一個Go語言程序或者包的導入語句會首先搜索GOPATH定義的路徑,然後再搜索GOROOT所定義的路徑。在這個例子中,程序的源代碼位於$HOME/goeg/src/stacker/stacker.go中,而 stack 包則位於$HOME/goeg/src/stacker/stack/stack.go 中。只要 GOPATH 是$HOME/goeg或包含了$HOME/goeg這個路徑,go構建工具就會將stack和stacker都構建好。

包導入的路徑使用Unix風格的「/」來聲明,就算在Windows平台上也是這樣。每一個本地包都需要保存在一個與包名同名的目錄下。本地包可以包含它們自己的子包(如path/filepath),其形式與標準庫完全相同(創建和使用自定義包的內容將在第9章中詳細闡述)。

下面是打印出輸出結果的簡單測試程序的main函數:

func main {

var haystack stack.Stack

haystack.Push(〞hay〞)

haystack.Push(-15)

haystack.Push(string{〞pin〞, 〞clip〞, 〞needle〞})

haystack.Push(81.52)

for {

item, err := haystack.Pop

if err != nil {

break

}

fmt.Println(item)

}

}

函數的開頭聲明了一個stack.Stack類型的變量haystack。在Go語言中,導入包中的類型、函數、變量以及其他項的慣例是使用pkg.item這樣的語法。其中,pkg是包名中的最後一部分(或唯一一項)。這樣有助於避免名字衝突。然後,我們往棧中壓入一些元素,並將其逐一彈出後再輸出,直至棧被清空。

使用自定義棧的一個奇妙之處在於可以自由地將異構(類型不同)的元素混合存儲,而不僅僅是存儲同構(類型相同)的元素。雖然Go語言是強類型的,但是我們可以通過空接口來實現這一點。我們這個例子裡的stack.Stack類型就是這麼做的,無需關心它們的實際類型是什麼。當然,在實際使用中,這些元素的實際類型我們還是要知道的。不過,在這裡我們只使用到了fmt.Println函數,它可以使用Go語言的類型檢視功能(在reflect包中)來獲得它要打印的元素的類型信息(反射將在後面的9.4.9節中講到)。

這段代碼展示的另一個Go語言的美妙特性就是不帶條件的for循環。這是一個無限循環,因此大部分情況下,我們需要提供一種方法來跳出循環,比如這裡使用的break語句或者一個return語句。我們會在下一個例子中看到另一種for循環語法(參見1.6節)。for循環的完整語法將在第5章敘述。

Go語言的函數和方法均可返回單一值或者多個值。Go語言中報告錯誤的慣例是函數或者方法的最後一個返回值是一個錯誤值(其類型為error)。我們的自定義類型stack.Stack也遵從這樣的慣例。

既然我們知道自定義類型stack.Stack是怎麼使用的,就讓我們再來看看它的具體實現(源碼在文件staker/stack/stack.go中)。

package stack

import 〞errors〞

type Stack interface{}

按照慣例,該文件開始處聲明其包名,然後導入需要使用的包,在這裡只有一個包,即errors。

在 Go語言中定義一個命名的自定義類型時,我們所做的是將一個標識符(類型名稱)綁定在一個新類型上,這個新類型與已有的(內置的或者自定義的)類型有相同的底層表示。但Go語言又會認為這兩個底層表示有所區別。在這裡,Stack類型只是一個空接口類型切片(也就是一個可變長數組的引用)的別名,但它與普通的interface{}類型又有所區別。

由於Go語言的所有類型都實現了空接口,因此任意類型的值都可以存儲在Stack中。

內置的數據集合類型(映射和切片)、通信通道(可緩衝)和字符串等都可以使用內置的len函數來獲取其長度(或者緩衝大小)。類似地,切片和通道也可以使用內置的cap函數來獲取容量(它可能比其使用的長度大)。(Go語言的所有內置函數都以交叉引用的形式列在表5-1中,切片在第4章有詳細闡述,參見4.2節。)通常所有的自定義數據集合類型(包括我們自己實現的以及Go語言標準庫中的自定義數據集合類型)都應實現Len和Cap方法。

由於 Stack 類型使用切片作為其底層表示,因此我們應為其實現 Stack.Len和Stack.Cap方法。

func (stack Stack) Len int {

return len(stack)

}

函數和方法都使用關鍵字func定義。但是,定義方法的時候,方法所作用的值的類型需寫在 func關鍵字之後和方法名之前,並用圓括號包圍起來。函數或方法名之後,則是小括號包圍起來的參數列表(可能為空),每個參數使用逗號分隔(每個參數以variableName type這種形式聲明)。參數後面,則是該函數的左大括號(如果它沒有返回值的話),或者是一個單一的返回值(例如,Stack.Len方法中的int返回值),也可以是一對圓括號包圍起來的返回值列表,後面再緊跟著一個左大括號。

大部分情況下,會為調用該方法的值命名,例如這裡我們使用 stack 命名(並且與其包名並不衝突)。調用該方法的值在Go語言中以術語「接收器」來稱呼[8]。

本例中,接收器的類型是Stack,因此接收器是按值傳遞的。這也意味著任何對該接收器的改變都只是作用於其原始值的一份副本,因此會丟失。這對於不需要修改接收器的方法來說是沒問題的,例如本例中的Stack.Len方法。

Stack.Cap方法基本上和Stack.Len一樣(所以這裡沒有給出)。唯一的不同是,Stack.Cap方法返回的是棧的cap而非len的值。源代碼中還包含一個Stack.IsEmpty方法,但它也跟Stack.Len方法極為相似,只是返回一個bool值以表示棧的len是否等於0,因此也就不再列出。

func (stack *Stack) Push(x interface{}) {

*stack = append(*stack, x)

}

Stack.Push方法在一個指向Stack的指針上被調用(稍後解釋),並且接收一個任意類型的值作為參數。內置的append函數可以將一個或多個值追加到一個切片裡去,並返回一個切片(可能是新建的),該切片包含原始切片的內容和在尾部追加進去的內容。

如果之前有數據從該棧彈出過,則底層的切片容量可能比切片的實際長度大,因此壓棧操作會非常的廉價:只需簡單地將x這項保存在len(stack)這個位置,並將棧的長度加1。

Stack.Push函數永遠有效(除非計算機的內存耗盡),因此我們沒必要返回一個 error值來表示成功或者失敗。

如果我們要修改接收器,就必須將接收器設為一個指針。[9]指針是指一個保存了另一個值的內存地址的變量。使用指針的原因之一是為了效率,比如我們有一個很大的值,傳入一個指向該值所在內存地址的指針會比傳入該值本身更廉價得多。指針的另外一個用處是使一個值可被修改。例如,當一個變量傳入到一個函數中,該函數只得到該值的一份副本(例如,傳stack給stack.Len函數)。這意味著我們對該值所做的任何改動,對於原始值來說都是無效的。如果我們想修改原始值(就像這裡一樣我們想往棧中壓入數據),我們必須傳入一個指向原始值的指針,這樣在函數內部我們就可以修改指針所指向的值了。

指針通過在類型名字前面添加一個星號來聲明(即星號*)。因此,在Stack.Push方法中,變量stack的類型為 *Stack,也就是說變量stack保存了一個指向Stack類型值的指針,而非一個實際的Stack類型值。我們可以通過解引用操作來獲取該指針所指向值的實際Stack值,解引用操作只是簡單意味著我們在試圖獲得該指針所指處的值。解引用操作通過在變量前面加上一個星號來完成。因此,我們寫stack時,是指一個指向Stack的指針(也就是一個 *Stack)。寫*stack時,是指解引用該指針變量,也就是引用該指針所指之處的實際Stack類型值。

此外星號處於不同的位置所表達的含義也不盡相同。在兩個數字或者變量之間時表示乘法,例如x*y,這一點Go和C、C++等是一樣的。在類型名稱前面時表示指針,例如 *MyType。在變量名稱之前時表示解引用,例如 *Z。不過不要太擔心這些,我們在第4章中將詳細闡述Go語言指針的用法。

需要注意的是,Go語言中的通道(channel)、映射(map)和切片(slice)等數據結構必須通過make函數創建,而且make函數返回的是該類型的一個引用。引用的行為和指針非常類似,當把它們傳入函數的時候,函數內對該引用所做的任何改變都會作用到該引用所指向的原始數據。然而,引用不需要被解引用,因此大部分情況下不需要將其與星號一起使用。但是,如果我們要在一個函數或者方法內部使用 append修改一個切片(不同於僅僅修改其中的一個元素內容),必須要麼傳入指向這個切片的一個指針,要麼就返回該切片(也就是將原始切片設置為該函數或者方法返回的值),因為有時候append返回的切片引用與之前所傳入的不同。

Stack 類型使用一個切片來表示,因此 Stack 類型的值也可以在操作切片的函數如append和len中使用。然而,Stack類型的值僅僅是該類型的值,與其底層表示的類型值不一樣,因此如果我們需要修改它就必須傳入指針。

func(stack Stack) Top (interface{}, error) {

if len(stack) == 0 {

return nil, errors.New(〞can't Top en empty stack〞)

}

return stack[len(stack)-1], nil

}

Stack.Top方法返回棧中最頂層的元素(最後被添加進去的元素)和一個error類型的錯誤值,棧不為空時這個錯誤值為nil,否則不為nil。這個名為stack的接收器之所以被按值傳遞,是因為棧沒有被修改。

error是一個接口類型(參見6.3節),其中包含了一個方法Error string。通常, Go語言的庫函數的最後一個返回值為error類型,表示成功(error的值為nil)或者失敗。這段代碼裡我們通過使用errors包中的errors.New函數將Stack類型設計成與標準庫中的類型一樣工作。

Go語言使用nil來表示空指針(以及空引用),即表示指向為空的指針或者引用值為空的引用。[10]這種指針只在條件判斷或者賦值的時候用到,而不應該調用nil值的成員方法。

Go語言中的構造函數從來不會被顯式調用。相反地,Go語言會保證當一個值創建時,它會被初始化成相應的空值。例如,數字默認被初始化成0,字符串默認被初始化成空字符串,指針默認被初始化成nil值,而結構體中的各個字段也被初始化成相應的空值。因此,在Go語言中不存在未初始化的數據,這減少了很多在其他語言中導致出錯的麻煩。如果默認初始化的空值不合適,我們可以自己寫一個創建函數然後顯式地調用它,就像在這裡創建一個新的error 值一樣。也可以防止調用者不通過創建函數而直接構造某個類型的值,我們在第6章將詳細闡述如何做到這一點。

如果棧不為空,我們返回其最頂端的值和一個nil錯誤值。由於Go語言中的索引從0開始,因此切片或者數組的第一個元素的位置為0,最後一個元素的位置為len(sliceOrArray) - 1。

在函數或者方法中返回一個或多個返回值時無需拘泥於形式,只需在所定義函數的函數名後列上返回值類型,並在函數體中保證至少有一個return語句能夠返回相應的所有返回值即可。

func (stack *Stack) Pop (interface{}, error) {

theStack := *stack

if len(theStack) == 0 {

return nil, errors.New(〞Can't pop an empty stack〞)

}

x := theStack[len(theStack) - 1] 1

*stack = theStack[:len(theStack) - 1] 2

return x, nil

}

Stack.Pop方法用於刪除並返回棧中最頂端(最新添加)的元素。像Stack.Top方法一樣,它返回該元素和一個nil錯誤值,或者如果棧為空則返回一個nil元素和一個非nil錯誤值。

由於該方法需要通過刪除元素來修改棧,因此它的接收器必須是一個指針類型的值。為了方便,我們在方法內不使用 *stack(stack變量實際所指向的棧)這樣的語法,而是將其賦值給一個臨時變量(theStack),然後在代碼中使用該臨時變量。這樣做的性能開銷非常小,因為 *stack指向的是一個Stack值,該值使用一個切片來表示,因此這樣做的性能開銷僅僅比直接使用一個指向切片的引用稍微大一點。

如果棧為空,我們返回一個合適的錯誤值。否則,我們將該棧最頂端的值保存在一個臨時變量x中,然後對原始棧(本身是一個切片)做一次切片操作(新的切片只是少了一個元素),並將切片後的新棧賦值給 stack 指針所指向的原始棧。最後,我們返回彈出的值和一個 nil錯誤值。Go編譯器會重用這個切片,僅僅將其長度減1,並保持其容量不變,而非真地將所有數據拷到另一個新的切片中。

返回的元素通過使用索引操作符和一個索引來得到(標識1)。本例中,該元素索引就是切片最後一個元素的索引。

新的切片通過使用切片操作符和一個索引範圍來獲得(標識2)。索引範圍的形式是first:end。如果first值像這個示例中一樣被省略,則其默認值為0,而如果end值被省略,則其默認值為該切片的len值。新獲得的切片包含原切片中從第first個元素到第end個元素之間的所有元素,其中包含第first個元素而不包含第end個元素。因此,在本例中,通過將其最後一個元素設置為其原切片的長度減1,我們獲得了原切片中除最後一個元素外的所有元素組成的切片,快速有效地刪除了切片中的最後一個元素(切片索引將在第4章詳細闡述,參見4.2.1節)。

對於本例中那些無需修改 Stack的方法,我們將接收器的類型設置為 Stack 而非指針(即*Stack類型)。對於其底層表示較為輕量(比如只包含少量int類型和string類型的成員)的自定義類型來說,這是非常合理的。但是對於比較複雜的自定義類型,無論該方法是否需要修改值內容,我們最好一直都使用指針類型的接收器,因為傳遞一個指針的開銷遠比傳遞一個大塊的值低得多。

關於指針和方法,有個小細節需要注意的是,如果我們在某個值類型上調用其方法,而該方法所需要的又是一個指針參數,那麼Go語言會很智能地將該值的地址(假設該值是可尋址的,參見6.2.1節)傳遞給該方法,而非該值的一份副本。相應地,如果我們在某個值的指針上調用方法,而該方法所需要的是一個值,Go語言也會很智能地將該指針解引用,並將該指針所指的值傳遞給方法。[11]

正如本例所示,在 Go語言中創建自定義類型通常非常簡單明瞭,無需引入其他語言中的各種笨重的形式。Go語言的面向對像特性將在第6章中詳細闡述。

1.6 americanise示例——文件、映射和閉包

為了滿足實際需求,一門編程語言必須提供某些方式來讀寫外部數據。在前面的小節中,我們概覽了Go語言標準庫裡fmt包中強大的打印函數,本節中我們將介紹Go語言中基本的文件處理功能。接下來我們還會介紹一些更高級的Go語言特性,比如將函數或者方法當做第一類值(first-class value)來對待,這樣就可以將它們當做參數傳遞。另外,我們還將用到Go語言的映射(map,也稱為數據字典或者散列)類型。

本節盡可能詳盡地講述如何編寫一個文本文件讀寫程序,使得示例和相應的練習都更加生動有趣。第8章將會更詳盡地講述Go語言中的文件處理工具。

大約在20世紀中期,美式英語超越英式英語成為最廣泛使用的英語形式。本小節中的示例程序將讀取一個文本文件,將文本文件中的英式拼寫法替換成相應的美式拼寫法(當然,該程序對於語義分析和慣用語分析無能為力),然後將修改結果寫入到一個新的文本文件中。這個示例程序的源代碼位於americanise/americanise.go中。我們採用自上而下的方式來分析這段程序,先講解導入包,然後是main函數,再到main函數里面所調用的函數,等等。

import (

〞bufio〞

〞fmt〞

〞io〞

〞io/ioutil〞

〞log〞

〞os〞

〞path/filepath〞

〞regexp〞

〞strigns〞

)

該示例程序所引用的都是 Go 標準庫裡的包。每個包都可以有任意個子包,就如上面程序中所看到的io包中的ioutil包以及path包中的filepath包一樣。

bufio包提供了帶緩衝的I/O處理功能,包括從UTF-8編碼的文本文件中讀寫字符串的能力。io包提供了底層的I/O功能,其中包含了我們的americanise程序中所用到的io.Reader和io.Writer接口。io/ioutil包提供了一系列高級文件處理函數。regexp包則提供了強大的正則表達式支持。其他的包(fmt、log、filepath和strings)已在本書之前介紹過。

func main {

inFilename, outFilename, err := filenamesFromCommandLine1

if err != nil {

fmt.Println(err) 2

os.Exit(1)

}

inFile, outFile := os.Stdin, os.Stdout3

if inFilename != 〞〞 {

if inFile, err = os.Open(inFilename); err != nil {

log.Faal(err)

}

defer inFile.Close4

}

if outFilename != 〞〞 {

if outFile, err = os.Create(outFilename); err != nil {

log.Fatal(err)

}

defer outFile.Close5

}

if err = americanize(inFile, outFile); err != nil {

log.Fatal(err)

}

}

這個 main函數從命令行中獲取輸入和輸出的文件名,放到相應的變量中,然後將這些變量傳入americanise函數,由該函數做相應的處理。

該函數開始時取得所需輸入和輸出文件的文件名以及一個 error 值。如果命令行的解析有誤,我們將輸出相應的錯誤信息(其中包含程序的使用幫助),然後立即終止程序。如果某些類型包含Error string方法或者String string方法,Go語言的部分打印函數會使用反射功能來調用相應的函數獲取打印信息,否則 Go語言也會盡量獲取能獲取的信息並進行打印。如果我們為自定義類型提供這兩個方法中的一個,Go語言的打印函數將會打印該自定義類型的相應信息。我們將在第6章詳細闡述相關的做法。

如果err的值為nil,說明變量inFilename和outFilename中包含字符串(可能為空),程序繼續。Go語言中的文件類型表示為一個指向 os.File 值的指針,因此我們創建了兩個這樣的變量並將其初始化為標準輸入輸出流(這些流的類型都為*os.File)。正如你在以上程序中所看到的,Go語言的函數和方法支持多返回值,也支持多重賦值操作(標識1和3)。

本質上講,每一個文件名的處理方式都相同。如果文件名為空,則相應的文件句柄已經被設置成os.Stdin或者os.Stdout(它們的類型都為*os.File,即一個指向os.File類型值的指針),但如果文件名不為空,我們就創建一個新的*os.File指針來讀寫對應的文件。

os.Open函數接受一個文件名字符串,並返回一個 *os.File類型值,該值可以用來從文件中讀取數據。相應地,os.Create函數接受一個文件名字符串,返回一個 *os.File值,該值可以用來從文件中讀取數據或者將數據寫入文件。如果文件名所指向的文件不存在,我們會先創建該文件,若文件已經存在則會將文件的長度截為 0(Go語言也提供了os.OpenFile函數來打開文件,該函數可以讓使用者自由地控制文件的打開模式和權限)。

事實上os.Open、os.Create和os.OpenFile這幾個函數都有兩個返回值:如果文件打開成功,則返回*os.File和nil錯誤值;如果文件打開失敗,則返回一個nil文件句柄和相應非nil的error值。

返回的err值為nil意味著文件已被成功打開,我們在後面緊跟一個defer語句用於關閉文件。任何屬於defer語句所對應的語句(參見5.5節)都保證會被執行(因此需要在函數名後面加上括號),但是該函數只會在defer語句所在的函數返回時被調用。因此,defer語句先「記住」該函數,並不馬上執行。這也意味著defer 語句本身幾乎不用耗時,而執行語句的控制權馬上會交給defer語句的下一條語句。因此,被推遲執行的os.File.Close語句實際上不會馬上被執行,直到包含它的main函數返回(無論是正常返回還是程序崩潰,稍後我們會討論)。這樣,打開的文件就可以被繼續使用,並且保證會在我們使用完後自動關閉,即便是程序崩潰了。

如果我們打開文件失敗,則調用 log.Fatal函數並傳入相應的錯誤信息。正如我們在前文中所看的,這個函數會記錄日期、時間和相應的錯誤信息(除非指定了其他輸出目標,否則錯誤記錄會默認打印到os.Stderr),並調用os.Exit來終止程序。當os.Exit函數被直接調用或通過 log.Fatal間接調用時,程序會立即終止,任何延遲執行的語句都會被丟失。不過這不是個問題,因為 Go語言的運行時系統會將所有打開的文件關閉,其垃圾回收器會釋放程序的內存,而與該程序通信的任何設計良好的數據庫或者網絡應用都會檢測到程序的崩潰,從而從容地應對。正如bigdigits示例程序中那樣,我們不在第一個if語句(標識2)中使用log.Fatal,因為err中包含了程序的使用信息,而且我們不需要打印log.Fatal函數通常會輸出的日期和時間信息。

在Go語言中,panic是一個運行時錯誤(很像其他語言中的異常,因此本書將panic直接翻譯為「異常」)。我們可以使用內置的panic函數來觸發一個異常,還可以使用recover函數(參見5.5節)來在其調用棧上阻止該異常的傳播。理論上,Go語言的panic/recover功能可以用於多用途的錯誤處理機制,但我們並不推薦這麼用。更合理的錯誤處理方式是讓函數或者方法返回一個 error值作為其最後或者唯一的返回值(如果沒錯誤發生則返回nil值),並讓調用方來檢查所收到的錯誤值。panic/recover機制的目的是用來處理真正的異常(即不可預料的異常)而非常規錯誤。[12]

兩個文件都成功打開後(os.Stdin、os.Stdout和os.Stderr文件是由Go語言的運行時系統自動打開的),我們將要處理的文件傳給americanise函數,由該函數對文件進行處理。如果americanse函數返回nil值,main函數將正常終止,所有被延遲的語句(在這裡是指關閉inFile和outFile文件,如果它們不是os.Stdin和os.Stdout的話)都將被一一執行。如果err的值不是nil,則錯誤會被打印出來,程序退出,Go語言的運行時系統會自動將所有打開的文件關閉。

americanise函數的參數是io.Reader和io.Writer接口, 但我們傳入的是 *os.File,原因很簡單,因為 os.File 類型實現了 io.ReadWriter 結構(而 io.ReadWriter 是io.Reader和io.Writer 接口的組合),也就是說,os.File 類型的值可以用於任何要求io.Reader或者io.Writer接口的地方。這是一個典型的鴨子類型的實例,也就是任何類型只要實現了該接口所定義的方法,它的值都可以用於這個接口。如果americanise函數執行成功,則返回nil值,否則返回相應的error值。

func filenamesFromCommandLine (inFilename, outFilename string,

err error){

if len(os.Args) > 1 && (os.Args[1] == 〞-h〞 || os.Args[1] == 〞--help〞) {

err = fmt.Errorf(〞usage: %s [<]infile.txt [>]outfile.txt〞,

filepath.Base(os.Args[0]))

return 〞〞, 〞〞, err

}

if len(os.Args) > 1 {

inFilename = os.Args[1]

if len(os.Args) > 2 {

outFilename = os.Args[2]

}

}

if inFilename != 〞〞 && inFilename == outFilename {

log.Fatal(〞won't overwrite the infile〞)

}

return inFilename, outFilename, nil

}

filenamesFromCommandLine這個函數返回兩個字符串和一個錯誤值。與我們所看到的其他函數不同的是,這裡的返回值除了類型外還指定了名字。返回值在函數被執行時先被設置成空值(字符串被設置成空字符串,錯誤值err被設置成nil),直到函數體內有賦值語句為其賦值時返回值才改變。(下面討論americanise函數的時候,我們會更加深入這個主題。)

函數先判斷用戶是否需要打印幫助信息[13]。如果是,就用fmt.Errorf函數來創建一個新的error值,打印合適的用法,並立即返回。與普通的Go語言代碼一樣,這個函數也要求調用者檢查返回的error值,從而做出相應的處理。這也是main函數的做法。fmt.Errorf函數與我們之前所看的fmt.Printf函數類似,不同之處是它返回一個錯誤值,其中包含由給定的字符串格式和參數生成的字符串,而非將字符串輸出到os.Stdout中(errors.New函數使用一個給定的字符串來生成一個錯誤值)。

如果用戶不需要打印幫助信息,我們再檢查他是否輸入了命令行參數。如果用戶輸入了參數,我們將其輸入的第一個命令行參數存放到inFilename中,將第二個命令行參數存放到outFilename中。當然,用戶也可能沒有輸入命令行參數,這樣inFilename和outFilename變量都為空。或者他們也可能只傳入了一個參數,其中inFilename有文件名而outFilename為空。

最後,我們再做一些完整性檢查,以保證不會用輸出文件來覆蓋輸入文件,並在必要時退出。如果一切都如預期所料,則正常返回。[14]帶返回值的函數或方法中必須至少有一個return語句。正如在這個函數中所做的一樣,給返回值命名,是為了程序清晰,同時也可以用來生成godoc 文檔。在包含變量名和類型作為返回值的函數或者方法中,使用一個不帶返回值的return 語句來返回是合法的。在這種情況下,所有返回值變量的值都會被正常返回。本書中我們並不推薦使用不帶返回值的return語句,因為這是一種不好的Go語言編程風格。

Go語言使用一種非常一致的方式來讀寫數據。這讓我們可以用統一的方式從文件、內存緩衝(即字節或者字符串類型的切片)、標準輸入輸出或錯誤流讀寫數據,甚至也可以用統一的方式從我們的自定義類型讀寫數據,只要我們自定義的類型實現了相應的讀寫接口。

一個可讀的值必須滿足 io.Reader 接口。該接口只聲明了一個方法 Read(byte) (int, error)。Read方法從調用該方法的值中讀取數據,並將其放到一個字節類型的切片中。它返回成功讀到的字節數和一個錯誤值。如果沒有錯誤發生,則該錯誤值為nil。如果沒有錯誤發生但是已讀到文件末尾,則返回 io.EOF。如果錯誤發生,則返回一個非空的錯誤值。類似的,一個可寫的值必須滿足 io.Writer 接口。該接口也只聲明了一個方法Write(byte) (int, error)。該Write方法將字節類型的切片中的數據寫入到調用該方法的值中,然後返回其寫入的字節數和一個錯誤值(如果沒有錯誤發生則其值為nil)。

io包提供了讀寫模塊,但它們都是非緩衝的,並且只在原始的字節層面上操作。bufio包提供了帶緩衝的輸入輸出處理模塊,其中的輸入模塊可作用於任何滿足io.Reader接口的值(即實現了相應的Read方法),而輸出模塊則可作用於任何滿足 io.Writer接口的值(即實現了相應的Write方法)。bufio 包的讀寫模塊提供了針對字節或者字符串類型的緩衝機制,因此很適合用於讀寫UTF-8編碼的文本文件。

var britishAmerican = 〞british-american.txt〞

func americanise(inFile io.Reader, outFile io.Writer)(err error) {

reader := bufio.NewReader(inFile)

writer := bufio.NewWriter(outFile)

defer func {

if err == nil {

err = writer.Flush

}

}

var replacer func(string) string1

if replacer, err = makeReplacerFunc(britishAmerican); err != nil {

return err

}

wordRx := regexp.MustCompile(〞[A-Za-z]+〞)

eof := false

for !eof {

var line string 2

line, err = reader.ReadString('\n')

if err == io.EOF {

err = nil // 並不是一個真正的

eof = true // 在下一次迭代這會結束該循環

} else if err != nil {

return err // 對於真正的error,會立即結束

}

line = wordRx.ReplaceAllStringFunc(line, replacer)

if _, err = writer.WriteString(line); err != nil { 3

return err

}

}

return nil

}

americanise函數為inFile和outFile分別創建了一個reader和writer,然後從輸入文件中逐行讀取數據,然後將所有英式英語詞彙替換成等價的美式英語詞彙,並將處理結果逐行寫入到輸出文件中。

只需要往bufio.NewReader函數里傳入任何一個實現了io.Reader接口的值(即實現了Read方法),就能得到一個帶有緩衝的reader,bufio.NewWriter函數也類似。需要注意的是,americanise函數不知道也不用關心它從何處讀,寫向何處,比如 reader和writer可以是壓縮文件、網絡連接、字節切片,只要是任何實現io.Reader和io.Writer接口的值即可。這種處理接口的方式非常靈活,並且使得在Go語言編程中非常易於組合功能。

接下來我們創建一個匿名的延遲函數,它會在americanise函數返回並將控制權交給其調用者之前刷新writer的緩衝。這個匿名函數只會在americanise函數正常返回或者異常退出時才執行,由於刷新緩衝區操作也可能會失敗,所以我們將 writer.Flush函數的返回值賦值給err。如果想忽略任何在刷新操作之前或者在刷新操作過程中發生的任何錯誤,可以簡單地調用defer writer.Flush,但是這樣做的話程序對錯誤的防禦性將較低。

Go語言支持具名返回值,就像我們在之前的filenamesFromCommandLine函數中所做的,在這裡我們也充分利用了這個特性(err error)。此外,還有一點需要注意的是,在使用具名返回值時有一個作用域的細節。例如,如果已經存在一個名為value的返回值,我們可以在函數內的任一位置對該返回值進行賦值,但是如果我們在函數內部某個地方使用了if value :=...這樣的語句,因為if語句會創建一個新的塊,所以這個value是一個新的變量,它會隱藏掉名字同為value的返回值。在americanise函數中,err是一個具名返回值,因此我們必須保證不使用快速變量聲明符:=來為其賦值,以避免意外創建出一個影子變量。基於這樣的考慮,我們有時必須在賦值時先聲明一個變量,如這裡的replacer變量(標識1)和我們這裡讀入的line變量(標識2)。另一種可選的方式是顯式地返回所有返回值,就像我們在其他地方所做的那樣。

另外一點需要注意的是,我們在這裡使用了空標記符_(標識3)。這裡的空標記符作為一個佔位符放在需要一個變量的地方,並丟棄掉所有賦給它的值。空佔位符不是一個新的變量,因此如果我們使用:=,至少需要聲明一個其他的新變量。

Go的標準庫中包含一個強大的名為regexp的正則表達式包(參見3.6.5節)。這個包可以用來創建一個指向regexp.Regexp值的指針(即regexp.Regexp類型)。這些值提供了許多供查找和替換的方法。這裡我們使用 regexp.Regexp.ReplaceAllStringFunc方法。它接受一個字符串變量和一個簽名為func(string) string的replacer函數作為輸入,每發現一個匹配的值就調用一次 replacer 函數,並將該匹配到的文本內容替換為replacer函數返回的文本內容。

如果我們有一個非常小的replacer 函數,比如只是簡單地將匹配的字母轉換成大寫,我們可以在調用替換函數的時候將其創建為一個匿名函數。例如:

line = wordRx.ReplaceAllStringFunc(line,

func(word string) string {return strings.ToUpper(word)})

然而,americanise 程序的replacer 函數雖然也就是幾行代碼,但它也需要一些準備工作,因此我們創建了一個獨立函數makeReplacerFunction。該函數接受一個包含原始待替換文本的文件名以及用來替換的文字內容,返回一個replacer函數用來執行適當的替換工作。

如果makeReplacerFunction函數返回一個非nil的錯誤值,函數將直接返回。這種情況下調用者需檢查所返回的error內容並做出相應的處理(如上文所做的那樣)。

正則表達式可以使用 regexp.Compile函數來編譯。該函數執行成功將返回一個*regexp.Regexp值和nil,否則返回一個nil值和相應的error值。這個函數比較適合於正則表達式內容是從外部文件讀取或由用戶輸入的場景,因為需要做一些錯誤處理。但是這裡我們用的是regexp.MustCompile函數,它僅僅返回一個 *regexp.Regexp值,或者在正則表達式非法的情況下執行異常流程。示例中所使用的正則表達式盡可能長地匹配一個或者多個英文字母字符。

有了replacer函數和正則表達式後,我們開始創建一個無限循環語句,每次循環先從reader中讀取一行內容。bufio.Reader.ReadString方法將底層reader讀取過來的原始字節碼按UTF-8編碼文本的方式讀取(嚴格地講應該是解碼成UTF-8,對於7位的ASCII編碼也有效),它最多只能讀取指定長度的字節(也可能已讀到文件末尾)。該函數將讀取的文本內容以方便使用的string類型返回,同時返回一個error值(不出錯誤的話為nil)。

如果調用 bufio.Reader.ReadString返回的err 值非空,可能是讀到文件末尾或是讀取數據過程中遇到了問題。如果是前者,那麼err的值應該是io.EOF,這是正常的,我們不應該將它作為一個真正的錯誤來處理,所以這種情況下我們將err重新設置為nil,並將eof設置為true以退出循環體。遇到io.EOF錯誤的時候,我們並不立即返回,因為文件的最後一行可能並不是以換行符結尾,在這種情況下我們還需要處理這最後一行文本。

每讀到一行,就調用 regexp.Regexp.ReplaceAllStringFunc方法來處理,並傳入這行讀取到的文本和對應的replacer函數。然後我們調用bufio.Writer.WriteString方法將處理的結果文本行(可能已經被修改)寫入到writer中。這個bufio.Writer.WriteString函數接受一個string類型的輸入,並以UTF-8編碼的字節流寫出到相應目的地,返回成功寫出的字節數和一個error類型值(如果沒有發生問題,這個error類型值將為nil)。這裡我們並不關心寫入了多少字節,所以用_把第一返回值忽略掉。如果 err 為非空,那麼函數將立即返回,調用者會馬上接收到相應的錯誤信息。

正如我們程序中的用法,用bufio來創建reader和writer可以很容易地應用一些字符串處理的高級技巧,完全不用關心原始數據在磁盤上是怎麼組織存儲的。當然,別忘了我們前面延遲了一個匿名函數,如果沒有錯誤發生所有被緩衝的字節數據都會在americanise函數返回時被寫入到writer裡。

func makeReplacerFunction(file string) (func(string) string, error) {

rawBytes, err := ioutil.ReadFile(file)

if err != nil {

return nil, err

}

text := string(rawBytes)

usForBritish := make(map[string]string)

lines := strings.Split(text, 〞\n〞)

for _, line := range lines {

fields := strings.Fields(line)

if len(fields) == 2 {

usForBritish[fields[0]] = fields[1]

}

}

return func(word string) string{

if usWord, found := usForBritish[word]; found {

return usWord

}

return word

}, nil

}

makeReplacerFunction函數接受包含原始字符串和替換字符串文件的文件名作為輸入,並返回一個替換函數和一個錯誤值,這個被返回的替換函數接受一個原始字符串,返回一個被替換的字符串。該函數假設輸入的文件是以UTF-8編碼的文本文件,其中的每一行使用空格將原始和要替換的單詞分隔開來。

除了bufio包的reader和writer之外,Go的io/ioutil包也提供了一些使用方便的高級函數,比如我們這裡用的ioutil.ReadFile。這個函數將一個文件的內容以byte值的方式返回,同時返回一個error類型的錯誤值。如果讀取出錯,返回nil和相應的錯誤,否則,就將它轉換成字符串。將UTF-8編碼的字節轉換成一個字符串是一個非常廉價的操作,因為Go語言中字符串類型的內部表示統一是UTF-8編碼的(Go語言的字符串轉換內容將在第3章詳細闡述)。

由於我們創建的replacer 函數參數和返回值都是一個字符串,所以我們需要的是一種合適的查找表。Go語言的內置集合類型map就非常適合這種情況(參見4.3節)。用map來保存鍵值對,查找速度是很快的,比如我們這裡將英式單詞作為鍵,美式單詞作為相應的值。

Go語言中的映射、切片和通道都必須通過make函數來創建,並返回一個指向特定類型的值的引用。該引用可以用於傳遞(如傳入到其他函數),並且在被引用的值上做的任何改變對於任何訪問該值的代碼而言都是可見的。在這裡我們創建了一個名為 usForBritish的空映射,它的鍵和值都是字符串類型。

在映射創建完成後,我們調用strings.Split函數將文件的內容(就是一個字符串)使用分隔符「\n」切分為若干個文本行。這個函數的輸入參數為一個字符串和一個分隔符,會對輸入的字符串進行盡可能多次數的切分(如果我們想限制切分的次數,可以使用strings.SplitN函數)。

我們使用一個之前沒有接觸過的for 循環語法來遍歷每一行,這一次我們使用的是一個range語句。這種語法用來遍歷映射中的鍵值對非常方便,可用於讀取通道的元素,另外也可用於遍歷切片或者數組。當我們使用切片(或數組)時,每次迭代返回的是切片的索引和在該索引上的元素值,其索引從0開始(如果該切片為非空的話)。在本例中,我們使用循環來迭代每一行,但由於我們並不關心每一行的索引,所以用了一個_佔位符把它忽略掉。

我們需要將每行切分成兩部分: 原始字符串和替換的字符串。 我們可以使用strings.Split函數,但它要求聲明一個確定的分隔符,如〞 〞,這在某些手動分隔的文件中可能失敗,因為用戶可能意外地輸入多個空格或者使用製表符來代替空格。幸虧 Go語言標準庫提供了另一個strings.Fields函數以空白分隔符來分隔字符串,因此能更恰當地處理用戶手動編輯的文本。

如果變量 fields(其類型為string)恰好有兩個元素,我們將對應的「鍵值「對插入映射中。一旦該映射的內容準備好,我們就可以開始創建用來返回給調用者的replacer函數。

我們將 replacer 函數創建為匿名函數,並將其當做一個參數來讓 return 語句返回,該return語句同時返回一個空的錯誤值(當然,我們本來可以更繁瑣點,將該匿名函數賦值給一個變量,並將該變量返回)。這個匿名函數的簽名與regexp.Regexp.ReplaceAllStringFun方法所期望傳入的函數簽名必須完全一致。

我們在匿名函數replacer裡所做的只是查找一個給定的單詞。如果我們在左邊通過一個變量來獲取一個映射的元素,該元素將被賦值給對應的變量。如果映射中對應的鍵不存在,那麼所獲取的值為該類型的空值。如果該映射值類型的空值本身也是一個合法的值,那我們還能如何判斷一個給定的值是否在映射中呢?Go語言為此提供了一種語法,即賦值語句的左邊同時為兩個變量賦值,第一個變量用來接收該值,第二個變量用來接收一個布爾值,表示該鍵在映射中是否找到。如果我們只是想知道某個特定的值是否在映射中,該方法通常有效。本例中我們在 if 語句中使用第二種形式,其中有一個簡單的語句(一個簡短的變量聲明)和一個條件(那個布爾變量found)。因此,我們得到usWord變量(如果所給出的單詞不在映射中,該變量的值為空字符串)和一個布爾類型的found標誌。如果英式英語的單詞找到了,我們返回相應的美式英語單詞;否則,我們簡單地將原始單詞原封不動地返回。

我們從makeReplacerFunction函數中還可以發現一個有些微妙的地方。在匿名函數內部我們訪問了在匿名函數的外層創建的usForBritish 變量(是一個映射)。之所以可以這麼做,是因為Go支持閉包(參見5.6.3節)。閉包是一個能夠「捕獲」一些外部狀態的函數,例如可以捕獲創建該函數的函數的某些狀態,或者閉包所捕獲的該狀態的任意一部分。因此在這裡,在函數makeReplacerFunction內部創建的匿名函數是一個閉包,它捕獲了usForBritish變量。

還有一個微妙的地方就是,usForBritish本應該是一個本地變量,然而我們卻可以在它被聲明的函數之外使用它。在 Go語言中完全可以返回本地變量。即使是引用或者指針,如果還在被使用,Go語言並不會刪除它們,只有在它們不再被使用時(也就是當任何保存、引用或者指向它們的變量超出作用域範圍時)才用垃圾回收機制將它們回收。

本節給出了一些利用os.Open、os.Create和ioutil.ReadFile函數來處理文件的基礎和高級功能。在第8章中我們將介紹更多的文件處理相關內容,包括讀寫文本文件、二進制文件、JSON文件和XML文件。Go語言的內置集合類型如切片和映射提供了非常良好的性能和極大的便利性,幫助開發者大大降低了創建自定義類型的需求。我們將在第4章詳細闡述Go語言的集合類型。Go語言將函數當做一類值來對待並支持閉包,使得開發者在寫程序時可以使用一些高級而非常有用的編程技巧。同時,Go語言的defer語句能非常直接簡單明瞭地避免資源洩露。

1.7 從極坐標到笛卡兒坐標——並發

Go語言的一個關鍵特性在於其充分利用現代計算機的多處理器和多核的功能,且無需給程序員帶來太大負擔。完全無需任何顯式鎖就可寫出許多並發程序(雖然 Go語言也提供了鎖原語以便在底層代碼需要用到時使用,我們將在第7章中詳細闡述)。

Go語言有兩個特性使得用它來做並發編程非常輕鬆。第一,無需繼承什麼「線程」(thread)類(這在Go語言中其實也不可能)即可輕易地創建goroutine(實際上是非常輕量級的線程或者協程)。第二,通道(channel)為goroutine之間提供了類型安全的單向或者雙向通信,這也可以用來同步goroutine。

Go語言處理並發的方式是傳遞數據,而非共享數據。這使得與使用傳統的線程和鎖方式相比,用Go語言來編寫並發程序更為簡單。由於沒有使用共享數據,我們不會進入競態條件(例如死鎖),我們也不必記住何時該加鎖和解鎖,因為沒有共享的數據需要保護。