《Go語言聖經》學習筆記 第七章 接口

《Go語言聖經》學習筆記 第七章 接口


目錄

  1. 接口是合約
  2. 接口類型
  3. 實現接口的條件
  4. flag.Value接口
  5. 接口值
  6. sort.Interface接口
  7. http.Handle接口
  8. error接口
  9. 示例:表達式求值
  10. 類型斷言
  11. 基於類型斷言識別錯誤類型
  12. 通過類型斷言查詢接口
  13. 類型分支
  14. 示例:基於標記的XML解碼
  15. 補充幾點

注:學習《Go語言聖經》筆記,PDF點擊下載,建議看書。
Go語言小白學習筆記,書上的內容照搬,大佬看了勿噴,以後熟悉了會總結成自己的讀書筆記。


  1. 接口類型是對其它類型行爲的抽象和概括; 因爲接口類型不會和特定的實現細節綁定在一起, 通過這種抽象的方式我們可以讓我們的函數更加靈活和更具有適應能力。
  2. 很多面向對象的語言都有相似的接口概念, 但Go語言中接口類型的獨特之處在於它是滿足隱式實現的。 也就是說, 我們沒有必要對於給定的具體類型定義所有滿足的接口類型; 簡單地擁有一些必需的方法就足夠了。 這種設計可以讓你創建一個新的接口類型滿足已經存在的具體類型卻不會去改變這些類型的定義; 當我們使用的類型來自於不受我們控制的包時這種設計尤其有用。
  3. 在本章, 我們會開始看到接口類型和值的一些基本技巧。 順着這種方式我們將學習幾個來自標準庫的重要接口。 很多Go程序中都儘可能多的去使用標準庫中的接口。 最後,我們會在(§7.10)看到類型斷言的知識, 在(§7.13)看到類型開關的使用並且學到他們是怎樣讓不同的類型的概括成爲可能。

1. 接口是合約

  1. 目前爲止, 我們看到的類型都是具體的類型。 一個具體的類型可以準確的描述它所代表的值並且展示出對類型本身的一些操作方式就像數字類型的算術操作, 切片類型的索引、 附加和取範圍操作。 具體的類型還可以通過它的方法提供額外的行爲操作。 總的來說, 當你拿到一個具體的類型時你就知道它的本身是什麼和你可以用它來做什麼。
  2. 在Go語言中還存在着另外一種類型: 接口類型。 接口類型是一種抽象的類型。 它不會暴露出它所代表的對象的內部值的結構和這個對象支持的基礎操作的集合; 它們只會展示出它們自己的方法。 也就是說當你有看到一個接口類型的值時, 你不知道它是什麼, 唯一知道的就是可以通過它的方法來做什麼。
  3. 在本書中, 我們一直使用兩個相似的函數來進行字符串的格式化: fmt.Printf它會把結果寫到標準輸出和fmt.Sprintf它會把結果以字符串的形式返回。 得益於使用接口, 我們不必可悲的因爲返回結果在使用方式上的一些淺顯不同就必需把格式化這個最困難的過程複製一份。 實際上, 這兩個函數都使用了另一個函數fmt.Fprintf來進行封裝。 fmt.Fprintf這個函數對它的計算結果會被怎麼使用是完全不知道的。
    在這裏插入圖片描述
  4. Fprintf的前綴F表示文件(File)也表明格式化輸出結果應該被寫入第一個參數提供的文件中。 在Printf函數中的第一個參數os.Stdout是*os.File類型; 在Sprintf函數中的第一個參數&buf是一個指向可以寫入字節的內存緩衝區, 然而它 並不是一個文件類型儘管它在某種意義上和文件類型相似。
  5. 即使Fprintf函數中的第一個參數也不是一個文件類型。 它是io.Writer類型這是一個接口類型定義如下:
    在這裏插入圖片描述
  6. io.Writer類型定義了函數Fprintf和這個函數調用者之間的約定。 一方面這個約定需要調用者提供具體類型的值就像os.File和bytes.Buffer, 這些類型都有一個特定簽名和行爲的Write的函數。 另一方面這個約定保證了Fprintf接受任何滿足io.Writer接口的值都可以工作。 Fprintf函數可能沒有假定寫入的是一個文件或是一段內存, 而是寫入一個可以調用Write函數的值。
  7. 因爲fmt.Fprintf函數沒有對具體操作的值做任何假設而是僅僅通過io.Writer接口的約定來保證行爲, 所以第一個參數可以安全地傳入一個任何具體類型的值只需要滿足io.Writer接口。 一個類型可以自由的使用另一個滿足相同接口的類型來進行替換被稱作可替換性(LSP里氏替換)。這是一個面向對象的特徵。
  8. 讓我們通過一個新的類型來進行校驗, 下面ByteCounter類型裏的Write方法, 僅僅在丟失寫向它的字節前統計它們的長度。 (在這個+=賦值語句中, 讓len§的類型和c的類型匹配的轉換是必須的。 )
  9. gopl.io/ch7/bytecounter
    在這裏插入圖片描述
  10. 因爲*ByteCounter滿足io.Writer的約定, 我們可以把它傳入Fprintf函數中; Fprintf函數執行字符串格式化的過程不會去關注ByteCounter正確的累加結果的長度。
    在這裏插入圖片描述
  11. 除了io.Writer這個接口類型, 還有另一個對fmt包很重要的接口類型。 Fprintf和Fprintln函數向類型提供了一種控制它們值輸出的途徑。 在2.5節中, 我們爲Celsius類型提供了一個String方法以便於可以打印成這樣"100°C" , 在6.5節中我們給*IntSet添加一個String方法, 這樣集合可以用傳統的符號來進行表示就像"{1 2 3}"。 給一個類型定義String方法, 可以讓它滿足最廣泛使用之一的接口類型fmt.Stringer:
    在這裏插入圖片描述
  12. 我們會在7.10節解釋fmt包怎麼發現哪些值是滿足這個接口類型的。

2. 接口類型

  1. 接口類型具體描述了一系列方法的集合, 一個實現了這些方法的具體類型是這個接口類型的實例。
  2. io.Writer類型是用的最廣泛的接口之一, 因爲它提供了所有的類型寫入bytes的抽象, 包括文件類型, 內存緩衝區, 網絡鏈接, HTTP客戶端, 壓縮工具, 哈希等等。 io包中定義了很多其它有用的接口類型。 Reader可以代表任意可以讀取bytes的類型, Closer可以是任意可以關閉的值, 例如一個文件或是網絡鏈接。 ( 到現在你可能注意到了很多Go語言中單方法接口的命名習慣)
    在這裏插入圖片描述
  3. 在往下看, 我們發現有些新的接口類型通過組合已經有的接口來定義。 下面是兩個例子:
    在這裏插入圖片描述
  4. 上面用到的語法和結構內嵌相似, 我們可以用這種方式以一個簡寫命名另一個接口, 而不用聲明它所有的方法。 這種方式本稱爲接口內嵌。 儘管略失簡潔, 我們可以像下面這樣, 不使用內嵌來聲明io.Writer接口。
    5.
  5. 或者甚至使用種混合的風格:
    在這裏插入圖片描述
  6. 上面3種定義方式都是一樣的效果。 方法的順序變化也沒有影響, 唯一重要的就是這個集合裏面的方法。

