Go語言核心36講(Go語言基礎知識三)--學習筆記

MingsonZheng 發表於 2021-10-13
Go

03 | 庫原始碼檔案

在我的定義中,庫原始碼檔案是不能被直接執行的原始碼檔案,它僅用於存放程式實體,這些程式實體可以被其他程式碼使用(只要遵從 Go 語言規範的話)。

這裡的“其他程式碼”可以與被使用的程式實體在同一個原始碼檔案內,也可以在其他原始碼檔案,甚至其他程式碼包中。

那麼程式實體是什麼呢?在 Go 語言中,程式實體是變數、常量、函式、結構體和介面的統稱。我們總是會先宣告(或者說定義)程式實體,然後再去使用。

比如在上一篇的例子中,我們先定義了變數name,然後在main函式中呼叫fmt.Printf函式的時候用到了它。

回到正題。

我們今天的問題是:怎樣把命令原始碼檔案中的程式碼拆分到其他庫原始碼檔案?

我們用程式碼演示,把這個問題說得更具體一些。

如果在某個目錄下有一個命令原始碼檔案 demo4.go,如下:

package main

import (
  "flag"
)

var name string

func init() {
  flag.StringVar(&name, "name", "everyone", "The greeting object.")
}

func main() {
  flag.Parse()
  hello(name)
}

其中的程式碼你應該比較眼熟了。我在講命令原始碼檔案的時候貼過很相似的程式碼,那個原始碼檔名為 demo2.go。

這兩個檔案的不同之處在於,demo2.go 直接通過呼叫fmt.Printf函式列印問候語,而當前的 demo4.go 在同樣位置呼叫了一個叫作hello的函式。

函式hello被宣告在了另外一個原始碼檔案中,我把它命名為 demo4_lib.go,並且放在與 demo4.go 相同的目錄下。如下:

// 需在此處新增程式碼。[1]

import "fmt"

func hello(name string) {
  fmt.Printf("Hello, %s!\n", name)
}

那麼問題來了:註釋 1 處應該填入什麼程式碼?

典型回答

答案很簡單,填入程式碼包宣告語句package main。為什麼?我之前說過,在同一個目錄下的原始碼檔案都需要被宣告為屬於同一個程式碼包。

如果該目錄下有一個命令原始碼檔案,那麼為了讓同在一個目錄下的檔案都通過編譯,其他原始碼檔案應該也宣告屬於main包。

如此一來,我們就可以執行它們了。比如,我們可以在這些檔案所在的目錄下執行如下命令並得到相應的結果。

$ go run demo4.go demo4_lib.go 
Hello, everyone!

或者,像下面這樣先構建當前的程式碼包再執行。

$ go build demo4
$ ./demo4
Hello, everyone!

或者,像下面這樣先構建當前的程式碼包再執行。

$ go build puzzlers/article3/q1
$ ./q1            
Hello, everyone!

go build puzzlers/article3/q1 報錯

go: go.mod file not found in current directory or any parent directory; see 'go help modules'

需要先在根目錄安裝 mod

PS F:\Golang\Golang_Puzzlers> go mod init Golang_Puzzlers

在這裡,我把 demo4.go 和 demo4_lib.go 都放在了一個相對路徑為puzzlers/article3/q1的目錄中。

在預設情況下,相應的程式碼包的匯入路徑會與此一致。我們可以通過程式碼包的匯入路徑引用其中宣告的程式實體。但是,這裡的情況是不同的。

注意,demo4.go 和 demo4_lib.go 都宣告自己屬於main包。我在前面講 Go 語言原始碼的組織方式的時候提到過這種用法,即:原始碼檔案宣告的包名可以與其所在目錄的名稱不同,只要這些檔案宣告的包名一致就可以。

順便說一下,我為本專欄建立了一個名為“Golang_Puzzlers”的專案 https://github.com/hyper0x/Golang_Puzzlers。該專案的 src 子目錄下會存有我們涉及的所有程式碼和相關檔案。

也就是說,正確的用法是,你需要把該專案的打包檔案下載到本地的任意目錄下,然後經解壓縮後把“Golang_Puzzlers”目錄加入到環境變數GOPATH中,配置環境變數之後需要重啟Terminal。還記得嗎?這會使“Golang_Puzzlers”目錄成為工作區之一。

問題解析

這個問題考察的是程式碼包宣告的基本規則。

第一條規則,同目錄下的原始碼檔案的程式碼包宣告語句要一致。也就是說,它們要同屬於一個程式碼包。這對於所有原始碼檔案都是適用的。

如果目錄中有命令原始碼檔案,那麼其他種類的原始碼檔案也應該宣告屬於main包。這也是我們能夠成功構建和執行它們的前提。

第二條規則,原始碼檔案宣告的程式碼包的名稱可以與其所在的目錄的名稱不同。在針對程式碼包進行構建時,生成的結果檔案的主名稱與其父目錄的名稱一致。

對於命令原始碼檔案而言,構建生成的可執行檔案的主名稱會與其父目錄的名稱相同,這在我前面的回答中也驗證過了。

知識精講

1. 怎樣把命令原始碼檔案中的程式碼拆分到其他程式碼包?

我們先不用關注拆分程式碼的技巧。我在這裡仍然依從前面的拆分方法。我把 demo4.go 另存為 demo5.go,並放到一個相對路徑為puzzlers/article3/q2的目錄中。

然後我再建立一個相對路徑為puzzlers/article3/q2/lib的目錄,再把 demo4_lib.go 複製一份並改名為 demo5_lib.go 放到該目錄中。

現在,為了讓它們通過編譯,我們應該怎樣修改程式碼?你可以先思考一下。我在這裡給出一部分答案,我們一起來看看已經過修改的 demo5_lib.go 檔案。

