讀古今文學網 > Go語言程序設計 > 第3章 字符串 >

第3章 字符串

本章講解了Go語言的字符串類型,以及標準庫中與字符串類型相關的關鍵包。本章中各小節的內容包括如何寫字面量字符串以及如何使用字符串操作符,如何索引和切片字符串,如何格式化字符串、數值和其他內置類型甚至是自定義類型的輸出。

Go語言的高級字符串處理相關的功能幾乎每天都要用到,如一個字符一個字符迭代字符串的for…range循環,strings包和strconv包中的函數以及Go語言切片字符串的功能。儘管如此,本章還會深入講解 Go語言的字符串,包括一些底層細節,如字符串類型的內部表示。底層方面的東西非常有趣,並且有時非常有用。

一個 Go語言字符串是一個任意字節的常量序列。大部分情況下,一個字符串的字節使用UTF-8編碼表示Unicode文本(詳見上文中的「Unicode編碼」一欄)。Unicode編碼的使用意味著Go語言可以包含世界上任意語言的混合,代碼頁沒有任何混亂與限制。

Go語言的字符串類型在本質上就與其他語言的字符串類型不同。Java的String、C++的std::string以及Python 3的str類型都只是定寬字符序列,而Go語言的字符串是一個用UTF-8編碼的變寬字符序列,它的每一個字符都用一個或多個字節表示。

初次接觸時可能會覺得這些其他語言的字符串類型比 Go語言的字符串類型更加方便,因為它們的字符串中的單個字符可以被字節索引,這在 Go語言中只有在字符串只包含 7 位的ASCII字符(因為它們都用一個單一的UTF-8字節表示)時才可能。但在實際情況下,這從來都不是個問題。首先,直接索引使用得不多,而Go語言支持一個字符一個字符的迭代;其次,標準庫提供了大量的字符串搜索和操作函數;最後,我們隨時都可以將 Go語言的字符串轉換成一個Unicode碼點切片(其類型為rune),而這個切片是可以直接索引的。

雖然Java或者Python兩者也都有提供Unicode編碼的字符串,但與這些語言的字符串類型相比,Go語言使用UTF-8 編碼有更多的優點。Java使用碼點序列來表示字符串,每一個字符串佔用16位;2.x版本 到3.2版本的Python使用類似的方法,只是不同的方式編譯的Python使用的是16位 或者32位字符。對於英文文本,這意味著Go語言使用8位來表示每一個字符,Java或者Python則至少兩倍於此。UTF-8編碼的另一個優點是,無需關心機器碼的排列順序,而UTF-16和UTF-32編碼的字符串需要知道機器碼的排列順序以便將文本正確地解碼。其次,由於UTF-8是世界上文本文件的編碼標準,其他語言必須通過編碼解碼該文件的方式來從其內部編碼格式轉換過來,而Go語言能夠直接讀或者寫這些文件。此外,有些主要的庫(如GTK+)也原生使用UTF-8編碼的字符串,因此Go語言無需編碼解碼就可以使用它們。

Unicode編碼

在 Unicode編碼出現之前,要在單個文件中包含多種語言的文本幾乎是不可能的,比如在英文中引用某些日文或者俄文。因為每種語言使用的編碼方式不一樣,而一個文本文件只支持一種編碼方式。

Unicode被設計成能夠表示世界上各種寫作系統的字符,因此一個使用Unicode編碼的單一文件可以包含任意種語言的混合體,包括數學符號、「修飾符」以及其他特殊字符。

每一個 Unicode字符都有一個唯一的叫做「碼點」的標識數字。目前定義了超過 10 萬個 Unicode 字符,其碼點的值從 0x0 到 0x10FFFF(後者在 Go語言中被定義成一個常量unicode.MaxRune),其中有一些斷層和許多特殊的情況。在Unicode文檔中,碼點是用4個或者更多個十六進制數字以U+hhhh的形式表示的,如U+21D4表示⇔字符。

在 Go語言中,一個單一的碼點在內存中以 rune的形式表示。(rune 類型是 int32類型的別名,詳見2.3.1節。)

無論是在文件裡還是內存裡,Unicode 文本都必須用統一的編碼方式表示。Unicode 標準定義了一些Unicode變體格式(編碼),如UTF-8、UTF-16以及UTF-32編碼。Go語言的字符串類型使用UTF-8編碼。UTF-8編碼是用得最廣的編碼,也是文本文件的標準以及XML文件和JSON文件的默認編碼方式。

UTF-8編碼使用1~4個字節來表示每一個碼點。對於只包含7位的ASCII字符的字符串來說,字節和字符之間有一個一對一的關係,因為7位的ASCII字符正好可以用一個UTF-8字節來表示。這樣表示的結果是,UTF-8存儲英文文本時會非常緊湊(一個字節表示一個字符)的,另一個結果是一個用7位ASCII編碼的文本與一個用UTF-8編碼的文本沒有區別。

在實際使用中,只要我們學會了Go語言中使用字符串的範式,會發現Go語言的字符串與其他語言中的字符串類型一樣方便。

3.1 字面量、操作符和轉義

字符串字面量使用雙引號(〞)或者反引號(′)來創建。雙引號用來創建可解析的字符串字面量,如表 3-1 中所示的那些支持轉義的序列,但不能用來引用多行。反引號用來創建原生的字符串字面量,這些字符串可能由多行組成;它們不支持任何轉義序列,並且可以包含除了反引號之外的任何字符。可解析的字符串使用得最廣泛,而原生的字符串字面量則用於書寫多行消息、HTML以及正則表達式。這裡有些例子。

text1 := 〞\〞what's that?\〞, he said〞  // 可解析的字符串字面量

text2 := '〞what's that?〞, he said'   // 原生的字符串字面量

radicals := 〞√\u221A \U0000221a〞    // radicals == 〞√ √ √〞

表3-1 Go語言的字符串和字符轉義

上文中創建的3個變量都是字符串類型,變量text1和變量text2包含的是完全相同的文本。由於.go文件使用的是UTF-8編碼,因此我們可以包含Unicode編碼字符而無需拘泥於形式。然而我們仍然可以使用Unicode的轉義字符來表示第二個或者第三個√字符。但在這個特殊的例子中,我們不能使用八進制或者十六進制的轉義符,因為它們的碼點僅限於U+0000到U+00FF之間,對於√這個字符的碼點U+221A來說太小了。

如果我們想要創建一個長的可解析字符串字面量,但又不想在代碼中寫同樣長的一行,那麼我們可以創建多個字面量片段,使用+級聯符將這些片段連接起來。此外,雖然Go語言的字符串是不可變的,但它們支持+=追加操作符。如果底層的字符串容量不夠大,不能適應添加的字符串,級聯追加操作將導致底層的字符串被替換。這些操作符詳見表3-2。字符串也可以使用比較操作符(見表2-3)來進行比較。這裡有個例子使用到了這些操作符:

book := 〞The Spirit Level〞 +             // 字符串級聯

〞 by Richard Wilkinson〞

book += 〞 and Kate Pickett〞              // 字符串追加

fmt.Println(〞Josey〞 < 〞Jose 〞, 〞Josey〞 == 〞Jose 〞)  // 字符串比較

其結果是book變量將包含文本〞The Spirit Level by Richard Wilkinson and Kate Pickett〞,並會輸出〞true false〞到os.Stdout。

表3-2 字符串操作符

註:*這種轉換總是成功的。非法數字被轉換成Unicode編碼的替換符U+FFFD,看起來像「?」。

3.2 比較字符串

如前所述,Go語言字符串支持常規的比較操作(<、<=、==、!=、>和>=),這些操作符在表 2-3 中已給出。這些比較操作符在內存中一個字節一個字節地比較字符串。比較操作可以直接使用,如比較兩個字符串的相等性,也可以間接使用,例如在排序string時使用 < 操作符來比較字符串。遺憾的是,執行比較操作時可能會產生3個問題。這3個問題困擾每種使用Unicode字符串的編程語言,都不局限於Go語言。

第一個問題是,有些Unicode編碼的字符可以用兩個或者多個不同的字節序列來表示。例如,字符A可以是Angstrom中的字符,也可以只是一個A上面加了一個小環,這兩者通常不能區分。Angstrom字符的Unicode編碼是U+212B,但是一個A上面加了一個小圈的字符使用Unicode編碼U+00C5來表示,或者使用兩個編碼U+0041(A)以及U+030A(°,將小圈放到上面)來表示。Angstrom中的A在UTF-8中表示成字節[0xE2, 0X84, 0XAB],字符A則表示成字節[0XC3, 0X85],而一個帶有°的A字符則表示成[0X41, 0XCC, 0X81]。當然,從用戶的角度看,字符A應該在比較和排序時都是相等的,無論其底層字節如何表示。

第一個問題並不是我們想像的那樣嚴重,因為所有Go語言中的UTF-8字節序列(即字符串)使用的都是同樣的碼點到字節的映射。這也意味著,Go語言中的e字符在字符或者字符串字面量中使用同樣的字節進行表示。同時,如果我們只關心ASCII字符(即英語),這個問題也就不存在。即便是要處理非ASCII字符,這個問題也僅僅在以下情況下才存在:當我們有兩個看起來一樣的字符時,或者當我們從一個外部來源中讀取UTF-8字節時,這個來源的碼點到字節的映射是合法的UTF-8但又不同於Go語言的映射。如果這真的是一個問題,那麼也可以寫一個自定義的標準化函數來保證不出錯。例如,寫一個函數使得e總是使用字節[0xC3, 0xA9](Go語言原生支持這種表示)來表示,而非字節[0x65, 0xCC, 0x81](即是一個e和一個 ′ 組合起來的字符)。Unicode標準格式文檔(unicode.org/reports/tr15)中對如何標準化Unicode編碼字符有詳細解釋。撰寫本文時,Go語言的標準庫有一個實驗性的標準化包(exp/norm)。