3. 實現接口的條件

  1. 一個類型如果擁有一個接口需要的所有方法, 那麼這個類型就實現了這個接口。 例如,*os.File類型實現了io.Reader, Writer, Closer, 和ReadWriter接口。 *bytes.Buffer實現了Reader, Writer, 和ReadWriter這些接口, 但是它沒有實現Closer接口因爲它不具有Close方法。 Go的程序員經常會簡要的把一個具體的類型描述成一個特定的接口類型。 舉個例子,*bytes.Buffer是io.Writer; *os.Files是io.ReadWriter。
  2. 接口指定的規則非常簡單: 表達一個類型屬於某個接口只要這個類型實現這個接口。 所以:
    在這裏插入圖片描述
  3. 這個規則甚至適用於等式右邊本身也是一個接口類型
    在這裏插入圖片描述
  4. 因爲ReadWriter和ReadWriteCloser包含所有Writer的方法, 所以任何實現了ReadWriter和ReadWriteCloser的類型必定也實現了Writer接口
  5. 在進一步學習前, 必須先解釋表示一個類型持有一個方法當中的細節。 回想在6.2章中, 對於每一個命名過的具體類型T; 它一些方法的接收者是類型T本身然而另一些則是一個T的指針。還記得在T類型的參數上調用一個T的方法是合法的, 只要這個參數是一個變量; 編譯器隱式的獲取了它的地址。 但這僅僅是一個語法糖: T類型的值不擁有所有*T指針的方法, 那這樣它就可能只實現更少的接口。
  6. 舉個例子可能會更清晰一點。 在第6.5章中, IntSet類型的String方法的接收者是一個指針類型, 所以我們不能在一個不能尋址的IntSet值上調用這個方法:
    在這裏插入圖片描述
  7. 但是我們可以在一個IntSet值上調用這個方法:
    在這裏插入圖片描述
  8. 然而, 由於只有IntSet類型有String方法, 所有也只有IntSet類型實現了fmt.Stringer接口:
    在這裏插入圖片描述
  9. 12.8章包含了一個打印出任意值的所有方法的程序, 然後可以使用godoc -analysis=typetool(§10.7.4)展示每個類型的方法和具體類型和接口之間的關係
  10. 就像信封封裝和隱藏信件起來一樣, 接口類型封裝和隱藏具體類型和它的值。 即使具體類型有其它的方法也只有接口類型暴露出來的方法會被調用到:
    11.
  11. 一個有更多方法的接口類型, 比如io.ReadWriter, 和少一些方法的接口類型,例如io.Reader,進行對比; 更多方法的接口類型會告訴我們更多關於它的值持有的信息, 並且對實現它的類型要求更加嚴格。 那麼關於interface{}類型, 它沒有任何方法, 請講出哪些具體的類型實現了它?
  12. 這看上去好像沒有用, 但實際上interface{}被稱爲空接口類型是不可或缺的。 因爲空接口類型對實現它的類型沒有要求, 所以我們可以將任意一個值賦給空接口類型。
    在這裏插入圖片描述
  13. 儘管不是很明顯, 從本書最早的的例子中我們就已經在使用空接口類型。 它允許像fmt.Println或者5.7章中的errorf函數接受任何類型的參數。
  14. 對於創建的一個interface{}值持有一個boolean, float, string, map, pointer, 或者任意其它的類型; 我們當然不能直接對它持有的值做操作, 因爲interface{}沒有任何方法。 我們會在7.10章中學到一種用類型斷言來獲取interface{}中值的方法。
  15. 因爲接口實現只依賴於判斷的兩個類型的方法, 所以沒有必要定義一個具體類型和它實現的接口之間的關係。 也就是說, 嘗試文檔化和斷言這種關係幾乎沒有用, 所以並沒有通過程序強制定義。 下面的定義在編譯期斷言一個*bytes.Buffer的值實現了io.Writer接口類型:
    在這裏插入圖片描述
  16. 因爲任意bytes.Buffer的值, 甚至包括nil通過(bytes.Buffer)(nil)進行顯示的轉換都實現了這個接口, 所以我們不必分配一個新的變量。 並且因爲我們絕不會引用變量w, 我們可以使用空標識符來來進行代替。 總的看, 這些變化可以讓我們得到一個更樸素的版本:
    在這裏插入圖片描述
  17. 非空的接口類型比如io.Writer經常被指針類型實現, 尤其當一個或多個接口方法像Write方法那樣隱式的給接收者帶來變化的時候。 一個結構體的指針是非常常見的承載方法的類型。
  18. 但是並不意味着只有指針類型滿足接口類型, 甚至連一些有設置方法的接口類型也可能會被Go語言中其它的引用類型實現。 我們已經看過slice類型的方法(geometry.Path, §6.1)和map類型的方法(url.Values, §6.2.1), 後面還會看到函數類型的方法的例子(http.HandlerFunc,§7.7)。 甚至基本的類型也可能會實現一些接口; 就如我們在7.4章中看到的time.Duration類型實現了fmt.Stringer接口。
  19. 一個具體的類型可能實現了很多不相關的接口。 考慮在一個組織出售數字文化產品比如音樂, 電影和書籍的程序中可能定義了下列的具體類型:
    在這裏插入圖片描述
  20. 我們可以把每個抽象的特點用接口來表示。 一些特性對於所有的這些文化產品都是共通的,例如標題, 創作日期和作者列表。
    在這裏插入圖片描述
  21. 其它的一些特性只對特定類型的文化產品纔有。 和文字排版特性相關的只有books和magazines, 還有隻有movies和TV劇集和屏幕分辨率相關。
    在這裏插入圖片描述
  22. 這些接口不止是一種有用的方式來分組相關的具體類型和表示他們之間的共同特定。 我們後面可能會發現其它的分組。 舉例, 如果我們發現我們需要以同樣的方式處理Audio和Video,我們可以定義一個Streamer接口來代表它們之間相同的部分而不必對已經存在的類型做改變。
    在這裏插入圖片描述
  23. 每一個具體類型的組基於它們相同的行爲可以表示成一個接口類型。 不像基於類的語言, 他們一個類實現的接口集合需要進行顯式的定義, 在Go語言中我們可以在需要的時候定義一個新的抽象或者特定特點的組, 而不需要修改具體類型的定義。 當具體的類型來自不同的作者時這種方式會特別有用。 當然也確實沒有必要在具體的類型中指出這些共性。

4. flag.Value接口

  1. 在本章, 我們會學到另一個標準的接口類型flag.Value是怎麼幫助命令行標記定義新的符號的。 思考下面這個會休眠特定時間的程序:
  2. gopl.io/ch7/sleep
    在這裏插入圖片描述
  3. 在它休眠前它會打印出休眠的時間週期。 fmt包調用time.Duration的String方法打印這個時間週期是以用戶友好的註解方式, 而不是一個納秒數字:
    在這裏插入圖片描述
  4. 默認情況下, 休眠週期是一秒, 但是可以通過 -period 這個命令行標記來控制。 flag.Duration函數創建一個time.Duration類型的標記變量並且允許用戶通過多種用戶友好的方式來設置這個變量的大小, 這種方式還包括和String方法相同的符號排版形式。 這種對稱設計使得用戶交互良好。
    在這裏插入圖片描述
  5. 因爲時間週期標記值非常的有用, 所以這個特性被構建到了flag包中; 但是我們爲我們自己的數據類型定義新的標記符號是簡單容易的。 我們只需要定義一個實現flag.Value接口的類型,如下:
    在這裏插入圖片描述
  6. String方法格式化標記的值用在命令行幫組消息中; 這樣每一個flag.Value也是一個fmt.Stringer。 Set方法解析它的字符串參數並且更新標記變量的值。 實際上, Set方法和String是兩個相反的操作, 所以最好的辦法就是對他們使用相同的註解方式。
  7. 讓我們定義一個允許通過攝氏度或者華氏溫度變換的形式指定溫度的celsiusFlag類型。 注意celsiusFlag內嵌了一個Celsius類型(§2.5), 因此不用實現本身就已經有String方法了。 爲了實現flag.Value, 我們只需要定義Set方法:
  8. gopl.io/ch7/tempconv
    在這裏插入圖片描述
  9. 調用fmt.Sscanf函數從輸入s中解析一個浮點數( value) 和一個字符串( unit) 。 雖然通常必須檢查Sscanf的錯誤返回, 但是在這個例子中我們不需要因爲如果有錯誤發生, 就沒有switchcase會匹配到。
  10. 下面的CelsiusFlag函數將所有邏輯都封裝在一起。 它返回一個內嵌在celsiusFlag變量f中的Celsius指針給調用者。 Celsius字段是一個會通過Set方法在標記處理的過程中更新的變量。調用Var方法將標記加入應用的命令行標記集合中, 有異常複雜命令行接口的全局變量flag.CommandLine.Programs可能有幾個這個類型的變量。 調用Var方法將一個celsiusFlag參數賦值給一個flag.Value參數,導致編譯器去檢查celsiusFlag是否有必須的方法。
    在這裏插入圖片描述
  11. 現在我們可以開始在我們的程序中使用新的標記:
  12. gopl.io/ch7/tempflag
    在這裏插入圖片描述
  13. 下面是典型的場景:
    在這裏插入圖片描述

