讀古今文學網 > Go語言程序設計 > 第2章 布爾與數值類型 >

第2章 布爾與數值類型

這是關於過程式編程的四章內容中的第一章,它構成了 Go語言編程的基礎——無論是過程式編程、面向對像編程、並發編程,還是這些編程方式的任何組合。

本章涵蓋了Go語言內置的布爾類型和數值類型,同時簡要介紹了一下Go標準庫中的數值類型。本章將介紹,除了各種數值類型之間需要進行顯式類型轉換以及內置了複數類型外,從C、C++以及Java等語言轉過來的程序員還會有更多驚喜。

本章第一小節講解了Go語言的基礎,比如如何寫註釋,Go語言的關鍵字和操作符,一個合法標識符的構成,等等。一旦這些基礎性的東西講解完後,接下來的小節將講解布爾類型、整型以及浮點型,之後也對複數進行了介紹。

2.1 基礎

Go語言支持兩種類型的註釋,都是從 C++借鑒而來的。行註釋以//開始,直到出現換行符時結束。行註釋被編譯器簡單當做一個換行符。塊註釋以/*開頭,以*/結尾,可能包含多個行。如果塊註釋只佔用了一行(即/* inline comment*/),編譯器把它當做一個空格,但是如果該塊註釋佔用了多行,編譯器就把它當做一個換行符。(我們將在第5章看到,換行符在Go語言中非常重要。)

Go標識符是一個非空的字母或數字串,其中第一個字符必須是字母,該標識符也不能是關鍵字的名字。字母可以是一個下劃線_,或者 Unicode 編碼分類中的任何字符,如大寫字母「Lu」(letter,uppercase)、小寫字母「Ll」(letter,lowercase)、首字母大寫「Lt」(letter,titlecase)、修飾符字母「Lm」(letter, modifier)或者其他字母,「Lo」(letter,other)。這些字符包含所有的英文字母(A~Z以及a~z)。數字則是Unicode編碼〞Nd〞分類(number, decimal digit)中的任何字符,這些字符包括阿拉伯數字 0~9。編譯器不允許使用與某個關鍵字(見表 2-1)一樣的

名字作為標識符。

表2-1 Go語言的關鍵字

Go語言預先定義了許多標識符(見表 2-2),雖然可以定義與這些預定義的標識符名字一樣的標識符,但是這樣做通常很不明智。

表2-2 Go語言預定義的標識符

標識符都是區分大小寫的,因此 LINECOUNT、Linecount、LineCount、lineCount和linecount是5個不一樣的標識符。以大寫字母開頭的標識符,即Unicode分類中屬於「Lu」的字母(包含A~Z),是公開的——以Go語言的術語來說就是導出的,而任何其他的標識符都是私有的——用Go語言的術語來說就是未導出的。(這項規則不適用於包的名字,包名約定為全小寫。)第6章討論面向對像編程以及第9章討論包時,我們會在實際的代碼中看到這兩者的區別。

空標識符「_」是一個佔位符,它用於在賦值操作的時候將某個值賦值給空標識符,從而達到丟棄該值的目的。空標識符不是一個新的變量,因此將它用於:=操作符的時候,必須同時為至少另一個值賦值。通過將函數的某個甚至是所有返回值賦值給空標識符的形式將其丟棄是合法的。然而,如果不需要得到函數的任何返回值,更為方便的做法是簡單地忽略它。這裡有些例子:

count, err = fmt.Println(x)  // 獲取打印的字節數以及相應的error值

count, _ = fmt.Println(x)   // 獲取打印的字節數,丟棄error值

_, err = fmt.Println(x)    // 丟棄所打印的字節數,並返回error值

fmt.Println(x)         // 忽略所有返回值

打印到終端的時候忽略返回值很常見,但是使用fmt.Fprint以及類似函數打印到文件和網絡連接等情況時,則應該檢查返回的錯誤值。(Go語言的打印函數將在3.5節詳細介紹。)

常量和變量

常量使用關鍵字const聲明;變量可以使用關鍵字var聲明,也可以使用快捷變量聲明語法。Go語言可以自動推斷出所聲明變量的類型,但是如果需要,顯式指定其類型也是合法的,比如聲明一種與Go語言的常規推斷不同的類型。下面是一些聲明的例子:

const limit = 512       // 常量,其類型兼容任何數字

const top uint16 = 1421    // 常量,類型:uint16

start := -19          // 變量,推斷類型:int

end := int64(9876543210)    // 變量,類型:int64

var i int            // 變量,值為0,類型:int

var debug = false       // 變量,推斷類型:bool

checkResults := true      // 變量,推斷類型:bool

stepSize := 1.5        // 變量,推斷類型:float64

acronym := "FOSS"       // 變量,推斷類型:string

對於整型字面量 Go語言推斷其類型為 int,對於浮點型字面量 Go語言推斷其類型為float64,對於複數字面量Go語言推斷其類型為complex128(名字上的數字代表它們所佔的位數)。通常的做法是不去顯式地聲明其類型,除非我們需要使用一個Go語言無法推斷的特殊類型。這點我們會在 2.3 節中討論。指定類型的數值常量(即這裡的top)只可用於別的數值類型相同的表達式中(除非經過轉換)。未指定類型的數值常量可用於別的數值類型為任何內置類型的表達式中(例如,常量limit可以用於包含整型或者浮點型數值的表達式中)。

變量i並沒有顯式的初始化。這在Go語言中非常安全,因為如果沒有顯式初始化,Go語言總是會將零值賦值給該變量。這意味著每一個數值變量的默認值都保證為 0,而每個字符串都默認為空。這可以保證Go程序避免遭受其他語言中的未初始化的垃圾值之災。

枚舉

需要設置多個常量的時候,我們不必重複使用const關鍵字,只需使用const關鍵字一次就可以將所有常量聲明組合在一起。(第1章中我們導入包的時候使用了相同的語法。該語法也可以用於使用 var 關鍵字來聲明一組變量。)如果我們只希望所聲明的常量值不同,並不關心其值是多少,那麼可以使用Go語言中相對比較簡陋的枚舉語法。