由於第一個問題只有當字符串來自於外部源時才可能引起,並且只有當它們使用不同於Go語言的碼點到字節的映射時才發生,這個可以通過隔離接收外部字符串的代碼來解決。隔離的代碼可以在將接收到的字符串提供給程序之前將其標準化。

第二個問題是,有些情況下用戶可能會希望把不同的字符看成相同的。例如,我們可能寫一個程序來為用戶提供文本搜索功能,而用戶可能輸入單詞「file」。通常,用戶可能希望搜索所有包含「file」的地方,但用戶也可能希望輸入所有與「file」(即一個緊跟著「le」的「fi」字符)匹配的地方。類似地,用戶可能希望搜索「5」的時候能夠匹配「5」、「5」、「5」,甚至是「○5」。與第一個問題一樣,這也可以使用一些標準化形式來解決。

第三個問題是,有些字符的排序是與語言相關的。其中一個例子是,瑞典語中的a在排序時排z之後,但在德國的電話本中排序時拼成ae,而在德國的字典上則被拼成a。另一個例子是,雖然在英文中我們在排序時將其排成 o,但在丹麥語和挪威語中,它往往排在 z 之後。這方面有許許多多的規則,並且由於有時應用程序被不同國家的人使用(因此期望不同的排序規則),有時字符串中混雜著各種語言(如一些西班牙語和英語),有些字符(如箭頭、修飾符以及數學符號)根本上就沒有實際的排序索引意義,這些規則可能很複雜。

從有利的方面講,Go語言對字符串按字節比較的方式相當於英文的ASCII 排序方式。並且,如果將要比較的字符串轉成全部小寫或者全部大寫,我們可以得到一個更加自然的英語語言順序,我們將在後面的例子中看到(參見4.2.4節)。

3.3 字符和字符串

在 Go語言中,字符使用兩種不同的方式(可以很容易地相互轉換)來表示。一個單一的字符可以用一個單一的rune(或者int32)來表示。從現在開始,我們交替使用術語「字符」、「碼點」、「Unicode字符」以及「Unicode碼點」來表示保存一個單一字符的rune(或者int32)。Go語言的字符串表示一個包含0個或者多個字符序列的串。在一個字符串內部,每個字符都表示成一個或者多個UTF-8編碼的字節。

我們可以使用Go語言的標準轉換語法(string(char))將一個字符轉換成一個只包含單個字符的字符串。這裡有一個例子。

as := 〞〞

for _, char := range rune{'a', 0xE6, 0346, 230, '\xE6', '\u00E6'} {

fmt.Printf(〞[0x%X '%c'] 〞, char, char)

as += string(char)

}

這段程序會輸出一個行,其中包含6個重複的「[0XE6 'a']」文本。最後,字符串a會包含文本 aaaaaa。(馬上我們會看到使用字符串的+= 操作符通過循環來寫成的一個更高效的解決方案。)

一個字符串可以使用語法 chars := rune(s)轉換成一個 rune(即碼點)切片,其中S是一個字符串類型的值。變量chars的類型為int32,因為rune是int32的同義詞。這在我們需要逐個字符解析字符串,同時需要在解析過程中能查看前一個或後一個字符時會有用。相反的轉換也同樣簡單,其語法為S:=string(chars),其中chars的類型為rune或者int32,得到的S的類型為字符串。這兩個轉換都不是無代價的,但這兩個轉換理論上都比較快(時間代價為O(n),其中n是字節數,看下文中的「大O詳解」)。更多關於字符串轉換的示例請看表3-2。關於數字到字符串的轉換情況見表3-8和表3-9。

雖然方便,但是使用+= 操作符並不是在一個循環中往字符串末尾追加字符串最有效的方式。一個更好的方式(Python程序員可能非常熟悉)是準備好一個字符串切片(string),然後使用strings.Join函數一次性將其中所有字符串串聯起來。但在Go語言中還有一個更好的方法,其原理類似於Java中的StringBuilder。這裡有個例子。

var buffer bytes.Buffer

for {

if piece, ok := getNextValidString; ok {

buffer.WriteString(piece)

} else {

break

}

}

fmt.Print(buffer.String, 〞\n〞)

我們開始時創建了一個空的bytes.Buffer 類型值。然後使用 bytes.Buffer.WriteString方法將我們需要串聯起來的字符串寫入到 buffer 中(當然,我們也可以在每個字符串之間寫入一個分隔符)。最後,bytes.Buffer.String方法可以用於取回整個級聯的字符串(後面我們會看到bytes.Buffer類型的強大功能)。

將一個bytes.Buffer類型中的字符串累加起來可能比 += 操作符在節省內存和操作符方面高效得多,特別是當需要級聯的字符串數量很大時。

Go語言的for...range循環(參見5.3節)可以用於一個字符一個字符的迭代字符串,每次迭代都產生一個索引位置和一個碼點。下面是一個例子,旁邊為其輸出。

大O表示法

大O表示法O(…)在複雜性理論中是為特定算法所需的處理器和內存消耗給出一個近似邊界。大多數都是以n的比例來衡量,其中n為需要處理的項的數量,或者該項的長度。它們可以用來衡量內存消耗或者處理器的時間消耗。

O(1)意味著常量時間,也就是說,無論n的大小為何,這都是最快的可能。O(log n)意味著對數時間,速度很快,與log n成正比。O(n) 意味著線性時間,速度也很快,並且與n成正比。O(n2)(n的2次方)意味著二次方時間,速度開始變慢,並且與n的平方成正比。O(nm) (n的m次方),意味著多項式時間,隨著n的增長,它很快就變得很慢,特別是當m≥3時。O(n!)意味著階乘時間,即使是對於小的n值,這在實際使用中也會非常慢。

本書在很多地方都使用大O表示法來方便地解釋處理程序的代價,例如,將字符串轉換成rune的代價。

上面程序先創建 phrase 字符串字面量,然後在下一行的一個標題之後將其輸出。然後我們迭代字符串中的每一個字符。Go語言的for...range循環在迭代時將UTF-8字節解碼成Unicode碼點(rune類型),因此我們不必關心其底層實現。對於每一個字符,我們將其索引位置、碼點的值(使用Unicode表示法)、它所表示的字符以及對應的UTF-8字節編碼等信息輸出。

為了得到一串字節碼,我們將碼點(rune 類型的字符)轉換成字符串(它包含一個由一個或者多個 UTF-8 編碼字節編碼而成的字符)。然後,我們將該單字符的字符串轉換成一個byte切片,以便獲取其真實的字節碼。其中的byte(string)轉換非常快(O(1)),因為在底層byte 可以簡單地引用字符串的底層字節而無需複製。同樣,其逆向轉換string(byte)的原理也類似,其底層字節也無需複製,因此其代價也是O(1)。表3-2列出了Go語言的字符串與字節碼之間的相互轉換。

我們會馬上解釋程序中的%-2d、%U、%c以及%X格式化聲明符(參見3.5節)。如你所見,當 %X 聲明符用於數字時,它輸出該數字的十六進制,當其用於byte 時,它輸出一個含兩個十六進制數字的序列,一個數字代表一個字節。這裡我們通過在格式聲明符中加入空格來聲明其輸出結果需以空格分隔。

在實際的編程中,通過與strings包和fmt包(以及少數情況下來自於strconv、unicode、unicode/utf8的包)中的函數相配合,使用for...range循環來迭代字符串中的字符為處理和操作字符串提供了方便而強大的功能。此外字符串類型還支持切片(因為在底層一個字符串實際上就是一個增強的byte切片),這非常有用,只要我們小心不將一個多字節的字符切片成一半。

3.4 字符串索引與切片

正如表3-2所示,Go語言支持Python中字符串分割語法的一個子集。我們將在第4章看到,這個語法可以用於任意類型的切片。

由於Go語言的字符串將其文本保存為UTF-8編碼的字節,因此我們必須非常小心地只在字符邊界處進行切片。這在我們的文本中所包含的字符是7位的ASCII編碼字符的情況下非常簡單,因為一個字節代表一個字符,但是對於非ASCII文本將更有挑戰,因為這些字符可能用一個或者多個字節表示。通常我們完全不需要切片一個字符串,只需使用for...range循環將其一個字符一個字符地迭代,但是有些情況下我們確實需要使用切片來獲得一個子字符串。有個能夠確定能按字符邊界進行切片得到索引位置的方法是,使用Go語言的strings包中的函數如strings.Index或者strings.LastIndex。strings包的函數已列在表3-6和表3-7中。

我們將從不同的角度解析字符串。索引位置(即字符串的UTF-8編碼字節的位置)從0開始,直到該字符串的長度減1。當然也可以使用從len(s)-n這樣的索引形式來從字符串切片的末尾開始往前索引,其中n為從後往前數的字節數。例如,給定一個賦值s := 〞naive〞,如圖3-1給出了其Unicode字符、碼點、字節以及一些合法的索引位置和一對切片。

圖3-1 字符串剖析

圖3-1所示的每一個位置索引都可以用索引操作符來返回其對應的ASCII字符(以字節的形式)。例如,s[0] == 'n'和s[len(s)-1] == 'e'。i字符的起始索引位置為2,但如果我們使用s[2]我們只能夠得到編碼i字符(0xC3)的第一個UTF-8字節,而這並不是我們想要的。

對於只包含7位ASCII字符的字符串,我們可以使用s[0]這樣的語法來取得其第一個字符(以字節的形式),也可以使用s[len(s)-1]的形式來取得其最後一個字符。然而,通常而言,我們應該使用 utf8.DecodeRuneInString來獲得第一個字符(作為一個 rune,與UTF-8字節數字一起表示該字符),而使用utf8.DecodeLastRuneInString來獲得其最後一個字符(詳見表3-10)。

如果我們確實需要索引單個字符,也有許多可選的方法。對於只包含7位ASCII字符的字符串,我們只需簡單地使用索引操作符,該查找非常的快速(O(1))。對於包含非 ASCII 字符組成的字符串,我們可以將其轉換成rune再使用索引操作符。這也提供了非常快速的查找性能(O(1)),其代價在於一次性的轉換耗費了CPU和內存(O(n))。