5. 接口值

  1. 概念上講一個接口的值, 接口值, 由兩個部分組成, 一個具體的類型和那個類型的值。 它們被稱爲接口的動態類型和動態值。 對於像Go語言這種靜態類型的語言, 類型是編譯期的概念; 因此一個類型不是一個值。 在我們的概念模型中, 一些提供每個類型信息的值被稱爲類型描述符, 比如類型的名稱和方法。 在一個接口值中, 類型部分代表與之相關類型的描述符。
  2. 下面4個語句中, 變量w得到了3個不同的值。 ( 開始和最後的值是相同的)
    在這裏插入圖片描述
  3. 讓我們進一步觀察在每一個語句後的w變量的值和動態行爲。 第一個語句定義了變量w:
    在這裏插入圖片描述
  4. 在Go語言中, 變量總是被一個定義明確的值初始化, 即使接口類型也不例外。 對於一個接口的零值就是它的類型和值的部分都是nil( 圖7.1) 。
    在這裏插入圖片描述
  5. 一個接口值基於它的動態類型被描述爲空或非空, 所以這是一個空的接口值。 你可以通過使用w==nil或者w!=nil來判讀接口值是否爲空。 調用一個空接口值上的任意方法都會產生panic:
    在這裏插入圖片描述
  6. 第二個語句將一個*os.File類型的值賦給變量w:
    在這裏插入圖片描述
  7. 這個賦值過程調用了一個具體類型到接口類型的隱式轉換, 這和顯式的使用io.Writer(os.Stdout)是等價的。 這類轉換不管是顯式的還是隱式的, 都會刻畫出操作到的類型和值。 這個接口值的動態類型被設爲*os.Stdout指針的類型描述符, 它的動態值持有os.Stdout的拷貝; 這是一個代表處理標準輸出的os.File類型變量的指針( 圖7.2) 。
    在這裏插入圖片描述
  8. 調用一個包含*os.File類型指針的接口值的Write方法, 使得(*os.File).Write方法被調用。 這個調用輸出“hello”。
    在這裏插入圖片描述
  9. 通常在編譯期, 我們不知道接口值的動態類型是什麼, 所以一個接口上的調用必須使用動態分配。 因爲不是直接進行調用, 所以編譯器必須把代碼生成在類型描述符的方法Write上, 然後間接調用那個地址。 這個調用的接收者是一個接口動態值的拷貝, os.Stdout。 效果和下面這個直接調用一樣:
    在這裏插入圖片描述
  10. 第三個語句給接口值賦了一個*bytes.Buffer類型的值
    在這裏插入圖片描述
  11. 現在動態類型是*bytes.Buffer並且動態值是一個指向新分配的緩衝區的指針( 圖7.3) 。
    在這裏插入圖片描述
  12. Write方法的調用也使用了和之前一樣的機制:
    在這裏插入圖片描述
  13. 這次類型描述符是*bytes.Buffer, 所以調用了(*bytes.Buffer).Write方法, 並且接收者是該緩衝區的地址。 這個調用把字符串“hello”添加到緩衝區中。
  14. 最後, 第四個語句將nil賦給了接口值:
    在這裏插入圖片描述
  15. 這個重置將它所有的部分都設爲nil值, 把變量w恢復到和它之前定義時相同的狀態圖, 在圖7.1中可以看到。
  16. 一個接口值可以持有任意大的動態值。 例如, 表示時間實例的time.Time類型, 這個類型有幾個對外不公開的字段。 我們從它上面創建一個接口值,
    在這裏插入圖片描述
  17. 結果可能和圖7.4相似。 從概念上講, 不論接口值多大, 動態值總是可以容下它。 ( 這只是一個概念上的模型; 具體的實現可能會非常不同)
    在這裏插入圖片描述
  18. 接口值可以使用==和!=來進行比較。 兩個接口值相等僅當它們都是nil值或者它們的動態類型相同並且動態值也根據這個動態類型的==操作相等。 因爲接口值是可比較的, 所以它們可以用在map的鍵或者作爲switch語句的操作數。
  19. 然而, 如果兩個接口值的動態類型相同, 但是這個動態類型是不可比較的( 比如切片) , 將它們進行比較就會失敗並且panic:
    在這裏插入圖片描述
  20. 考慮到這點, 接口類型是非常與衆不同的。 其它類型要麼是安全的可比較類型( 如基本類型和指針) 要麼是完全不可比較的類型( 如切片, 映射類型, 和函數) , 但是在比較接口值或者包含了接口值的聚合類型時, 我們必須要意識到潛在的panic。 同樣的風險也存在於使用接口作爲map的鍵或者switch的操作數。 只能比較你非常確定它們的動態值是可比較類型的接口值。
  21. 當我們處理錯誤或者調試的過程中, 得知接口值的動態類型是非常有幫助的。 所以我們使用fmt包的%T動作:
    在這裏插入圖片描述
  22. 在fmt包內部, 使用反射來獲取接口動態類型的名稱。 我們會在第12章中學到反射相關的知識。

1. 警告: 一個包含nil指針的接口不是nil接口

  1. 一個不包含任何值的nil接口值和一個剛好包含nil指針的接口值是不同的。 這個細微區別產生了一個容易絆倒每個Go程序員的陷阱。
  2. 思考下面的程序。 當debug變量設置爲true時, main函數會將f函數的輸出收集到一個bytes.Buffer類型中。
    在這裏插入圖片描述
  3. 我們可能會預計當把變量debug設置爲false時可以禁止對輸出的收集, 但是實際上在out.Write方法調用時程序發生了panic:
    在這裏插入圖片描述
  4. 當main函數調用函數f時, 它給f函數的out參數賦了一個bytes.Buffer的空指針, 所以out的動態值是nil。 然而, 它的動態類型是bytes.Buffer, 意思就是out變量是一個包含空指針值的非空接口( 如圖7.5) , 所以防禦性檢查out!=nil的結果依然是true。
    在這裏插入圖片描述
  5. 動態分配機制依然決定(bytes.Buffer).Write的方法會被調用, 但是這次的接收者的值是nil。對於一些如os.File的類型, nil是一個有效的接收者(§6.2.1), 但是*bytes.Buffer類型不在這些類型中。 這個方法會被調用, 但是當它嘗試去獲取緩衝區時會發生panic。
  6. 問題在於儘管一個nil的*bytes.Buffer指針有實現這個接口的方法, 它也不滿足這個接口具體的行爲上的要求。 特別是這個調用違反了(*bytes.Buffer).Write方法的接收者非空的隱含先覺條件, 所以將nil指針賦給這個接口是錯誤的。 解決方案就是將main函數中的變量buf的類型改爲io.Writer, 因此可以避免一開始就將一個不完全的值賦值給這個接口:
    在這裏插入圖片描述
  7. 現在我們已經把接口值的技巧都講完了, 讓我們來看更多的一些在Go標準庫中的重要接口類型。 在下面的三章中, 我們會看到接口類型是怎樣用在排序, web服務, 錯誤處理中的。

