Go 语言实战(7):命令行程序(2)

上一期 我们有了一个最简单的命令行程序。从命令行参数输入一系列的编号,程序就会排好序重新输出。

接下来让我们继续改进程序。

准备知识

标志(flag)参数

我们在之前已经了解过命令行参数。在 gosort 程序中,就是通过命令行参数,输入需要排序的编号。

简单的命令行参数输入后变成了字符串切片,只有位置(下标)的差别,不方便传递复杂的参数。如果规定特定次序的参数表示特定的含义,不好记忆不说,还无法缺省(因为一旦缺省,次序就乱了)。而像 gosort 程序这样,普通参数(又叫位置参数)数量不确定,把特殊参数放到最后面也达不到缺省的效果。

我们需要 标志(flag)参数,通过在短横线(也就是减号 - )后面加上参数名,构成一个标志,使得参数变得有名字,不再受限于顺序。使用效果类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 假定 gosort 支持标志参数 -x
# 允许缺省,此时 -x 为默认值
# 123 456 789 为三个普通参数,是需要排序的编号,下同
gosort 123 456 789
# 如果 -x 是一个布尔参数,那么有 -x 表示 true
gosort -x 123 456 789
# 如果 -x 不是布尔型的参数,那么还要指定 -x 的值
# 可以使用等号
gosort -x=out.txt 123 456 789
# 也可以不用等号,紧接着的第一个参数被认为是指定的值
gosort -x out.txt 123 456 789

自己实现标志参数并不难,只需要检查每一个命令行参数,找出短横线开头的,然后根据类型决定要不要读下一个参数作为值;处理的同时,要把标志参数和位置参数分开,供后续使用。虽然不难,实现起来比较琐碎,一不小心会漏掉一些边界条件,需要耐心地去测试完善。

你可以尝试实现看看。不过这里我们偷个懒,使用标准库自带的 flag 包。

1
2
3
4
5
6
7
8
9
10
11
// 注意 xFlag 不是 bool 而是 *bool(bool指针)
// 创建时的三个参数,分别是参数名,默认值和参数说明
var xFlag = flag.Bool("x", flase, "试用 flag 参数,默认为 false")
// 解析需要放在所有 flag 设置好之后
flag.Parse()
// 需要解引用获取 bool 值
if *xFlag {
// -x 设置为 true 时的操作
}
// 可以通过 flag 包访问位置参数的数量和值(去掉了程序名和标志参数)
fmt.Println("位置参数一共有", flag.NArg(), "个,分别是:", flag.Args())

除了直接生成标志参数后返回储存地址(指针),也可以声明好变量之后,在设置标志参数时指定储存的变量。

1
2
3
4
5
6
7
8
// 注意这里的 name 直接就是 string,不是指针
var name string
// 将 name 的地址传给标志参数
flag.StringVar(&name, "name", "无名", "试用 string 类型的 flag 参数")
// 解析时参数会储存到指定的变量
flag.Parse()
// name 本身就是 string,无需解引用
fmt.Println(name)

简单总结一下:

  • flag 包保存着关于标志参数的全局状态, flag.Parse() 必须在 所有标志参数设置好之后、访问任意一个参数值之前调用
  • flag 只支持基本类型 + time.Duration 类型的参数,每个类型有两个设置函数:不带 Var 结尾的直接返回储存变量的指针,带 Var 结尾的则需要你指定指针。
  • 在使用时,标志参数必须位于所有位置参数之前,否则会被当做位置参数。例如 gosort -x 123 456 -name bob 无法得到 name 参数,反而会得到这样的位置参数: ["123","456","-name","bob"]
  • 标志参数名尽量避开 hhelp ,因为 flag 包默认实现了这两个标志,打印帮助信息。

更复杂的标志参数,可以使用第三方包 github.com/urfave/cligithub.com/spf13/cobra,它们在 flag 的基础上,封装了更高级的用法。但目前为止,flag 已经够用了。

记得标准库和第三方包的详细文档,可以在 pkg.go.dev 搜到。

在这一期里,部分函数只会做简略的介绍,详细的函数签名和用法需要大家自行看文档。

改进

篇幅关系,只展示代码的关键部分,需要补足剩余的代码才能编译运行。