在我們的例子中,如果我們這樣寫chars := rune(s),那麼chars變量將被創建為一個包含5個碼點的rune(即int32)切片,而非圖3-1中所示的6個字節。同時,我們也講過可以使用string(char)語法很容易地將任何rune類型轉換成一個包含一個字符的字符串。

對於任意字符串(即那些可能含有非 ASCII 字符的字符串),通過索引來提取其字符通常不是正確的方法。更好的方法是使用字符串切片,它可以很方便地返回一個字符串而非一個字節。為了安全地切片任意字符串,最好使用表3-6和表3-7中介紹的strings包中的函數來獲得我們需要切片的索引位置。

以下等式對於任意字符串切片都成立,事實上,對於任意類型的切片都成立:

s == s[:i] + s[i:] // s是一個字符串,i是一個整型,0 <= i <= len(s)

現在讓我們看一個實際的切片例子,其中使用的方法很原始。假設我們有一行文本,並且想從該文本中提取該行的第一個和最後一個字。一個簡單的方式是這樣寫代碼:

line := 〞rode og gule slojfer〞

i := strings.Index(line, 〞 〞)   // 獲得第一個空格的索引位置

firstWord := line[:i]        // 從第一個字開始時切片直到第一個空格

j := strings.LastIndex(line, 〞 〞) // 獲得最後一個空格

lastWord := line[j+1:]       // 從最後一個空格開始切片到最後一個字

fmt.Println(firstWord, lastWord)  // 輸出:rode slojfer

字符串類型的變量firstWord被賦值為字符串line中的從索引位置0(第一個字節)開始到索引位置i-1(第一個空格之前的字節)之間的字符串,因為字符串切片返回從開始到其結束位置處的字符串,但不包含該結束位置。類似地,lastWord 被賦值為字符串 line 中從索引位置j+1(最後一個空格後面的字節)到line結尾處(即到索引位置為len(line)-1處)的字符串。

雖然這個實例可以用於處理空格以及所有 7 位的ASCII 字符,但是卻不適於處理任意的Unicode空白字符如U+2028(行分隔符)或者U+2029(段落分隔符)。

下面這個例子在以任意空白符分隔字的情況下都可以找出其第一個字和最後一個字。

line := 〞 ar tort\u2028var〞

i := strings.IndexFunc(line, unicode.IsSpace)    // i == 3

firstWord := line[:i]

j := strings.LastIndexFunc(line, unicode.IsSpace)  // j == 9

_, size := utf8.DecodeRuneInString(line[j:])     // size == 3

lastWord := line[j+size:]               // j + size == 12

fmt.Println(firstWord, lastWord)           // 打印:ra var

如圖3-2所示,字符串line以字符、碼點以及字節的形式給出。該圖也給出了其字節索引位置以及上文代碼片段中使用到的切片。

圖3-2 帶空白符的字符串剖析

strings.IndexFunc函數返回作為第一個參數傳入的字符串中對於作為第二個參數傳入的函數(其簽名為 func(rune)bool)返回 true 時的字符索引位置。函數 stirngs.LastIndexFunc與此類似,只不過它適於從字符串的結尾處開始工作並返回當函數返回true時的最後一個字符索引位置。這裡我們傳入unicode包的IsSpace函數作為其第二個參數,該函數接受一個Unicode碼點(其類型為rune)作為其唯一的參數,如果該碼點是一個空白符則返回true(詳見表3-11)。一個函數的名字是該函數的引用,因此可以用於傳遞給另一個需要函數參數的地方,只要該命名函數(即所引用的函數)的簽名與聲明的參數相符合(參見4.1節)。

使用 strings.IndexFunc函數來找到第一個空白符,並從頭開始到該空白符索引位置的前一位將字符串切片,就可以很容易地得到字符串的第一個字。但是在搜索最後一個空白符的時候就得小心點,因為有些空白符被編碼成不止一個 UTF-8 字節。我們可以通過使用utf8.DecodeRuneInString函數解決這個問題,這個函數可以告訴我們字符串切片中起始位置與最後一個空格符的起始位置對應的那個字符所佔字節數為多少。然後,我們將這個數字與最後一個空白符所在的索引位置相加,就能夠跳過最後一個空白字符,無論用於表示空白字符的字節數為多少,這樣我們就能夠將最後一個字切片出來。

3.5 使用fmt包來格式化字符串

Go語言標準庫中的fmt包提供了打印函數將數據以字符串形式輸出到控制台、文件、其他滿足io.Writer 接口的值以及其他字符串中。這些函數已在表 3-3 中列出。有些輸出函數返回值為error。當將數據打印到控制台時,常常將該錯誤值忽略,但是如果打印到文件和網絡連接等地方時,則一定要檢查該錯誤值[1]。

表3-3 fmt包中的打印函數

fmt包也提供了一系列掃瞄函數(如fmt.Scan、fmt.Scanf以及fmt.Scanln函數)用於從控制台、文件以及其他字符串類型中讀取數據。其中有些函數將在第8章用到(參見8.1.3.2節)以及表8-2。掃瞄函數的一種替代是使用strings.Fields函數將字符串分隔為若干字段然後使用strconv包中的轉換函數將那些非字符串的字段轉換成相應的值(如數值),詳見表3-8和表3-9。第1章中我們提到,我們可以創建一個bufio.Reader通過從os.Stdin讀取數據來獲得用戶的輸入,然後使用bufio.Reader.ReadString函數來讀取用戶輸入的每一行(參見1.7節)。

輸出值的最簡單方式是使用fmt.Print函數和fmt.Println函數(輸出到os.Stdout,即控制台),或者使用fmt.Fprint函數和fmt.Fprintf函數來輸出到給定的io.Writer(如一個文件),或者使用fmt.Sprint函數和fmt.Sprintln函數來輸出到一個字符串。

type polar struct {radius, 0 float64}

p := polar{8.32,.49}

fmt.Print(-18.5, 17, 〞Elephant〞, -8+.7i, 0x3C7, '\u03C7', 〞a〞, 〞b〞, p)

fmt.Println

fmt.Println(-18.5, 17, 〞Elephant〞, -8+.7i, 0x3C7, '\u03C7', 〞a〞, 〞b〞, p)

-18.5·17Elephant(-8+0.7i)·967·967ab{8.32·0.49}

-18.5·17·Elephant·(-8+0.7i)·967·967·a·b·{8.32·0.49}

為了清晰起見,特別是當連續輸出空格的時候,我們必須在每一個顯示的空格之間放入一個字符(·)。

fmt.Print函數和fmt.Fprint函數處理空白符的方式與fmt.Println函數和fmt.Fprintln函數處理空白符的方式略有不同。作為一個經驗法則,前者更多地用於輸出單個值,或者用於不檢查錯誤值的情況下將某個值轉換成字符串(使用 strconv 包來做更好的轉換),因為它們只在非字符串的值之間輸出空格。後者更適用於輸出多個值,因為它們會在多個輸出值之間加入空格,並在末尾添加一個換行符。

在底層,這些函數都統一使用%v格式符,並且它們都可以以各種形式打印任何內置的或者自定義的值。例如,這裡的打印函數對自定義的polar類型一無所知,但仍然能夠成功地打印polar的值。

在第6章中,我們將會為自定義類型提供一個String方法,這個方法允許我們將該自定義類型以我們期望的方式輸出。如果我們想要對內置類型的打印也擁有類似的控制權,我們可以使用一個將格式化字符串作為第一個參數的打印函數。

用於 fmt.Errorf、fmt.Printf、fmt.Fprintf以及 fmt.Sprintf函數的格式字符串包含一個或者多個格式指令,這些格式指令的形式是%ML,其中M表示一個或者多個可選的格式指令修飾符,而 L 則表示一個特定的格式指令字符。這些格式指令已在表 3-4中列出。有些格式指令可以接收一個或者多個修飾符,這些修飾符已在表3-5中列出。

表3-4 fmt包中的格式指令

續表

表3-5 fmt包中的格式指令修飾符

續表

現在讓我們來看一些格式化字符串的代表性例子,以便弄清楚它們是如何工作的。在每一個案例中,我們會給出一小段代碼以及該代碼的輸出[2]。

3.5.1 格式化布爾值

布爾值使用%t(真值,truth value)格式指令來輸出。

fmt.Printf(〞%t %t\n〞, true, false)

true false

如果我們想以數值的形式輸出布爾值,那麼我們必須做這樣的轉換:

fmt.Printf(〞%d %d\n〞, IntForBool(true), IntForBool(false))

1 0

這裡使用了一個小的自定義函數。

func IntForBool(b bool) int {

if b{

return 1

}

return 0

}

我們也可以使用 strconv.ParseBool函數來將字符串轉換回布爾值。當然,將字符串轉換成數字也有類似的函數(參見3.6.2節)。

3.5.2 格式化整數

現在讓我們來看看整數的格式化,從二進制數字(基數為2)的輸出開始。

fmt.Printf(〞|%b|%9b|%-9b|%09b|% 9b|\n〞, 37, 37, 37, 37, 37)

|100101|···100101|100101···|000100101|···100101|

第一個格式(%b)使用%b(二進制)格式指令,它使用盡量少的數字將一個整數以二進制的形式輸出。第二個格式(%9b)聲明了一個長度為9的字符(為了防止截斷,可能會超出輸出時所需要的長度),並且使用了默認的右對齊符。第三個格式(%-9b)使用-修飾符來左對齊。第四個格式(%09b)使用0作為填充符,第五個格式(% 9b)使用空格作為填充符。

八進制格式類似於二進制,但支持另一種格式。它使用 %o (八進制,octal)格式指令。