6. sort.Interface接口

  1. 排序操作和字符串格式化一樣是很多程序經常使用的操作。 儘管一個最短的快排程序只要15行就可以搞定, 但是一個健壯的實現需要更多的代碼, 並且我們不希望每次我們需要的時候都重寫或者拷貝這些代碼。
  2. 幸運的是, sort包內置的提供了根據一些排序函數來對任何序列排序的功能。 它的設計非常獨到。 在很多語言中, 排序算法都是和序列數據類型關聯, 同時排序函數和具體類型元素關聯。 相比之下, Go語言的sort.Sort函數不會對具體的序列和它的元素做任何假設。 相反, 它使用了一個接口類型sort.Interface來指定通用的排序算法和可能被排序到的序列類型之間的約定。 這個接口的實現由序列的具體表示和它希望排序的元素決定, 序列的表示經常是一個切片。
  3. 一個內置的排序算法需要知道三個東西: 序列的長度, 表示兩個元素比較的結果, 一種交換兩個元素的方式; 這就是sort.Interface的三個方法:
    在這裏插入圖片描述
  4. 爲了對序列進行排序, 我們需要定義一個實現了這三個方法的類型, 然後對這個類型的一個實例應用sort.Sort函數。 思考對一個字符串切片進行排序, 這可能是最簡單的例子了。 下面是這個新的類型StringSlice和它的Len, Less和Swap方法
    5.
  5. 現在我們可以通過像下面這樣將一個切片轉換爲一個StringSlice類型來進行排序:
    在這裏插入圖片描述
  6. 這個轉換得到一個相同長度, 容量, 和基於names數組的切片值; 並且這個切片值的類型有三個排序需要的方法。
  7. 對字符串切片的排序是很常用的需要, 所以sort包提供了StringSlice類型, 也提供了Strings函數能讓上面這些調用簡化成sort.Strings(names)。
  8. 這裏用到的技術很容易適用到其它排序序列中, 例如我們可以忽略大些或者含有特殊的字符。 ( 本書使用Go程序對索引詞和頁碼進行排序也用到了這個技術, 對羅馬數字做了額外邏輯處理。 ) 對於更復雜的排序, 我們使用相同的方法, 但是會用更復雜的數據結構和更復雜地實現sort.Interface的方法。
  9. 我們會運行上面的例子來對一個表格中的音樂播放列表進行排序。 每個track都是單獨的一行, 每一列都是這個track的屬性像藝術家, 標題, 和運行時間。 想象一個圖形用戶界面來呈現這個表格, 並且點擊一個屬性的頂部會使這個列表按照這個屬性進行排序; 再一次點擊相同屬性的頂部會進行逆向排序。 讓我們看下每個點擊會發生什麼響應。
  10. 下面的變量tracks包好了一個播放列表。 ( One of the authors apologizes for the otherauthor’s musical tastes.) 每個元素都不是Track本身而是指向它的指針。 儘管我們在下面的代碼中直接存儲Tracks也可以工作, sort函數會交換很多對元素, 所以如果每個元素都是指針會更快而不是全部Track類型, 指針是一個機器字碼長度而Track類型可能是八個或更多。
  11. gopl.io/ch7/sorting
    在這裏插入圖片描述
  12. printTracks函數將播放列表打印成一個表格。 一個圖形化的展示可能會更好點, 但是這個小程序使用text/tabwriter包來生成一個列是整齊對齊和隔開的表格, 像下面展示的這樣。 注意到*tabwriter.Writer是滿足io.Writer接口的。 它會收集每一片寫向它的數據; 它的Flush方法會格式化整個表格並且將它寫向os.Stdout( 標準輸出) 。
    在這裏插入圖片描述
  13. 爲了能按照Artist字段對播放列表進行排序, 我們會像對StringSlice那樣定義一個新的帶有必須Len, Less和Swap方法的切片類型。
    在這裏插入圖片描述
  14. 爲了調用通用的排序程序, 我們必須先將tracks轉換爲新的byArtist類型, 它定義了具體的排序:
    在這裏插入圖片描述
  15. 在按照artist對這個切片進行排序後, printTrack的輸出如下
    在這裏插入圖片描述
  16. 如果用戶第二次請求“按照artist排序”, 我們會對tracks進行逆向排序。 然而我們不需要定義一個有顛倒Less方法的新類型byReverseArtist, 因爲sort包中提供了Reverse函數將排序順序轉換成逆序。
    在這裏插入圖片描述
  17. 在按照artist對這個切片進行逆向排序後, printTrack的輸出如下
    在這裏插入圖片描述
  18. sort.Reverse函數值得進行更近一步的學習因爲它使用了(§6.3)章中的組合, 這是一個重要的思路。 sort包定義了一個不公開的struct類型reverse, 它嵌入了一個sort.Interface。 reverse的Less方法調用了內嵌的sort.Interface值的Less方法, 但是通過交換索引的方式使排序結果變成逆序。
    在這裏插入圖片描述
  19. reverse的另外兩個方法Len和Swap隱式地由原有內嵌的sort.Interface提供。 因爲reverse是一個不公開的類型, 所以導出函數Reverse函數返回一個包含原有sort.Interface值的reverse類型實例。
  20. 爲了可以按照不同的列進行排序, 我們必須定義一個新的類型例如byYear:
    在這裏插入圖片描述
  21. 在使用sort.Sort(byYear(tracks))按照年對tracks進行排序後, printTrack展示了一個按時間先後順序的列表:
    在這裏插入圖片描述
  22. 對於我們需要的每個切片元素類型和每個排序函數, 我們需要定義一個新的sort.Interface實現。 如你所見, Len和Swap方法對於所有的切片類型都有相同的定義。 下個例子, 具體的類型customSort會將一個切片和函數結合, 使我們只需要寫比較函數就可以定義一個新的排序。 順便說下, 實現了sort.Interface的具體類型不一定是切片類型; customSort是一個結構體類型。
    在這裏插入圖片描述
  23. 讓我們定義一個多層的排序函數, 它主要的排序鍵是標題, 第二個鍵是年, 第三個鍵是運行時間Length。 下面是該排序的調用, 其中這個排序使用了匿名排序函數:
    在這裏插入圖片描述
  24. 這下面是排序的結果。 注意到兩個標題是“Go”的track按照標題排序是相同的順序, 但是在按照year排序上更久的那個track優先。
    在這裏插入圖片描述
  25. 儘管對長度爲n的序列排序需要 O(n log n)次比較操作, 檢查一個序列是否已經有序至少需要n−1次比較。 sort包中的IsSorted函數幫我們做這樣的檢查。 像sort.Sort一樣, 它也使用sort.Interface對這個序列和它的排序函數進行抽象, 但是它從不會調用Swap方法: 這段代碼示範了IntsAreSorted和Ints函數和IntSlice類型的使用:
    在這裏插入圖片描述
  26. 爲了使用方便, sort包爲[]int,[]string和[]float64的正常排序提供了特定版本的函數和類型。 對於其他類型, 例如[]int64或者[]uint, 儘管路徑也很簡單, 還是依賴我們自己實現。

