RPA 實戰:讓小姐姐填滿你的硬盤(上)
656
2022-05-29
互聯網時代的來臨,改變甚至顛覆了很多東西。從前,一臺主機就能搞定一切;而在互聯網時代,后臺由大量分布式系統構成,任何單個后臺服務器節點的故障都不會影響整個系統的正常運行。以七牛云、阿里云和騰訊云為代表的云廠商的出現和崛起,標志著云時代的到來。在云時代,掌握分布式編程已經成為軟件工程師的基本技能,而基于Go語言構建的Docker、Kubernetes等系統正是將云時代推向頂峰的關鍵力量。
今天,Go語言已歷經十年,最初的追隨者也已經逐漸成長為Go語言資深用戶。隨著資深用戶的不斷積累,Go語言相關教程隨之增加,在內容層面主要涵蓋Go語言基礎編程、Web編程、并發編程和內部源碼剖析等諸多領域。
七月,新書《GO語言高級編程》推薦給小伙伴們!!
###《GO語言高級編程》
一本能滿足Gopher好奇心的Go語言進階讀物
更傾向于描述實現細節,極大地滿足開發者的探索欲望
本書適合有一定Go語言經驗,并想深入了解Go語言各種高級用法的開發人員。對于Go語言新手,建議在閱讀本書前先閱讀一些基礎Go語言編程圖書。
目錄
內容提要
序一
序二
前言
致謝
資源與支持
第1章 語言基礎
第2章 CGO編程
第3章 Go匯編語言
第4章 RPC和Protobuf
第5章 Go和Web
第6章 分布式系統
附錄A 使用Go語言常遇到的問題
附錄B 有趣的代碼片段
樣章試讀:第1章 語言基礎(截選)
我不知道,你過去10年為什么不快樂。但相信我,拋掉過去的沉重,使用Go語言,體會最初的快樂!
{--:}——469856321
搬磚民工也會建成自己的“羅馬帝國”。
{--:}——小張
本章首先簡要介紹Go語言的發展歷史,并較詳細地分析“Hello, World”程序在各個祖先語言中的演化過程。然后,對以數組、字符串和切片為代表的基礎結構,以函數、方法和接口體現的面向過程和鴨子對象的編程,以及Go語言特有的并發編程模型和錯誤處理哲學做簡單介紹。最后,針對macOS、Windows、Linux幾個主流的開發平臺,推薦幾種較友好的Go語言編輯器和集成開發環境,因為好的工具可以極大地提高我們的效率。
1.1 Go語言創世紀
Go語言最初由谷歌公司的Robert Griesemer、Ken Thompson和Rob Pike這3位技術大咖于2007年開始設計發明,設計新語言的最初動力來自對超級復雜的C++11特性的吹捧報告的鄙視,最終的目標是設計網絡和多核時代的C語言。到2008年中期,在語言的大部分特性設計已經完成并開始著手實現編譯器和運行時,Russ Cox作為主力開發者加入。到2010年,Go語言已經逐步趨于穩定,并在9月正式發布并開源了代碼。
Go語言很多時候被描述為“類C語言”,或者“21世紀的C語言”。從各種角度看,Go語言確實是從C語言繼承了相似的表達式語法、控制流結構、基礎數據類型、調用參數傳值、指針等諸多編程思想,并徹底繼承和發揚了C語言簡單直接的暴力編程哲學等。圖1-1給出的是The Go Programming Language中給出的Go語言的基因圖譜,我們可以從中看到有哪些編程語言對Go語言產生了影響。
圖1-1 Go語言基因圖譜
首先看基因圖譜的左邊一支。可以明確看出Go語言的并發特性是由貝爾實驗室的Hoare于1978年發布的CSP理論演化而來。其后,CSP并發模型在Squeak/Newsqueak和Alef等編程語言中逐步完善并走向實際應用,最終這些設計經驗被消化并吸收到了Go語言中。業界比較熟悉的Erlang編程語言的并發編程模型也是CSP理論的另一種實現。
再看基因圖譜的中間一支。中間一支主要包含了Go語言中面向對象和包特性的演化歷程。Go語言中包和接口以及面向對象等特性則繼承自Niklaus Wirth所設計的Pascal語言以及其后衍生的相關編程語言。其中包的概念、包的導入和聲明等語法主要來自Modula-2編程語言,面向對象特性所提供的方法的聲明語法等則來自Oberon編程語言。最終Go語言演化出了自己特有的支持鴨子面向對象模型的隱式接口等諸多特性。
最后是基因圖譜的右邊一支,這是對C語言的致敬。Go語言是對C語言最徹底的一次揚棄,不僅在語法上和C語言有著很多差異,最重要的是舍棄了C語言中靈活但是危險的指針運算。而且,Go語言還重新設計了C語言中部分不太合理運算符的優先級,并在很多細微的地方都做了必要的打磨和改變。當然,C語言中少即是多、簡單直接的暴力編程哲學則被Go語言更徹底地發揚光大了(Go語言居然只有25個關鍵字,語言規范還不到50頁)。
Go語言的其他特性零散地來自其他一些編程語言,例如,iota語法是從APL語言借鑒的,詞法作用域與嵌套函數等特性來自Scheme語言(和其他很多編程語言)。Go語言中也有很多自己發明創新的設計。例如Go語言的切片為輕量級動態數組提供了有效的隨機存取的性能,這可能會讓人聯想到鏈表的底層的共享機制。還有Go語言新發明的defer語句(Ken發明)也是神來之筆。
1.1.1 來自貝爾實驗室特有基因
作為Go語言標志性的并發編程特性則來自貝爾實驗室的Tony Hoare于1978年發表的鮮為外界所知的關于并發研究的基礎文獻:順序通信進程(Communicating Sequential Processes,CSP)。在最初的CSP論文中,程序只是一組沒有中間共享狀態的并發運行的處理過程,它們之間使用通道進行通信和控制同步。Tony Hoare的CSP并發模型只是一個用于描述并發性基本概念的描述語言,它并不是一個可以編寫可執行程序的通用編程語言。
CSP并發模型最經典的實際應用是來自愛立信公司發明的Erlang編程語言。不過在Erlang將CSP理論作為并發編程模型的同時,同樣來自貝爾實驗室的Rob Pike以及其同事也在不斷嘗試將CSP并發模型引入當時的新發明的編程語言中。他們第一次嘗試引入CSP并發特性的編程語言叫Squeak(老鼠的叫聲),是一個用于提供鼠標和鍵盤事件處理的編程語言,在這個語言中通道是靜態創建的。然后是改進版的Newsqueak語言(新版老鼠的叫聲),新提供了類似C語言語句和表達式的語法,還有類似Pascal語言的推導語法。Newsqueak是一個帶垃圾回收機制的純函數式語言,它再次針對鍵盤、鼠標和窗口事件管理。但是在Newsqueak語言中通道已經是動態創建的,通道屬于第一類值,可以保存到變量中。然后是Alef編程語言(Alef也是C語言之父Ritchie比較喜愛的編程語言),Alef語言試圖將Newsqueak語言改造為系統編程語言,但是因為缺少垃圾回收機制而導致并發編程很痛苦(這也是繼承C語言手工管理內存的代價)。在Alef語言之后還有一個名為Limbo的編程語言(地獄的意思),這是一個運行在虛擬機中的腳本語言。Limbo語言是與Go語言最接近的祖先,它和Go語言有著最接近的語法。到設計Go語言時,Rob Pike在CSP并發編程模型的實踐道路上已經積累了幾十年的經驗,關于Go語言并發編程的特性完全是信手拈來,新編程語言的到來也是水到渠成了。
圖1-2展示了Go語言庫早期代碼庫日志,可以看出最直接的演化歷程(在Git中用git log --before={2008-03-03} --reverse命令查看)。
圖1-2 Go語言開發日志
從早期提交日志中也可以看出,Go語言是從Ken Thompson發明的B語言、Dennis M. Ritchie發明的C語言逐步演化過來的,它首先是C語言家族的成員,因此很多人將Go語言稱為21世紀的C語言。
圖1-3給出的是Go語言中來自貝爾實驗室特有并發編程基因的演化過程。
圖1-3 Go語言并發演化歷史
縱觀整個貝爾實驗室的編程語言的發展進程,從B語言、C語言、Newsqueak、Alef、Limbo語言一路走來,Go語言繼承了來自貝爾實驗室的半個世紀的軟件設計基因,終于完成了C語言革新的使命。縱觀這幾年來的發展趨勢,Go語言已經成為云計算、云存儲時代最重要的基礎編程語言。
1.1.2 你好,世界
按照慣例,介紹所有編程語言的第一個程序都是“Hello, World!”。雖然本書假設讀者已經了解了Go語言,但是我們還是不想打破這個慣例(因為這個傳統正是從Go語言的前輩C語言傳承而來的)。下面的代碼展示的Go語言程序輸出的是中文“你好,世界!”。
將以上代碼保存到hello.go文件中。因為代碼中有非ASCII的中文字符,我們需要將文件的編碼顯式指定為無BOM的UTF8編碼格式(源文件采用UTF8編碼是Go語言規范所要求的)。然后進入命令行并切換到hello.go文件所在的目錄。目前我們可以將Go語言當作腳本語言,在命令行中直接輸入go run hello.go來運行程序。如果一切正常的話,應該可以在命令行看到輸出“你好, 世界!”的結果。
而雙引號包含的“你好, 世界!”則是Go語言的字符串面值常量。和C語言中的字符串不同,Go語言中的字符串內容是不可變更的。在以字符串作為參數傳遞給fmt.Println()函數時,字符串的內容并沒有被復制——傳遞的僅是字符串的地址和長度(字符串的結構在reflect.StringHeader中定義)。在Go語言中,函數參數都是以復制的方式(不支持以引用的方式)傳遞(比較特殊的是,Go語言閉包函數對外部變量是以引用的方式使用的)。
1.2 “Hello, World”的革命
1.1節中簡單介紹了Go語言的演化基因圖譜,對其中來自貝爾實驗室的特有并發編程基因做了重點介紹,最后引出了Go語言版的“Hello, World”程序。其實“Hello, World”程序是展示各種語言特性的最好的例子,是通向該語言的一個窗口。本節將沿著各個編程語言演化的時間軸(如圖1-3所示),簡單回顧一下“Hello, World”程序是如何逐步演化到目前的Go語言形式并最終完成它的使命的。
1.2.1 B語言——Ken Thompson, 1969
首先是B語言,B語言是“Go語言之父”——貝爾實驗室的Ken Thompson早年間開發的一種通用的程序設計語言,設計目的是為了用于輔助UNIX系統的開發。但是由于B語言缺乏靈活的類型系統導致使用比較困難。后來,Ken Thompson的同事Dennis Ritchie以B語言為基礎開發出了C語言,C語言提供了豐富的類型,極大地增強了語言的表達能力。到目前為止,C語言依然是世界上最常用的程序語言之一。而B語言自從被它取代之后,就只存在于各種文獻之中,成為了歷史。
目前見到的B語言版本的“Hello, World”,一般認為是來自Brian W. Kernighan編寫的B語言入門教程(Go核心代碼庫中第一個提交者的名字正是Brian W. Kernighan),程序如下:
由于B語言缺乏靈活的數據類型,只能分別以全局變量a/b/c來定義要輸出的內容,并且每個變量的長度必須對齊到4字節(有一種寫匯編語言的感覺)。然后通過多次調用putchar()函數輸出字符,最后的'!*n'表示輸出一個換行的意思。
總體來說,B語言簡單,功能也比較有限。
1.2.2 C語言——Dennis Ritchie,1972—1989
C語言是由Dennis Ritchie在B語言的基礎上改進而來,它增加了豐富的數據類型,并最終實現了用它重寫UNIX的偉大目標。C語言可以說是現代IT行業最重要的軟件基石,目前主流的操作系統幾乎全部是由C語言開發的,許多基礎系統軟件也是C語言開發的。C系家族的編程語言占據統治地位達幾十年之久,半個多世紀以來依然充滿活力。
在Brian W. Kernighan于1974年左右編寫的C語言入門教程中,出現了第一個C語言版本的“Hello, World”程序。這給后來大部分編程語言教程都以“Hello, World”為第一個程序提供了慣例。第一個C語言版本的“Hello, World”程序如下:
關于這個程序,有幾點需要說明:首先是main()函數因為沒有明確返回值類型,所以默認返回int類型;其次printf()函數默認不需要導入函數聲明即可以使用;最后main ()沒有明確返回語句,但默認返回0。在這個程序出現時,C語言還遠未標準化,我們看到的是早先的C語言語法:函數不用寫返回值,函數參數也可以忽略,使用printf ()時不需要包含頭文件等。
這個例子在字符串末尾增加了一個換行,C語言的換行\n比B語言的換行'!*n'看起來要簡潔了一些。
在K&R的教程面世10年之后的1988年,《C程序設計語言(第2版)》終于出版了。此時ANSI C語言的標準化草案已經初步完成,但正式版本的文檔尚未發布。不過書中的“Hello, World”程序根據新的規范增加了#include
然后到了1989年,ANSI C語言第一個國際標準發布,一般被稱為C89。C89是流行最廣泛的一個C語言標準,目前依然被大量使用。《C程序設計語言》也出版了新版本,并針對新發布的C89規范建議,給main()函數的參數增加了void輸入參數說明,表示沒有輸入參數的意思。
至此,C語言本身的進化基本完成。后面的C92/C99/C11都只是針對一些語言細節做了完善。因為各種歷史因素,C89依然是使用最廣泛的標準。
1.2.3 Newsqueak——Rob Pike, 1989
Newsqueak是Rob Pike發明的老鼠語言的第二代,是他用于實踐CSP并發編程模型的戰場。Newsqueak是新的Squeak語言的意思,其中squeak是老鼠“吱吱吱”的叫聲,也可以看作是類似鼠標點擊的聲音。Squeak是一個提供鼠標和鍵盤事件處理的編程語言,Squeak語言的通道是靜態創建的。改進版的Newsqueak語言則提供了類似C語言語句和表達式的語法和類似Pascal語言的推導語法。Newsqueak是一個帶自動垃圾回收機制的純函數式語言,它再次針對鍵盤、鼠標和窗口事件管理。但是在Newsqueak語言中通道是動態創建的,屬于第一類值,因此可以保存到變量中。
Newsqueak類似腳本語言,內置了一個print()函數,它的“Hello, World”程序看不出什么特色:
從上面的程序中,除了猜測print()函數可以支持多個參數,我們很難看到Newsqueak語言相關的特性。由于Newsqueak語言和Go語言相關的特性主要是并發和通道,因此,我們這里通過一個并發版本的“素數篩”算法來略窺Newsqueak語言的特性。“素數篩”的原理如圖1-4所示。
圖1-4 素數篩
Newsqueak語言并發版本的“素數篩”程序如下:
其中counter()函數用于向通道輸出原始的自然數序列,每個filter()函數對象則對應每一個新的素數過濾通道,這些素數過濾通道根據當前的素數篩將輸入通道流入的數列篩選后重新輸出到輸出通道。mk(chan of int)用于創建通道,類似Go語言的make(chan int)語句;begin filter(p, c, newc)關鍵字啟動素數篩的并發體,類似Go語言的go filter(p, c, newc)語句;become用于返回函數結果,類似return語句。
Newsqueak語言中并發體和通道的語法與Go語言已經比較接近了,后置的類型聲明和Go語言的語法也很相似。
1.2.4 Alef——Phil Winterbottom, 1993
由于Alef語言同時支持進程和線程并發體,而且在并發體中可以再次啟動更多的并發體,導致Alef的并發狀態異常復雜。同時Alef沒有自動垃圾回收機制(Alef保留的C語言靈活的指針特性,也導致自動垃圾回收機制實現比較困難),各種資源充斥于不同的線程和進程之間,導致并發體的內存資源管理異常復雜。Alef語言全部繼承了C語言的語法,可以認為是增強了并發語法的C語言。圖1-5給出的是Alef語言文檔中展示的一個可能的并發體狀態。
圖1-5 Alef并發模型
Alef語言并發版本的“Hello, World”程序如下:
程序開頭的#include
Alef的語法和C語言基本保持一致,可以認為它是在C語言的語法基礎上增加了并發編程相關的特性,可以看作是另一個維度的C++語言。
1.2.5 Limbo——Sean Dorward, Phil Winterbottom, Rob Pike, 1995
Limbo(地獄)是用于開發運行在小型計算機上的分布式應用的編程語言,它支持模塊化編程、編譯期和運行時的強類型檢查、進程內基于具有類型的通信通道、原子性垃圾收集和簡單的抽象數據類型。Limbo被設計為:即便是在沒有硬件內存保護的小型設備上,也能安全運行。Limbo語言主要運行在Inferno系統之上。
Limbo語言版本的“Hello, World”程序如下:
從這個版本的“Hello, World”程序中,已經可以發現很多Go語言特性的雛形。第一句implement Hello;基本對應Go語言的包聲明語句package Hello。然后是include "sys.m"; sys: Sys;和include "draw.m";語句用于導入其他模塊,類似Go語言的import "sys"和import "draw"語句。Hello包模塊還提供了模塊初始化函數init(),并且函數的參數的類型也是后置的,不過Go語言的初始化函數是沒有參數的。
1.2.6 Go語言——2007—2009
下面是初期Go語言程序正式開始測試的版本:
其中內置的用于調試的print語句已經存在,不過是以命令的方式使用的。入口main()函數還和C語言中的main()函數一樣返回int類型的值,而且需要return顯式地返回值。每個語句末尾的分號也還存在。
下面是2008年6月的Go代碼:
入口函數main()已經去掉了返回值,程序默認通過隱式調用exit(0)來返回。Go語言朝著簡單的方向逐步進化。
下面是2008年8月的代碼:
用于調試的內置的print由開始的命令改為普通的內置函數,使語法更加簡單一致。
下面是2008年10月的代碼:
作為C語言中招牌的printf ()格式化函數已經移植到了Go語言中,函數放在fmt包中(fmt是格式化單詞format的縮寫)。不過printf()函數名的開頭字母依然是小寫字母,采用大寫字母表示導出的特性還沒有出現。
下面是2009年1月的代碼:
Go語言開始采用是否大小寫首字母來區分符號是否可以導出。大寫字母開頭表示導出的公共符號,小寫字母開頭表示包內部的私有符號。但需要注意的是,漢字中沒有大小寫字母的概念,因此以漢字開頭的符號目前是無法導出的(針對該問題,中國用戶已經給出相關建議,等Go 2之后或許會調整對漢字的導出規則)。
下面是2009年12月的代碼:
1.2.7 你好,世界!——V2.0
在經過半個世紀的涅槃重生之后,Go語言不僅打印出了Unicode版本的“Hello, World”,而且可以方便地向全球用戶提供打印服務。下面版本通過http服務向每個訪問的客戶端打印中文的“你好, 世界!”和當前的時間信息。
這里我們通過Go語言標準庫自帶的net/http包,構造了一個獨立運行的HTTP服務。其中http.HandleFunc("/", ...)針對根路徑/請求注冊了響應處理函數。在響應處理函數中,我們依然使用fmt.Fprintf ()格式化輸出函數實現了通過HTTP協議向請求的客戶端打印格式化的字符串,同時通過標準庫的日志包在服務器端也打印相關字符串。最后通過http.ListenAndServe()函數調用來啟動HTTP服務。
至此,Go語言終于完成了從單機單核時代的C語言到21世紀互聯網時代多核環境的通用編程語言的蛻變。
1.3 數組、字符串和切片
在主流的編程語言中數組及其相關的數據結構是使用得最為頻繁的,只有在它(們)不能滿足時才會考慮鏈表、散列表(散列表可以看作是數組和鏈表的混合體)和更復雜的自定義數據結構。
Go語言中數組、字符串和切片三者是密切相關的數據結構。這3種數據類型,在底層原始數據有著相同的內存結構,在上層,因為語法的限制而有著不同的行為表現。首先,Go語言的數組是一種值類型,雖然數組的元素可以被修改,但是數組本身的賦值和函數傳參都是以整體復制的方式處理的。Go語言字符串底層數據也是對應的字節數組,但是字符串的只讀屬性禁止了在程序中對底層字節數組的元素的修改。字符串賦值只是復制了數據地址和對應的長度,而不會導致底層數據的復制。切片的行為更為靈活,切片的結構和字符串結構類似,但是解除了只讀限制。切片的底層數據雖然也是對應數據類型的數組,但是每個切片還有獨立的長度和容量信息,切片賦值和函數傳參時也是將切片頭信息部分按傳值方式處理。因為切片頭含有底層數據的指針,所以它的賦值也不會導致底層數據的復制。其實Go語言的賦值和函數傳參規則很簡單,除閉包函數以引用的方式對外部變量訪問之外,其他賦值和函數傳參都是以傳值的方式處理。要理解數組、字符串和切片這3種不同的處理方式的原因,需要詳細了解它們的底層數據結構。
1.3.1 數組
數組是一個由固定長度的特定類型元素組成的序列,一個數組可以由零個或多個元素組成。數組的長度是數組類型的組成部分。因為數組的長度是數組類型的一部分,不同長度或不同類型的數據組成的數組都是不同的類型,所以在Go語言中很少直接使用數組(不同長度的數組因為類型不同無法直接賦值)。和數組對應的類型是切片,切片是可以動態增長和收縮的序列,切片的功能也更加靈活,但是要理解切片的工作原理還是要先理解數組。
我們先看看數組有哪些定義方式:
第一種方式是定義一個數組變量的最基本的方式,數組的長度明確指定,數組中的每個元素都以零值初始化。
第二種方式是定義數組,可以在定義的時候順序指定全部元素的初始化值,數組的長度根據初始化元素的數目自動計算。
第三種方式是以索引的方式來初始化數組的元素,因此元素的初始化值出現順序比較隨意。這種初始化方式和map[int]Type類型的初始化語法類似。數組的長度以出現的最大的索引為準,沒有明確初始化的元素依然用零值初始化。
第四種方式是混合了第二種和第三種的初始化方式,前面兩個元素采用順序初始化,第三個和第四個元素采用零值初始化,第五個元素通過索引初始化,最后一個元素跟在前面的第五個元素之后采用順序初始化。
數組的內存結構比較簡單。例如,圖1-6給出的是一個[4]int{2,3,5,7}數組值對應的內存結構。
圖1-6 數組布局
Go語言中數組是值語義。一個數組變量即表示整個數組,它并不是隱式地指向第一個元素的指針(例如C語言的數組),而是一個完整的值。當一個數組變量被賦值或者被傳遞的時候,實際上會復制整個數組。如果數組較大的話,數組的賦值也會有較大的開銷。為了避免復制數組帶來的開銷,可以傳遞一個指向數組的指針,但是數組指針并不是數組。
其中b是指向數組a的指針,但是通過b訪問數組中元素的寫法和a是類似的。還可以通過for range來迭代數組指針指向的數組元素。其實數組指針類型除類型和數組不同之外,通過數組指針操作數組的方式和通過數組本身的操作類似,而且數組指針賦值時只會復制一個指針。但是數組指針類型依然不夠靈活,因為數組的長度是數組類型的組成部分,指向不同長度數組的數組指針類型也是完全不同的。
可以將數組看作一個特殊的結構體,結構的字段名對應數組的索引,同時結構體成員的數目是固定的。內置函數len()可以用于計算數組的長度,cap()函數可以用于計算數組的容量。不過對數組類型來說,len()和cap()函數返回的結果始終是一樣的,都是對應數組類型的長度。
我們可以用for循環來迭代數組。下面常見的幾種方式都可以用來遍歷數組:
用for range方式迭代的性能可能會更好一些,因為這種迭代可以保證不會出現數組越界的情形,每輪迭代對數組元素的訪問時可以省去對下標越界的判斷。
用for range方式迭代,還可以忽略迭代時的下標:
其中times對應一個[5][0]int類型的數組,雖然第一維數組有長度,但是數組的元素[0]int大小是0,因此整個數組占用的內存大小依然是0。不用付出額外的內存代價,我們就通過for range方式實現times次快速迭代。
數組不僅可以定義數值數組,還可以定義字符串數組、結構體數組、函數數組、接口數組、通道數組等:
我們還可以定義一個空的數組:
長度為0的數組(空數組)在內存中并不占用空間。空數組雖然很少直接使用,但是可以用于強調某種特有類型的操作時避免分配額外的內存空間,例如用于通道的同步操作:
在這里,我們并不關心通道中傳輸數據的真實類型,其中通道接收和發送操作只是用于消息的同步。對于這種場景,我們用空數組作為通道類型可以減少通道元素賦值時的開銷。當然,一般更傾向于用無類型的匿名結構體代替空數組:
我們可以用fmt.Printf()函數提供的%T或%#v謂詞語法來打印數組的類型和詳細信息:
在Go語言中,數組類型是切片和字符串等結構的基礎。以上對于數組的很多操作都可以直接用于字符串或切片中。
1.3.2 字符串
一個字符串是一個不可改變的字節序列,字符串通常是用來包含人類可讀的文本數據。和數組不同的是,字符串的元素不可修改,是一個只讀的字節數組。每個字符串的長度雖然也是固定的,但是字符串的長度并不是字符串類型的一部分。由于Go語言的源代碼要求是UTF8編碼,導致Go源代碼中出現的字符串面值常量一般也是UTF8編碼的。源代碼中的文本字符串通常被解釋為采用UTF8編碼的Unicode碼點(rune)序列。因為字節序列對應的是只讀的字節序列,所以字符串可以包含任意的數據,包括字節值0。我們也可以用字符串表示GBK等非UTF8編碼的數據,不過這時候將字符串看作是一個只讀的二進制數組更準確,因為for range等語法并不能支持非UTF8編碼的字符串的遍歷。
Go語言字符串的底層結構在reflect.StringHeader中定義:
字符串結構由兩個信息組成:第一個是字符串指向的底層字節數組;第二個是字符串的字節的長度。字符串其實是一個結構體,因此字符串的賦值操作也就是reflect.StringHeader結構體的復制過程,并不會涉及底層字節數組的復制。1.3.1節中提到的[2]string字符串數組對應的底層結構和[2]reflect.StringHeader對應的底層結構是一樣的,可以將字符串數組看作一個結構體數組。
我們可以看看字符串"hello, world"本身對應的內存結構,如圖1-7所示。
圖1-7 字符串布局
分析可以發現,"hello, world"字符串底層數據和以下數組是完全一致的:
字符串雖然不是切片,但是支持切片操作,不同位置的切片底層訪問的是同一塊內存數據(因為字符串是只讀的,所以相同的字符串面值常量通常對應同一個字符串常量):
字符串和數組類似,內置的len()函數返回字符串的長度。也可以通過reflect.StringHeader結構訪問字符串的長度(這里只是為了演示字符串的結構,并不是推薦的做法):
根據Go語言規范,Go語言的源文件都采用UTF8編碼。因此,Go源文件中出現的字符串面值常量一般也是UTF8編碼的(對于轉義字符,則沒有這個限制)。提到Go字符串時,一般都會假設字符串對應的是一個合法的UTF8編碼的字符序列。可以用內置的print調試函數或fmt.Print()函數直接打印,也可以用for range循環直接遍歷UTF8解碼后的Unicode碼點值。
下面的"hello,世界"字符串中包含了中文字符,可以通過打印轉型為字節類型來查看字符底層對應的數據:
輸出的結果是:
分析可以發現,0xe4, 0xb8, 0x96對應中文“世”,0xe7, 0x95, 0x8c對應中文“界”。我們也可以在字符串面值中直接指定UTF8編碼后的值(源文件中全部是ASCII碼,可以避免出現多字節的字符)。
圖1-8展示了“hello, 世界”字符串的內存結構布局。
圖1-8 字符串布局
Go語言的字符串中可以存放任意的二進制字節序列,而且即使是UTF8字符序列也可能會遇到錯誤的編碼。如果遇到一個錯誤的UTF8編碼輸入,將生成一個特別的Unicode字符'\uFFFD',這個字符在不同的軟件中的顯示效果可能不太一樣,在印刷中這個符號通常是一個黑色六角形或鉆石形狀,里面包含一個白色的問號“?”。
下面的字符串中,我們故意損壞了第一字符的第二和第三字節,因此第一字符將會打印為“?”,第二和第三字節則被忽略,后面的“abc”依然可以正常解碼打印(錯誤編碼不會向后擴散是UTF8編碼的優秀特性之一)。
不過在for range迭代這個含有損壞的UTF8字符串時,第一字符的第二和第三字節依然會被單獨迭代到,不過此時迭代的值是損壞后的0:
如果不想解碼UTF8字符串,想直接遍歷原始的字節碼,可以將字符串強制轉為[]byte字節序列后再進行遍歷(這里的轉換一般不會產生運行時開銷):
或者是采用傳統的下標方式遍歷字符串的字節數組:
Go語言除了for range語法對UTF8字符串提供了特殊支持外,還對字符串和[]rune類型的相互轉換提供了特殊的支持。
從上面代碼的輸出結果可以發現[]rune其實是[]int32類型,這里的rune只是int32類型的別名,并不是重新定義的類型。rune用于表示每個Unicode碼點,目前只使用了21個位。
字符串相關的強制類型轉換主要涉及[]byte和[]rune兩種類型。每個轉換都可能隱含重新分配內存的代價,最壞的情況下它們運算的時間復雜度都是O(n)。不過字符串和[]rune的轉換要更為特殊一些,因為一般這種強制類型轉換要求兩個類型的底層內存結構要盡量一致,顯然它們底層對應的[]byte和[]int32類型是完全不同的內存結構,因此這種轉換可能隱含重新分配內存的 操作。
下面分別用偽代碼簡單模擬Go語言對字符串內置的一些操作,這樣對每個操作的處理的時間復雜度和空間復雜度都會有較明確的認識。
for range對字符串的迭代模擬實現如下:
for range迭代字符串時,每次解碼一個Unicode字符,然后進入for循環體,遇到崩潰的編碼并不會導致迭代停止。
[]byte(s)轉換模擬實現如下:
模擬實現中新創建了一個切片,然后將字符串的數組逐一復制到切片中,這是為了保證字符串只讀的語義。當然,在將字符串轉換為[]byte時,如果轉換后的變量沒有被修改,編譯器可能會直接返回原始的字符串對應的底層數據。
string(bytes)轉換模擬實現如下:
因為Go語言的字符串是只讀的,無法以直接構造底層字節數組的方式生成字符串。在模擬實現中通過unsafe包獲取字符串的底層數據結構,然后將切片的數據逐一復制到字符串中,這同樣是為了保證字符串只讀的語義不受切片的影響。如果轉換后的字符串在生命周期中原始的[]byte的變量不發生變化,編譯器可能會直接基于[]byte底層的數據構建字符串。
[]rune(s)轉換模擬實現如下:
因為底層內存結構的差異,所以字符串到[]rune的轉換必然會導致重新分配[]rune內存空間,然后依次解碼并復制對應的Unicode碼點值。這種強制轉換并不存在前面提到的字符串和字節切片轉換時的優化情況。
string(runes)轉換模擬實現如下:
同樣因為底層內存結構的差異,[]rune到字符串的轉換也必然會導致重新構造字符串。這種強制轉換并不存在前面提到的優化情況。
1.3.3 切片
簡單地說,切片(slice)就是一種簡化版的動態數組。因為動態數組的長度不固定,所以切片的長度自然也就不能是類型的組成部分了。數組雖然有適用的地方,但是數組的類型和操作都不夠靈活,因此在Go代碼中數組使用得并不多。而切片則使用得相當廣泛,理解切片的原理和用法是Go程序員的必備技能。
我們先看看切片的結構定義,即reflect.SliceHeader:
由此可以看出切片的開頭部分和Go字符串是一樣的,但是切片多了一個Cap成員表示切片指向的內存空間的最大容量(對應元素的個數,而不是字節數)。圖1-9給出了x := []int{2,3,5, 7,11}和y := x[1:3]兩個切片對應的內存結構。
圖1-9 切片布局
讓我們看看切片有哪些定義方式:
和數組一樣,內置的len()函數返回切片中有效元素的長度,內置的cap()函數返回切片容量大小,容量必須大于或等于切片的長度。也可以通過reflect.SliceHeader結構訪問切片的信息(只是為了說明切片的結構,并不是推薦的做法)。切片可以和nil進行比較,只有當切片底層數據指針為空時切片本身才為nil,這時候切片的長度和容量信息將是無效的。如果有切片的底層數據指針為空,但是長度和容量不為0的情況,那么說明切片本身已經被損壞了(例如,直接通過reflect.SliceHeader或unsafe包對切片作了不正確的修改)。
遍歷切片的方式和遍歷數組的方式類似:
其實除了遍歷之外,只要是切片的底層數據指針、長度和容量沒有發生變化,對切片的遍歷、元素的讀取和修改就和數組一樣。在對切片本身進行賦值或參數傳遞時,和數組指針的操作方式類似,但是只復制切片頭信息(reflect.SliceHeader),而不會復制底層的數據。對于類型,和數組的最大不同是,切片的類型和長度信息無關,只要是相同類型元素構成的切片均對應相同的切片類型。
如前所述,切片是一種簡化版的動態數組,這是切片類型的靈魂。除構造切片和遍歷切片之外,添加切片元素、刪除切片元素都是切片處理中經常遇到的操作。
內置的泛型函數append()可以在切片的尾部追加N個元素:
不過要注意的是,在容量不足的情況下,append ()操作會導致重新分配內存,可能導致巨大的內存分配和復制數據的代價。即使容量足夠,依然需要用append()函數的返回值來更新切片本身,因為新切片的長度已經發生了變化。
除了在切片的尾部追加,還可以在切片的開頭添加元素:
在開頭一般都會導致內存的重新分配,而且會導致已有的元素全部復制一次。因此,從切片的開頭添加元素的性能一般要比從尾部追加元素的性能差很多。
由于append()函數返回新的切片,也就是它支持鏈式操作,因此我們可以將多個append ()操作組合起來,實現在切片中間插入元素:
每個添加操作中的第二個append ()調用都會創建一個臨時切片,并將a[i:]的內容復制到新創建的切片中,然后將臨時創建的切片再追加到a[:i]。
用copy()和append()組合可以避免創建中間的臨時切片,同樣是完成添加元素的操作:
第一句中的append()用于擴展切片的長度,為要插入的元素留出空間。第二句中的copy()操作將要插入位置開始之后的元素向后挪動一個位置。第三句真實地將新添加的元素賦值到對應的位置。操作語句雖然冗長了一點,但是相比前面的方法,可以減少中間創建的臨時切片。
用copy()和append()組合也可以實現在中間位置插入多個元素(也就是插入一個切片):
稍顯不足的是,在第一句擴展切片容量的時候,擴展空間部分的元素復制是沒有必要的。沒有專門的內置函數用于擴展切片的容量,append()本質是用于追加元素而不是擴展容量,擴展切片容量只是append()的一個副作用。
根據要刪除元素的位置,有從開頭位置刪除、從中間位置刪除和從尾部刪除3種情況,其中刪除切片尾部的元素最快:
刪除開頭的元素可以直接移動數據指針:
刪除開頭的元素也可以不移動數據指針,而將后面的數據向開頭移動。可以用append()原地完成(所謂原地完成是指在原有的切片數據對應的內存區間內完成,不會導致內存空間結構的變化):
也可以用copy()完成刪除開頭的元素:
對于刪除中間的元素,需要對剩余的元素進行一次整體挪動,同樣可以用append()或copy()原地完成:
刪除開頭的元素和刪除尾部的元素都可以認為是刪除中間元素操作的特殊情況。
在本節開頭的數組部分我們提到過有類似[0]int的空數組,空數組一般很少用到。但是對于切片來說,len為0但是cap容量不為0的切片則是非常有用的特性。當然,如果len和cap都為0的話,則變成一個真正的空切片,雖然它并不是一個nil的切片。在判斷一個切片是否為空時,一般通過len獲取切片的長度來判斷,一般很少將切片和nil做直接的比較。
例如下面的TrimSpace()函數用于刪除[]byte中的空格。函數實現利用了長度為0的切片的特性,實現高效而且簡潔。
其實類似的根據過濾條件原地刪除切片元素的算法都可以采用類似的方式處理(因為是刪除操作,所以不會出現內存不足的情形):
切片高效操作的要點是要降低內存分配的次數,盡量保證append()操作不會超出cap的容量,降低觸發內存分配的次數和每次分配內存的大小。
如前所述,切片操作并不會復制底層的數據。底層的數組會被保存在內存中,直到它不再被引用。但是有時候可能會因為一個小的內存引用而導致底層整個數組處于被使用的狀態,這會延遲垃圾回收器對底層數組的回收。
例如,FindPhoneNumber()函數加載整個文件到內存,然后搜索第一個出現的電話號碼,最后結果以切片方式返回。
這段代碼返回的[]byte指向保存整個文件的數組。由于切片引用了整個原始數組,導致垃圾回收器不能及時釋放底層數組的空間。一個小的需求可能導致需要長時間保存整個文件數據。這雖然不是傳統意義上的內存泄漏,但是可能會降低系統的整體性能。
要解決這個問題,可以將感興趣的數據復制到一個新的切片中(數據的傳值是Go語言編程的一個哲學,雖然傳值有一定的代價,但是換取的好處是切斷了對原始數據的依賴):
類似的問題在刪除切片元素時可能會遇到。假設切片里存放的是指針對象,那么下面刪除末尾的元素后,被刪除的元素依然被切片底層數組引用,從而導致不能及時被垃圾回收器回收(這要依賴回收器的實現方式):
保險的方式是先將指向需要提前回收內存的指針設置為nil,保證垃圾回收器可以發現需要回收的對象,然后再進行切片的刪除操作:
當然,如果切片存在的周期很短的話,可以不用刻意處理這個問題。因為如果切片本身已經可以被垃圾回收器回收的話,切片對應的每個元素自然也就可以被回收了。
為了安全,當兩個切片類型[]T和[]Y的底層原始切片類型不同時,Go語言是無法直接轉換類型的。不過安全都是有一定代價的,有時候這種轉換是有它的價值的——可以簡化編碼或者是提升代碼的性能。例如在64位系統上,需要對一個[]float64切片進行高速排序,我們可以將它強制轉換為[]int整數切片,然后以整數的方式進行排序(因為float64遵循IEEE 754浮點數標準特性,所以當浮點數有序時對應的整數也必然是有序的)。
下面的代碼通過兩種方法將[]float64類型的切片轉換為[]int類型的切片:
第一種強制轉換是先將切片數據的開始地址轉換為一個較大的數組的指針,然后對數組指針對應的數組重新做切片操作。中間需要unsafe.Pointer來連接兩個不同類型的指針傳遞。需要注意的是,Go語言實現中非0大小數組的長度不得超過2 GB,因此需要針對數組元素的類型大小計算數組的最大長度范圍([]uint8最大2 GB,[]uint16最大1 GB,依此類推,但是[]struct{}數組的長度可以超過2 GB)。
第二種轉換操作是分別取兩個不同類型的切片頭信息指針,任何類型的切片頭部信息底層都對應reflect.SliceHeader結構,然后通過更新結構體方式來更新切片信息,從而實現a對應的[]float64切片到c對應的[]int切片的轉換。
通過基準測試,可以發現用sort.Ints對轉換后的[]int排序的性能要比用sort.Float64s排序的性能高一點。不過需要注意的是,這個方法可行的前提是要保證[]float64中沒有NaN和Inf等非規范的浮點數(因為浮點數中NaN不可排序,正0和負0相等,但是整數中沒有這類情形)。
###沒看過癮,可以移步這里購買
本文轉載自異步社區。
軟件開發
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。