上一期 我们有了一个最简单的命令行程序。从命令行参数输入一系列的编号,程序就会排好序重新输出。
接下来让我们继续改进程序。
准备知识
标志(flag)参数
我们在之前已经了解过命令行参数。在 gosort
程序中,就是通过命令行参数,输入需要排序的编号。
简单的命令行参数输入后变成了字符串切片,只有位置(下标)的差别,不方便传递复杂的参数。如果规定特定次序的参数表示特定的含义,不好记忆不说,还无法缺省(因为一旦缺省,次序就乱了)。而像 gosort
程序这样,普通参数(又叫位置参数)数量不确定,把特殊参数放到最后面也达不到缺省的效果。
我们需要 标志(flag)参数,通过在短横线(也就是减号 -
)后面加上参数名,构成一个标志,使得参数变得有名字,不再受限于顺序。使用效果类似这样:
|
|
自己实现标志参数并不难,只需要检查每一个命令行参数,找出短横线开头的,然后根据类型决定要不要读下一个参数作为值;处理的同时,要把标志参数和位置参数分开,供后续使用。虽然不难,实现起来比较琐碎,一不小心会漏掉一些边界条件,需要耐心地去测试完善。
你可以尝试实现看看。不过这里我们偷个懒,使用标准库自带的 flag
包。
|
|
除了直接生成标志参数后返回储存地址(指针),也可以声明好变量之后,在设置标志参数时指定储存的变量。
|
|
简单总结一下:
flag
包保存着关于标志参数的全局状态,flag.Parse()
必须在 所有标志参数设置好之后、访问任意一个参数值之前调用 。flag
只支持基本类型 +time.Duration
类型的参数,每个类型有两个设置函数:不带Var
结尾的直接返回储存变量的指针,带Var
结尾的则需要你指定指针。- 在使用时,标志参数必须位于所有位置参数之前,否则会被当做位置参数。例如
gosort -x 123 456 -name bob
无法得到name
参数,反而会得到这样的位置参数:["123","456","-name","bob"]
。 - 标志参数名尽量避开
h
和help
,因为flag
包默认实现了这两个标志,打印帮助信息。
更复杂的标志参数,可以使用第三方包 github.com/urfave/cli
和 github.com/spf13/cobra
,它们在 flag
的基础上,封装了更高级的用法。但目前为止,flag
已经够用了。
记得标准库和第三方包的详细文档,可以在 pkg.go.dev 搜到。
在这一期里,部分函数只会做简略的介绍,详细的函数签名和用法需要大家自行看文档。
改进
篇幅关系,只展示代码的关键部分,需要补足剩余的代码才能编译运行。
在开始改进之前,先将之前的程序做一点小调整:把排序和拼接的代码,单独抽取成一个函数:
|
|
将功能相对独立的、会被复用的代码抽取成函数是一个好的编程习惯,将函数内部控制在较少的容易理解的行数,可以让程序代码行数持续膨胀的同时,保持一个较好的可读性。
按数值排序
还记得我们的需求吗?待排序的是一组提交编号,它们是单调递增的序列号。在实际使用中,因为代码库特别庞大,到后期提交编号达到好几位数,位数要过很长时间才增长一位,给人一种编号位数一直就是这么多的错觉。实际上并不是这样,编号用完了还是要进位的,9999 之后,就是 10000 了。
之前的实现按文本(字符串)排序,就出问题了。例如 9,80,564,1253
这几个数,如果按照数值排序,现在的顺序就是升序;可如果按字符串排序,则刚好反过来,顺序是 1253,564,80,9
。因为字符串是头部对齐后从左到右比较的,前缀分出先后就直接结束比较。
我们需要先将提交编号转换为数字,再按数字的规则排序。另一方面,按照字符串排序可以保留,让 gosort
程序有更多的用途,这时就需要一个标志参数区分开。现在规定默认情况按数值排序,而当设置 -l
(lexically)时按字符串排序。
|
|
首先是设置标志参数并储存在变量 lex
,这部分内容参考前面的准备知识。然后程序根据 lex
的值执行不同的分支。如果是字符串排序,就是之前的函数。另外一个分支则多了几个新函数。
strsToNums()
是我们自己实现的函数,用来把字符串切片转换为整型数切片。因为转换有可能失败,所以返回值列表里还带着一个 error
类型的返回值。
|
|
这里使用了标准库函数 strconv.Atoi()
。strconv
是 String Convert 的缩写,这个包里是跟字符串转换相关的工具函数,其中 Atoi()
就是把按十进制显示的字符串,转换为 int
型。因为字符串储存的不一定是十进制数,就有可能转换失败。
sortNums()
是另一个我们自己实现的函数,跟之前的 sortStrings()
类似。
|
|
由于整型切片无法直接 strings.Join()
,为了让 sortNums()
内部跟 sortStrings()
保持类似,我们又自行实现了 numsJoin()
。把整型数拼接成逗号隔开的字符串有很多种具体的做法,这里是其中一种:
|
|
不过我嫌这里做了两次转换(先从整型到一个个字符串,再把字符串拼成长字符串),有点浪费。能不能一步到位呢,于是我又写了第二种实现:
|
|
我还能基于 bytes.Buffer
和 strings.Builder
写出别的实现版本。
但第一种实现就挺好的。这里只是展示,有时同一个功能可以有多种实现方式。开发的首要任务是实现功能,并且尽可能让代码易读,不容易出错。性能有时也重要,但必须是经过分析,确认有性能差异,并且这个差异对于程序的表现有影响。第二种实现一定比第一种性能好吗?差异是否大到值得特意去优化?使用看起来性能好但是不熟悉的实现,是否会带入潜在的 bug?答案都是不确定的。
这里为了行文方便,每个函数分开讨论,实际上它们都放在 main.go
里。在 Go 里,包级成员(包括函数)的引用顺序和声明顺序无关,只要不存在循环引用即可。一般的惯例是,init()
和 main
(如果有)最前面,然后是导出(exported)成员(就是首字母大写那些),然后是未导出(unexported)成员。未导出函数之间,先被引用到的就放前面。
现在重新编译之后执行一下程序看看效果:
|
|
从文件输入输出
现在程序默认按数值排序,即使遇到长度不同的提交编号也不怕;同时按文本排序的功能也没丢掉,偶尔还能用来排一下人名之类的文本信息。程序够用了吗?
还是回到最初的需求。为什么不人工检查排序呢?因为编号多,最多达几十上百,这种数量,人工排序又慢又累又容易错。甚至不要说排序,就是把编号全部输入一遍,也是慢、累、易错。实际工作中都是打开记事本,把大家回复的编号整理起来,然后直接复制到命令行作为参数。有时还得追加提交编号,就把新的编号放到记事本最后面,然后 Ctrl + A(全选),Ctrl + C(复制),来到命令行,Ctrl + V(粘贴),熟练到麻木。
为什么要重复做这四个动作,能不能告诉程序,直接从指定的文件读编号?当然可以。不过从命令行参数输入编号还是得保留,方便数量少时使用。
这时可以设置标志参数 -f
(file,不过为了跟后面的输出区分,还是理解为 from 吧),传入一个文件,让程序改为从文件读入。这里假定文本文件里只有编号,以空白字符隔开。
|
|
增加了 -f
参数之后,如果 from
有值,而且这个值确实是一个有效的文件,就会从里面读取内容。位置参数的值同时也追加到切片里。
这里面用到的新函数,只有 isFile()
是自行实现,其它像 ioutil.ReadFile()
和 strings.Fields()
可以直接查询文档。
|
|
相应地,从命令行复制大段的输出也不够方便。极端的情况下,太多的输出甚至会超出命令行的缓冲区。可以从文件输入,自然也可以从文件输出。跟 -f
参数类似,我使用 -o
(output)参数指定输出文件。参数设置就不再示范了,这里看一下怎么写入文件。
|
|
同样地,为了偷懒,直接用 ioutil.WriteFile()
,三个参数分别是文件名(路径),写入的数据(string
需要转换为 字节切片)和 文件权限。这个函数在遇到目标文件存在且有写权限时,会直接覆盖原来的内容,但不改动权限;如果文件不存在,则以指定权限创建文件。0666
为八进制数,对应 Linux 的权限值(在 Windows 系统 Go 会自动转换为相应的操作)。
接下来试一下修改后的程序。我们首先创建一个 input.txt
(注意前后和中间间隔有多余空格,实际操作中不小心多输入空格是非常常见的):
|
|
然后执行程序:
|
|
其它改进
除此之外,还能想到一些有用的功能。
去重
无论是有人不小心回复了重复的编号,还是管理员整理时多写了,编号偶尔会出现重复。重复的内容,在不同场景下,造成的影响可大可小。对于一些严格的场景,我们更希望编号里没有重复。这就要求对结果进行去重。
这时我们可以增加一个 -u
(unique)的 bool
参数,表示开启去重。(当然也可以默认去重,设置一个反向的开关表示保留重复。)去重可以在排序之后做,因为这时重复元素相邻,更容易处理。这个功能实现起来并不难,对切片做一次遍历即可,大家可以尝试自己实现。如果一时没有概念,可以先用 Go 语言完成这道题 https://leetcode-cn.com/problems/remove-element/ ,做出这道题就知道如何高效地在切片里去除元素了。
需要注意的是,因为有 []string
和 []int
两种切片,去重的逻辑可能要实现两个版本(视乎你的代码实现)。这是 Go 目前不便的其中一个地方:没有泛型,会一定程度导致代码重复。
自定义分隔符
现在的程序,根据常用的场景,默认了输入的分隔符是空白字符,输出的分隔符是半角逗号 ,
。但这个设定不会总是好使。有可能输入文件是其他人整理的,可能是别的系统导出的,用了别的分隔符;输出内容也可能用于别的场景,需要别的格式。
这时我们需要针对每个场景修改代码,重新编译吗?没有必要。只要设置对应的标志参数,允许分别指定分隔符就好。而如果没有指定,还是用原本的默认符号。两个符号分别用于分割输入的内容,和拼接输出的内容,需要调整相关的代码,可能用到的函数基本都在 strings
包里。
这两个功能添加上之后,实现效果大概是这样的:
|
|
练习
numsJoin()
的第二种实现有几个 bug,你发现了吗?- 通过自行查阅文档,了解
strings.Builder
的用法,你可以写出numsJoin()
的第三种实现吗? - 你可以自行实现 去重 和 自定义分隔符 两个功能吗?
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。