7. http.Handler接口

  1. 在第一章中, 我們粗略的瞭解了怎麼用net/http包去實現網絡客戶端(§1.5)和服務器(§1.7)。 在這個小節中, 我們會對那些基於http.Handler接口的服務器API做更進一步的學習:
  2. net/http
    在這裏插入圖片描述
  3. ListenAndServe函數需要一個例如“localhost:8000”的服務器地址, 和一個所有請求都可以分派的Handler接口實例。 它會一直運行, 直到這個服務因爲一個錯誤而失敗( 或者啓動失敗) , 它的返回值一定是一個非空的錯誤。
  4. 想象一個電子商務網站, 爲了銷售它的數據庫將它物品的價格映射成美元。 下面這個程序可能是能想到的最簡單的實現了。 它將庫存清單模型化爲一個命名爲database的map類型, 我們給這個類型一個ServeHttp方法, 這樣它可以滿足http.Handler接口。 這個handler會遍歷整個map並輸出物品信息。
  5. gopl.io/ch7/http1
    在這裏插入圖片描述
  6. 如果我們啓動這個服務,
    7.
  7. 然後用1.5節中的獲取程序( 如果你更喜歡可以使用web瀏覽器) 來連接服務器,我們得到下面的輸出:
    在這裏插入圖片描述
  8. 目前爲止, 這個服務器不考慮URL只能爲每個請求列出它全部的庫存清單。 更真實的服務器會定義多個不同的URL, 每一個都會觸發一個不同的行爲。 讓我們使用/list來調用已經存在的這個行爲並且增加另一個/price調用表明單個貨品的價格, 像這樣/price?item=socks來指定一個請求參數。
  9. gopl.io/ch7/http2
    在這裏插入圖片描述
  10. 現在handler基於URL的路徑部分( req.URL.Path) 來決定執行什麼邏輯。 如果這個handler不能識別這個路徑, 它會通過調用w.WriteHeader(http.StatusNotFound)返回客戶端一個HTTP錯誤; 這個檢查應該在向w寫入任何值前完成。 ( 順便提一下, http.ResponseWriter是另一個接口。 它在io.Writer上增加了發送HTTP相應頭的方法。 ) 等效地, 我們可以使用實用的http.Error函數:
    在這裏插入圖片描述
  11. /price的case會調用URL的Query方法來將HTTP請求參數解析爲一個map, 或者更準確地說一個net/url包中url.Values(§6.2.1)類型的多重映射。 然後找到第一個item參數並查找它的價格。如果這個貨品沒有找到會返回一個錯誤。
  12. 這裏是一個和新服務器會話的例子。
    在這裏插入圖片描述
  13. 顯然我們可以繼續向ServeHTTP方法中添加case, 但在一個實際的應用中, 將每個case中的邏輯定義到一個分開的方法或函數中會很實用。 此外, 相近的URL可能需要相似的邏輯; 例如幾個圖片文件可能有形如/images/*.png的URL。 因爲這些原因, net/http包提供了一個請求多路器ServeMux來簡化URL和handlers的聯繫。 一個ServeMux將一批http.Handler聚集到一個單一的http.Handler中。 再一次, 我們可以看到滿足同一接口的不同類型是可替換的: web服務器將請求指派給任意的http.Handler 而不需要考慮它後面的具體類型。
  14. 對於更復雜的應用, 一些ServeMux可以通過組合來處理更加錯綜複雜的路由需求。 Go語言目前沒有一個權威的web框架, 就像Ruby語言有Rails和python有Django。 這並不是說這樣的框架不存在, 而是Go語言標準庫中的構建模塊就已經非常靈活以至於這些框架都是不必要的。此外, 儘管在一個項目早期使用框架是非常方便的, 但是它們帶來額外的複雜度會使長期的維護更加困難.
  15. 在下面的程序中, 我們創建一個ServeMux並且使用它將URL和相應處理/list和/price操作的handler聯繫起來, 這些操作邏輯都已經被分到不同的方法中。 然後我門在調用ListenAndServe函數中使用ServeMux最爲主要的handler。
  16. gopl.io/ch7/http3
    在這裏插入圖片描述
  17. 讓我們關注這兩個註冊到handlers上的調用。 第一個db.list是一個方法值 (§6.4), 它是下面這個類型的值
    18.
  18. 也就是說db.list的調用會援引一個接收者是db的database.list方法。 所以db.list是一個實現了handler類似行爲的函數, 但是因爲它沒有方法, 所以它不滿足http.Handler接口並且不能直接傳給mux.Handle。
  19. 語句http.HandlerFunc(db.list)是一個轉換而非一個函數調用, 因爲http.HandlerFunc是一個類型。 它有如下的定義:
  20. net/http
    在這裏插入圖片描述
  21. HandlerFunc顯示了在Go語言接口機制中一些不同尋常的特點。 這是一個有實現了接口http.Handler方法的函數類型。 ServeHTTP方法的行爲調用了它本身的函數。 因此HandlerFunc是一個讓函數值滿足一個接口的適配器, 這裏函數和這個接口僅有的方法有相同的函數簽名。 實際上, 這個技巧讓一個單一的類型例如database以多種方式滿足http.Handler接口: 一種通過它的list方法, 一種通過它的price方法等等。
  22. 因爲handler通過這種方式註冊非常普遍, ServeMux有一個方便的HandleFunc方法, 它幫我們簡化handler註冊代碼成這樣:
    gopl.io/ch7/http3a
    在這裏插入圖片描述
  23. 從上面的代碼很容易看出應該怎麼構建一個程序, 它有兩個不同的web服務器監聽不同的端口的, 並且定義不同的URL將它們指派到不同的handler。 我們只要構建另外一個ServeMux並且在調用一次ListenAndServe( 可能並行的) 。 但是在大多數程序中, 一個web服務器就足夠了。 此外, 在一個應用程序的多個文件中定義HTTP handler也是非常典型的, 如果它們必須全部都顯示的註冊到這個應用的ServeMux實例上會比較麻煩。
  24. 所以爲了方便, net/http包提供了一個全局的ServeMux實例DefaultServerMux和包級別的http.Handle和http.HandleFunc函數。 現在, 爲了使用DefaultServeMux作爲服務器的主handler, 我們不需要將它傳給ListenAndServe函數; nil值就可以工作。
  25. 然後服務器的主函數可以簡化成:
    gopl.io/ch7/http4
    在這裏插入圖片描述
  26. 最後, 一個重要的提示: 就像我們在1.7節中提到的, web服務器在一個新的協程中調用每一個handler, 所以當handler獲取其它協程或者這個handler本身的其它請求也可以訪問的變量時一定要使用預防措施比如鎖機制。 我們後面的兩章中講到併發相關的知識。

8. error接口

  1. 從本書的開始, 我們就已經創建和使用過神祕的預定義error類型, 而且沒有解釋它究竟是什麼。 實際上它就是interface類型, 這個類型有一個返回錯誤信息的單一方法:
    在這裏插入圖片描述
  2. 創建一個error最簡單的方法就是調用errors.New函數, 它會根據傳入的錯誤信息返回一個新的error。 整個errors包僅只有4行:
    在這裏插入圖片描述
  3. 承載errorString的類型是一個結構體而非一個字符串, 這是爲了保護它表示的錯誤避免粗心( 或有意) 的更新。 並且因爲是指針類型*errorString滿足error接口而非errorString類型, 所以每個New函數的調用都分配了一個獨特的和其他錯誤不相同的實例。 我們也不想要重要的error例如io.EOF和一個剛好有相同錯誤消息的error比較後相等。
    在這裏插入圖片描述
  4. 調用errors.New函數是非常稀少的, 因爲有一個方便的封裝函數fmt.Errorf, 它還會處理字符串格式化。 我們曾多次在第5章中用到它。
    在這裏插入圖片描述
  5. 雖然*errorString可能是最簡單的錯誤類型, 但遠非只有它一個。 例如, syscall包提供了Go語言底層系統調用API。 在多個平臺上, 它定義一個實現error接口的數字類型Errno, 並且在Unix平臺上, Errno的Error方法會從一個字符串表中查找錯誤消息, 如下面展示的這樣:
    在這裏插入圖片描述
  6. 下面的語句創建了一個持有Errno值爲2的接口值, 表示POSIX ENOENT狀況:
    在這裏插入圖片描述
  7. 下面的語句創建了一個持有Errno值爲2的接口值, 表示POSIX ENOENT狀況:
    在這裏插入圖片描述
  8. err的值圖形化的呈現在圖7.6中。
    在這裏插入圖片描述
  9. Errno是一個系統調用錯誤的高效表示方式, 它通過一個有限的集合進行描述, 並且它滿足標準的錯誤接口。 我們會在第7.11節瞭解到其它滿足這個接口的類型。

9. 示例: 表達式求值

  1. 在本節中, 我們會構建一個簡單算術表達式的求值器。 我們將使用一個接口Expr來表示Go語言中任意的表達式。 現在這個接口不需要有方法, 但是我們後面會爲它增加一些。
    在這裏插入圖片描述

  2. 我們的表達式語言由浮點數符號( 小數點) ; 二元操作符+, -, *, 和/; 一元操作符-x和+x;調用pow(x,y), sin(x), 和sqrt(x)的函數; 例如x和pi的變量; 當然也有括號和標準的優先級運算符。 所有的值都是float64類型。 這下面是一些表達式的例子:
    在這裏插入圖片描述

  3. 下面的五個具體類型表示了具體的表達式類型。 Var類型表示對一個變量的引用。 ( 我們很快會知道爲什麼它可以被輸出。 ) literal類型表示一個浮點型常量。 unary和binary類型表示有一到兩個運算對象的運算符表達式, 這些操作數可以是任意的Expr類型。 call類型表示對一個函數的調用; 我們限制它的fn字段只能是pow, sin或者sqrt。

  4. gopl.io/ch7/eval
    在這裏插入圖片描述

  5. 爲了計算一個包含變量的表達式, 我們需要一個environment變量將變量的名字映射成對應的值:
    6.

  6. 我們也需要每個表示式去定義一個Eval方法, 這個方法會根據給定的environment變量返回表達式的值。 因爲每個表達式都必須提供這個方法, 我們將它加入到Expr接口中。 這個包只會對外公開Expr, Env, 和Var類型。 調用方不需要獲取其它的表達式類型就可以使用這個求值器。
    在這裏插入圖片描述

  7. 下面給大家展示一個具體的Eval方法。 Var類型的這個方法對一個environment變量進行查找,如果這個變量沒有在environment中定義過這個方法會返回一個零值, literal類型的這個方法簡單的返回它真實的值。
    在這裏插入圖片描述

  8. unary和binary的Eval方法會遞歸的計算它的運算對象, 然後將運算符op作用到它們上。 我們不將被零或無窮數除作爲一個錯誤, 因爲它們都會產生一個固定的結果無限。 最後, call的這個方法會計算對於pow, sin, 或者sqrt函數的參數值, 然後調用對應在math包中的函數。

    func (u unary) Eval(env Env) float64 {
    	switch u.op {
    	case '+':
    		return +u.x.Eval(env)
    	case '-':
    		return -u.x.Eval(env)
    	}
    	panic(fmt.Sprintf("unsupported unary operator: %q", u.op))
    }
    
    func (b binary) Eval(env Env) float64 {
    	switch b.op {
    	case '+':
    		return b.x.Eval(env) + b.y.Eval(env)
    	case '-':
    		return b.x.Eval(env) - b.y.Eval(env)
    	case '*':
    		return b.x.Eval(env) * b.y.Eval(env)
    	case '/':
    		return b.x.Eval(env) / b.y.Eval(env)
    	}
    	panic(fmt.Sprintf("unsupported binary operator: %q", b.op))
    }
    
    func (c call) Eval(env Env) float64 {
    	switch c.fn {
    	case "pow":
    		return math.Pow(c.args[0].Eval(env), c.args[1].Eval(env))
    	case "sin":
    		return math.Sin(c.args[0].Eval(env))
    	case "sqrt":
    		return math.Sqrt(c.args[0].Eval(env))
    	}
    	panic(fmt.Sprintf("unsupported function call:%s", c.fn))
    }
    
  9. 一些方法會失敗。 例如, 一個call表達式可能未知的函數或者錯誤的參數個數。 用一個無效的運算符如!或者<去構建一個unary或者binary表達式也是可能會發生的( 儘管下面提到的Parse函數不會這樣做) 。 這些錯誤會讓Eval方法panic。 其它的錯誤, 像計算一個沒有在environment變量中出現過的Var, 只會讓Eval方法返回一個錯誤的結果。 所有的這些錯誤都可以通過在計算前檢查Expr來發現。 這是我們接下來要講的Check方法的工作, 但是讓我們先測試Eval方法。

  10. 下面的TestEval函數是對evaluator的一個測試。 它使用了我們會在第11章講解的testing包, 但是現在知道調用t.Errof會報告一個錯誤就足夠了。 這個函數循環遍歷一個表格中的輸入, 這個表格中定義了三個表達式和針對每個表達式不同的環境變量。 第一個表達式根據給定圓的面積A計算它的半徑, 第二個表達式通過兩個變量x和y計算兩個立方體的體積之和, 第三個表達式將華氏溫度F轉換成攝氏度。
    在這裏插入圖片描述

  11. 對於表格中的每一條記錄, 這個測試會解析它的表達式然後在環境變量中計算它, 輸出結果。 這裏我們沒有空間來展示Parse函數, 但是如果你使用go get下載這個包你就可以看到這個函數。

  12. go test(§11.1) 命令會運行一個包的測試用例:
    在這裏插入圖片描述

  13. 這個-v標識可以讓我們看到測試用例打印的輸出; 正常情況下像這個一樣成功的測試用例會阻止打印結果的輸出。 這裏是測試用例裏fmt.Printf語句的輸出:
    在這裏插入圖片描述

  14. 幸運的是目前爲止所有的輸入都是適合的格式, 但是我們的運氣不可能一直都有。 甚至在解釋型語言中, 爲了靜態錯誤檢查語法是非常常見的; 靜態錯誤就是不用運行程序就可以檢測出來的錯誤。 通過將靜態檢查和動態的部分分開, 我們可以快速的檢查錯誤並且對於多次檢查只執行一次而不是每次表達式計算的時候都進行檢查。

  15. 讓我們往Expr接口中增加另一個方法。 Check方法在一個表達式語義樹檢查出靜態錯誤。 我們馬上會說明它的vars參數。
    在這裏插入圖片描述

  16. 具體的Check方法展示在下面。 literal和Var類型的計算不可能失敗, 所以這些類型的Check方法會返回一個nil值。 對於unary和binary的Check方法會首先檢查操作符是否有效, 然後遞歸的檢查運算單元。 相似地對於call的這個方法首先檢查調用的函數是否已知並且有沒有正確個數的參數, 然後遞歸的檢查每一個參數。

    func (v Var) Check(vars map[Var]bool) error {
    	vars[v] = true
    	return nil
    }
    
    func (literal) Check(vars map[Var]bool) error {
    	return nil
    }
    
    func (u unary) Check(vars map[Var]bool) error {
    	if !strings.ContainsRune("+-", u.op) {
    		return fmt.Errorf("unexpected unary op %q", u.op)
    	}
    	return u.x.Check(vars)
    }
    
    func (b binary) Check(vars map[Var]bool) error {
    	if strings.ContainsRune("+-*/", b.op) {
    		return fmt.Errorf("unexpected binary op %q", b.op)
    	}
    	if err := b.x.Check(vars); err != nil {
    		return err
    	}
    	return b.y.Check(vars)
    }
    
    func (c call) Check(vars map[Var]bool) error {
    	arity, ok := numParams[c.fn]
    	if !ok {
    		return fmt.Errorf("nuknow function %q", c.fn)
    	}
    	if len(c.args) != arity {
    		return fmt.Errorf("call to %s has %d args, want %d", c.fn, len(c.args), arity)
    	}
    	for _, arg := range c.args {
    		if err := arg.Check(vars); err != nil {
    			return err
    		}
    	}
    	return nil
    }
    
    var numParams = map[string]int{"pow": 2, "sin": 1, "sqrt": 1}
    
    
  17. 我們在兩個組中有選擇地列出有問題的輸入和它們得出的錯誤。 Parse函數( 這裏沒有出現)會報出一個語法錯誤和Check函數會報出語義錯誤。
    在這裏插入圖片描述

  18. 在第3.2節中, 我們繪製了一個在編譯器才確定的函數f(x,y)。 現在我們可以解析, 檢查和計算在字符串中的表達式, 我們可以構建一個在運行時從客戶端接收表達式的web應用並且它會繪製這個函數的表示的曲面。 我們可以使用集合vars來檢查表達式是否是一個只有兩個變量,x和y的函數——實際上是3個, 因爲我們爲了方便會提供半徑大小r。 並且我們會在計算前使用Check方法拒絕有格式問題的表達式, 這樣我們就不會在下面函數的40000個計算過程( 100x100個柵格, 每一個有4個角) 重複這些檢查。

  19. 這個ParseAndCheck函數混合瞭解析和檢查步驟的過程:
    在這裏插入圖片描述

  20. 爲了編寫這個web應用, 所有我們需要做的就是下面這個plot函數, 這個函數有和http.HandlerFunc相似的簽名:
    21.
    在這裏插入圖片描述
    在這裏插入圖片描述

  21. 這個plot函數解析和檢查在HTTP請求中指定的表達式並且用它來創建一個兩個變量的匿名函數。 這個匿名函數和來自原來surface-plotting程序中的固定函數f有相同的簽名, 但是它計算一個用戶提供的表達式。 環境變量中定義了x, y和半徑r。 最後plot調用surface函數, 它就是gopl.io/ch3/surface中的主要函數, 修改後它可以接受plot中的函數和輸出io.Writer作爲參數,而不是使用固定的函數f和os.Stdout。 圖7.7中顯示了通過程序產生的3個曲面。


