Go語言核心36講(新年彩蛋)–學習筆記
- 2021 年 12 月 27 日
- 筆記
- 【016】Go語言核心36講
新年彩蛋 | 完整版思考題答案
基礎概念篇
- Go 語言在多個工作區中查找依賴包的時候是以怎樣的順序進行的?
答:你設置的環境變量GOPATH的值決定了這個順序。如果你在GOPATH中設置了多個工作區,那麼這種查找會以從左到右的順序在這些工作區中進行。
你可以通過試驗來確定這個問題的答案。例如:先在一個源碼文件中導入一個在你的機器上並不存在的代碼包,然後編譯這個代碼文件。最後,將輸出的編譯錯誤信息與GOPATH的值進行對比。
- 如果在多個工作區中都存在導入路徑相同的代碼包會產生衝突嗎?
答:不會產生衝突。因為代碼包的查找是按照已給定的順序逐一地在多個工作區中進行的。
- 默認情況下,我們可以讓命令源碼文件接受哪些類型的參數值?
答:這個問題通過查看flag代碼包的文檔就可以回答了。概括來講,有布爾類型、整數類型、浮點數類型、字符串類型,以及time.Duration類型。
- 我們可以把自定義的數據類型作為參數值的類型嗎?如果可以,怎樣做?
答:狹義上講是不可以的,但是廣義上講是可以的。這需要一些定製化的工作,並且被給定的參數值只能是序列化的。具體可參見flag代碼包文檔中的例子。
- 如果你需要導入兩個代碼包,而這兩個代碼包的導入路徑的最後一級是相同的,比如:dep/lib/flag和flag,那麼會產生衝突嗎?
答:這會產生衝突。因為代表兩個代碼包的標識符重複了,都是flag。
- 如果會產生衝突,那麼怎樣解決這種衝突?有幾種方式?
答:接上一個問題。很簡單,導入代碼包的時候給它起一個別名就可以了,比如: import libflag “dep/lib/flag”。或者,以本地化的方式導入代碼包,如:import . “dep/lib/flag”。
- 如果與當前的變量重名的是外層代碼塊中的變量,那麼意味着什麼?
答:這意味着這兩個變量成為了「可重名變量」。在內層的變量所處的那個代碼塊以及更深層次的代碼塊中,這個變量會「屏蔽」掉外層代碼塊中的那個變量。
- 如果通過import . XXX這種方式導入的代碼包中的變量與當前代碼包中的變量重名了,那麼 Go 語言是會把它們當做「可重名變量」看待還是會報錯呢?
答:這兩個變量會成為「可重名變量」。雖然這兩個變量在這種情況下的作用域都是當前代碼包的當前文件,但是它們所處的代碼塊是不同的。
前文件中的變量處在該文件所代表的代碼塊中,而被導入的代碼包中的變量卻處在聲明它的那個文件所代表的代碼塊中。當然,我們也可以說被導入的代碼包所代表的代碼塊包含了這個變量。
在當前文件中,本地的變量會「屏蔽」掉被導入的變量。
- 除了《程序實體的那些事兒 3》一文中提及的那些,你還認為類型轉換規則中有哪些值得注意的地方?
答:簡單來說,我們在進行類型轉換的時候需要注意各種符號的優先級。具體可參見 Go 語言規範中的轉換部分。
- 你能具體說說別名類型在代碼重構過程中可以起到的哪些作用嗎?
答:簡單來說,我們可以通過別名類型實現外界無感知的代碼重構。具體可參見 Go 語言官方的文檔 Proposal: Type Aliases。
數據類型和語句篇
- 如果有多個切片指向了同一個底層數組,那麼你認為應該注意些什麼?答:我們需要特別注意的是,當操作其中一個切片的時候是否會影響到其他指向同一個底層數組的切片。
如果是,那麼問一下自己,這是你想要的結果嗎?無論如何,通過這種方式來組織或共享數據是不正確的。你需要做的是,要麼徹底切斷這些切片的底層聯繫,要麼立即為所有的相關操作加鎖。
- 怎樣沿用「擴容」的思想對切片進行「縮容」?
答:關於切片的「縮容」,可參看官方的相關 wiki。不過,如果你需要頻繁的「縮容」,那麼就可能需要考慮其他的數據結構了,比如:container/list代碼包中的List。
- container/ring包中的循環鏈表的適用場景都有哪些?
答:比如:可重用的資源(緩存等)的存儲,或者需要靈活組織的資源池,等等。
- container/heap包中的堆的適用場景又有哪些呢?
答:它最重要的用途就是構建優先級隊列,並且這裡的「優先級」可以很靈活。所以,想像空間很大。
- 字典類型的值是並發安全的嗎?如果不是,那麼在我們只在字典上添加或刪除鍵 – 元素對的情況下,依然不安全嗎?
答:字典類型的值不是並發安全的,即使我們只是增減其中的鍵值對也是如此。其根本原因是,字典值內部有時候會根據需要進行存儲方面的調整。
- 通道的長度代表着什麼?
它在什麼時候會通道的容量相同?通道的長度代表它當前包含的元素值的個數。當通道已滿時,其長度會與容量相同。
- 元素值在經過通道傳遞時會被複制,那麼這個複製是淺表複製還是深層複製呢?
答:淺表複製。實際上,在 Go 語言中並不存在深層次的複製,除非我們自己來做。
- 如果在select語句中發現某個通道已關閉,那麼應該怎樣屏蔽掉它所在的分支?
答:很簡單,把nil賦給代表了這個通道的變量就可以了。如此一來,對於這個通道(那個變量)的發送操作和接收操作就會永遠被阻塞。
- 在select語句與for語句聯用時,怎樣直接退出外層的for語句?
答:這一般會用到goto語句和標籤(label),具體請參看 Go 語言規範的這部分。
- complexArray1被傳入函數的話,這個函數中對該參數值的修改會影響到它的原值嗎?
答:文中complexArray1變量的聲明如下:
complexArray1 := [3][]string{
[]string{"d", "e", "f"},
[]string{"g", "h", "i"},
[]string{"j", "k", "l"},
}
這要看怎樣修改了。雖然complexArray1本身是一個數組,但是其中的元素卻都是切片。如果對complexArray1中的元素進行增減,那麼原值就不會受到影響。但若要修改它已有的元素值,那麼原值也會跟着改變。
- 函數真正拿到的參數值其實只是它們的副本,那麼函數返回給調用方的結果值也會被複制嗎?
答:函數返回給調用方的結果值也會被複制。不過,在一般情況下,我們不用太在意。但如果函數在返回結果值之後依然保持執行並會對結果值進行修改,那麼我們就需要注意了。
- 我們可以在結構體類型中嵌入某個類型的指針類型嗎?如果可以,有哪些注意事項?
答:當然可以。在這時,我們依然需要注意各種「屏蔽」現象。由於某個類型的指針類型會包含與前者有關聯的所有方法,所以我們更要注意。
另外,我們在嵌入和引用這樣的字段的時候還需要注意一些衝突方面的問題,具體請參看 Go 語言規範的這一部分。
- 字面量struct{}代表了什麼?又有什麼用處?
答:字面量struct{}代表了空的結構體類型。這樣的類型既不包含任何字段也沒有任何方法。該類型的值所需的存儲空間幾乎可以忽略不計。
因此,我們可以把這樣的值作為佔位值來使用。比如:在同一個應用場景下,map[int] [int]bool類型的值佔用更少的存儲空間。
- 如果我們把一個值為nil的某個實現類型的變量賦給了接口變量,那麼在這個接口變量上仍然可以調用該接口的方法嗎?
如果可以,有哪些注意事項?如果不可以,原因是什麼?答:可以調用。但是請注意,這個被調用的方法在此時所持有的接收者的值是nil。因此,如果該方法引用了其接收者的某個字段,那麼就會引發 panic!
- 引用類型的值的指針值是有意義的嗎?如果沒有意義,為什麼?如果有意義,意義在哪裡?
答:從存儲和傳遞的角度看,沒有意義。因為引用類型的值已經相當於指向某個底層數據結構的指針了。當然,引用類型的值不只是指針那麼簡單。
- 用什麼手段可以對 goroutine 的啟用數量加以限制?
答:一個很簡單且很常用的方法是,使用一個通道保存一些令牌。只有先拿到一個令牌,才能啟用一個 goroutine。另外在go函數即將執行結束的時候還需要把令牌及時歸還給那個通道。
更高級的手段就需要比較完整的設計了。比如,任務分發器 + 任務管道(單層的通道)+ 固定個數的 goroutine。又比如,動態任務池(多層的通道)+ 動態 goroutine 池(可由前述的那個令牌方案演化而來)。等等。
- runtime包中提供了哪些與模型三要素 G、P 和 M 相關的函數?
答:關於這個問題,我相信你一查文檔便知。不過光知道還不夠,還要會用。
- 在類型switch語句中,我們怎樣對被判斷類型的那個值做相應的類型轉換?
答:其實這個事情可以讓 Go 語言自己來做,例如:
switch t := x.(type) {
// cases
}
當流程進入到某個case子句的時候,變量t的值就已經被自動地轉換為相應類型的值了。
- 在if語句中,初始化子句聲明的變量的作用域是什麼?
答:如果這個變量是新的變量,那麼它的作用域就是當前if語句所代表的代碼塊。注意,後續的else if子句和else子句也包含在當前的if語句代表的代碼塊之內。
- 請列舉出你經常用到或者看到的 3 個錯誤類型,它們所在的錯誤類型體系都是怎樣的?你能畫出一棵樹來描述它們嗎?
答:略。這需要你自己去做,我代替不了你。
- 請列舉出你經常用到或者看到的 3 個錯誤值,它們分別在哪個錯誤值列表裡?這些錯誤值列表分別包含的是哪個種類的錯誤?
答:略。這需要你自己去做,我代替不了你。
- 一個函數怎樣才能把 panic 轉化為error類型值,並將其作為函數的結果值返回給調用方?
答:可以這樣編寫:
func doSomething() (err error) {
defer func() {
p := recover()
err = fmt.Errorf("FATAL ERROR: %s", p)
}()
panic("Oops!!")
}
- 我們可以在defer函數中恢復 panic,那麼可以在其中引發 panic 嗎?
答:當然可以。這樣做可以把原先的 panic 包裝一下再拋出去。Go 程序的測試
- 除了本文中提到的,你還知道或用過testing.T類型和testing.B類型的哪些方法?它們都是做什麼用的?
答:略。這需要你自己去做,我代替不了你。
- 在編寫示例測試函數的時候,我們怎樣指定預期的打印內容?
答:這個問題的答案就在testing代碼包的文檔中。
- -benchmem標記和-benchtime標記的作用分別是什麼?
答:-benchmem標記的作用是在性能測試完成後打印內存分配統計信息。-benchtime標記的作用是設定測試函數的執行時間上限。具體請看這裡的文檔。
- 怎樣在測試的時候開啟測試覆蓋度分析?如果開啟,會有什麼副作用嗎?
答:go test命令可以接受-cover標記。該標記的作用就是開啟測試覆蓋度分析。不過,由於覆蓋度分析開啟之後go test命令可能會在程序被編譯之前注釋掉一部分源代碼,所以,若程序編譯或測試失敗,那麼錯誤報告可能會記錄下與原始的源代碼不對應的行號。
標準庫的用法
- 你知道互斥鎖和讀寫鎖的指針類型都實現了哪一個接口嗎?
答:它們都實現了sync.Locker接口。
- 怎樣獲取讀寫鎖中的讀鎖?
答:sync.RWMutex類型有一個名為RLocker的指針方法可以獲取其讀鎖。
- *sync.Cond類型的值可以被傳遞嗎?那sync.Cond類型的值呢?
答:sync.Cond類型的值一旦被使用就不應該再被傳遞了,傳遞往往意味着拷貝。拷貝一個已經被使用的sync.Cond值會引發 panic。但是它的指針值是可以被拷貝的。
- sync.Cond類型中的公開字段L是做什麼用的?我們可以在使用條件變量的過程中改變這個字段的值嗎?
答:這個字段代表的是當前的sync.Cond值所持有的那個鎖。我們可以在使用條件變量的過程中改變該字段的值,但是在改變之前一定要搞清楚這樣做的影響。
- 如果要對原子值和互斥鎖進行二選一,你認為最重要的三個決策條件應該是什麼?
答:我覺得首先需要考慮下面幾個問題。
- 被保護的數據是什麼類型的
- 是值類型的還是引用類型的?
- 操作被保護數據的方式是怎樣的?是簡單的讀和寫還是更複雜的操作?操作被保護數據的代碼是集中的還是分散的?如果是分散的,是否可以變為集中的?
在搞清楚上述問題(以及你關注的其他問題)之後,優先使用原子值。
- 在使用WaitGroup值實現一對多的 goroutine 協作流程時,怎樣才能讓分發子任務的 goroutine 獲得各個子任務的具體執行結果?
答:可以考慮使用鎖 + 容器(數組、切片或字典等),也可以考慮使用通道。另外,你或許也可以用上golang.org/x/sync/errgroup代碼包中的程序實體,相應的文檔在這裡。
- Context值在傳達撤銷信號的時候是廣度優先的還是深度優先的?其優勢和劣勢都是什麼?
答:它是深度優先的。其優勢和劣勢都是:直接分支的產生時間越早,其中的所有子節點就會越先接收到信號。至於什麼時候是優勢、什麼時候是劣勢還要看具體的應用場景。
例如,如果子節點的存續時間與資源的消耗是正相關的,那麼這可能就是一個優勢。但是,如果每個分支中的子節點都很多,而且各個分支中的子節點的產生順序並不依從於分支的產生順序,那麼這種優勢就很可能會變成劣勢。最終的定論還是要看測試的結果。
- 怎樣保證一個臨時對象池中總有比較充足的臨時對象?
答:首先,我們應該事先向臨時對象池中放入足夠多的臨時對象。其次,在用完臨時對象之後,我們需要及時地把它歸還給臨時對象池。
最後,我們應該保證它的New字段所代表的值是可用的。雖然New函數返回的臨時對象並不會被放入池中,但是起碼能夠保證池的Get方法總能返回一個臨時對象。
- 關於保證並發安全字典中的鍵和值的類型正確性,你還能想到其他的方案嗎?
答:這是一道開放的問題,需要你自己去思考。其實怎樣做完全取決於你的應用場景。不過,我們應該盡量避免使用反射,因為它對程序性能還是有一定的影響的。
- 判斷一個 Unicode 字符是否為單位元組字符通常有幾種方式?
答:unicode/utf8代碼包中有幾個可以做此判斷的函數,比如:RuneLen函數、EncodeRune函數等。我們需要根據輸入的不同來選擇和使用它們。具體可以查看該代碼包的文檔。
- strings.Builder和strings.Reader都分別實現了哪些接口?這樣做有什麼好處嗎?
答:strings.Builder類型實現了 3 個接口,分別是:fmt.Stringer、io.Writer和io.ByteWriter。而strings.Reader類型則實現了 8 個接口,即:io.Reader、io.ReaderAt、io.ByteReader、io.RuneReader、io.Seeker、io.ByteScanner、io.RuneScanner和io.WriterTo。
好處是顯而易見的。實現的接口越多,它們的用途就越廣。它們會適用於那些要求參數的類型為這些接口類型的地方。
- 對比strings.Builder和bytes.Buffer的String方法,並判斷哪一個更高效?原因是什麼?
答:strings.Builder的String方法更高效。因為該方法只對其所屬值的內容容器(那個位元組切片)做了簡單的類型轉換,並且直接使用了底層的值(或者說內存空間)。它的源碼如下:
// String returns the accumulated string.
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
數組值和字符串值在底層的存儲方式其實是一樣的。所以從切片值到字符串值的指針值的轉換可以是直截了當的。又由於字符串值是不可變的,所以這樣做也是安全的。
不過,由於一些歷史、結構和功能方面的原因,bytes.Buffer的String方法卻不能這樣做。
- io包中的同步內存管道的運作機制是什麼?
答:我們實際上已經在正文中做了基本的說明。
io.Pipe函數會返回一個io.PipeReader類型的值和一個io.PipeWriter類型的值,並將它們分別作為管道的兩端。而這兩個值在底層其實只是代理了同一個*io.pipe類型值的功能而已。
io.pipe類型通過無緩衝的通道實現了讀操作與寫操作之間的同步,並且通過互斥鎖實現了寫操作之間的串行化。另外,它還使用原子值來處理錯誤。這些共同保證了這個同步內存管道的並發安全性。
- bufio.Scanner類型的主要功用是什麼?它有哪些特點?
答:bufio.Scanner類型俗稱帶緩存的掃描器。它的功能還是比較強大的。
比如,我們可以自定義每次掃描的邊界,或者說內容的分段方法。我們在調用它的Scan方法對目標進行掃描之前,可以先調用其Split方法並傳入一個函數來自定義分段方法。
在默認情況下,掃描器會以行為單位對目標內容進行掃描。bufio代碼包提供了一些現成的分段方法。實際上,掃描器在默認情況下會使用bufio.ScanLines函數作為分段方法。
又比如,我們還可以在掃描之前自定義緩存的載體和緩存的最大容量,這需要調用它的Buffer方法。在默認情況下,掃描器內部設定的最大緩存容量是64K個位元組。
換句話說,目標內容中的每一段都不能超過64K個位元組。否則,掃描器就會使它的Scan方法返回false,並通過其Err方法給予我們一個表示「token too long」的錯誤值。這裡的「token」代表的就是一段內容。
關於bufio.Scanner類型的更多特點和使用注意事項,你可以通過它的文檔獲得。
- 怎樣通過os包中的 API 創建和操縱一個系統進程?
答:你可以從os包的FindProcess函數和StartProcess函數開始。前者用於通過進程 ID(pid)查找進程,後者用來基於某個程序啟動一個進程。
這兩者都會返回一個*os.Process類型的值。該類型提供了一些方法,比如,用於殺掉當前進程的Kill方法,又比如,可以給當前進程發送系統信號的Signal方法,以及會等待當前進程結束的Wait方法。
與此相關的還有os.ProcAttr類型、os.ProcessState類型、os.Signal類型,等等。你可以通過積極的實踐去探索更多的玩法。
- 怎樣在net.Conn類型的值上正確地設定針對讀操作和寫操作的超時時間?
答:net.Conn類型有 3 個可用於設置超時時間的方法,分別是:SetDeadline、SetReadDeadline和SetWriteDeadline。
這三個方法的簽名是一模一樣的,只是名稱不同罷了。它們都接受一個time.Time類型的參數,並都會返回一個error類型的結果。其中的SetDeadline方法是用來同時設置讀操作超時和寫操作超時的。
有一點需要特別注意,這三個方法都會針對任何正在進行以及未來將要進行的相應操作進行超時設定。
因此,如果你要在一個循環中進行讀操作或寫操作的話,最好在每次迭代中都進行一次超時設定。
否則,靠後的操作就有可能因觸達超時時間而直接失敗。另外,如果有必要,你應該再次調用它們並傳入time.Time類型的零值來表達不再限定超時時間。
- 怎樣優雅地停止基於 HTTP 協議的網絡服務程序?
答:net/http.Server類型有一個名為Shutdown的指針方法可以實現「優雅的停止」。也就是說,它可以在不中斷任何正處在活動狀態的連接的情況下平滑地關閉當前的服務器。
它會先關閉所有的空閑連接,並一直等待。只有活動的連接變為空閑之後,它才會關閉它們。當所有的連接都被平滑地關閉之後,它會關閉當前的服務器並返回。當有錯誤發生時,它還會把相應的錯誤值返回。
另外,你還可以通過調用Server值的RegisterOnShutdown方法來註冊可以在服務器即將關閉時被自動調用的函數。
更確切地說,當前服務器的Shutdown方法會以異步的方式調用如此註冊的所有函數。我們可以利用這樣的函數來通知長連接的客戶端「連接即將關閉」。
- runtime/trace代碼包的功用是什麼?答:簡單來說,這個代碼包是用來幫助 Go 程序實現內部跟蹤操作的。其中的程序實體可以幫助我們記錄程序中各個 goroutine 的狀態、各種系統調用的狀態,與 GC 有關的各種事件,以及內存相關和 CPU 相關的變化,等等。
通過它們生成的跟蹤記錄可以通過go tool trace命令來查看。更具體的說明可以參看runtime/trace代碼包的文檔。
有了runtime/trace代碼包,我們就可以為 Go 程序加裝上可以滿足個性化需求的跟蹤器了。Go 語言標準庫中有的代碼包正是通過使用該包實現了自身的功能,例如net/http/pprof包。
筆記源碼
//github.com/MingsonZheng/go-core-demo
本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發佈,但務必保留文章署名 鄭子銘 (包含鏈接: //www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發佈。