fmt.Printf(〞|%o|%#o|%# 8o|%#+ 8o|%+08o|\n〞, 41, 41, 41, 41, -41)

|51|051|·····051|····+051|-0000051|

使用 # 修飾符可以切換格式,從而在輸出的時候以 0 打頭。+ 修飾符會強制輸出正號,如果沒有該修飾符,正整數輸出時前面沒有正號。

十六進制格式使用 %x和%X格式指令,選擇哪個取決於希望將16進制中的A到F字母以小寫還是大寫表示。

i := 3931

fmt.Printf(〞|%x|%X|%8x|%08x|%#04X|0x%04X|\n〞, i, i, i, i, i, i)

|f5b|F5B|·····f5b|00000f5b|0X0F5B|0x0F5B|

對於十六進制數字,變更格式修飾符(#)將導致輸出時以0x或者0X開頭。對於所有的數字,如果我們聲明了一個比所需更寬的寬度,輸出時會輸出額外的空格以便將數字右對齊。如果所聲明的寬度太小,則將整個數字輸出,因此沒有截斷的風險。

十進制的數字使用%d(十進制,decimal)格式指令。唯一可用於當做填充符的字符是空格和0,但也容易使用自定義的函數來填充別的字符。

i = 569

fmt.Printf(〞|$%d|$%06d|$%+06d|$%s|\n〞, i, i, i, Pad(i, 6, '*'))

|$569|$000569|$+00569|$***569|

在最後一種格式中,我們使用%s(字符串,string)格式指令來輸出一個字符串,因為那就是我們的Pad 函數所返回的。

func Pad(number, width int, pad rune) string {

s := fmt.Sprint(number)

gap := width - utf8.RuneCountInString(s)

if gap > 0 {

return strings.Repeat(string(pad), gap) + s

}

return s

}

utf8.RuneCountInString函數返回給定字符串的字符數。這個數字永遠小於或等於其字節數。strings.Repeat函數接收一個字符串和一個計數,返回一個將該字符串重複給定次數後產生的字符串。我們選擇將填充符以rune(即Unicode碼點)的方式傳遞以防止該函數的用戶傳入包含不止一個字符的字符串。

3.5.3 格式化字符

Go語言的字符都是rune(即int32值),它們可以以數字或者Unicode字符的形式輸出。

fmt.Printf(〞%d %#04x %U '%c'\n〞, 0x3A6, 934, '\u03A6', '\U000003A6')

934·0x03a6·U+03A6·'Φ'

這裡我們以十進制和十六進制的形式輸出了一個大寫的希臘字母Phi(「Φ」),使用 %U格式指令來輸出Unicode碼點,以及使用%c(字符或者碼點)格式指令來輸出Unicode字符.

3.5.4 格式化浮點數

浮點數格式可以指定整體長度、小數位數,以及使用標準計數法還是科學計數法。

for _, x := range float64{-.258, 7194.84, -60897162.0218, 1.500089e-8} {

fmt.Printf(〞|%20.5e|%20.5f|%s|\n〞, x, x, Humanize(x, 20, 5, '*', ','))

}

|········-2.58000e-01|············-0.25800|************-0.25800|

|·········7.19484e+03|··········7194.84000|*********7,194.84000|

|········-6.08972e+07|·····-60897162.02180|***-60,897,162.02180|

|·········1.50009e-08|·············0.00000|*************0.00000|

這裡我們使用一個for...range循環來迭代一個float64類型切片中的數字。

自定義的函數 Humanize返回一個該數字的字符串表示,該表示法包含了分組分隔符和填充符。

func Humanize(amount float64, width, decimals int, pad, separator rune) string {

dollars, cents := math.Modf(amount)

whole := fmt.Sprintf(〞%+.0f〞, dollars)[1:] // 去除〞±〞

fraction := 〞〞

if decimals > 0 {

fraction = fmt.Sprintf(〞%+.*f〞, decimals, cents)[2:] // 去除〞±0〞

}

sep := string(separator)

for i := len(whole) - 3; i > 0; i -= 3 {

whole = whole[:i] + sep + whole[i:]

}

if amount < 0.0 {

whole = 〞-〞 + whole

}

number := whole + fraction

gap := width - utf8.RuneCountInString(number)

if gap > 0 {

return strings.Repeat(string(pad), gap) + number

}

return number

}

math.Modf函數將一個float64類型的數的整數部分和小數部分以兩個float64類型的數的形式返回。為了以字符串的形式得到其整數部分,我們使用帶正號格式的fmt.Sprintf函數強制輸出正號,然後立即將其切片以去除正號。針對小數部分,我們也使用類似的技術,只是這次我們使用.m格式指令修飾符來聲明需要使用*佔位符的小數位數(因此在本例中,如果小數的值為2,那麼其有效格式為 %+.2f)。對於小數部分,我們會去除其頭部的−0或者+0。

組分隔符從右至左插入整個字符串中,如果數字為負值,則插入一個 − 符號。最後,我們將整個結果串聯起來並返回,如果位數不夠則填充。

%e、%E、%f、%g和%G格式指令既可以用於複數,也可以用於浮點數。%e和%E是科學計算法格式(指數的)格式指令,%f是浮點數格式指令,而%g和%G則是通用的浮點數格式指令。

然而,需要注意的一點是,修飾符會分別作用於複數的實部和虛部。例如,如果參數是一個複數,%6f格式產生的結果會佔用至少20個字符。

for _, x := range complex128{2 + 3i, 172.6 - 58.3019i, -.827e2 + 9.04831e-3i} {

fmt.Printf(〞|%15s|%9.3f|%.2f|%.1e|\n〞,

fmt.Sprintf(〞%6.2f%+.3fi〞, real(x), imag(x)), x, x, x)

}

|····2.00+3.000i|(····2.000···+3.000i)|(2.00+3.00i)|(2.0e+00+3.0e+00i)|

|·172.60-58.302i|(··172.600··-58.302i)|(172.60-58.30i)|(1.7e+02-5.8e+01i)|

|··-82.70+0.009i|(··-82.700···+0.009i)|(-82.70+0.01i)|(-8.3e+01+9.0e-03i)|

對於第一組複數,我們希望小數點後輸出不同數量的數字。為此,我們需要使用 fmt.Sprintf分別格式化複數的實部和虛部部分,然後以%15s格式將結果以字符串的形式輸出。對於其他組的複數,我們直接使用%f和%e格式指令,它們總會在輸出的複數兩邊加上圓括號。

3.5.5 格式化字符串和切片

字符串輸出時可以指定一個最小寬度(如果字符串太短,打印函數會以空格填充)或者一個最大輸出字符數(會將太長的字符串截斷)。字符串可以以 Unicode 編碼(即字符)、一個碼點序列(即rune)或者表示它們的UTF-8字節碼的形式輸出。

slogan := 〞End O re ttlati♥〞

fmt.Printf(〞%s\n%q\n%+q\n%#q\n〞, slogan, slogan, slogan, slogan)

End O re ttlati♥

〞End O re ttlati♥〞

〞End \u00d3r\u00e9ttl\u00e6ti\u2665〞

'End O re ttlati♥'

%s格式指令用於輸出字符串,我們將很快提到它。%q(引用字符串)格式指令用於以Go語言的雙引號形式輸出字符串,其中會直接將可打印字符的可打印字面量輸出,而其他不可打印字符則使用轉義的形式輸出(見表3-1)。如果使用了+號修飾符,那麼只有 ASCII 字符(從U+0020到U+007E)會直接輸出,而其他字符則以轉義字符形式輸出。如果使用了#修飾符,那麼只要在可能的情況下就會輸出Go原始字符串,否則輸出以雙引號引用的字符串。

雖然通常與一個格式指令相對應的變量是一個兼容類型的單一值(例如int型值相對應的%d或者%x),該變量也可以是一個切片數組或者一個映射,如果該映射的鍵與值與該格式指令都是兼容的(比如都是字符串或者數字)。

chars := rune(slogan)

fmt.Printf(〞%x\n%#x\n%#X\n〞, chars, chars, chars)

[45·6e·64·20·d3·72·e9·74·74·6c·e6·74·69·2665]

[0x45·0x6e·0x64·0x20·0xd3·0x72·0xe9·0x74·0x74·0x6c·0xe6·0x74·0x69·0x2665]

[0X45·0X6E·0X64·0X20·0XD3·0X72·0XE9·0X74·0X74·0X6C·0XE6·0X74·0X69·0X2665]

這裡我們使用%x和%X格式指令以十六進制數字序列的形式打印了一個rune類型的切片,在本例中是一個碼點切片,一個十六進制數字對應一個碼點。

對於大多數類型,該類型的切片被輸出時都會以方括號包圍並以空格分隔。其中有個例外, byte只有在使用%v格式指令時才會輸出方括號和空格。

bytes := byte(slogan)

fmt.Printf(〞%s\n%x\n%X\n% X\n%v\n〞, bytes, bytes, bytes, bytes, bytes)

End·Orettlati♥

456e6420c39372c3a974746cc3a67469e299a5

456E6420C39372C3A974746CC3A67469E299A5

45·6E·64·20·C3·93·72·C3·A9·74·74·6C·C3·A6·74·69·E2·99·A5

[69·110·100·32·195·147·114·195·169·116·116·108·195·166·116·105·226·153·165]

一個字節切片(這裡是表示字符串的UTF-8 字節)可以以十六進制兩位數序列的形式輸出,其中一個數字表示一個字節。如果我們使用%s 格式指令,則字節切片會被假設為 UTF-8 編碼的Unicode,並且以字符串的形式輸出。雖然bytes類型沒有可選的十六進制格式,但這些數字可以像上面倒數第二行所輸出的那樣使用空格分隔。格式指令%v 以一個方括號包圍並以空格分隔的十進制值的形式輸出bytes類型的值。

Go語言默認是居右對齊,我們可以使用-修飾符來將其居左對齊。當然,我們可以為像下面的例子所示範的那樣,指定一個最小的域寬以及一個最大的字符數。

s := 〞Dare to be naive〞

fmt.Printf(〞|%22s|%-22s|%10s|\n〞, s, s, s)

|······Dare·to·be·nai ve|Dare·to·be·nai ve······|Dare·to·be·naive|

在這段代碼中,第三個格式(%10s)指定了最小域寬為10個字符,但因為字符串的長度比這個域寬要長(該域寬為最小值),所以字符串被完整打印出來。

i := strings.Index(s, 〞n〞)

fmt.Printf(〞|%.10s|%.*s|%-22.10s|%s|\n〞, s, i, s, s, s)

|Dare·to·be|Dare·to·be·|Dare·to·be············|Dare·to·be·naive|

這裡,第一個格式(%.10s)聲明了最多打印字符串的10個字符,因此這裡輸出的字符串被截斷成指定的寬度。第二個格式(%.*s)希望輸入兩個參數——所打印字符個數的最大值和一個字符串,這裡我們使用了字符串的第n個字符的索引位置來作為最大值,這意味著其索引位置小於該值的字符都將被打印出來。第三個格式(%-22.10s)同時聲明了最小域寬度為22 個字符和最大輸出字符個數為10字符,這也意味著在一個寬為22字符的域中最多只輸出該字符串的前10個字符,由於其域寬比要打印的字符數大,因此該域使用空格填充,同時使用 - 修飾符來將其居左對齊。

3.5.6 為調試格式化

%T(類型)格式指令用於打印一個內置的或者自定義值的類型,而%v格式指令則用於打印一個內置值的值。事實上,%v 也可以打印自定義類型的值,對於沒有定義String方法的值使用默認的格式,對於定義了String方法的值則使用該方法打印。

p := polar{-83.40, 71.60}

fmt.Printf(〞|%T|%v|%#v|\n〞, p, p, p)

fmt.Printf(〞|%T|%v|%t|\n〞, false, false, false)

fmt.Printf(〞|%T|%v|%d|\n〞, 7607, 7607, 7607)

fmt.Printf(〞|%T|%v|%f|\n〞, math.E, math.E, math.E)

fmt.Printf(〞|%T|%v|%f|\n〞, 5+7i, 5+7i, 5+7i)

s := 〞Relativity〞

fmt.Printf(〞|%T|\〞%v\〞|\〞%s\〞|%q|\n〞, s, s, s, s)

|main.polar|{-83.4·71.6}|main.polar{radius:-83.4,·θ:71.6}|

|bool|false|false|

|int|7607|7607|

|float64|2.718281828459045|2.718282|

|complex128|(5+7i)|(5.000000+7.000000i)|

|string|〞Relativity〞|〞Relativity〞|〞Relativity〞|

上面這個例子給出了如何使用%T和%v來輸出任意值的類型和值。如果滿足%v格式指令的格式,那麼我們可以簡單地使用fmt.Print或者類似的使用%v作為默認格式的函數。與%v一起使用可選的格式化格式指令修飾符 #只對結構體類型起作用,這使得結構體輸出它們的類型名字和字段名字。對於浮點數,%v格式更像%g格式指令而非%f格式指令。%T格式在調試方面非常有用,對於自定義類型可以包含其包名(本例中是main)。對字符串使用%q格式指令可以將它們放入引號中方便調試。

Go語言中有兩種類型是同義的:uint8和byte,int32和rune。處理int不能處理的32位有符號整數(例如讀寫二進制文件)時使用int32,處理Unicode碼點時使用rune(字符)。

s := 〞Alias↔Synonym〞

chars := rune(s)

bytes := byte(s)

fmt.Printf(〞%T: %v\n%T: %v\n〞, chars, chars, bytes, bytes)

int32: [65 108 105 97 115 8596 83 121 110 111 110 121 109]

uint8: [65 108 105 97 115 226 134 148 83 121 110 111 110 121 109]

如上例說明的那樣,%T格式指令總是輸出其原始類型名,而非其同義詞。由於字符串中包含一個非ASCII的字符,因此很明顯可以發現我們創建了一個rune切片(碼點)和一個UTF-8編碼的字節切片。

我們也可以使用%p格式指令來輸出任意值的地址。

i := 5

f := -48.3124

s := 〞Toma s Breton〞

fmt.Printf(〞|%p→ %d|%p→ %f|%#p→ %s|\n〞, &i, i, &f, f, &s, s)

|0xf840000300·→·5|0xf840000308·→·-48.312400|f840001990·→·Tomas·Breton|

&地址操作符將在下一章介紹(參見4.1節)。如果我們使用%p格式指令和#修飾符,則會將地址開頭處的0x剔除掉。這樣輸出的地址對於調試非常有幫助。

Go語言的輸出切片和映射的功能對調試非常有用,正如輸出通道的功能一樣,也就是說我們可以輸出該通道支持發送和接收的類型以及該通道的內存地址。

fmt.Println(float64{math.E, math.Pi, math.Phi})

fmt.Printf(〞%v\n〞, float64{math.E, math.Pi, math.Phi})

fmt.Printf(〞%#v\n〞, float64{math.E, math.Pi, math.Phi})

fmt.Printf(〞%.5f\n〞, float64{math.E, math.Pi, math.Phi})

[2.718281828459045·3.141592653589793·1.618033988749895]

[2.718281828459045·3.141592653589793·1.618033988749895]

float64{2.718281828459045,·3.141592653589793,·1.618033988749895}

[2.71828·3.14159·1.61803]

使用未修飾的%v格式指令,切片可以以方括號包圍並將每一項以空格分隔的形式輸出。通常我們使用類似fmt.Print和fmt.Sprint這樣的函數將其輸出,但如果我們使用一個格式化的輸出函數,那麼其常用的格式指令是%v或者%#v。然而,我們也可以使用一個類型兼容的格式指令,如用於浮點數的%f和用於字符串的%s。

fmt.Printf(〞%q\n〞, string{〞Software patents〞, 〞kill〞, 〞innovation〞})

fmt.Printf(〞%v\n〞, string{〞Software patents〞, 〞kill〞, 〞innovation〞})

fmt.Printf(〞%#v\n〞, string{〞Software patents〞, 〞kill〞, 〞innovation〞})

fmt.Printf(〞%17s\n〞, string{〞Software patents〞, 〞kill〞, 〞innovation〞})

[〞Software·patents〞·〞kill〞·〞innovation〞]

[Software·patents·kill·innovation]

string{〞Software·patents〞,·〞kill〞,·〞innovation〞}

[·Software·patents··············kill········innovation]

當字符串中包含空格時,使用%q格式指令來輸出字符串切片非常有用,因為這使得每個單個的字符串都是可識別的。使用%v格式指令無法做到這點。

最後一個輸出初看起來可能有誤,因為它佔用了 53 個字符(不包括兩邊的方括號)而非51個(3個17字符的字符串,都不大)。這個明顯的差異在於輸出的每一個切片項目之間的空格分隔符。

為了更好地調試,使用%#v格式指令可以以編程的形式輸出Go語言代碼。

fmt.Printf(〞%v\n〞, map[int]string{1: 〞A〞, 2: 〞B〞, 3: 〞C〞, 4: 〞D〞})

fmt.Printf(〞%#v\n〞, map[int]string{1: 〞A〞, 2: 〞B〞, 3: 〞C〞, 4: 〞D〞})

fmt.Printf(〞%v\n〞, map[int]int{1: 1, 2: 2, 3: 4, 4: 8})

fmt.Printf(〞%#v\n〞, map[int]int{1: 1, 2: 2, 3: 4, 4: 8})

fmt.Printf(〞%04b\n〞, map[int]int{1: 1, 2: 2, 3: 4, 4: 8})

map[4:D·1:A·2:B·3:C]

map[int]·string{4:〞D〞,·1:〞A〞,·2:〞B〞,·3:〞C〞}

map[4:8·1:1·2:2·3:4]

map[int]·int{4:8,·1:1,·2:2,·3:4}

map[0100:1000·0001:0001·0010:0010·0011:0100]

映射的輸出內容以關鍵字「map」開頭,然後是該映射的「鍵/值」對(以任意的順序,因為映射是無序的)。正如切片一樣,它也可以使用除%v 之外的格式指令輸出,但只限於其鍵和值與該格式指令相兼容的情況,正如本例中最後一條語句中那樣。(映射和切片將在第4章詳細闡述。)

fmt包的輸出函數功能非常豐富,並且可以用於輸出任意我們想要的東西。該包唯一沒有提供的功能是以某種特定的字符串進行填充(而非 0 或者空格),但正如我們所看到的Pad (參見3.5.2節)和Humanize(參見3.5.4節)函數一樣,要做到這些也非常簡單。

3.6 其他字符處理相關的包

Go語言處理字符串的強大之處不僅限於對索引和切片的支持,也不限於fmt的格式化功能。strings包提供了非常強大的功能,此外strconv、unicode/utf8、unicode等也提供了大量實用的函數,這一節出現的就不少。這本書有好幾個地方都用到了regexp提供的正則表達式,本節後面也有介紹。

除此之外,標準庫裡還有很多其他的包同樣提供了字符串相關的功能,其中有一些在我們這本書的例子和習題裡經常用到。

3.6.1 strings包

一個常見的字符串處理場景是,我們需要將一個字符串分隔成幾個字符串後再做其他處理(例如轉換成數字或者過濾空格等)。

為了讓大家知道怎麼去使用strings包裡的函數,我們來看一些非常簡單的使用示例。表3-6和表3-7里列出了strings包裡所有的函數。首先,我們從分隔一個字符串開始:

names := 〞Niccolo‧Noel‧Geoffrey‧Amelie‧‧Turlough‧Jose〞

fmt.Print(〞|〞)

for _, name := range strings.Split(names, 〞‧〞) {

fmt.Printf(〞%s|〞, name)

}

fmt.Println

|Niccolo|Noel|Geoffrey|Amelie||Turlough|Jose|

names 是一個使用圓點符號分隔的名字列表(注意,有一個名字是空的)。我們使用strings.Split函數來切分它,這個函數可以將一個字符串按照指定的分隔符全部切分開,使用strings.SplitN可以指定切的次數(從左到右)。如果使用strings.SplitAfter函數的話輸出結果是這樣的:

|Niccolo‧|Noel‧|Geoffrey‧|Amelie‧|‧|Turlough‧|Jose|

函數strings.SplitAfter 執行的操作和strings.Split是一樣的,但是保留了分隔符。同理,strings.SplitAfterN函數可以指定切割的次數。

如果我們想按兩個或更多字符進行切分,可以使用strings.FieldsFunc函數。

for _, record := range string{〞Laszlo Lajtha*1892*1963〞,

〞Edouard Lalo\t1823\t1892〞, 〞Jose Angel Lamas|1775|1814〞} {

fmt.Println(strings.FieldsFunc(record, func(char rune) bool {

switch char {

case '\t', '*', '|':

return true

}

return false

}))

}

[Laszlo·Lajtha·1892·1963]

[Edouard·Lalo·1823·1892]

[Jose·Angel·Lamas·1775·1814]

strings.FieldsFunc 函數有兩個參數,一個字符串(這個例子裡是record變量),一個簽名為func(rune) bool的函數引用。因為這個函數很小而且只用在這個地方,所以我們直接在調用它的地方創建了一個匿名函數(用這種方式創建的函數稱之為閉包,不過在這裡我們並沒有用到引用環境,參見 5.6.3 節)。strings.FieldsFunc函數遍歷字符串並將每一個字符作為參數傳遞給函數引用,如果該函數返回true則執行切分操作。從上面的代碼我們可以看出,程序在遇到縮進符號、星號或者豎線的地方進行切分。(Go語言的switch語句在5.2.2節介紹。)

使用 strings.Replace函數,我們可以將在一個字符串中出現的某個字符串全部替換成另一個,例如:

names = 〞 Antonio\tAndre\tFriedrich\t\t\tJean\t\tElisabeth\tIsabella \t〞

names = strings.Replace(names, 〞\t〞, 〞 〞, -1)

fmt.Printf(〞|%s|\n〞, names)

|·Antonio·Andre··Friedrich···Jean··Elisabeth·Isabella··|

strings.Replace的參數有原字符串、被替換的字符串、用來替換的字符串,還有一個指定要替換(從左到右)的次數(−1 表示沒有限制),返回一個完成替換的字符串(替換結果不會相互交疊)。

表3-6 strings包裡的函數列表 #1

續表

表3-7 strings包裡的函數列表#2

續表

通常,當我們接收到一些用戶輸入或者是外部輸入的數據時,需要處理一下字符串中出現的空白,比如說去掉首尾的空白字符,還有將中間出現的空白用一個簡單的空格符來代替等,可以這麼做:

fmt.Printf(〞|%s|\n〞, SimpleSimplifyWhitespace(names))

|Antonio·Andre·Friedrich·Jean·Elisabeth·Isabella|

函數SimpleSimplifyWhitespace 實際上只有一行代碼。

func SimpleSimplifyWhitespace(s string) string {

return strings.Join(strings.Fields(strings.TrimSpace(s)), 〞 〞)

}

其中,strings.TrimSpace返回一個去掉首尾空白的字符串。strings.Fields在字符串空白上進行分隔,返回一個字符串切片。而函數 strings.Join則將一個字符串切片重新拼湊成一個字符串,並用指定的分隔符隔開(分隔符可以為空,這裡我們用了一個空格)。這3個函數的組合使用,就可以實現規範字符串空白的效果。

當然,我們還可以用bytes.Buffer來實現一種更加高效的空白處理方法。

func SimplifyWhitespace(s string) string {

var buffer bytes.Buffer

skip := true

for _, char := range s {

if unicode.IsSpace(char) {

if !skip {

buffer.WriteRune(' ')

skip = true

}

} else {

buffer.WriteRune(char)

skip = false

}

}

s = buffer.String

if skip && len(s) > 0 {

s = s[:len(s)-1]

}

return s

}

從上面的代碼我們可知,函數SimplifyWhitespace 遍歷輸入字符串的每一個字符,使用unicode.IsSpace函數(見表3-11)跳過字符串開頭所有的空白,然後將其他字符累加到 bytes.Buffer 裡去,對於中間出現的所有空白處都用一個簡單的空格符替換,原字符串結尾處的空白也會被去掉(算法允許結尾最多只有一個空格),最後返回需要的字符串。後面還有一種使用正則表達式來處理的版本,更加簡單(參見3.6.5節)。

strings.Map函數可以用來替換或者去掉字符串中的字符。它需要兩個參數,第一個是簽名為func(rune) rune的映射函數,第二個是字符串。對字符串中的每一個字符,都會調用映射函數,將映射函數返回的字符替換掉原來的字符,如果映射函數返回負數,則原字符會被刪掉。

asciiOnly := func(char rune) rune {

if char > 127 {

return '?'

}

return char

}

fmt.Println(strings.Map(asciiOnly, 〞JeromeOsterreich〞))

J?r?me·?sterreich

在這裡我們沒有像之前的例子 strings.FieldsFunc 那樣直接在調用它的地方創建一個匿名函數,而是將一個匿名函數賦值給一個變量asciiOnly(相當於一個函數的引用)。然後我們將變量asciiOnly和一個待處理的字符串作為參數來調用strings.Map。最後打印返回的的字符串,把原字符串中所有的非ASCII字符都替換為「?」。當然了,我們也可以在直接調用映射函數的地方創建它,但是如果函數太長或者我們需要在多個地方用到它的話,分離它可以提高代碼的復用程度。

要把非ASCII編碼的字符刪除掉然後輸出下面這樣的結果也是很容易的:

Jrme·sterreich

實現的方法就是修改映射函數,對於非ASCII編碼的字符返回「−1」而不是「?」即可。

我們之前提到過可以用for...range循環(循環語句在5.3節介紹)以Unicode碼點的形式來遍歷一個字符串中所有的字符。從實現了ReadRune方法的類型中讀取數據時可以得到類似的效果,例如bufio.Reader類型。

for {

char, size, err := reader.ReadRune

if err != nil { // 如果讀者正在讀文件可能發生

if err == io.EOF { // 沒有事故結束

break

}

panic(err) // 出現了一個問題

}

fmt.Printf(〞%U '%c' %d: % X\n〞, char, char, size, byte(string(char)))

}

U+0043·'C'·1:·43

U+0061·'a'·1:·61

U+0066·'f'·1:·66

U+00E9·'e'·2:·C3·A9

這段代碼讀取一個字符串,輸出每個字符的碼點、字符本身和這個字符佔用了多少個UTF-8字節,還有用來表示這個字符的字節序列。通常情況下reader是對文件進行操作,因此我們可能會假設reader變量是通過基於一個os.Open調用返回的reader調用bufio.NewReader而創建。我們曾在第一章的americanise 示例中見過這種用法(參見 1.6 節)。不過在本例中reader被創建用於操作一個字符串:

reader := strings.NewReader(〞Cafe〞)

strings.NewReader 返回的*strings.Reader實現了bufio.Reader的部分功能,包括 strings.Reader.Read、strings.Reader.ReadByte、strings.Reader.ReadRune、strings.Reader.UnreadByte、strings.Reader.UnreadRune等。這種能夠操作具有某個特定接口(例如,這個類型實現了 ReadRune方法)的值而不是某個特定類型的值的能力,是Go語言一個非常強大和靈活的特性,這在第6章會有更詳盡的介紹。

3.6.2 strconv包

strconv包提供了許多可以在字符串和其他類型的數據之間進行轉換的函數。所有的函數都在表3-8和表3-9里(也可以看一下fmt包的打印和掃瞄函數,分別在3.5節和8.2節有介紹)。我們先來看一個簡單的例子。

一種常見的需求是將真值的字符串表示轉換成一個 bool。這可以使用 strconv.ParseBool函數來實現。

for _, truth := range string{〞1〞, 〞t〞, 〞TRUE〞, 〞false〞, 〞F〞, 〞0〞, 〞5〞} {

if b, err := strconv.ParseBool(truth); err != nil {

fmt.Printf(〞\n{%v}〞, err)

} else {

fmt.Print(b, 〞 〞)

}

}

fmt.Println

true·true·true·false·false·false

{strconv.ParseBool:·parsing·〞5〞:·invalid·syntax}

表3-8 strconv包涵數列表 #1

表3-9 strconv包涵數列表 #2

所有的strconv轉換函數返回一個結果和error變量,如果轉換成功的話error為nil。

x, err := strconv.ParseFloat(〞-99.7〞, 64)

fmt.Printf(〞%8T %6v %v\n〞, x, x, err)

y, err := strconv.ParseInt(〞71309〞, 10, 0)

fmt.Printf(〞%8T %6v %v\n〞, y, y, err)

z, err := strconv.Atoi(〞71309〞)

fmt.Printf(〞%8T %6v %v\n〞, z, z, err)

·float64··-99.7·<nil>

···int64··71309·<nil>

·····int··71309·<nil>

上述代碼中的strconv.ParseFloat、strconv.ParseInt、strconv.Atoi (ASCII 轉換成 int)這 3 個函數可以做的事情比我們想像的多。strconv.Atoi(s)和strconv.ParseInt(s, 10, 0)的作用是一樣的,就是將字符串形式表示的十進制數轉換成一個整形值,唯一不同的是Atoi返回int型而ParseInt返回int64類型。顧名思義, strconv.ParseUint函數可以將一個無符號整數轉換成字符串,字符串不能以負號開頭,否則會轉換失敗。還要注意的是,當字符串開始處或者結尾處包含空白的話,所有的這些函數都會返回失敗,但是我們可以使用 strings.TrimSpace函數來避免這種情況,或者使用fmt包裡的掃瞄函數(表8-2中)。此外,浮點數轉換還能處理包含數學標記或者指數符號的字符串,例如〞984〞、〞424.019〞、〞3.916e-12〞等。

s := strconv.FormatBool(z > 100)

fmt.Println(s)

i, err := strconv.ParseInt(〞0xDEED〞, 0, 32)

fmt.Println(i, err)

j, err := strconv.ParseInt(〞0707〞, 0, 32)

fmt.Println(j, err)

k, err := strconv.ParseInt(〞10111010001〞, 2, 32)

true

57069·<nil>

455·<nil>

1489·<nil>

strconv.FormatBool函數根據給定的布爾變量true或者false返回一個表示布爾表達式的字符串。strconv.ParseInt函數將一個字符串表示的整數轉換成int64值。第二個參數是用來指定進制大小的,為 0的話表示根據字符串前綴來判斷,如〞0x〞、〞0X〞表示十六進制,〞0〞表示八進制,其他都是十進制。在上面的例子裡,我們根據字符串的前綴自動判斷和轉換了一個十六進制和一個八進制數,並以明確指定進制為2的方式轉換了一個二進制數。進制大小在2到36之間,如果進制大於10則用A或a來表示10,其他以此類推。函數第三個參數是位大小(為0則默認是int大小),所以雖然函數總是返回int64,但是只有在真正能夠轉換成指定大小的整數時才會返回成功。

i := 16769023

fmt.Println(strconv.Itoa(i))

fmt.Println(strconv.FormatInt(int64(i), 10))

fmt.Println(strconv.FormatInt(int64(i), 2))

fmt.Println(strconv.FormatInt(int64(i), 16))

16769023

16769023

111111111101111111111111

ffdfff

函數strconv.Itoa(函數名是「Integer to ASCII」的縮寫)將int型的整數轉換成以十進製表示的字符串。而函數 strconv.FormatInt則可以將其轉換成任意進制形式的字符串(進制參數一定要指定,必須在2~36這個範圍內)。

s = 〞Alle onsker a vare fri.〞

quoted := strconv.Quote(s)

fmt.Println(quoted)

fmt.Println(strconv.Unquote(quoted))

〞Alle·\u00f8nsker·\u00e5·v\u00e6re·fri.〞

Alle·onsker·a·vare·fri.·<nil>

函數 strconv.Quote 返回一個字面量字符串,首尾增加了雙引號,並對所有不可打印的ASCII字符和非ASCII字符進行轉義(Go語言的轉義參見表3-1)。strconv.Unquote函數接受的參數為一個雙引號字符串或者使用反引號的原生字符串,或者單引號括起來的字符,返回去除引號後的字符串和一個error變量(成功則為nil)。

3.6.3 utf8包

unicode/utf8 有幾個很有用的函數,主要用來查詢和操作UTF-8 編碼的字符串或者字節切片,參見表 3-10。之前我們已經知道如何使用 utf8.DecodeRuneString函數和utf8.DecodeLastRuneInString 函數來獲得一個字符串的首尾字符。

表3-10 utf8包

續表

3.6.4 unicode包

unicode包主要提供了一些用來檢查Unicode碼點是否符合主要標準的函數,例如,判斷一個字符是否是一個數字或者小寫字母。表3-11列出了一些常用的函數。除了unicode.ToLower和unicode.IsUpper等,還有一個通用的函數 unicode.Is,檢查一個字符是否屬於一個特定的Unicode分類。

fmt.Println(IsHexDigit('8'), IsHexDigit('x'), IsHexDigit('X'),

IsHexDigit('b'), IsHexDigit('B'))

true·false·false·true·true

表3-11 unicode包

續表

unicode 包裡有 unicode.IsDigit 這樣的函數,可以用來檢查一個字符是否是一個十進制數字,但是並沒有類似的函數可以檢查十六進制數,所以這裡用了一個自己實現的IsHexDigit函數。

func IsHexDigit(char rune) bool {

return unicode.Is(unicode.ASCII_Hex_Digit, char)

}

這個函數很簡單,只用了一個 unicode.Is函數檢查給定的字符是否在 unicode.ASCII_Hex_Digit範圍內,以此來判斷這是否是一個十六進制數。我們還可以創建類似的函數來測試其他Unicode字符。

3.6.5 regexp包

這一節的表很多,主要是列舉了regexp包裡的函數和支持的正則表達式語法,還包含一些示例。在開始講這一節之前,我們假設大家都有一定的正則表達式基礎[3]。

regexp包是Russ Cox的RE2正則表達式引擎的Go語言實現[4]。這個引擎非常快而且是線程安全的。RE2引擎並不使用回溯,所以能夠保證線性的執行時間O(n),n是匹配字符串的長度,那些使用回溯的引擎的時間複雜度很容易達到指數級別O(2n)(參見3.3 節的大 O 表示法)。獲取出色性能的代價是不支持搜索時的反向引用,不過通常只要合理利用regexp的API就能繞開這些限制。

表3-12列出了regexp包裡的函數,有4個可以創建一個*regexp.Regexp類型的值,表3-18和表3-19列出了*regexp.Regexp提供的方法。RE2引擎支持表3-13列出的轉義序列、表3-14列出的字符類別、表3-15列出的零寬斷言、表3-16列出的數量匹配,還有表3-17列出的標識。

regexp.Regexp.ReplaceAll方法和regexp.Regexp.ReplaceAllString方法都支持按編號或者名字進行替換。編號對應於正則表達式中的括號括起來的捕獲組,而名字則對應已命名的捕獲組。儘管我們可以直接使用數字或名字引用來進行替換,例如$2 或者$filename 等,但最好將數字和名字用大括號括起來,如${2}和${filename}等,如果替換的字符串中包含$字符,要使用$$來進行轉義。

表3-12 regexp包涵數列表

表3-13 regexp包支持的轉義符號

續表

表3-14 regexp包支持的字符類

表3-15 regexp包的零寬斷言

表3-16 regexp包的數量匹配

表3-17 regexp包的標識和分組

表3-18 *regexp.Regexp類型的方法 #1

續表

表3-19 *regexp.Regexp類型的方法 #2

續表

一個典型的替換例子就是,假如我們有一個形式如「forname1 … fornameN surname」格式的名字列表,現在我們想把它們轉換成「surname, forname1 … fornameN」。看看我們是如何使用regexp包來實現這個功能且正確處理重音符號和其他的非英文字符。

nameRx := regexp.MustCompile(' (\pL+\.?(?:\s+\pL+\.?)*)\s+(\pL+)')

for i := 0; i < len(names); i++ {

names[i] = nameRx.ReplaceAllString(names[i], 〞${2}, ${1}〞)

}

變量names是一個字符串切片,保存了原來的名字列表。循環結束後names變量將被更新為修改後的名字列表。

這個正則表達式匹配一個或多個用空白分隔開的名字,每個名字由一個或者多個Unicode字母組成(名字後面可能有句號),然後緊接著空白和姓,姓也由一個或者多個Unicode字母組成。

根據數字編號來替換可能引入後期代碼維護問題,例如,如果我們在中間插入一個捕獲組,則至少有一個數字是錯誤的。解決的辦法就是使用顯式命名的方式執行替換,而不是依賴於數字型順序。

nameRx := regexp.MustCompile(

'(?P<forenames>\pL+\.?(?:\s+\pL+\.?)*)\s+(?P<surname>\pL+)')

for i := 0; i < len(names); i++ {

names[i] = nameRx.ReplaceAllString(names[i],〞${surname}, ${forenames}〞)

}

這裡我們給兩個捕獲組指定了有意義的名字,使得正則表達式和替換字符串更容易被理解。

在Python或者Perl裡,如果要匹配一個重複的單詞,可以這樣寫「\b(\w+)\s+\1\b」,但是這種正則語法需要依賴於反向引用,而這個恰好是Go語言裡regexp引擎所不支持的,為了實現相同的效果,我們還得多寫點代碼才行。

wordRx := regexp.MustCompile('\w+')

if matches := wordRx.FindAllString(text, -1); matches != nil {

previous := 〞〞

for _, match := range matches {

if match == previous {

fmt.Println(〞Duplicate word:〞, match)

}

previous = match

}

}

這個正則表達式貪婪匹配一個或者多個單詞,函數regexp.Regexp.FindAllString 返回一個不重疊的匹配結果,為string類型。如果至少存在一個匹配(matches不為nil),我們就遍歷這個字符串切片,通過比較當前的單詞和上一個單詞,打印出所有重複的單詞。

另一個常用的正則表達式是用來匹配一個配置文件裡的「鍵:值」行,下面是一個例子,匹配指定的行並將其填充到map裡面去。

valueForKey := make(map[string]string)

keyValueRx := regexp.MustCompile('\s*([[:alpha:]]\w*)\s*:\s*(.+)')

if matches := keyValueRx.FindAllStringSubmatch(lines, -1); matches != nil {

for _, match := range matches {

valueForKey[match[1]] = strings.TrimRight(match[2], 〞\t 〞)

}

}

這個正則表達式是說跳過所有字符串開始處的空白,然後匹配一個鍵,鍵必須是以英文字母開頭後面可接著0個或者多個字母、數字、下劃線,然後是冒號和值,注意鍵和冒號之間或者值和冒號之間可以允許存在空白,值可以是任何字符但不包括換行符和字符串結束符。這裡順便提及,我們可以使用更短一點的[A-Za-z]替換[[:alpha:]],或者如果我們想支持Unicode編碼的鍵的話,可以使用(\pL[\pL\p{Nd}_]*),表示一個Unicode字母后面緊接著0個或者多個Unicode字母、數字或者下劃線。因為.+ 表達式不能匹配換行符,所以這個正則表達式能夠處理連續包含多個「鍵:值」的字符串。

得益於貪婪匹配(默認),這個正則表達式能夠除掉所有在值之前的空白。但我們必須使用裁剪函數除掉在值後面的空白,因為.+ 表達式的貪婪性意味著在其後跟隨\s*將無效。我們也無法使用惰性匹配(例如.+?),因為這樣的話只會匹配值的第一個單詞,實際情況是值可能包含多個由空白分隔開的單詞。

使用regexp.Regexp.FindAllStringSubmatch 函數我們可以獲得一個字符串切片的切片(string)或者nil,-1表示盡可能多的匹配(不能重疊)。在我們這個例子裡,每一個匹配都會產生包含3個字符串的切片,第一個字符串包含整個匹配,第二個字符串為鍵,第三個字符串為值。鍵和值都必須至少有一個字符,因為它們的最小數量是1。

儘管使用Go語言提供的xml.Decoder包來分析XML是最好的方法,但有時候我們只是簡單地想得到XML文件裡的屬性值,格式通常為name=〞value〞 或者name='value' 這樣的字符串,這種情況下,用一個簡單的正則表達式更加高效。

attrValueRx := regexp.MustCompile(regexp.QuoteMeta(attrName) + '=(?:〞([^〞]+)〞|'([^']+)')')

if indexes := attrValueRx.FindAllStringSubmatchIndex(attribs, -1); indexes != nil {

for _, positions := range indexes {

start, end := positions[2], positions[3]

if start == -1 {

start, end = positions[4], positions[5]

}

fmt.Printf(〞'%s'\n〞, attribs[start:end])

}

}

attrValueRx 表達式匹配一個已經被轉義了屬性名後面緊隨著一個等號和一個單雙引號括起來的字符串。為〞|〞線正常工作而添加的一對括號也會捕獲匹配的表達式,但因為我們不希望捕獲引號,所以我們將這一對括號設置為非捕獲狀態((?:))。為了展示它是怎麼完成的,我們遍歷得到匹配的索引而不是實際匹配的字符串,在這個例子裡有3對索引,第一對索引是整個匹配的,第二對索引是雙引號值的,第三對索引是單引號值的。當然,實際上只有一個值會被匹配,其他兩個值都是−1。

和剛才的例子一樣,我們這裡也是匹配字符串裡所有不重疊的匹配,然後得到一個int類型的索引位置(或者為 nil)。對於每一個 int 類型的positions 切片,完整的匹配是切片attribs[positions[0]:positions[1]]。引號包含的字符串是 attribs[positions[2]:positions[3]]或者attribs[positions[4]:positions[5]],這取決於你配置文件裡引號的類型,上面這段代碼默認為我們的配置文件使用的是雙引號,但如果不是的話(如start == -1),那它就讀取單引號的位置。

之前我們見過怎麼去寫一個SimplifyWhitespace函數(參見3.6.1節),下面的代碼使用正則表達式和strings.TrimSpace函數來完成同樣的功能。

simplifyWhitespaceRx := regexp.MustCompile('[\s\p{Zl}\p{Zp}]+')

text = strings.TrimSpace(simplifyWhitespaceRx.ReplaceAllLiteralString(text, 〞 〞))

這個正則表達式對於字符串開頭的空白只是做簡單的跳過處理,對於結尾處的空白則使用strings.TrimSpace函數來處理,這兩部分的組合併沒有做太多工作。函數regexp.Regexp.ReplaceAllLiteralString將字符串中所有的匹配都給替換掉(regexp.Regexp.ReplaceAllString和regexp.Regexp.ReplaceAllLiteralString不同的是前者會對$標識的變量進行展開,但後者不會)。所以,現在這種情況是,任何一個或多個的空白字符(ASCII編碼的空格和Unicode行以及段落分隔符)都被替換成一個簡單的空格。

下面是我們最後一個關於正則表達式的例子,我們來看看如何使用一個函數來執行具體的替換操作。

unaccentedLatin1Rx := regexp.MustCompile(

'[AAAAAAACEEEEIIIIDNOOOOOOUUUUYaaaaaaaceeeeiiiineoooooouuuuyy]+')

unaccented := unaccentedLatin1Rx.ReplaceAllStringFunc(latin1, UnaccentedLatin1)

這個正則表達式簡單地匹配一個或者多個重音拉丁字母。只要存在一個匹配,regexp.Regexp.ReplaceAllStringFunc函數都會調用作為第二個參數傳入的函數(函數必須是 func (string) string類型的),這個函數接受匹配的字符串,該匹配的字符串將被該函數返回的字符串(可以是一個空字符串)替代。

func UnaccentedLatin1(s string) string {

chars := make(rune, 0, len(s))

for _, char := range s {

switch char {

case 'A', 'A', 'A', 'A', 'A', 'A':

char = 'A'

case 'A':

chars = append(chars, 'A')

char = 'E'

//...

case 'y', 'y':

char = 'y'

}

chars = append(chars, char)

}

return string(chars)

}

這個函數簡單地將所有重音的拉丁字符替換成它們的非重音形式,也會將連字a (在一些語言裡這是一個全字符)替換為 a和e。當然,這個例子有些刻意為之,因為這裡為執行轉換其實我們只需寫成unaccented := UnaccentedLatin1(latin1)。

現在我們完成了對正則表達式例子的介紹。注意在表3-18和表3-19中,每個處理對像為字符串的函數都有一個對應處理對像為bytes上的函數。書中還有一些其他例子也用到了正則表達式(例如1.6節和7.2.4.1節)。

現在我們已將Go語言的strings和相關的包都介紹完了,我們將用一個使用了一些Go語言string函數的例子來結束整章所學的內容,後面照常會有一些練習。

3.7 例子:m3u2pls

這一節我們介紹一個短小精悍的程序,它從命令行輸入讀取任意後綴名為.m3u的音樂播放列表文件並輸出一個等同的.pls播放列表文件。程序裡使用了很多strings包裡的函數,還有一些這兩章接觸過的東西,同時還會介紹一些新的東西。

下面是一個.m3u文件解開的內容,中間一大部分用省略號替代了。

#EXTM3U

#EXTINF:315,David Bowie - Space Oddity

Music/David Bowie/Singles 1/01-Space Oddity.ogg

#EXTINF:-1,David Bowie - Changes

Music/David Bowie/Singles 1/02-Changes.ogg

...

#EXTINF:251,David Bowie - Day In Day Out

Music/David Bowie/Singles 2/18-Day In Day Out.ogg

文件的開始內容是一個字符串常量 #EXTM3U。每一首歌用兩行來表示。第一行以字符串#EXTINF:開始,緊跟著是歌曲的持續時間(以秒為單位),然後是一個逗號,接著就是歌曲名。如果持續時間是−1的話意味著歌曲的長度是未知的(或者是未知格式)。第二行是保存歌曲的路徑。這裡我們使用開源且非專利保護的音頻壓縮格式並採用Ogg封裝格式(www.vorbis.com),以及Unix風格的路徑分隔符。

下面是一個等同的.pls文件的釋放內容,同樣使用省略號省略歌曲的大部分。

[playlist]

File1=Music/David Bowie/Singles 1/01-Space Oddity.ogg

Title1=David Bowie - Space Oddity

Length1=315

File2=Music/David Bowie/Singles 1/02-Changes.ogg

Title2=David Bowie - Changes

Length2=-1

...

File33=Music/David Bowie/Singles 2/18-Day In Day Out.ogg

Title33=David Bowie - Day In Day Out

Length33=251

NumberOfEntries=33

Version=2

.pls文件格式相比.m3u格式稍微可讀一點,文件以字符串[playlist]開始,每一首歌用3個「鍵/值」條目分別來表示文件名、標題和持續時間(以秒為單位)。實際上.pls 文件格式相當於是一種特殊的.ini文件(Windows系統的配置文件格式),在ini裡每一個鍵(在一個中括號表示的節裡面)必須是唯一的,因此我們用數字來進行區分。最後文件以兩行元數據結束。

m3u2pls程序(在文件m3u2pls/m3u2pls.go裡)在運行時需要在命令行提供一個後綴名為.m3u的文件,他會將一個對應的.pls文件寫到標準輸出(即控制台)。我們可以使用重定向將.pls數據輸出到一個實際的文件。下面是這個程序用法的一個例子。

$./m3u2pls Bowie-Singles.m3u > Bowie-Singles.pls

這裡我們讓程序從 Bowie-Singles.m3u 文件讀取數據,然後利用控制台的重定向功能將.pls版本格式的數據寫到Bowie-Singles.pls文件裡(當然,如果你能用其他的方式來轉換,那就更好了,正好這也是我們這一節後面的練習所要求的)。

後面我們會介紹差不多整個程序的代碼,除了一些import語句。

func main {

if len(os.Args) == 1 || !strings.HasSuffix(os.Args[1], 〞.m3u〞) {

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

os.Exit(1)

}

if rawBytes, err := ioutil.ReadFile(os.Args[1]); err != nil {

log.Fatal(err)

} else {

songs := readM3uPlaylist(string(rawBytes))

writePlsPlaylist(songs)

}

}

main函數首先會檢查命令行是否指定了包含.m3u 後綴名的文件。函數 strings.HasSuffix輸入兩個字符串,如果第一個字符串是以第二個字符串結束的話返回true。如果沒有指定.m3u文件的話就打印使用幫助信息並退出程序。函數filepath.Base返回給定路徑的基名(例如文件名),還有os.Exit函數會在退出前清理所有的資源,例如,停止所有的goroutine和關閉所有打開的文件,然後將它的參數返回給操作系統。

如果我們從命令行讀取到一個.m3u 文件,我們就嘗試用 ioutil.ReadFile函數將整個文件的數據讀取出來,這個函數返回文件的所有的字節流(用byte 類型保存)和一個error變量。如果讀取過程中沒發生任何錯誤的話error的值為nil,否則(例如文件不存在或者不可讀),我們用log.Fatal 函數往控制台(實際上是os.Stderr)輸出錯誤信息,然後以退出碼1退出整個程序。

如果我們成功讀取了一個文件,我們將原始的字節流轉換成字符串,這裡假定這些字節均表示一個7位的ASCII碼或者UTF-8編碼的Unicode字符,然後立即將這個字符串作為參數傳遞給自定義函數readM3uPlaylist,這個函數返回一個Song切片(Song類型),然後我們用函數writePlsPlaylist 將這些歌曲寫到標準輸出。

type Song struct {

Title  string

Filename string

Seconds int

}

這裡我們定義了一個Song類型的結構體(關於結構體的說明參見6.4節),方便用來單獨保存和文件格式無關的歌曲信息。

func readM3uPlaylist(data string) (songs Song) {

var song Song

for _, line := range strings.Split(data, 〞\n〞) {