10. 類型斷言

  1. 類型斷言是一個使用在接口值上的操作。 語法上它看起來像x.(T)被稱爲斷言類型, 這裏x表示一個接口的類型和T表示一個類型。 一個類型斷言檢查它操作對象的動態類型是否和斷言的類型匹配。
  2. 這裏有兩種可能。 第一種, 如果斷言的類型T是一個具體類型, 然後類型斷言檢查x的動態類型是否和T相同。 如果這個檢查成功了, 類型斷言的結果是x的動態值, 當然它的類型是T。 換句話說, 具體類型的類型斷言從它的操作對象中獲得具體的值。 如果檢查失敗, 接下來這個操作會拋出panic。 例如:
    在這裏插入圖片描述
  3. 第二種, 如果相反斷言的類型T是一個接口類型, 然後類型斷言檢查是否x的動態類型滿足T。如果這個檢查成功了, 動態值沒有獲取到; 這個結果仍然是一個有相同類型和值部分的接口值, 但是結果有類型T。 換句話說, 對一個接口類型的類型斷言改變了類型的表述方式, 改變了可以獲取的方法集合( 通常更大) , 但是它保護了接口值內部的動態類型和值的部分。
  4. 在下面的第一個類型斷言後, w和rw都持有os.Stdout因此它們每個有一個動態類型*os.File,但是變量w是一個io.Writer類型只對外公開出文件的Write方法, 然而rw變量也只公開它的Read方法。
    在這裏插入圖片描述
  5. 如果斷言操作的對象是一個nil接口值, 那麼不論被斷言的類型是什麼這個類型斷言都會失敗。 我們幾乎不需要對一個更少限制性的接口類型( 更少的方法集合) 做斷言, 因爲它表現的就像賦值操作一樣, 除了對於nil接口值的情況。
    在這裏插入圖片描述
  6. 經常地我們對一個接口值的動態類型是不確定的, 並且我們更願意去檢驗它是否是一些特定的類型。 如果類型斷言出現在一個預期有兩個結果的賦值操作中, 例如如下的定義, 這個操作不會在失敗的時候發生panic但是代替地返回一個額外的第二個結果, 這個結果是一個標識成功的布爾值:
    在這裏插入圖片描述
  7. 第二個結果常規地賦值給一個命名爲ok的變量。 如果這個操作失敗了, 那麼ok就是false值,第一個結果等於被斷言類型的零值, 在這個例子中就是一個nil的*bytes.Buffer類型。
  8. 這個ok結果經常立即用於決定程序下面做什麼。 if語句的擴展格式讓這個變的很簡潔:
    在這裏插入圖片描述
  9. 當類型斷言的操作對象是一個變量, 你有時會看見原來的變量名重用而不是聲明一個新的本地變量, 這個重用的變量會覆蓋原來的值, 如下面這樣:
    在這裏插入圖片描述