這3個代碼片段的作用完全一樣。聲明一組常量的方式是,如果第一個常量的值沒有被顯式設置(設為一個值或者是iota),則它的值為零值,第二個以及隨後的常量值則設為前面一個常量的值,或者如果前面常量的值為iota,則將其後續值也設為iota。後續的每一個iota值都比前面的iota值大1。

更正式的,使用iota預定義的標識符表示連續的無類型整數常量。每次關鍵字const出現時,它的值重設為零值(因此,每次都會定義一組新的常量),而每個常量的聲明的增量為1。因此在最右邊的代碼片段中,所有常量(指Magenta和Yellow)都被設為iota值。由於Cyan緊跟著一個const關鍵字,其iota值重設為0,即Cyan的值。Magenta的值也設為iota,但是這裡iota的值為1。類似地,Yellow的值也是iota,它的值為2。而且,如果我們在其末尾再添加一個Black(在const組內部),它的值就被隱式地設為iota,這時它的值就是3。

另一方面,如果最右邊的代碼片段中沒有iota標識符,Cyan就會被設為0,而Magenta的值則會設為Cyan的值,Yellow的值則被設為Magenta的值,因此最後它們都被設為零值。類似的,如果Cyan被設為9,那麼隨後的值也會被設為9。或者,如果Magenta的值設為5, Cyan的值就被設為 0(因為是組中的第一個值,並且沒有被設為一個顯式的值或者 iota), Magenta的值就是5(顯式地設置),而Yellow的值也是5(前一個常量的值)。

也可以將iota與浮點數、表達式以及自定義類型一起使用。

type BitFlag int

const (

Active BitFlag = 1 << iota         // 1 << 0 == 1

Send  // 隱式地設置成BitFlag = 1 << iota // 1 << 1 == 2

Receive //隱式地設置成BitFlag = 1 << iota  // 1 << 2 == 4

)

flag := Active | Send

在這個代碼片段中,我們創建了3個自定義類型BitFlag的位標識,並將變量flag(其類型為BitFlag)的值設為其中兩個值的按位或(因此flag的值為3,Go語言的按位操作符已在表2-6中給出)。我們可以略去自定義類型,這樣Go語言就會認為定義的常量是無類型整數,並將 flag的類型推斷成整型。BitFlag 類型的變量可以保存任何整型值,然而由於BitFlag是一個不同的類型,因此只有將其轉換成int型後才能將其與int型數據一起操作(或者將int型數據轉換成BitFlag類型數據)。

正如這裡所表示的,BitFlag 類型非常有用,但是用來調試不太方便。如果我們打印 flag的值,那麼得到的只是一個3,沒有任何標記表示這是什麼意思。Go語言很容易控制自定義類型的值如何打印,因為如果某個類型定義了String方法,那麼fmt包中的打印函數就會使用它來進行打印。因此,為了讓 BitFlag 類型可以打印出更多的信息,我們可以給該類型添加一個簡單的String方法。(自定義類型和方法的內容將在第6章詳細闡述。)

func (flag BitFlag) String string {

var flags string

if flag & Active == Active {

flags = append(flags, "Active")

}

if flag & Send == Send {

flags = append(flags, "Send")

}

if flag & Receive == Receive {

flags = append(flags, "Receive")

}

if len(flags) > 0 { // 在這裡,int(flag)用於防止無限循環,至關重要!

return fmt.Sprintf("%d(%s)", int(flag), strings.Join(flags, "|"))

}

return "0"

}

對於已設置好值的位域,該方法構建了一個(可能為空的)字符串切片,並將其以十進制整型表示的位域的值以及表示該值的字符串打印出來。(通過將%d標識符設為%b,我們可以輕易地將該值以二進制整數打印出來。)正如其中的註釋所說,當將flag傳遞給fmt.Sprintf函數的時候,將其類型轉換成底層的int 類型至關重要,否則 BitFlag.String方法會在flag上遞歸地調用,這樣就會導致無限的遞歸調用。(內置的append函數將在4.2.3節中講解。fmt.Sprintf和strings.Join函數將在第3章講解。)

Println(BitFlag(0), Active, Send, flag, Receive, flag|Receive)

0 1(Active) 2(Send) 3(Active|Send) 4(Receive) 7(Active|Send|Receive)

上面的代碼片段給出了帶String方法的BitFlag類型的打印結果。很明顯,與打印純整數相比,這樣的打印結果對於調試代碼更有用。

當然,也可以創建表示某個特定範圍內的整數的自定義類型,以便創建一個更加精細的自定義枚舉類型,我們會在第6章詳細闡述自定義類型的內容。Go語言中關於枚舉的極簡方式是Go哲學的典型:Go語言的目標是為程序員提供他們所需要的一切,包括許多強大而方便的特性,同時又讓該語言盡可能地保持簡小、連貫而且快速編譯和運行。

2.2 布爾值和布爾表達式

Go語言提供了內置的布爾值true和false。Go語言支持標準的邏輯和比較操作,這些操作的結果都是布爾值,如表2-3所示。

表2-3 布爾值和比較操作符

續表

布爾值和表達式可以用於if語句中,也可以用於for語句的條件中,以及switch語句的case子句的條件判斷中,這些都將在第5章講述。

二元邏輯操作符(||和&&)使用短路邏輯。這意味著如果我們的表達式是b1||b2,並且表達式b1的值為true,那麼無論b2的值為什麼,表達式的結果都為true,因此b2的值不會再計算而直接返回true。類似地,如果我們的表達式為b1&&b2,而表達式b1的計算結果為false,那麼無論表達式b2的值是什麼,都不會再計算它的值,而直接返回false。

Go語言會嚴格篩選用於使用比較操作符(<、<=、==、!=、>=、>)進行比較的值。這兩個值必須是相同類型的,或者如果它們是接口,就必須實現了相同的接口類型。如果有一個值是常量,那麼它的類型必須與另一個類型相兼容。這意味著一個無類型的數值常量可以跟另一個任意數值類型的值進行比較,但是不同類型且非常量的數值不能直接比較,除非其中一個被顯式的轉換成與另一個相同類型的值。(數字之間轉換的內容已在2.3節討論過。)