在开始改进之前,先将之前的程序做一点小调整:把排序和拼接的代码,单独抽取成一个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
// 为了不修改包级变量(os.Args 以及后面的 flag.Args())
// 排序前先拷贝一份
s := make([]string, len(os.Args)-1)
copy(s, os.Args[1:]))
fmt.Println(sortStrings(s))
}
// 因为不需要供包外调用,函数名小写开头即可
// 上一期介绍过函数签名和函数原型,读者想必可以看懂这个函数的参数和返回值
func sortStrings(strs []string) string {
sort.Strings(strs)
return strings.Join(strs, ",")
}

将功能相对独立的、会被复用的代码抽取成函数是一个好的编程习惯,将函数内部控制在较少的容易理解的行数,可以让程序代码行数持续膨胀的同时,保持一个较好的可读性。

按数值排序

还记得我们的需求吗?待排序的是一组提交编号,它们是单调递增的序列号。在实际使用中,因为代码库特别庞大,到后期提交编号达到好几位数,位数要过很长时间才增长一位,给人一种编号位数一直就是这么多的错觉。实际上并不是这样,编号用完了还是要进位的,9999 之后,就是 10000 了。

之前的实现按文本(字符串)排序,就出问题了。例如 9,80,564,1253 这几个数,如果按照数值排序,现在的顺序就是升序;可如果按字符串排序,则刚好反过来,顺序是 1253,564,80,9 。因为字符串是头部对齐后从左到右比较的,前缀分出先后就直接结束比较。

我们需要先将提交编号转换为数字,再按数字的规则排序。另一方面,按照字符串排序可以保留,让 gosort 程序有更多的用途,这时就需要一个标志参数区分开。现在规定默认情况按数值排序,而当设置 -l (lexically)时按字符串排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func main() {
// 设置 bool 型的标志参数 -l
var lex bool
flag.BoolVar(&lex, "l", false, "sort lexically")
flag.Parse()
var res string
if lex {
// 如果设置了 -l,调用之前的sortStrings()
// flag.Args() 返回的切片在 Parse() 的时候已经去掉了多余的参数
s := make([]string, flag.NArg())
copy(s, flag.Args())
res = sortStrings(s)
} else {
// 否则先转为整型切片再排序
// 由于没有对返回的切片进行修改,所以无需拷贝
nums, err := strsToNums(flag.Args())
// 命令行参数不一定都是数字,转换有可能失败,此时要打印错误信息并退出
if err != nil {
fmt.Println(err)
// 0 以外的值表示异常退出
// 退出后,后面的代码都不会再执行
os.Exit(1)
}
res = sortNums(nums)
}
fmt.Println(res)
}

首先是设置标志参数并储存在变量 lex ,这部分内容参考前面的准备知识。然后程序根据 lex 的值执行不同的分支。如果是字符串排序,就是之前的函数。另外一个分支则多了几个新函数。

strsToNums() 是我们自己实现的函数,用来把字符串切片转换为整型数切片。因为转换有可能失败,所以返回值列表里还带着一个 error 类型的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func strsToNums(strs []string) ([]int, error) {
// 创建同样大小的 int 切片,用来存放转换的结果
nums := make([]int, len(strs))
var err error
// 遍历每个字符串并转换
for i := range strs {
nums[i], err = strconv.Atoi(strs[i])
// 这里的逻辑是,只要其中一个字符串转换失败,就返回空白切片和错误
// 你也可以改为忽略不能转换的字符串,继续转换和排序
if err != nil {
return nil, err
}
}
return nums, nil
}

这里使用了标准库函数 strconv.Atoi()strconv 是 String Convert 的缩写,这个包里是跟字符串转换相关的工具函数,其中 Atoi() 就是把按十进制显示的字符串,转换为 int 型。因为字符串储存的不一定是十进制数,就有可能转换失败。

sortNums() 是另一个我们自己实现的函数,跟之前的 sortStrings() 类似。

1
2
3
4
func sortNums(nums []int) string {
sort.Ints(nums)
return numsJoin(nums)
}

由于整型切片无法直接 strings.Join() ,为了让 sortNums() 内部跟 sortStrings() 保持类似,我们又自行实现了 numsJoin()。把整型数拼接成逗号隔开的字符串有很多种具体的做法,这里是其中一种:

1
2
3
4
5
6
7
8
9
10
// 实现 1
func numsJoin(nums []int) string {
// 一种直观的想法就是,再逐个转换回字符串,再 strings.Join()
strs := make([]string, len(nums))
for i := range nums {
// 每个整型数都一定有对应的字符串表示,不存在失败,所以返回值里没有 error
strs[i] = strconv.Itoa(nums[i])
}
return strings.Join(strs, ",")
}