11. 基於類型斷言區別錯誤類型

  1. 思考在os包中文件操作返回的錯誤集合。 I/O可以因爲任何數量的原因失敗, 但是有三種經常的錯誤必須進行不同的處理: 文件已經存在( 對於創建操作) , 找不到文件( 對於讀取操作) , 和權限拒絕。 os包中提供了這三個幫助函數來對給定的錯誤值表示的失敗進行分類:
    在這裏插入圖片描述
  2. 對這些判斷的一個缺乏經驗的實現可能會去檢查錯誤消息是否包含了特定的子字符串,
    在這裏插入圖片描述
  3. 但是處理I/O錯誤的邏輯可能一個和另一個平臺非常的不同, 所以這種方案並不健壯並且對相同的失敗可能會報出各種不同的錯誤消息。 在測試的過程中, 通過檢查錯誤消息的子字符串來保證特定的函數以期望的方式失敗是非常有用的, 但對於線上的代碼是不夠的。
  4. 一個更可靠的方式是使用一個專門的類型來描述結構化的錯誤。 os包中定義了一個PathError類型來描述在文件路徑操作中涉及到的失敗, 像Open或者Delete操作,並且定義了一個叫LinkError的變體來描述涉及到兩個文件路徑的操作, 像Symlink和Rename。 這下面是os.PathError:
    在這裏插入圖片描述
  5. 大多數調用方都不知道PathError並且通過調用錯誤本身的Error方法來統一處理所有的錯誤。儘管PathError的Error方法簡單地把這些字段連接起來生成錯誤消息, PathError的結構保護了內部的錯誤組件。 調用方需要使用類型斷言來檢測錯誤的具體類型以便將一種失敗和另一種區分開; 具體的類型比字符串可以提供更多的細節。
    在這裏插入圖片描述
  6. 這就是三個幫助函數是怎麼工作的。 例如下面展示的IsNotExist, 它會報出是否一個錯誤和syscall.ENOENT(§7.8)或者和有名的錯誤os.ErrNotExist相等(可以在§5.4.2中找到io.EOF) ;或者是一個*PathError, 它內部的錯誤是syscall.ENOENT和os.ErrNotExist其中之一。
    在這裏插入圖片描述
  7. 下面這裏是它的實際使用:
    在這裏插入圖片描述
  8. 如果錯誤消息結合成一個更大的字符串, 當然PathError的結構就不再爲人所知, 例如通過一個對fmt.Errorf函數的調用。 區別錯誤通常必須在失敗操作後, 錯誤傳回調用者前進行。