==和!=操作符可以用於任何可比較的類型,包括數組和結構體,只要它們的元素和成員變量與==和!=操作符相兼容。這些操作符不能用於比較切片,儘管這種比較可以通過 Go 標準庫中的reflect.DeepEqual函數來完成。==和!=操作符可以用於比較兩個指針和接口,或者將指針、接口或者引用(比如指向通道、映射或切片)與nil比較。別的比較操作符(<、<=、>=和>)只適用於數字和字符串。(由於Go也跟C和Java一樣,不支持操作符重載,對於我們自定義的類型,如果需要,可以實現自己的比較方法或者函數,如Less或者Equal,詳見第6章。)

2.3 數值類型

Go語言提供了大量內置的數值類型,標準庫也提供了big.Int類型的整數和big.Rat類型的有理數,這些都是大小不限的(只限於機器的內存)。每一個數值類型都不同,這意味著我們不能在不同的類型(例如,類型int32和類型int)之間進行二進制數值運算或者比較操作(如+或者<)。無類型的數值常量可以兼容表達式中任何(內置的)類型的數值,因此我們可以直接將一個無類型的數值常量與另一個數值做加法,或者將一個無類型的常量與另一個數值進行比較,無論另一個數值是什麼類型(但必須為內置類型)。

如果我們需要在不同的數值類型之間進行數值運算或者比較操作,就必須進行類型轉換,通常是將類型轉換成最大的類型以防止精度丟失。類型轉換採用 type(value)的形式,只要合法,就總能轉換成功——即使會導致數據丟失。請看下面的例子。

const factor = 3      // factor與任何數值類型兼容

i := 20000         // 通過推斷得出i的類型為int

i *= factor

j := int16(20)       // j的類型為int16,與這樣定義效果一樣:var j int16 = 20

i += int(j)        // 類型必須匹配,因此需要轉換

k := uint8(0)       // 效果與這樣定義一樣:var k uint8

k = uint8(i)        // 轉換成功,但是k的值被截為8位

fmt.Println(i, j, k)   // 打印:60020 20 16

為了執行縮小尺寸的類型轉換,我們可以創建合適的函數。例如:

func Uint8FromInt(x int) (uint8, error) {

if 0 <= x && x <= math.MaxUint8 {

return uint8(x), nil

}

return 0, fmt.Errorf("%d is out of the uint8 range", x)

}

該函數接受一個int型參數,如果給定的int值在給定的範圍內,則返回一個uint8和nil,否則返回0和相應的錯誤值。math.MaxUint8常量來自於math包,該包中也有一些類似的Go語言中其他內置類型的常量。(當然,無符號的類型沒有最小值常量,因為它們的最小值都為 0。)fmt.Errorf函數返回一個基於給定的格式化字符串和值創建的錯誤值。(字符串格式化的內容將在3.5節討論。)

相同類型的數值可以使用比較操作符進行比較(參見表 2-3)。類似地,Go語言的算術操作符可以用於數值。表 2-4 給出的算術運算操作符可用於任何內置的數值,而表 2-6 給出的算術運算操作符適用於任何整型值。

表2-4 可用於任何內置的數值的算術運算操作符

1 異常,即panic,見1.6節和5.5節。

常量表達式的值在編譯時計算,它們可能使用任何算術、布爾以及比較操作符。例如:

const (

efri int64 = 10000000000 // 類型:int64

hlutfollum = 16.0 / 9.0 // 類型:float64

malikvarea = complex(-2, 3.5) * hlutfo llum // 類型:complex128

erGjaldgengur = 0.0 <= hlutfollum && hlutfollum < 2.0 // 類型: bool

)

該例子使用冰島語標識符表示 Go語言完全支持本土語言的標識符。(我們馬上會討論complex,參見2.3.2節。)

雖然Go語言的優先級規則比較合理(即不像C和C++那樣),我們還是推薦使用括號來保證清晰的含義。強烈推薦使用多種語言進行編程的程序員使用括號,以避免犯一些難以發現的錯誤。

2.3.1 整型

Go語言提供了11種整型,包括5種有符號的和5種無符號的,再加上1種用於存儲指針的整型類型。它們的名字和值在表2-5中給出。另外,Go語言允許使用byte來作為無符號uint8類型的同義詞,並且使用單個字符(即Unicode碼點)的時候提倡使用rune來代替int32。大多數情況下,我們只需要一種整型,即int。它可以用於循環計數器、數組和切片索引,以及任何通用目的的整型運算符。通常,該類型的處理速度也是最快的。本書撰寫時,int類型表示成一個有符號的32位整型(即使在64位平台上也是這樣的),但在Go語言的新版本中可能會改成64位的。

表2-5 Go語言的整數類型及其範圍

從外部程序(如從文件或者網絡連接)讀寫整數時,可能需要別的整數類型。這種情況下需要確切地知道需要讀寫多少位,以便處理該整數時不會發生錯亂。

常用的做法是將一個整數以int 類型存儲在內存中,然後在讀寫該整數的時候將該值顯式地轉換為有符號的固定尺寸的整數類型。byte(uint8)類型用於讀或者寫原始的字節。例如,用於處理UTF-8編碼的文本。在前一章的americanise示例中,我們討論了讀寫UTF-8編碼的文本的基本方式,第8章中我們會繼續講解如何讀寫內置以及自定義的數據類型。

Go語言的整型支持表2-4中所列的所有算術運算,同時它們也支持表2-6中所列出的算術和位運算。所有這些操作的行為都是可預期的,特別是本書給出了很多示例,因此無需更深入討論。

表2-6 只適用於內置的整數類型的算術運算操作符

將一個更小類型的整數轉換成一個更大類型的整數總是安全的(例如,從 int16 轉換成int32),但是如果向下轉換一個太大的整數到一個目標類型或者將一個負整數轉換成一個無符號整數,則會產生無聲的截斷或者一個不可預期的值。這種情況下最好使用一個自定義的向下轉換函數,如前文給出的那個。當然,當試圖向下轉換一個字面量時(如int8(200)),編譯器會檢測到問題,並報告異常錯誤。也可以使用標準 Go 語法將整數轉換成浮點型數字(如float64(integer))。