不过我嫌这里做了两次转换(先从整型到一个个字符串,再把字符串拼成长字符串),有点浪费。能不能一步到位呢,于是我又写了第二种实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 实现 2
func numsJoin(nums []int) string {
// 声明一个 byte 切片作为缓冲区
// 这里无需 make,因为后续的两个 append 操作都会根据需要扩展切片,包括 nil 切片也能处理
var buf []byte
for _, n := range nums {
// 把转换后的字符串放进缓冲区(以字节的形式)
strconv.AppendInt(buf, int64(n), 10)
// 把逗号放进缓冲区
append(buf, ',')
}
// byte 切片转换为字符串,只转换一次
return string(buf)
}

我还能基于 bytes.Bufferstrings.Builder 写出别的实现版本。

但第一种实现就挺好的。这里只是展示,有时同一个功能可以有多种实现方式。开发的首要任务是实现功能,并且尽可能让代码易读,不容易出错。性能有时也重要,但必须是经过分析,确认有性能差异,并且这个差异对于程序的表现有影响。第二种实现一定比第一种性能好吗?差异是否大到值得特意去优化?使用看起来性能好但是不熟悉的实现,是否会带入潜在的 bug?答案都是不确定的。

这里为了行文方便,每个函数分开讨论,实际上它们都放在 main.go 里。在 Go 里,包级成员(包括函数)的引用顺序和声明顺序无关,只要不存在循环引用即可。一般的惯例是,init()main (如果有)最前面,然后是导出(exported)成员(就是首字母大写那些),然后是未导出(unexported)成员。未导出函数之间,先被引用到的就放前面。

现在重新编译之后执行一下程序看看效果:

1
2
3
4
5
6
7
8
9
10
11
12
> gosort -h
Usage of gosort:
-l sort lexically
> gosort 1253 80 9 564
9,80,564,1253
> gosort 1253 abc 80 9 564
strconv.Atoi: parsing "abc": invalid syntax
> gosort -l 1253 abc 80 9 564
1253,564,80,9,abc

从文件输入输出

现在程序默认按数值排序,即使遇到长度不同的提交编号也不怕;同时按文本排序的功能也没丢掉,偶尔还能用来排一下人名之类的文本信息。程序够用了吗?

还是回到最初的需求。为什么不人工检查排序呢?因为编号多,最多达几十上百,这种数量,人工排序又慢又累又容易错。甚至不要说排序,就是把编号全部输入一遍,也是慢、累、易错。实际工作中都是打开记事本,把大家回复的编号整理起来,然后直接复制到命令行作为参数。有时还得追加提交编号,就把新的编号放到记事本最后面,然后 Ctrl + A(全选),Ctrl + C(复制),来到命令行,Ctrl + V(粘贴),熟练到麻木。

为什么要重复做这四个动作,能不能告诉程序,直接从指定的文件读编号?当然可以。不过从命令行参数输入编号还是得保留,方便数量少时使用。

这时可以设置标志参数 -f (file,不过为了跟后面的输出区分,还是理解为 from 吧),传入一个文件,让程序改为从文件读入。这里假定文本文件里只有编号,以空白字符隔开。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func main() {
// 省略 -l 部分代码...
var from string
flag.StringVar(&from, "-f", "", "input from file")
flag.Parse()
var strs []string
if from != "" {
// 如果不是文件,输出错误信息并退出
if !isFile(from) {
fmt.Println(from, " is not a file")
os.Exit(1)
}
// 读取文件有多种实现方式,这里采用了最简单的 ioutil 包
buf, err := ioutil.ReadFile(from)
// 如果读取文件有错误,也是输出错误信息并退出
if err != nil {
fmt.Println("read ", from, " fail: ", err)
os.Exit(1)
}
// 这里不要用 strings.Spilt(string(buf), " "),因为间隔的空白字符可能不止一个
// Fields() 以任意数量的空白字符作为分割
strs = strings.Fields(string(buf))
}
// 无论是否从文件读入,位置参数都追加到后面
// ... 的含义,请参考上一期可变参数部分
strs = append(strs, flag.Args()...)
var res string
if lex {
// 前面 append 时字符串追加到了新切片,这里不用再拷贝
res = sortStrings(strs)
} else {
nums, err := strsToNums(strs)
// 省略之后的代码...
}

增加了 -f 参数之后,如果 from 有值,而且这个值确实是一个有效的文件,就会从里面读取内容。位置参数的值同时也追加到切片里。

这里面用到的新函数,只有 isFile() 是自行实现,其它像 ioutil.ReadFile()strings.Fields() 可以直接查询文档。

1
2
3
4
5
6
7
8
9
// 判断是否文件的固定套路
func isFile(path string) bool {
info, err := os.Stat(path)
if err != nil && !os.IsExist(err) {
return false
}
return !info.IsDir()
}

相应地,从命令行复制大段的输出也不够方便。极端的情况下,太多的输出甚至会超出命令行的缓冲区。可以从文件输入,自然也可以从文件输出。跟 -f 参数类似,我使用 -o (output)参数指定输出文件。参数设置就不再示范了,这里看一下怎么写入文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
// 省略...
var output string
// 省略...
if output == "" {
fmt.Println(res)
} else {
err := ioutil.WriteFile(output, []byte(res), 0666)
if err != nil {
fmt.Println("write result to ", output, " fail: ", err)
os.Exit(1)
}
}
}