12. 通過類型斷言詢問行爲

  1. 下面這段邏輯和net/http包中web服務器負責寫入HTTP頭字段( 例如: "Contenttype:text/html) 的部分相似。 io.Writer接口類型的變量w代表HTTP響應; 寫入它的字節最終被髮送到某個人的web瀏覽器上。
    在這裏插入圖片描述
  2. 因爲Write方法需要傳入一個byte切片而我們希望寫入的值是一個字符串, 所以我們需要使用[]byte(…)進行轉換。 這個轉換分配內存並且做一個拷貝, 但是這個拷貝在轉換後幾乎立馬就被丟棄掉。 讓我們假裝這是一個web服務器的核心部分並且我們的性能分析表示這個內存分配使服務器的速度變慢。 這裏我們可以避免掉內存分配麼?
  3. 這個io.Writer接口告訴我們關於w持有的具體類型的唯一東西: 就是可以向它寫入字節切片。如果我們回顧net/http包中的內幕, 我們知道在這個程序中的w變量持有的動態類型也有一個允許字符串高效寫入的WriteString方法; 這個方法會避免去分配一個零時的拷貝。 ( 這可能像在黑夜中射擊一樣, 但是許多滿足io.Writer接口的重要類型同時也有WriteString方法, 包括*bytes.Buffer, os.File和bufio.Writer。 )
  4. 我們不能對任意io.Writer類型的變量w, 假設它也擁有WriteString方法。 但是我們可以定義一個只有這個方法的新接口並且使用類型斷言來檢測是否w的動態類型滿足這個新接口。
    在這裏插入圖片描述
  5. 爲了避免重複定義, 我們將這個檢查移入到一個實用工具函數writeString中, 但是它太有用了 以致標準庫將它作爲io.WriteString函數提供。 這是向一個io.Writer接口寫入字符串的推薦方法。
  6. 這個例子的神奇之處在於沒有定義了WriteString方法的標準接口和沒有指定它是一個需要行爲的標準接口。 而且一個具體類型只會通過它的方法決定它是否滿足stringWriter接口, 而不是任何它和這個接口類型表明的關係。 它的意思就是上面的技術依賴於一個假設; 這個假設就是, 如果一個類型滿足下面的這個接口, 然後WriteString(s)就方法必須和Write([]byte(s))有相同的效果
    在這裏插入圖片描述
  7. 儘管io.WriteString記錄了它的假設, 但是調用它的函數極少有可能會去記錄它們也做了同樣的假設。 定義一個特定類型的方法隱式地獲取了對特定行爲的協約。 對於Go語言的新手, 特別是那些來自有強類型語言使用背景的新手, 可能會發現它缺乏顯式的意圖令人感到混亂,但是在實戰的過程中這幾乎不是一個問題。 除了空接口interface{},接口類型很少意外巧合地被實現。
  8. 上面的writeString函數使用一個類型斷言來知道一個普遍接口類型的值是否滿足一個更加具體的接口類型; 並且如果滿足, 它會使用這個更具體接口的行爲。 這個技術可以被很好的使用不論這個被詢問的接口是一個標準的如io.ReadWriter或者用戶定義的如stringWriter。
  9. 這也是fmt.Fprintf函數怎麼從其它所有值中區分滿足error或者fmt.Stringer接口的值。 在fmt.Fprintf內部, 有一個將單個操作對象轉換成一個字符串的步驟, 像下面這樣:
    在這裏插入圖片描述
  10. 如果x滿足這個兩個接口類型中的一個, 具體滿足的接口決定對值的格式化方式。 如果都不滿足, 默認的case或多或少會統一地使用反射來處理所有的其它類型; 我們可以在第12章知道具體是怎麼實現的。
  11. 再一次的, 它假設任何有String方法的類型滿足fmt.Stringer中約定的行爲, 這個行爲會返回一個適合打印的字符串。

13. 類型分支

  1. 接口被以兩種不同的方式使用。 在第一個方式中, 以io.Reader, io.Writer, fmt.Stringer,sort.Interface, http.Handler, 和error爲典型, 一個接口的方法表達了實現這個接口的具體類型間的相思性, 但是隱藏了代表的細節和這些具體類型本身的操作。 重點在於方法上, 而不是具體的類型上。
  2. 第二個方式利用一個接口值可以持有各種具體類型值的能力並且將這個接口認爲是這些類型的union( 聯合) 。 類型斷言用來動態地區別這些類型並且對每一種情況都不一樣。 在這個方式中, 重點在於具體的類型滿足這個接口, 而不是在於接口的方法( 如果它確實有一些的話) , 並且沒有任何的信息隱藏。 我們將以這種方式使用的接口描述爲discriminatedunions( 可辨識聯合) 。
  3. 如果你熟悉面向對象編程, 你可能會將這兩種方式當作是subtype polymorphism( 子類型多態) 和 ad hoc polymorphism( 非參數多態) , 但是你不需要去記住這些術語。 對於本章剩下的部分, 我們將會呈現一些第二種方式的例子。
  4. 和其它那些語言一樣, Go語言查詢一個SQL數據庫的API會乾淨地將查詢中固定的部分和變化的部分分開。 一個調用的例子可能看起來像這樣:
    在這裏插入圖片描述
  5. Exec方法使用SQL字面量替換在查詢字符串中的每個’?’; SQL字面量表示相應參數的值, 它有可能是一個布爾值, 一個數字, 一個字符串, 或者nil空值。 用這種方式構造查詢可以幫助避免SQL注入攻擊; 這種攻擊就是對手可以通過利用輸入內容中不正確的引文來控制查詢語句。 在Exec函數內部, 我們可能會找到像下面這樣的一個函數, 它會將每一個參數值轉換成它的SQL字面量符號。
    在這裏插入圖片描述
  6. switch語句可以簡化if-else鏈, 如果這個if-else鏈對一連串值做相等測試。 一個相似的type switch( 類型開關) 可以簡化類型斷言的if-else鏈。
  7. 在它最簡單的形式中, 一個類型開關像普通的switch語句一樣, 它的運算對象是x.(type)-它使用了關鍵詞字面量type-並且每個case有一到多個類型。 一個類型開關基於這個接口值的動態類型使一個多路分支有效。 這個nil的case和if x == nil匹配, 並且這個default的case和如果其它case都不匹配的情況匹配。 一個對sqlQuote的類型開關可能會有這些case:
    在這裏插入圖片描述
  8. 和(§1.8)中的普通switch語句一樣, 每一個case會被順序的進行考慮, 並且當一個匹配找到時, 這個case中的內容會被執行。 當一個或多個case類型是接口時, case的順序就會變得很重要, 因爲可能會有兩個case同時匹配的情況。 default case相對其它case的位置是無所謂的。 它不會允許落空發生。
  9. 注意到在原來的函數中, 對於bool和string情況的邏輯需要通過類型斷言訪問提取的值。 因爲這個做法很典型, 類型開關語句有一個擴展的形式, 它可以將提取的值綁定到一個在每個case範圍內的新變量。
    在這裏插入圖片描述
  10. 這裏我們已經將新的變量也命名爲x; 和類型斷言一樣, 重用變量名是很常見的。 和一個switch語句相似地, 一個類型開關隱式的創建了一個語言塊, 因此新變量x的定義不會和外面塊中的x變量衝突。 每一個case也會隱式的創建一個單獨的語言塊。
  11. 使用類型開關的擴展形式來重寫sqlQuote函數會讓這個函數更加的清晰:
    在這裏插入圖片描述
  12. 在這個版本的函數中, 在每個單一類型的case內部, 變量x和這個case的類型相同。 例如, 變量x在bool的case中是bool類型和string的case中是string類型。 在所有其它的情況中, 變量x是switch運算對象的類型( 接口) ; 在這個例子中運算對象是一個interface{}。 當多個case需要相同的操作時, 比如int和uint的情況, 類型開關可以很容易的合併這些情況。
  13. 儘管sqlQuote接受一個任意類型的參數, 但是這個函數只會在它的參數匹配類型開關中的一個case時運行到結束; 其它情況的它會panic出“unexpected type”消息。 雖然x的類型是interface{}, 但是我們把它認爲是一個int, uint, bool, string, 和nil值的discriminatedunion( 可識別聯合)

14. 示例: 基於標記的XML解碼

  1. 第4.5章節展示瞭如何使用encoding/json包中的Marshal和Unmarshal函數來將JSON文檔轉換成Go語言的數據結構。 encoding/xml包提供了一個相似的API。 當我們想構造一個文檔樹的表示時使用encoding/xml包會很方便, 但是對於很多程序並不是必須的。 encoding/xml包也提供了一個更低層的基於標記的API用於XML解碼。 在基於標記的樣式中, 解析器消費輸入和產生一個標記流; 四個主要的標記類型-StartElement, EndElement, CharData, 和Comment-每一個都是encoding/xml包中的具體類型。 每一個對(*xml.Decoder).Token的調用都返回一個標記。

  2. 這裏顯示的是和這個API相關的部分:

  3. encoding/xml
    在這裏插入圖片描述

  4. 這個沒有方法的Token接口也是一個可識別聯合的例子。 傳統的接口如io.Reader的目的是隱藏滿足它的具體類型的細節, 這樣就可以創造出新的實現; 在這個實現中每個具體類型都被統一地對待。 相反, 滿足可識別聯合的具體類型的集合被設計確定和暴露, 而不是隱藏。 可識別的聯合類型幾乎沒有方法; 操作它們的函數使用一個類型開關的case集合來進行表述;這個case集合中每一個case中有不同的邏輯。

  5. 下面的xmlselect程序獲取和打印在一個XML文檔樹中確定的元素下找到的文本。 使用上面的API, 它可以在輸入上一次完成它的工作而從來不要具體化這個文檔樹。

  6. gopl.io/ch7/xmlselect

    package main
    
    import (
    	"encoding/xml"
    	"fmt"
    	"io"
    	"os"
    	"strings"
    )
    
    func main() {
    	dec := xml.NewDecoder(os.Stdin)
    	var stack []string
    	for {
    		tok, err := dec.Token()
    		if err == io.EOF {
    			break
    		} else if err != nil {
    			fmt.Fprintf(os.Stderr, "xmlselect: %v\n", err)
    			os.Exit(1)
    		}
    		switch tok := tok.(type) {
    		case xml.StartElement:
    			stack = append(stack, tok.Name.Local) //入棧
    		case xml.EndElement:
    			stack = stack[:len(stack)-1] //出棧
    		case xml.CharData:
    			if containsall(stack, os.Args[1:]) {
    				fmt.Printf("%s: %s\n", strings.Join(stack, " "), tok)
    			}
    		}
    	}
    }
    
    // containsAll 判斷 x 是否包含 y 中的所有元素,且順序一直
    func containsall(x, y []string) bool {
    	for len(y) <= len(x) {
    		if len(y) == 0 {
    			return true
    		}
    		if x[0] == y[0] {
    			y = y[1:]
    		}
    		x = x[1:]
    	}
    	return false
    }
    
    
  7. 每次main函數中的循環遇到一個StartElement時, 它把這個元素的名稱壓到一個棧裏; 並且每次遇到EndElement時, 它將名稱從這個棧中推出。 這個API保證了StartElement和EndElement的序列可以被完全的匹配, 甚至在一個糟糕的文檔格式中。 註釋會被忽略。 當xmlselect遇到一個CharData時, 只有當棧中有序地包含所有通過命令行參數傳入的元素名稱時它纔會輸出相應的文本。

  8. 下面的命令打印出任意出現在兩層div元素下的h2元素的文本。 它的輸入是XML的說明文檔,並且它自己就是XML文檔格式的。
    在這裏插入圖片描述


15. 一些建議

  1. 當設計一個新的包時, 新的Go程序員總是通過創建一個接口的集合開始和後面定義滿足它們的具體類型。 這種方式的結果就是有很多的接口, 它們中的每一個僅只有一個實現。 不要再這麼做了。 這種接口是不必要的抽象; 它們也有一個運行時損耗。 你可以使用導出機制(§6.6)來限制一個類型的方法或一個結構體的字段是否在包外可見。 接口只有當有兩個或兩個以上的具體類型必須以相同的方式進行處理時才需要。
  2. 當一個接口只被一個單一的具體類型實現時有一個例外, 就是由於它的依賴, 這個具體類型不能和這個接口存在在一個相同的包中。 這種情況下, 一個接口是解耦這兩個包的一個好好方式。
  3. 因爲在Go語言中只有當兩個或更多的類型實現一個接口時才使用接口, 它們必定會從任意特定的實現細節中抽象出來。 結果就是有更少和更簡單方法( 經常和io.Writer或 fmt.Stringer一樣只有一個) 的更小的接口。 當新的類型出現時, 小的接口更容易滿足。 對於接口設計的一個好的標準就是 ask only for what you need( 只考慮你需要的東西)
  4. 我們完成了對methods和接口的學習過程。 Go語言良好的支持面向對象風格的編程, 但只不是說你僅僅只能使用它。 不是任何事物都需要被當做成一個對象; 獨立的函數有它們自己的用處, 未封裝的數據類型也是這樣。 同時觀察到這兩個, 在本書的前五章的例子中沒有調用超過兩打方法, 像input.Scan, 與之相反的是普遍的函數調用如fmt.Printf。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章