Go 语言实战:命令行程序(1)
共 4552字,需浏览 10分钟
·
2021-02-23 11:16
看到别人的好作品,像画作、模型还是代码,我们第一反应可能是感叹结构复杂、技巧精湛,然后紧接着冒出一个想法:太难了,我做不到。
这往往是因为我们对相应的领域了解不够,只看到复杂的结果,对如何通向目的地毫无概念。如果了解如何分解任务,到最简单的步骤为止,还有从最简单能看到反馈的雏形开始,逐步改善,普通人也能做出复杂的作品,最多时间比有天赋的人多花一些。
这一期开始,我们会花几期的时间,逐步地尝试改善一个命令行程序。
本文目录
准备
命令行界面
函数签名和函数类型
开发
需求背景
小目标
命令行参数
改善
排序
思考题
准备
我们从一个命令行程序开始。
命令行界面
命令行界面(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 行代码实现了我们阶段性的小目标。
下一期我们还是讨论这个程序,面对需求的变化,如何改善程序去支持更复杂的功能。
思考题
第一次改善后的程序里,输出的代码有什么 bug? sort.Strings(nums)
为什么没有返回值?字符串切片nums
只是作为实参传给了排序函数,按理说切片本身发生了拷贝,为什么排序最后对nums
生效了?
推荐阅读