同样地,为了偷懒,直接用 ioutil.WriteFile() ,三个参数分别是文件名(路径),写入的数据(string 需要转换为 字节切片)和 文件权限。这个函数在遇到目标文件存在且有写权限时,会直接覆盖原来的内容,但不改动权限;如果文件不存在,则以指定权限创建文件。0666 为八进制数,对应 Linux 的权限值(在 Windows 系统 Go 会自动转换为相应的操作)。

接下来试一下修改后的程序。我们首先创建一个 input.txt (注意前后和中间间隔有多余空格,实际操作中不小心多输入空格是非常常见的):

1
123 456 111 983

然后执行程序:

1
2
3
4
5
6
7
8
9
10
11
12
# 使用 strings.Spilt(string(buf), " ") 分割文件输入的效果
# 分割结果里出现了空白字符串,无法转换为数字
> gosort -f input.txt 256
strconv.Atoi: parsing "": invalid syntax
# 使用 strings.Fields(string(buf)) 的效果
> gosort -f input.txt 256
111,123,256,456,983
# 参数较多时,为了视觉上紧凑,可以用等号连接
> gosort -f=input.txt -o=output.txt 256
# 没有错误的话没有输出,结果在 output.txt 里

其它改进

除此之外,还能想到一些有用的功能。

去重

无论是有人不小心回复了重复的编号,还是管理员整理时多写了,编号偶尔会出现重复。重复的内容,在不同场景下,造成的影响可大可小。对于一些严格的场景,我们更希望编号里没有重复。这就要求对结果进行去重。

这时我们可以增加一个 -u (unique)的 bool 参数,表示开启去重。(当然也可以默认去重,设置一个反向的开关表示保留重复。)去重可以在排序之后做,因为这时重复元素相邻,更容易处理。这个功能实现起来并不难,对切片做一次遍历即可,大家可以尝试自己实现。如果一时没有概念,可以先用 Go 语言完成这道题 https://leetcode-cn.com/problems/remove-element/ ,做出这道题就知道如何高效地在切片里去除元素了。

需要注意的是,因为有 []string[]int 两种切片,去重的逻辑可能要实现两个版本(视乎你的代码实现)。这是 Go 目前不便的其中一个地方:没有泛型,会一定程度导致代码重复。

自定义分隔符

现在的程序,根据常用的场景,默认了输入的分隔符是空白字符,输出的分隔符是半角逗号 , 。但这个设定不会总是好使。有可能输入文件是其他人整理的,可能是别的系统导出的,用了别的分隔符;输出内容也可能用于别的场景,需要别的格式。

这时我们需要针对每个场景修改代码,重新编译吗?没有必要。只要设置对应的标志参数,允许分别指定分隔符就好。而如果没有指定,还是用原本的默认符号。两个符号分别用于分割输入的内容,和拼接输出的内容,需要调整相关的代码,可能用到的函数基本都在 strings 包里。

这两个功能添加上之后,实现效果大概是这样的:

1
2
3
# 这里我定义输入分隔符的参数为 -i,输出分隔符的参数为 -s
> gosort -u -i=, -s=- 111,111,555,678,333,567,678
111-333-555-567-678

练习

  1. numsJoin() 的第二种实现有几个 bug,你发现了吗?
  2. 通过自行查阅文档,了解 strings.Builder 的用法,你可以写出 numsJoin() 的第三种实现吗?
  3. 你可以自行实现 去重自定义分隔符 两个功能吗?

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