有些情況下,Go語言對64位整數的支持讓使用大規格的整數來進行高精度計算成為可能。例如,在商業上計算財務時使用int64類型的整數來表示百萬分之一美分,可以使得在數十億美元之內計算還保持著足夠高的精度,這樣做有很多用途,特別是當我們很關心除法操作的時候。如果計算財務時需要完美的精度,並且需要避免餘數錯誤,我們可以使用big.Rat類型。

大整數

有時我們需要使用甚至超過int64位和uint64位的數字進行完美的計算。這種情況下,我們就不能使用浮點數了,因為它們表示的是近似值。幸運的是,Go語言的標準庫提供了兩個無限精度的整數類型:用於整數的big.Int型以及用於有理數的big.Rat型(即包括可以表示成分數的數字如和1.1496,但不包括無理數如e或者π)。這些整數類型可以保存任意數量的數字——只要機器內存足夠大,但是其處理速度遠比內置的整型慢。

Go語言也像C和Java一樣不支持操作符重載,提供給big.Int和big.Rat類型的方法有它自己的名字,如Add和Mul。在大多數情況下,方法會修改它們的接收器(即調用它們的大整數),同時會返回該接收器來支持鏈式操作。我們並沒有列出 math/big 包中提供的所有函數和方法,它們都可以在文檔上查到,並且也可能在本書出版之後又添加了新內容。但是,我們會給出一個具有代表性的例子來看看big.Int是如何使用的。

使用Go語言內置的float64類型,我們可以很精確地計算包含大約15位小數的情況,這在大多數情況下足夠了。但是,如果我們想要計算包含更多位小數,即數十個甚至上百個小數時,例如計算 π的時候,那麼就沒有內置的類型可以滿足了。

1706年,約翰·梅欽(John Machin)發明了一個計算任意精度 π 值的公式(見圖2-1),我們可以將該公式與Go標準庫中的big.Int結合起來計算 π,以得到任意位數的值。在圖2-1中給出了該公式以及它依賴的arccot函數。(理解這裡介紹的big.Int包的使用無需理解梅欽的公式。)我們實現的arccot函數接受一個額外的參數來限制計算結果的精度,以防止超出所需的小數位數。

圖2-1 Machin的公式

整個程序在文件pi_by_digits/pi_by_digits.go中,不到80行。下面是它的main函數[1]。

func main {

places := handleCommandLine(1000)

scaledPi := fmt.Sprint(π(places))

fmt.Printf("3.%s\n", scaledPi[1:])

}

該程序假設默認的小數位數為1 000,但是用戶可以在命令行中指定任意的小數位數。handleCommandLine函數(這裡沒有給出)返回傳遞給它的值,或者是用戶從命令行輸入的數字(如果有並且是合法的話)。π函數將 π 以big.Int型返回,它的值為314159…。我們將該值打印到一個字符串,然後將字符串以適當的格式打印到終端,以便看起來像3.1415926535897 9323846264338327950288419716939937510這樣(這裡我們打印了將近50位)。

func π(places int) *big.Int {

digits := big.NewInt(int64(places))

unity := big.NewInt(0)

ten := big.NewInt(10)

exponent := big.NewInt(0)

unity.Exp(ten, exponent.Add(digits, ten), nil) 1

pi := big.NewInt(4)

left := arccot(big.NewInt(5), unity)

left.Mul(left, big.NewInt(4))  2

right := arccot(big.NewInt(239), unity)

left.Sub(left, right)

pi.Mul(pi, left) 3

return pi.Div(pi, big.NewInt(0).Exp(ten, ten, nil)) 4

}

π函數開始時計算unity變量的值(10digits+10),我們將其當做一個放大因子來使用,以便計算的時候可以使用整數。為了防止餘數錯誤,使用+10操作為用戶添加額外10個數字。然後,我們使用了梅欽公式,以及我們修改過的接受unity 變量作為其第二個參數的arccot函數(沒有給出)。最後,我們返回除以1010的結果,以還原放大因子unity的效果。

為了讓unity變量保存正確的值,我們開始創建4個變量,它們的類型都是*big.Int(即指向big.Int的指針,參見4.1節)。unity和exponent變量都被初始化成0,變量ten初始化成10,digits被初始化成用戶請求的數字的位數。unity值的計算一行就完成了(1)。big.Int.Add方法往變量digits中添加了10。然後big.Int.Exp方法用於將10增大到它的第二個參數(digits+10)的冪。如果第三個參數像這裡一樣是nil,big.Int.Exp(x, y, nil)進行xy計算。如果3個參數都是非空的,big.Int.Exp(x, y, z)執行(xy模z)。值得注意的是,我們無需將結果賦給unity變量,這是因為大部分big.Int方法返回的同時會修改它的接收器,因此在這裡unity被修改成包含結果值。

接下來的計算模式類似。我們為pi設置一個初始值4,然後返回梅欽公式內部的左半部分。創建完成之後,我們無需將left的值賦回去(2),因為big.Int.Mul方法會在返回時將結果(我們可以安全地忽略它)保存回其接收器中(在本例中即保存回 left 變量中)。接下來,我們計算公式內部右半部分的值,並從left中減去right的值(將其結果保存在left)中。現在我們用pi(其值為4)乘以left(它保存了梅欽公式的結果)。這樣就得到了結果,只是被放大了unity倍。因此,在最後一行中(4),我們將其值除以(1010)以還原其結果。

使用big.Int類型需小心,因為它的大多數方法都會修改它的接收器(這樣做是為了節省創建大量臨時big.Int值的開銷)。與執行pi×left計算並將計算結果保存在pi中的那一行(3)相比,我們計算pi÷1010並將結果立即返回(4),而無需關心pi的值最後已經被修改。

