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