看到別人的好作品,像畫作、模型還是代碼,我們第一反應可能是感嘆結構複雜、技巧精湛,然後緊接著冒出一個想法:太難了,我做不到。
這往往是因為我們對相應的領域了解不夠,只看到複雜的結果,對如何通向目的地毫無概念。如果了解如何分解任務,到最簡單的步驟為止,還有從最簡單能看到反饋的雛形開始,逐步改善,普通人也能做出複雜的作品,最多時間比有天賦的人多花一些。
這一期開始,我們會花幾期的時間,逐步地嘗試改善一個命令行程序。
本文目錄
我們從一個命令行程序開始。
命令行界面命令行界面(CLI,Command Line Interface),又叫字符用戶界面 (CUI,Character User Interface),區別於圖形用戶界面(GUI,Graphic User Interface)。GUI 就像在國外不用學當地語言,有一份我們能看懂的、甚至有圖片的菜單供選擇,指一下就有結果,無需語言交流。而在 CLI 裡,人和機器通過標準輸入輸出(可以簡單理解為打字)進行交互:你必須通過命令準確地告訴系統你想幹嘛,然後系統執行並把結果打在屏幕上。你必須得先知道系統接受什麼命令。如果輸入命令以外的東西,系統只能告訴你『我聽不懂』。
GUI 當然要比傻傻等著你打字的黑窗友好,也是日常使用的主流。但在方便之餘,你無法提出菜單以外的細緻要求,執行菜單上沒有顯示的操作。同一個動作(如點一下菜單第二項),結果高度依賴當前的菜單顯示,你必須等菜單顯示完成才能接著『交互』,而不能一口氣直接下達想要的一系列動作指令。這就好像你明明想好了要幹什麼,卻不能說話,非要等下屬慢慢翻到那頁菜單。相比之下,CLI 可以一口氣接受一系列精確的指令。所以即使在圖形界面的系統中,命令行也沒有被遺棄,甚至還在不斷地加強。
從開發的角度說,圖形界面開發的門檻反而比較高,命令行程序因為沒有圖形界面,減少了很多工作量,可以把精力集中在核心的功能上,適合練手。
函數籤名和函數類型別誤會,我沒有打算詳細介紹函數。
在實際的開發中,自然會接觸函數的用法。在寫出優雅強大的函數之前,我們可以先調用標準庫或第三方包裡別人寫好的函數,並從中學習。
要正確使用函數,我們需要查看文檔,看懂函數籤名和注釋,有些還會有例子,就像看說明書。如果想學習實現,則要進一步看源碼。
以經常用的 fmt.Println 為例。
可以在 https://pkg.go.dev 上搜索 fmt 包,找到 Println 這個函數,內容是這樣的:
func Println(a ...interface{}) (n int, err error)Println formats using the default formats for its operands and writes to standard output. Spaces are always added between operands and a newline is appended. It returns the number of bytes written and any write error encountered.
Example Code:
package main
import (
"fmt"
)
func main() {
const name, age = "Kim", 22
fmt.Println(name, "is", age, "years old.")
// It is conventional not to worry about any
// error returned by Println.
}Kim is 22 years old.註:pkg.go.dev 從 19 年起取代了 godoc.org 成為了 Go 語言的文檔網站,上面不僅可以搜索到標準庫,所有被緩存了的第三方 module 也都能搜到。(go module 默認會先向 proxy 請求第三方包,proxy 發現尚未緩存就會先獲取緩存再返回。換言之,幾乎所有公開的有人請求的 module 都可以搜到。)
函數籤名、注釋、例子還有例子的輸出,是標準的文檔構成。
註:文檔裡的實際上是函數原型(prototype),但要確認的主要是籤名信息。
Println 不是討論重點,注釋和例子就不展開了。主要介紹一下函數籤名。
函數籤名(function signature)定義了函數的輸入(參數列表)和輸出(返回值列表)。它本質上是函數開發者和調用者之間的契約,包含函數的關鍵信息:參數的類型、個數和順序,返回值的類型、個數和順序。調用者通過它了解調用時要提供什麼,以及在調用完成後會得到什麼。(當然,按籤名調用還是有可能出現邏輯上的錯誤,開發者需要在注釋中進一步說明注意事項。)函數名、參數名、返回值名可以出現在籤名裡也可以省略,命名信息對籤名來說並不重要 。
最簡單的函數籤名是這樣的:(參數列表) (返回值列表)。籤名信息前面加上 func 關鍵字就成了函數類型(type)字面量,再加上函數名就成了函數原型(prototype),再加上函數體 {/*代碼實現*/} 就變成完整的函數。實際使用中,雖然函數籤名是關鍵,但命名能幫助我們區分函數、參數和返回值,還能從命名中推測用途,所以很多函數籤名其實是帶著命名的類型字面量或函數原型的形式。
// 單純的籤名信息
(int, int) (int, error)
// 函數類型字面量,但不細究的話,也可以叫函數籤名
func (int, int) (int, error)
// 函數原型,有時這個也叫函數籤名
func Count(int, int) (int, error)
// 完整的函數
func Count(start int, end int) (int, error) {
// 有引用到的參數需要命名。一般函數沒有多餘的參數,所以參數都是命名的。
count := 0
for i := start; i < end; i++ {
// ...
count++
}
return count, nil
}Go 裡面函數也是一種類型,籤名相同的函數就被認為是同一個類型。下面的代碼是合法的:
var f func(a int, b int) (c int) = func(x int, y int) (z int) { return x + y }
var f2 func(int, int) int = f實際上,真正的籤名信息是 (int, int) int ,func 關鍵字和各種命名 a, b, c, x, y, z 都可以省略,有沒有命名、命名是否相同,不影響它們是同一個類型。(函數的參數名 x 和 y 在函數體沒有引用時也可以省略,例如 func(int, int) int {return 0} 。)
無論是哪一種形式,關注的要點都是參數列表和返回值列表。知道以下幾點規則,你就可以讀懂函數籤名:
跟其它 C 家族語言返回值類型在前、沒有關鍵字不同(C 語言:int myFunc(int a)),Go 以關鍵字開頭,函數名和參數列表在返回值列表前面。
(順序:關鍵字 - 函數名 - 參數列表 - 返回值列表。)
因為允許多返回值,參數和返回值都是列表。其中參數列表外面的括號不能省略,即使參數列表為空;而返回值列表如果為空或者只有一個匿名返回值,可以省略括號。
(區分參數還是返回值:第一個括號裡的是參數,右邊剩下的是返回值。Go 沒有類似 void 的關鍵字,沒有返回值時,返回值部分直接為空。)
連續多個相同類型的命名參數或返回值,可以一起聲明,(a, b, c int, s string) 等價於 (a int, b int, c int, s string) 。(要看懂這種寫法,但不推薦這樣寫。這樣寫在增減參數和調整參數順序時,容易出錯,會把類型張冠李戴。)
可變參數Go 支持可變參數(variadic arguments)。具體聲明形式是,在類型前面加上三個句點 ... ,表示可以接受 0 到多個該類型的參數。例如 Println 的 (a ...interface{}) 表示可以接受任意個空接口類型的值作為參數。
註:空接口方法列表為空,意味著任意類型都滿足空接口,任意類型都可以作為實參傳遞給函數。相當於 Java 裡用 Object 作為參數類型。
調用時:
// 可以沒有參數,只輸出一個換行符
fmt.Println()
// 可以 3 個 int 型字面量
fmt.Println(1, 2, 3)
// 不同類型混合著來
// 允許不同類型是因為用了空接口類型,數量可以為任意個才是可變參數的關鍵
fmt.Println("院子裡有", 1, "棵棗樹,另", 1, "棵也是棗樹?", true)函數最多只能聲明一個 可變參數 ,而且只能是最後一個參數(可變參數放中間,後面的參數就很難對得上號了)。
可變參數實際上是一個語法糖,傳給可變參數的一系列值被打包成了一個對應類型的切片,供函數內部引用。Println 的參數在函數內部相當於 (a []interface{}) 。不過今天不討論函數的實現,只討論調用。
既然可變參數實際上變成了一個切片,如果調用方剛好有一個同類型切片 s,可以直接拿來當實參嗎?
不能。可變參數調用時要求傳入的是一個一個對應類型的值,傳相應的切片類型不符。難道只能 (s[0], s[1], s[2]) 這樣一個個地傳參嗎?如果切片有一百個元素呢.
這時有另外一個語法糖,在實參後面同樣加上 ... ,就會產生類似 Python 解包(unpack)的效果。當然,只是像,實際上是告訴函數這是一個切片,可以直接複製給可變參數,並沒有解包再打包的操作。
... 的位置很容易搞混:可變參數(形參)聲明放在前面,給實參『解包』放在後面。
開發鋪墊了一些背景知識,下面開始動手。
需求背景準備這期內容時,我在讀者中間徵集過日常找不到軟體工具的小需求,作為實戰項目的選題。最後也沒找到合適選題,這期先用我曾經遇到的需求做例子。後續大家想到什麼需求,還是可以留言,也許就用在下一個項目上。
這個需求很簡單:排序。源自我第一份工作時,開發之餘偶爾幫項目做版本管理。VCS 用的 P4,所有手機型號的項目,在同一個代碼庫的同一棵源碼樹上,通過分支和特性開關區分型號。優點是,跨型號共性問題,只要在源頭上修改一次,隨著代碼定期集成到各分支,都會修復,避免重複勞動和遺漏型號。缺點是,針對某些型號的修改,如果隔離沒做好,會影響無關的型號。
送測和正式發布的編譯,為避免引入不確定的提交,採用基線(base)+ 追加提交的方式。會選擇一個經過驗證的提交作為 base,到 base 為止的所有修改都參與編譯;base 之後的提交,往往都不太確定,遇到必須包含的提交,就要添加到追加提交裡,編譯時會將這些提交當作補丁按順序應用到代碼上(相當於臨時 cherrypick)。但這個順序,不是提交順序,而是填寫順序。假如提交 A 修復問題 1 同時引起問題 2,之後提交 B 對同一個地方做修改修復問題 2。那麼填寫時必須按照 A 到 B 的順序,否則 B 的修改會被 A 覆蓋,問題 2 將仍然存在。
每次編譯之前,在內網公布 base,模塊負責人根據 base 回復需要追加的提交,然後管理員就得到了一堆提交號。P4 的提交號是自增序列號,所以只要將它們升序排列,就能保證先後順序。
交流大概是這樣的:
管理員:本次編譯,base 為 123456
驅動組:133297 修復兼容性問題
電源管理組:167834 修正功耗計算
圖形組:123467 調整刷新緩存
系統組:145683 修改進程管理策略
.管理員經過整理,得到了 123467,133297,145683,167834 作為編譯的參數。提交少的時候,人工處理一下就完了。但如果因為某些原因無法提高 base,後續的補丁卻源源不斷,提交可能會積累到過百,這時人工確認就又累又容易出錯了。於是我當時就寫了一個命令行工具來處理這麼一個簡單的需求。
為什麼不直接用 Excel 呢?首先是 Office 啟動慢,特別在已經打開一系列開發工具的前提下;其次需要將提交錄入,排序之後還得想辦法導出,又增加了工作量。Linux 底下倒是有一個 sort 命令,但是當時我在用 Windows。對於這種簡單的需求,自己開發不僅工作量不大,遇到需求有變化時還很容易按需調整。
當時還沒接觸 Go,用的 C 開發。現在當然要用 Go 來練習。
註:考慮到篇幅有限,下面只展示代碼的關鍵部分,需要補足剩餘的代碼成分才能編譯運行。
關於如何初始化一個項目,以及項目的基本結構,請參考第一期的內容。如果還有問題,歡迎在留言區或者加入交流群提問。
小目標一開始不要設太高的期待,先讓程序可以跑起來,這樣才能基於運行的反饋,一步步改善程序。為此先把需求簡化到最簡:從標準輸入獲取提交號,排好序之後,輸出到標準輸出,用英文逗號隔開(格式要方便後續使用,P4 要求的格式就是用逗號隔開的提交號,你也可以根據自己的需要調整)。
假定把這個程序叫 gosort ,那麼用起來大概是這樣的:
> gosort 133297 167834 123467 145683
123467,133297,145683,167834這個程度很簡單,調用標準庫就可以做到。
命令行參數gosort 133297 167834 123467 145683 這一串,對命令行環境來說,是(帶參數的)命令,會根據開頭的命令,傳遞給名為 gosort 的程序;而對 gosort 程序來說,這一串則是命令行參數。注意,命令(程序名)也是參數的一部分。有些程序實現了多種功能,對外連結到不同文件名,會根據傳進來的程序名稱不同,執行不同的動作。最典型的例子是 busybox ,它以單一可執行文件,提供了一個包含超過兩百個命令的 unix 工具集合,被稱為嵌入式 Linux 的瑞士軍刀。
不像其它 C 家族語言,Go 的命令行參數不是作為 main 函數的參數傳遞的,而是通過 os 包的 Args 變量獲取。os 包初始化時會獲取參數並儲存在 Args 中,它是一個字符串切片 []string。前面介紹過查詢文檔的方法,想了解更多可以自行到 pkg.go.dev 查詢;標準庫源碼則在 Go 的安裝目錄的 src 目錄下,按包名儲存,另外大多數 IDE 都支持源碼的跟蹤跳轉(一般的操作,是對著 os.Args 按 Ctrl + 滑鼠左鍵)。
先讀取命令行參數然後直接輸出看看效果:
// 包聲明、import 語句已省略,請自行補充
func main() {
fmt.Println(os.Args)
}# 先編譯
> go build
# 後執行。程序名請替換成你自己的 module 名。Linux 下本地執行需要加 ./
> gosort 133297 167834 123467 145683
# 以下是輸出,我們先不要糾結方括號
[gosort 133297 167834 123467 145683]
# 當然我們也可以直接 go run
> go run main.go 133297 167834 123467 145683
# go run 本質上是在臨時目錄編譯後執行,所以輸出的程序名裡帶有臨時目錄信息
[C:\Users\Jayce\AppData\Local\Temp\go-build065892054\b001\exe\main.exe 133297 167834 123467 145683]
改善這裡我們需要改善幾個問題:
在這個程序裡,程序名用不上,留在切片裡還會參與後續的排序。os.Args 是第三方包的包級變量,儘量不要直接在上面排序。雖然命令行參數在這個程序裡暫時沒有別的用處,但直接修改公共變量仍是一個壞習慣。方括號其實是輸出切片內容時的格式,最終結果不需要方括號,要想辦法去掉。main 函數裡的代碼改進如下(這裡就不再執行,請自己執行,查看改動後的輸出):
func main() {
// n 是除了程序名以外的參數數量
// len() 是內置函數,獲取集合(這裡是切片)的大小
n := len(os.Args) - 1
// 創建一個大小為 n 的切片
nums := make([]string, n)
// copy() 也是內置函數,把除程序名以外的參數拷貝到新切片
// [1:] 是從下標 1 開始重新切片,跳過下標 0(即程序名)
// 重新切片返回的新切片,跟原切片指向同一個底層數組,修改會互相影響,重新切片後還是要拷貝
copy(nums, os.Args[1:])
// 把參數逐個輸出,其中前面的參數後面跟逗號,最後一個參數後面跟換行
for i := 0; i < n-1; i++ {
fmt.Print(nums[i], ",")
}
fmt.Println(nums[n-1])
}
排序多快好省地實現排序算法,本身也是學問。但這次我們不研究這個,直接使用 sort 包。
自定義類型想要排序,需要實現 sort.Interface 接口的一系列方法;基本類型則預先實現了對應的函數。對於 string 類型的升序排序,sort 包給我們提供了 sort.Strings() 。
另外,前面最後的輸出代碼,實現起來還是比較麻煩,而且存在一個 bug。藉助字符串工具包裡的 strings.Join() 函數,可以先拼接成目標字符串,再一口氣輸出,既簡單又繞開了 bug:
// 這次不再詳細注釋,有疑問請習慣查文檔,或者參與討論
func main() {
n := len(os.Args) - 1
nums := make([]string, n)
copy(nums, os.Args[1:])
sort.Strings(nums)
fmt.Println(strings.Join(nums, ","))
}這時編譯之後再執行程序,效果如下:
> gosort 133297 167834 123467 145683
123467,133297,145683,167834通過調用標準庫,5 行代碼實現了我們階段性的小目標。
下一期我們還是討論這個程序,面對需求的變化,如何改善程序去支持更複雜的功能。
思考題 sort.Strings(nums) 為什麼沒有返回值?字符串切片 nums 只是作為實參傳給了排序函數,按理說切片本身發生了拷貝,為什麼排序最後對 nums 生效了?