無論什麼時候,最好只使用int類型,如果int型不能滿足則使用int64型,或者如果不是特別關心它們的近似值,則可以使用float32或者float64類型。然而,如果計算需要完美的精度,並且我們願意付出使用內存和處理器的代價,那麼就使用 big.Int 或者 big.Rat類型。後者在處理財務計算時特別有用。進行浮點計算時,如果需要可以像這裡所做的那樣對數值進行放大。

2.3.2 浮點類型

Go語言提供了兩種類型的浮點類型和兩種類型的複數類型,它們的名字及相應的範圍在表 2-7中給出。浮點型數字在 Go語言中以廣泛使用的IEEE-754 格式表示(http://en.wikipedia.org/wiki/IEEE_754-2008)。該格式也是很多處理器以及浮點數單元所使用的原生格式,因此大多數情況下Go語言能夠充分利用硬件對浮點數的支持。

表2-7 Go語言的浮點類型

Go語言的浮點數支持表2-4中所有的算術運算。math包中的大多數常量以及所有函數都在表2-8和表2-10中列出。

表2-8 math包中的常量與函數 #1

續表

表2-9 math包中的常量與函數 #2

續表

表2-10 math包中的常量與函數 #3

續表

浮點型數據使用小數點的形式或者指數符號來表示,例如0.0、3.、8.2、−7.4、−6e4、.1以及5.9E-3等。計算機通常使用二進製表示浮點數,這意味著有些小數可以精確地表示(如0.5),但是其他的浮點數就只能近似表示(如 0.1和0.2)。另外,這種表示使用固定長度的位,因此它所能表示的數字的位數有限。這不是Go語言特有的問題,而是困擾所有主流語言的浮點數問題。然而,這種不精確性並不是總都這麼明顯,因為 Go語言使用了智能算法來輸出浮點數,這些浮點數在保證精確性的前提下使用盡可能少的數字。

表2-3中所列出的所有比較操作都可以用於浮點數。不幸的是,由於浮點數是以近似值表示的,用它們來做相等或者不相等比較時並不總能得到預期的結果。

x, y := 0.0, 0.0

for i := 0; i < 10; i++ {

x += 0.1

if i%2 == 0{

y += 0.2

} else {

fmt.Printf("%-5t %-5t %-5t %-5t", x == y, EqualFloat(x, y, -1),

EqualFloat(x, y, 0.000000000001), EqualFloatPrec(x, y, 6))

fmt.Println(x, y)

}

}

true true true true 0.2 0.2

true true true true 0.4 0.4

false false true true 0.6 0.6000000000000001

false false true true 0.7999999999999999 0.8

false false true true 0.9999999999999999 1

這裡開始時我們定義了兩個float64型的浮點數,其初始值都為0。我們往第一個值中加上10個0.1,往第二個值中加上5個0.2,因此結果都為1。然而,正如代碼片段下面所給出的輸出所示,有些浮點數並不能得到完美的結果。這樣看來,計算使用==以及!= 對浮點數進行比較時,我們必須非常小心。當然,有些情況下可以使用內置的操作符來比較浮點數的相等或者不相等性。例如,為了避免除數為0,可以這樣做if y != 0.0 { return x / y}。

格式〞%-5〞以一個向左對齊的5 個字符寬的區域打印一個布爾值。字符串格式化的內容將在下一章講解,參見3.5節。

func EqualFloat(x, y, limit float64) bool {

if limit <= 0.0 {

limit = math.SmallestNonzeroFloat64

}

return math.Abs(x-y) <= (limit * math.Min(math.Abs(x), math.Abs(y)))

}

EqualFloat函數用於在給定精度範圍內比較兩個float64型數,如果給定的精度範圍為負數(如−1),則將該精度設為機器所能達到的最大精度。它還依賴於標準庫math包中的一個函數(以及一個常量)。

一個可替代(也更慢)的方式是以字符串的形式比較兩個數字。

func EqualFloatPrec(x, y float64, decimals int) bool {

a := fmt.Sprintf("%.*f", decimals, x)

b := fmt.Sprintf("%.*f", decimals, y)

return len(a) == len(b) && a == b

}

對於該函數,其精度以小數點後面數字的位數聲明。fmt.Sprintf函數的%格式化參數能夠接受一個*佔位符,用於輸入一個數字,因此這裡我們基於給定的float64創建了兩個字符串,每個字符串都以給定位數的尾數進行格式化。如果浮點數中數字的多少不一樣,那麼字符串a和b的長度也不一樣(例如,12.32和592.85),這樣就能給我們一個快速的短路測試。(字符串格式化的內容將在3.5節講解。)

大多數情況下如果需要浮點數,float64類型是最好的選擇,一個特別原因是math包中的所有函數都使用float64類型。然而,Go語言也支持float32類型,這在內存比較寶貴並且無需使用math包,或者願意處理在與float64類型之間進行來回轉換的不便時非常有用。由於Go語言的浮點類型是固定長度的,因此從外部文件或者網絡連接中讀寫時非常安全。

使用標準的Go語法(例如int(float))可以將浮點型數字轉換成整數,這種情況下小數部分會被丟棄。當然,如果浮點數的值超出了目標整型的範圍,那麼得到的結果值將是不可預期的。我們可以使用一個安全的轉換函數來解決該問題。例如:

func IntFromFloat64(x float64) int {

if math.MinInt32 <= x && x <= math.MaxInt32 {

whole, fraction := math.Modf(x)

if fraction >= 0.5 {

whole++

}

return int(whole)

}

panic(fmt.Sprintf("%g is out of the int32 range", x))

}

Go語言規範(golang.org/doc/go_spec.html)中說明了int型所佔的位數與uint相同,並且uint總是32位或者64位的。這意味著一個int型值至少是32位的,我們可以安全地使用math.MinInt32和math.MaxInt32常量來作為int的範圍。

我們使用 math.Modf函數來分離給定數字(都是 float64 型數字)的整數以及分數部分,而非簡單地返回整數部分(即截斷),如果小數部分大於或者等於0.5,則向上取整。

與我們的自定義Uint8FromInt函數不同的是,我們不是返回一個錯誤值,而是將值越界當做一個需要停止程序運行的重要問題,因此我們使用了內置的panic函數,它會產生一個運行時異常,並停止程序繼續運行,直到該異常被一個recover調用恢復(參見5.5節)。這意味著如果程序運行成功,我們就知道轉換過程沒有發生值越界。(值得注意的是,該函數並沒有以一個return語句結束,Go編譯器足夠智能,能夠意識到panic調用意味那裡不會出現正常的返回值。)

複數類型

Go語言支持的兩種複數類型已在表2-7中給出。複數可以使用內置的complex函數或者包含虛部數值的常量來創建。複數的各部分可以使用內置的real和imag函數來獲得,這兩個函數返回的都是float64型數(或者對於complex64類型的複數,返回一個float32型數)。

複數支持表2-4中所有的算術操作符。唯一可用於複數的比較操作符是==和!=(參見表2-3),但也會遇到與浮點數比較相同的問題。標準庫中有一個複數包math/cmplx,表2-11給出了它的函數。

表2-11 Complex數學包中的函數

續表

這裡有些簡單的例子:

f := 3.2e5             // 類型:float64

x := -7.3 - 8.9i          // 類型:complex128(字面量)

y := complex64(-18.3 + 8.9i)    // 類型:complex64(轉換)1

z := complex(f, 13.2)        // 類型:complex128(構造)2

fmt.Println(x, real(y), imag(z))  // 打印:(-7.3-8.9i) -18.3 13.2

正如數學中所表示的那樣,Go語言使用後綴i表示虛數[2]。這裡,數x和z都是complex128類型的,因此它們的實部和虛部都是float64類型的。y是complex64類型的,因此它的各部分都是float32類型的。需要注意的一點小細節是,使用complex64類型的名字(或者是任何其他內置的類型名)來作為函數會進行類型轉換。因此這裡(1)複數 -18.3+8.9i(從複數字面量推斷出來的複數類型為complex128)被轉換成一個complex64類型的複數。然而, complex是一個函數,它接受兩個浮點數輸入,返回對應的complex128(2)。

另一個細節點是fmt.Println函數可以統一打印複數。(就像將在第6章看到的那樣,我們可以創建自己的無縫兼容 Go語言的打印函數的類型,只需為它們簡單地添加一個String方法即可實現。)

一般而言,最適合使用的複數類型是complex128,因為math/cmplx包中的所有函數都工作於complex128類型。Go語言也支持complex64類型,這在內存非常緊缺的情況下是非常有用的。Go語言的複數類型是定長的,因此從外部文件或網絡連接中讀寫複數總是安全的。

本章中我們講解了 Go語言的布爾類型以及數值類型,同時在表格中給出了可以查詢和操作它們的操作符和函數。下一章將講解Go語言的字符串類型,包括對Go語言的格式化打印功能(參見 3.5 節)的全面講解,當然其中也包括我們需要的格式化打印布爾值和數字的內容。第8章中我們會看看如何對文件進行數據類型的讀寫,包括布爾型和數值類型,在本章結束之前,我們會講解一個短小但是完全能夠工作的示例程序。

2.4 例子:statistics

這個例子的目的是為了提高大家對Go編程的理解並提供實踐機會。就如同第一章,這個例子使用了一些還沒有完整講解的Go語言特性。這應該不是大問題,因為我們提供了相應的簡單解釋和交叉引用。這個例子還很簡單的使用了 Go語言官方網絡庫 net/http 包。使用net/http包我們可以非常容易地創建一個簡單的HTTP服務器。最後,為了不脫離本章的主題,這節的例子和練習都是數值類型的。

statistics程序(源碼在statistics/statistics.go文件裡)是一個Web應用,先讓用戶輸入一串數字,然後做一些非常簡單的統計計算,如圖 2-2 所示。我們分兩部分來講解這個例子,先介紹如何實現程序中相關的數學功能,然後再講解如何使用net/http包來創建一個Web應用程序。由於篇幅有限,而且書中的源碼均可從網上下載,所以有側重地只顯示部分代碼(對於import部分和一些常量等可能會被忽略掉),當然,為了讓大家能更好地理解我們會盡可能講解得全面些。

圖2-2 Linux和Windows上的Statistics示例程序

2.4.1 實現一個簡單的統計函數

我們定義了一個聚合類型的結構體,包含用戶輸入的數據以及我們準備計算的兩種統計:

type statistics struct {

numbers float64

mean  float64

mdian  float64

}

Go語言裡的結構體類似於C裡的結構體或者Java裡只有public數據成員的類(不能有方法),但是不同於C++的結構體,因為它並不是一個類。我們在6.4節將會看到,Go語言裡的結構體對聚合和嵌入的支持是非常完美的,是Go語言面向對像編程的核心(主要介紹在第6章)。

func getStats(numbers float64) (stats statistics) {

stats.numbers = numbers

sort.Float64s(stats.numbers)

stats.mean = sum(numbers) / float64(len(numbers))

stats.median = median(numbers)

return stats

}

getStats 函數的作用就是對傳入的float64 切片(這些數據都在processRequest裡得到)進行統計,然後將相應的結果保存到stats結果變量中。其中計算中位數使用了sort包裡的Float64s函數對原數組進行升序排列(原地排序),也就是說 getStats函數修改了它的參數,這種情況在傳切片、引用或者函數指針到函數時是很常見的。如果需要保留原始切片,可以使用Go語言內置的copy函數(參見4.2.3節)將它賦值到一個臨時變量,使用臨時變量來工作。

結構體中的mean(通常也叫平均數)是對一連串的數進行求和然後除以總個數得到的結果。這裡我們使用一個輔助函數sum求和,使用內置的len取得切片的大小(總個數)並將其強制轉換成float64類型的變量(因為sum函數返回一個float64的值)。這樣我們也就確保了這是一個浮點除法運算,避免了使用整數類型可能帶來的精度損失問題。median是用來保存中位數的,我們使用median函數來單獨計算它。

我們沒有檢查除數為0的情況,因為在我們的程序邏輯裡,getStats函數只有在至少有1個數據的時候才會被調用,否則程序會退出並產生一個運行時異常(runtime panic)。對於一個關鍵性應用當發生一個異常時程序是不應該被結束的,我們可以使用 recover來捕獲這個異常,將程序恢復到一個正常的狀態,讓程序繼續運行(5.5節)。

func sum(numbers float64) (total float64) {

for _, x := range numbers {

total += x

}

return total

}

這個函數使用一個for…range循環遍歷一個切片並將所有的數據相加計算出它們的和。Go語言總是將所有變量初始化為0,包括已經命名了的返回變量,例如total,這是一個相當有益的設計。

func median(numbers float64) float64 {

middle := len(numbers) / 2

result := numbers[middle]

if len(numbers)%2 == 0 {

result = (result + numbers[middle-1]) / 2

}

return result

}

這個函數必須傳入一個已經排序好了的切片,它一開始將切片裡最中間的那個數保存到result 變量中,但是如果總個數是偶數,就會產生兩個中間數,我們取這兩個中間數的平均值作為中位數返回。

在這一小部分裡我們講解了這個統計程序最主要的幾個處理過程,在下一部分我們來看看一個只有簡單頁面的Web程序的基本實現。(讀者如果對Web編程不感興趣的話可以略過本節直接跳到練習或者跳到下一章。)

2.4.2 實現一個基本的HTTP服務器

這個statistics程序在本機上提供了一個簡單網頁,它的主函數如下:

func main {

http.HandleFunc("/", homePage)

if err := http.ListenAndServe(":9001", nil); err != nil {

log.Fatal("failed to start server", err)

}

}

http.HandleFunc函數有兩個參數:一個路徑,一個當這個路徑被請求時會被執行的函數的引用。這個函數的簽名必須是func(http.ResponseWriter, *http.Request)我們可以註冊多個「路徑-函數」對,這裡我們只註冊了「/」(通常是網頁程序的主頁)和一個自定義的homePage函數。

http.ListenAndServe函數使用給定的TCP地址啟動一個Web服務器。這裡我們使用localhost和端口9001。如果只指定了端口號而沒有指定網絡地址,默認情況下網絡地址是 localhost。當然也可以這樣寫「localhost:9001」或者「127.0.0.1:9001」。端口的選擇是任意的,如果和現有的服務器有衝突的話,比如端口已經被其他進程佔用了等,修改代碼中的端口為其他端口號即可。http.ListenAndServe的第二個參數支持自定義的服務器,為空的話(傳一個nil參數)表示使用默認的類型。

這個程序使用了一些字符串常量,但是這裡我們只展示其中的一個。

form = '<form action="/" method="POST">

<label for="numbers">Numbers (comma or space-separated):</label><br />

<input type="text" name="numbers" size="30"><br />

<input type="submit" >

</form>'

字符串常量form包含一個HTML的表單元素,包含一些文本和一個提交按鈕。

func homePage(writer http.ResponseWriter, request *http.Request) {

err := request.ParseForm // 必須在寫響應內容之前調用

fmt.Fprint(writer, pageTop, form)

if err != nil {

fmt.Fprintf(writer, anError, err)

} else {

if numbers, message, ok := processRequest(request); ok {

stats := getStats(numbers)

fmt.Fprint(writer, formatStats(stats))

} else if message != "" {

fmt.Fprintf(writer, anError, message)

}

}

fmt.Fprint(writer, pageBottom)

}

當統計網站被訪問的時候會調用這個函數,request參數包含了請求的詳細信息,我們可以往writer裡寫入一些響應信息(HTML格式)。

我們從分析這個表單開始吧。這個表單一開始只有一個空的文本輸入框(text),我們將這個文本輸入框標識為「numbers」,這樣當後面我們處理這個表單的時候就能找到它。表單的action設置為"/",當用戶點擊Calculate按鈕的時候這個頁面被重新請求了一次。這也就是說不管什麼情況這個homePage函數總是會被調用的,所以它必須處理幾個情況:沒有數據輸入、有數據輸入或者發生錯誤了。實際上,所有的工作都是由一個叫processRequest的自定義函數來完成的,它對每一種情況都做了相應的處理。

分析完表單之後,我們將pageTop(源碼可見)和form這兩個字符串常量寫到writer裡去(返回數據給客戶端),如果分析表單失敗我們寫入一個錯誤信息:anError 是一個格式化字符串,err是即將被格式化的error值(格式化字符串3.5節會提到)。

anError = '<p>%s</p>'

如果分析成功了,我們調用自定義函數 processRequest處理用戶鍵入的數據。如果這些數據都是有效的,我們調用之前提到過的getStats函數來計算統計結果,然後將格式化後的結果返回給客戶端,如果接受到的數據無效,且我們得到了錯誤信息,則返回這個錯誤信息(當這個表單第一次顯示的時候是沒有數據的,也沒有錯誤發生,這種情況下 ok 變量的值是false,而且message為空)。最後我們打印出pageBottom字符串常量(源碼可見),用來關閉<body>和<html>標籤。

func processRequest(request *http.Request) (float64, string, bool) {

var numbers float64

if slice, found := request.Form["numbers"]; found && len(slice) > 0 {

text := strings.Replace(slice[0], ",", " ", -1)

for _, field := range strings.Fields(text) {

if x, err := strconv.ParseFloat(field, 64); err != nil {

return numbers, "'" + field + "' is invalid", false

} else {

numbers = append(numbers, x)

}

}

}

if len(numbers) == 0 {

return numbers, "", false // 第一次沒有數據被顯示

}

return numbers, "", true

}

這個函數從request裡讀取表單的數據。如果這是用戶首次請求的話,表單是空的,「numbers」輸入框裡沒有數據,不過這並不是一個錯誤,所以我們返回一個空的切片、一個空的錯誤信息和一個false 布爾型的值,表明從表單裡沒有讀取到任何數據。這些結果將會以空的表單形式被展示出來。如果用戶有輸入數據的話我們返回一個float64 類型的切片、一個空的錯誤信息以及true;如果存在非法數據,則返回一個可能為空的切片、一個錯誤消息和false。

request結構裡有一個map[string]string類型的Form成員(參見4.3節),它的鍵是一個字符串,值是一個字符串切片,所以一個鍵可能有任意多個字符串在它的值裡。例如:如果用戶鍵入「5 8.2 7 13 6」,那麼這個Form裡有一個叫「numbers」的鍵,它的值是string{"5 8.2 7 13 6"},也就是說它的值是一個只有一個字符串的字符串切片(作為對比,這裡有一個包含兩個字符串的字符串切片:string{"1 2 3","a b c"})。我們檢查這個「numbers」鍵是否存在(應該存在),如果存在,而且它的值至少有一個字符串,那麼我們有數據可以讀了。

我們使用strings.Replace函數(第三個參數指明要執行多少次替換,−1表示替換所有)將用戶輸入中的所有逗號轉換為空格,得到一個新的字符串。新字符串裡所有數據都是由空格分隔開的,再使用strings.Fields函數根據空白處將字符串切分成一個字符串切片,這樣我們就可以直接使用for...range 循環來遍歷它了(strings 這個包的函數參見3.6 節, for...range 循環請參見 5.3 節)。對於每一個字符串,例如「5」、「8.2」等,用strconv.ParseFloat函數將它轉換成float64類型,這個函數需要傳入一個字符串和一個位大小如32或者64(參見3.6節)。如果轉換失敗我們立即返回現有已經轉好了的數據切片、一個非空的錯誤信息和false。如果轉換成功我們將轉換的結果float64類型的數據追加到numbers切片裡去,內置的函數append可以將一個或多個值和原有切片合併返回一個新的切片,如果原來的切片的容量比長度大的話,這個函數執行的過程是非常快的,效率很高(關於append參見4.2.3節)。

假如程序沒有因為錯誤退出(存在非法數據),將返回數值和一個空的錯誤信息以及true。沒有數據需要處理(如這個表單第一次被訪問的時候)的情況下返回false。

func formatStats(stats statistics) string {

return fmt.Sprintf('<table border="1">

<tr><th colspan="2">Results</th></tr>

<tr><td>Numbers</td><td>%v</td></tr>

<tr><td>Count</td><td>%d</td></tr>

<tr><td>Mean</td><td>%f</td></tr>

<tr><td>Median</td><td>%f</td></tr>

</table>', stats.numbers, len(stats.numbers), stats.mean, stats.median)

}

一旦計算完畢我們必須將結果返回給用戶。因為程序是一個Web應用,所以我們需要生成HTML。(Go語言的標準庫提供了用於創建數據驅動文本和HTML的text/template和html/template包,但是我們這裡的需求比較簡單,所以我們選擇自己手動寫HTML。9.4.2節有一個簡單的使用text/template包的例子。)

fmt.Sprintf是一個字符串格式化函數,需要一個格式化字符串和一個或多個值,將這一個或多個值按照格式中指定的動作(如%v、%d、%f 等)進行轉換,返回一個新的格式化後的字符串(格式化字符串在3.5節裡有非常詳細的描述)。我們不需要做任何的HTML轉義,因為我們所有的值都是數字。(如果需要的話我們可以使用 template.HTMLEscape或者html.EscapeString函數。)

從這個例子可以瞭解,假如我們瞭解基本的HTML語法,使用Go語言來創建一個簡單的Web應用是非常容易的。Go語言標準庫提供的html、net/http、html/template和text/template等包讓整個事情就變得更加簡單。

2.5 練習

本章有兩道數值相關的練習題。第一題需要修改我們之前的statistics 程序。第二題就是動手創建一個Web應用,實現一些簡單的數學計算。

(1)複製 statistics 目錄為比如 my_statistics,然後修改 my_statistics/statistics.go 代碼,實現估算眾數和標準差的功能,當用戶點擊頁面上的Calculate 按鈕時能產生類似圖2-3所示的結果。

圖2-3 MacOSX上的statistics示例程序

這需要在statistics 結構體裡增加一些成員並實現兩個新函數去執行計算。可以參考statistics_ans/statistics.go 文件裡的答案。這大概增加了40 行代碼和使用了Go語言內置的append函數將數字追加到切片裡面。

寫一個計算標準差的函數也很容易,只需要使用math 包裡面的函數,不到 10 行代碼就可以完成。我們使用公式來計算,其中x表示每一個數字,表示數學平均數,n是數字的個數。

眾數是指出現最多次的數,可能不止一個,例如有兩個或者多個數的出現次數相等。但是,如果所有數的出現次數都是一樣的話,我們就認為眾數是不存在的。計算眾數要比標準差難,大概需要20行左右的代碼。

(2)創建一個Web應用,使用公式來求二次方程的解。要用複數,這樣即使判別式b2−4ac部分為負能計算出方程的解。剛開始的時候可以先讓程序能夠工作起來,如圖2-4左圖所示,然後再修改你的代碼讓它輸出得更美觀一些,如圖2-4右圖所示。

圖2-4 Linux上的二次方程求解

最簡單的做法就是直接使用 statistics 程序的main函數、homePage函數以及processRequest函數,然後修改 homePage讓它調用我們自定義的3 個函數:formatQuestion、solve和formatSolutions,還有processRequest函數要用來讀取那3個浮點數,這個改動的代碼多一點。

第一個參考答案在quadratic_ans1/quadratic.go裡,約120行代碼,只實現了基本的功能,使用EqualFloat函數來判斷方程的兩個解是否是約等的,如果約等,只返回一個解。(EqualFloat函數在之前有討論過。)

第二個參考答案在quadratic_ans2/quadratic.go裡,約160行代碼,相比第一個主要是優化了輸出的結果。例如,它將「+ -」替換成「-」,將「1x」替換成「x」,去掉係數為0的項(例如「0x」等),使用math/cmplx包裡的cmplx.IsNaN函數將一個虛數部分近似0的解轉換成浮點數,等等。此外,還用了一些高級的字符串格式化技巧(主要在3.5節介紹)。


[1].這裡的實現基於http://en.literateprograms.org/Pi_with_Machin's_formula_(Python)。

[2].相比之下,在工程上以及Python語言中,虛數用j來表示。