同一份名单,为什么排出两种顺序
同一份名单,同一个 sort,只改一个环境变量,顺序就变了。输入是四行:
banana
Apple
apple
Banana
两次运行,两种结果:
LC_ALL=C Apple Banana apple banana
LC_ALL=en_US.UTF-8 apple Apple banana Banana
两边都没有报错,也没有哪一边算「排错了」。差别在于用了哪一套比较规则。
规则不在数据里
排序要成立,先得能回答一件事:两个东西谁在前。这个判断不由数据给出,而由一套比较规则给出。GNU 的 sort 手册写得很直接:除非另有指定,所有比较都使用 LC_COLLATE 这个 locale 指定的字符排序序列;而一旦设置了 LC_ALL,它会盖过前者。
那套序列规定的是大小写、重音、标点各自的权重。权重一变,顺序就变,而数据一个字都没有动。
标点被降权以后
换一组输入,三行:a-b、ab、a b。
LC_ALL=C a b a-b ab
LC_ALL=en_US.UTF-8 a b ab a-b
按 C 规则,空格(0x20)最小,连字符(0x2D)居中,小写 b(0x62)最大,所以 ab 排在最后。到了 en_US.UTF-8,连字符在排序里的分量被降得很低,ab 反而跑到了 a-b 前面。同一行数据,在一套规则下位置不对,换一套规则就完全合理。
排在一起,不等于相同
再看两条只差大小写的输入。在 en_US.UTF-8 下它们相邻,而且小写在前:
LC_ALL=en_US.UTF-8 sort -u apple Apple
LC_ALL=en_US.UTF-8 sort -f -u apple
第一行里 -u 把两条都留了下来,说明比较时它们并不相等,只是主序相同,再靠大小写分出先后。要真正合并它们,得另说,比如加 -f 忽略大小写,那时才只剩一行。把「排在一起」读成「是同一个」,是这类规则最容易误导人的地方。
不点名,就不会按数值排
三个文件名:file10、file9、file2。
sort file10 file2 file9
sort -V file2 file9 file10
默认是逐字符比字符串:1 比 2 小,所以 file10 排在最前。这不是缺陷,是字符串序的定义。要按数字读就得明说:-V 把每段连续数字当版本号处理,-n 则整体按数值比较。所以「已经排好序了」这句话其实不完整,没说清按什么序,它可能正是你不想要的那一种。
目录列表也一样。同样三个文件名放进一个目录,ls 默认给出的是 file10、file2、file9,和 sort 一样按字符比;要按数值列,得加 -v。这一条最容易在日常里撞上,因为文件名的编号几乎总是数字,而我们看的时候总是按数字读。
把规则写进代码
上面两种顺序,locale 都是我显式给的。如果什么都不给,规则由程序启动时的环境决定,而不是由你眼前的终端决定。同一个脚本换个启动方式,就可能得到另一种顺序,而且它不会报错,只是安静地给出另一种结果。顺序错了,通常没有异常跳出来提醒你。
所以凡是有排序的地方,我把规则写进脚本:要稳定、跨机器一致,就写 LC_ALL=C;要贴近人的字母直觉,就写清真正需要的那个 locale;要按数值排,就写 -V 或 -n。把规则交给代码,而不是交给环境的默认值,因为默认值会随启动方式变,代码不会。
这里有个细节值得单独说:写 LC_ALL,而不是只写 LC_COLLATE。手册专门提醒过,单独设置 LC_COLLATE 有两个问题,其中之一是:如果环境里已经存在 LC_ALL,你设的那个会被它盖掉,等于没设。既然无法保证启动环境里有没有 LC_ALL,就直接用它,一步到位。
还有一点值得说明:规则的选择是取舍,不是「更严格」或「更正确」。C 规则快、跨机器一致,代价是它不认人类的字母表,它把所有大写字母排在小写字母前面。en_US.UTF-8 更接近人的预期,代价是它依赖一套可能不存在的 locale 数据。两种都能用,问题只在于你有没有说清用的是哪一种。
开头那两行输出,不是「正确与错误」,而是两套规则各自的结果。顺序从来不是名单自带的属性,它是「按什么规则读它」的属性。下次觉得顺序不对,先别急着翻数据,先问一句:这是按什么规则排的。
参考:GNU Coreutils 手册 · sort、locale(7)。文中的顺序都是我在本机实测的输出。
—— M-D