Go 语言实战(6):命令行程序(1)

看到别人的好作品,像画作、模型还是代码,我们第一反应可能是感叹结构复杂、技巧精湛,然后紧接着冒出一个想法:太难了,我做不到。

这往往是因为我们对相应的领域了解不够,只看到复杂的结果,对如何通向目的地毫无概念。如果了解如何分解任务,到最简单的步骤为止,还有从最简单能看到反馈的雏形开始,逐步改善,普通人也能做出复杂的作品,最多时间比有天赋的人多花一些。

这一期开始,我们会花几期的时间,逐步地尝试改善一个命令行程序。

如果是从这篇文章才开始看的新手,建议先简单浏览前几期的内容。当然,你也可以完成第一期的环境搭建之后直接跳到这期,在实际遇到问题时再去查看具体的内容。

  1. 初识
  2. 常量与变量
  3. 类型
  4. 运算符
  5. 流程控制

准备

我们从一个命令行程序开始。

命令行界面

命令行界面(CLI,Command Line Interface),又叫字符用户界面 (CUI,Character User Interface),区别于图形用户界面(GUI,Graphic User Interface)。GUI 就像在国外不用学当地语言,有一份我们能看懂的、甚至有图片的菜单供选择,指一下就有结果,无需语言交流。而在 CLI 里,人和机器通过标准输入输出(可以简单理解为打字)进行交互:你必须通过命令准确地告诉系统你想干嘛,然后系统执行并把结果打在屏幕上。你必须得先知道系统接受什么命令。如果输入命令以外的东西,系统只能告诉你『我听不懂』。

GUI 当然要比傻傻等着你打字的黑窗友好,也是日常使用的主流。但在方便之余,你无法提出菜单以外的细致要求,执行菜单上没有显示的操作。同一个动作(如点一下菜单第二项),结果高度依赖当前的菜单显示,你必须等菜单显示完成才能接着『交互』,而不能一口气直接下达想要的一系列动作指令。这就好像你明明想好了要干什么,却不能说话,非要等下属慢慢翻到那页菜单。相比之下,CLI 可以一口气接受一系列精确的指令。所以即使在图形界面的系统中,命令行也没有被遗弃,甚至还在不断地加强。

从开发的角度说,图形界面开发的门槛反而比较高,命令行程序因为没有图形界面,减少了很多工作量,可以把精力集中在核心的功能上,适合练手。

函数签名和函数类型

别误会,我没有打算详细介绍函数。

在实际的开发中,自然会接触函数的用法。在写出优雅强大的函数之前,我们可以先调用标准库或第三方包里别人写好的函数,并从中学习。

要正确使用函数,我们需要查看文档,看懂函数签名和注释,有些还会有例子,就像看说明书。如果想学习实现,则要进一步看源码。

以经常用的 fmt.Println 为例。

可以在 https://pkg.go.dev 上搜索 fmt 包,找到 Println 这个函数,内容是这样的:

1
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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.
}
1
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),再加上函数体 {/*代码实现*/} 就变成完整的函数。实际使用中,虽然函数签名是关键,但命名能帮助我们区分函数、参数和返回值,还能从命名中推测用途,所以很多函数签名其实是带着命名的类型字面量或函数原型的形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 单纯的签名信息
(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 里面函数也是一种类型,签名相同的函数就被认为是同一个类型。下面的代码是合法的:

1
2
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) intfunc 关键字和各种命名 a, b, c, x, y, z 都可以省略,有没有命名、命名是否相同,不影响它们是同一个类型。(函数的参数名 xy 在函数体没有引用时也可以省略,例如 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 作为参数类型。

调用时:

1
2
3
4
5
6
7
// 可以没有参数,只输出一个换行符
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 的提交号是自增序列号,所以只要将它们升序排列,就能保证先后顺序。

交流大概是这样的:

1
2
3
4
5
6
管理员:本次编译,base 为 123456
驱动组:133297 修复兼容性问题
电源管理组:167834 修正功耗计算
图形组:123467 调整刷新缓存
系统组:145683 修改进程管理策略
......

管理员经过整理,得到了 123467,133297,145683,167834 作为编译的参数。提交少的时候,人工处理一下就完了。但如果因为某些原因无法提高 base,后续的补丁却源源不断,提交可能会积累到过百,这时人工确认就又累又容易出错了。于是我当时就写了一个命令行工具来处理这么一个简单的需求。

为什么不直接用 Excel 呢?首先是 Office 启动慢,特别在已经打开一系列开发工具的前提下;其次需要将提交录入,排序之后还得想办法导出,又增加了工作量。Linux 底下倒是有一个 sort 命令,但是当时我在用 Windows。对于这种简单的需求,自己开发不仅工作量不大,遇到需求有变化时还很容易按需调整。

当时还没接触 Go,用的 C 开发。现在当然要用 Go 来练习。

注:考虑到篇幅有限,下面只展示代码的关键部分,需要补足剩余的代码成分才能编译运行。

关于如何初始化一个项目,以及项目的基本结构,请参考第一期的内容。如果还有问题,欢迎在留言区或者加入交流群提问。

小目标

一开始不要设太高的期待,先让程序可以跑起来,这样才能基于运行的反馈,一步步改善程序。为此先把需求简化到最简:从标准输入获取提交号,排好序之后,输出到标准输出,用英文逗号隔开(格式要方便后续使用,P4 要求的格式就是用逗号隔开的提交号,你也可以根据自己的需要调整)。

假定把这个程序叫 gosort ,那么用起来大概是这样的:

1
2
> 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.ArgsCtrl + 鼠标左键)。

先读取命令行参数然后直接输出看看效果:

1
2
3
4
// 包声明、import 语句已省略,请自行补充
func main() {
fmt.Println(os.Args)
}
1
2
3
4
5
6
7
8
9
10
11
# 先编译
> 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 函数里的代码改进如下(这里就不再执行,请自己执行,查看改动后的输出):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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:

1
2
3
4
5
6
7
8
// 这次不再详细注释,有疑问请习惯查文档,或者参与讨论
func main() {
n := len(os.Args) - 1
nums := make([]string, n)
copy(nums, os.Args[1:])
sort.Strings(nums)
fmt.Println(strings.Join(nums, ","))
}

这时编译之后再执行程序,效果如下:

1
2
> gosort 133297 167834 123467 145683
123467,133297,145683,167834

通过调用标准库,5 行代码实现了我们阶段性的小目标。

下一期我们还是讨论这个程序,面对需求的变化,如何改善程序去支持更复杂的功能。

思考题

  1. 第一次改善后的程序里,输出的代码有什么 bug?
  2. sort.Strings(nums) 为什么没有返回值?字符串切片 nums 只是作为实参传给了排序函数,按理说切片本身发生了拷贝,为什么排序最后对 nums 生效了?

知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。