package lib5

import "fmt"

func Hello(name string) {
  fmt.Printf("Hello, %s!\n", name)
}

可以看到,我在這裡修改了兩個地方。第一個改動是,我把程式碼包宣告語句由package main改為了package lib5。注意,我故意讓宣告的包名與其所在的目錄的名稱不同。第二個改動是,我把全小寫的函式名hello改為首字母大寫的Hello。

基於以上改動,我們再來看下面的幾個問題。

2. 程式碼包的匯入路徑總會與其所在目錄的相對路徑一致嗎?

庫原始碼檔案 demo5_lib.go 所在目錄的相對路徑是puzzlers/article3/q2/lib,而它卻宣告自己屬於lib5包。在這種情況下,該包的匯入路徑是puzzlers/article3/q2/lib,還是puzzlers/article3/q2/lib5?

這個問題往往會讓 Go 語言的初學者們困惑,就算是用 Go 開發過程式的人也不一定清楚。我們一起來看看。

首先,我們在構建或者安裝這個程式碼包的時候,提供給go命令的路徑應該是目錄的相對路徑,就像這樣:

go install puzzlers/article3/q2/lib 

該命令會成功完成。之後,當前工作區的 pkg 子目錄下會產生相應的歸檔檔案,具體的相對路徑是:

pkg/darwin_amd64/puzzlers/article3/q2/lib.a

其中的darwin_amd64就是我在講工作區時提到的平臺相關目錄。可以看到,這裡與原始碼檔案所在目錄的相對路徑是對應的。

為了進一步說明問題,我需要先對 demo5.go 做兩個改動。第一個改動是,在以import為前導的程式碼包匯入語句中加入puzzlers/article3/q2/lib,也就是試圖匯入這個程式碼包。

第二個改動是,把對hello函式的呼叫改為對lib.Hello函式的呼叫。其中的lib.叫做限定符,旨在指明右邊的程式實體所在的程式碼包。不過這裡與程式碼包匯入路徑的完整寫法不同,只包含了路徑中的最後一級lib,這與程式碼包宣告語句中的規則一致。

現在,我們可以通過執行go run demo5.go命令試一試。錯誤提示會類似於下面這種。

./demo5.go:5:2: imported and not used: "puzzlers/article3/q2/lib" as lib5
./demo5.go:16:2: undefined: lib

第一個錯誤提示的意思是,我們匯入了puzzlers/article3/q2/lib包,但沒有實際使用其中的任何程式實體。這在 Go 語言中是不被允許的,在編譯時就會導致失敗。

注意,這裡還有另外一個線索,那就是“as lib5”。這說明雖然匯入了程式碼包puzzlers/article3/q2/lib,但是使用其中的程式實體的時候應該以lib5.為限定符。這也就是第二個錯誤提示的原因了。Go 命令找不到lib.這個限定符對應的程式碼包。

為什麼會是這樣?根本原因就是,我們在原始碼檔案中宣告所屬的程式碼包與其所在目錄的名稱不同。請記住,原始碼檔案所在的目錄相對於 src 目錄的相對路徑就是它的程式碼包匯入路徑,而實際使用其程式實體時給定的限定符要與它宣告所屬的程式碼包名稱對應。

有兩個方式可以使上述構建成功完成。我在這裡選擇把 demo5_lib.go 檔案中的程式碼包宣告語句改為package lib。理由是,為了不讓該程式碼包的使用者產生困惑,我們總是應該讓宣告的包名與其父目錄的名稱一致。

3. 什麼樣的程式實體才可以被當前包外的程式碼引用?

你可能會有疑問,我為什麼要把 demo5_lib.go 檔案中的那個函式名稱hello的首字母大寫?實際上這涉及了 Go 語言中對於程式實體訪問許可權的規則。

超級簡單,名稱的首字母為大寫的程式實體才可以被當前包外的程式碼引用,否則它就只能被當前包內的其他程式碼引用。

通過名稱,Go 語言自然地把程式實體的訪問許可權劃分為了包級私有的和公開的。對於包級私有的程式實體,即使你匯入了它所在的程式碼包也無法引用到它。

4. 對於程式實體,還有其他的訪問許可權規則嗎?

答案是肯定的。在 Go 1.5 及後續版本中,我們可以通過建立internal程式碼包讓一些程式實體僅僅能被當前模組中的其他程式碼引用。這被稱為 Go 程式實體的第三種訪問許可權:模組級私有。

具體規則是,internal程式碼包中宣告的公開程式實體僅能被該程式碼包的直接父包及其子包中的程式碼引用。當然,引用前需要先匯入這個internal包。對於其他程式碼包,匯入該internal包都是非法的,無法通過編譯。

“Golang_Puzzlers”專案的puzzlers/article3/q4包中有一個簡單的示例,可供你檢視。你可以改動其中的程式碼並體會internal包的作用。

總結

我們在本篇文章中詳細討論了把程式碼從命令原始碼檔案中拆分出來的方法,這包括拆分到其他庫原始碼檔案,以及拆分到其他程式碼包。

這裡涉及了幾條重要的 Go 語言基本編碼規則,即:程式碼包宣告規則、程式碼包匯入規則以及程式實體的訪問許可權規則。在進行模組化程式設計時,你必須記住這些規則,否則你的程式碼很可能無法通過編譯。

思考題

  • 如果你需要匯入兩個程式碼包,而這兩個程式碼包的匯入路徑的最後一級是相同的,比如:dep/lib/flag和flag,那麼會產生衝突嗎?
  • 如果會產生衝突,那麼怎樣解決這種衝突,有幾種方式?

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。