2861 words
14 minutes
Shell 心智模型·炼气篇(三):从 `[ -d dir ] || mkdir dir` 说起,把 `[`、`[[ ]]`、`$`、`$?`、`&&`、`||` 一次讲透

有一次我写一个部署脚本,需求很简单:如果 /tmp/mydir 这个目录不存在就建一个,存在就跳过,别报错。翻了翻资料,抄来一行:

Terminal window
[ -d /tmp/mydir ] || mkdir /tmp/mydir

跑了一下,确实好使。可我盯着这行代码看了半天,越看越纳闷——这个孤零零的 [ 到底是什么东西?看着不像变量,不像操作符,倒像是打漏了一半的方括号,后面那个 ] 也孤零零地飘在句尾,活像谁忘了把它和前面的字连起来。我把它单独拎出来,敲了句:

Terminal window
$ which [
/bin/[

好家伙——[ 原来是个真正躺在磁盘上的可执行文件,是个命令。这篇要讲透的,就是这行代码背后那一整套基于”成功还是失败”的机制——[[[ ]]$$?&&||——以及它和上一篇讲的管道到底差在哪。

一、[ 是个命令,不是语法符号#

[ 其实是 test 这个命令的一个”化名”(shell 里管这种同一个程序、多个可执行文件名的关系叫 alias 或者 symlink),两者做的事完全一样:

Terminal window
$ test -d /tmp
$ echo $?
0
$ [ -d /tmp ]
$ echo $?
0

test -d /tmp[ -d /tmp ] 是同一件事的两种写法——[ 只是 test 套了张”看起来像语法”的皮,唯一的区别是,用 [ 这个写法时,结尾必须补一个 ] 当最后一个参数,这是 [ 这个程序自己定的规矩,不是 shell 语法逼你这么写的。

可以把 [ 想象成一个脾气很怪的函数:它坚持要求调用者把最后一个参数写成字面意义上的 ],不然它就会跟你翻脸报错;它对空格也异常敏感——[-d/tmp/mydir] 之间必须都留有空格,因为 shell 靠空格给命令行分词,[-d 或者 dir] 这种粘在一起的写法,shell 会把它当成一整个词去找同名可执行文件,自然找不到:

Terminal window
$ [-d /tmp/mydir ]
zsh: command not found: [-d
$ [ -d /tmp/mydir]
zsh: command not found: /tmp/mydir]

这两个报错乍一看莫名其妙,根源其实是同一个——[ 是个普通命令,普通命令的参数之间必须用空格隔开,少一个空格,shell 分词的时候就把 [-d(或者路径和 ])焊死成了一整个词。

二、$?:上一条命令刚刚是成功还是失败#

每个命令执行完,都会留下一个”退出状态”(exit status),存在特殊变量 $? 里:0 表示成功,非零表示失败(具体是几,不同程序自己定义,但”非零 = 出了点问题”这个约定是通用的)。可以把它想象成每个命令干完活都会顺手在门口贴一张”验收单”——0 就是”验收通过”,非零就是”验收不通过”,不同号码对应不同故障类型。下一个想知道结果的人,不用跟进去问一遍发生了什么,过来瞄一眼这张单子就够了。

Terminal window
$ true
$ echo $?
0
$ false
$ echo $?
1

truefalse 是两个专门用来测试的命令,分别总是返回成功和失败。日常场景里,$? 反映的是你刚跑完的那条命令到底顺不顺利——比如上一节的 [ -d /tmp/mydir ],目录存在,[ 这个命令就返回 0(验收通过),不存在就返回非零(验收不通过)。

三、&&:上一条成功了,才跑下一条#

Terminal window
$ true && echo "跑了,因为 true 成功"
跑了,因为 true 成功
$ false && echo "这行不会打印"
(什么都没输出)

&& 就像两扇挨着的门:你得先推开第一扇(左边命令的验收单写着 0),才有资格去碰第二扇门把手(右边命令才会被尝试执行)。第一扇门你没推开(退出状态非零),第二扇门连碰都不会去碰,不存在”万一能打开”这种侥幸。

四、||:上一条失败了,才跑下一条#

Terminal window
$ true || echo "这行不会打印,因为 true 已经成功"
(什么都没输出)
$ false || echo "跑了,因为 false 失败了"
跑了,因为 false 失败了

正好反过来,|| 更像一个备用方案:先试第一个办法,成了就万事大吉,备用方案根本不需要登场;第一个办法失败了,才轮到 || 右边的备用方案上台救场。回到开头那行让我纳闷了半天的代码,现在能完整解释了:

Terminal window
[ -d /tmp/mydir ] || mkdir /tmp/mydir

[ -d /tmp/mydir ] 检查目录在不在。,[ 贴出验收单 0,|| 右边的 mkdir 不执行——不需要,目录已经在了,备用方案没有登场的必要。不在,[ 贴出非零的验收单,|| 才去执行 mkdir 把它建出来。实测一遍:

Terminal window
$ [ -d /tmp/mydir_demo ] || mkdir /tmp/mydir_demo
$ echo $?
0
$ ls -d /tmp/mydir_demo
/tmp/mydir_demo
$ [ -d /tmp/mydir_demo ] || mkdir /tmp/mydir_demo # 再跑一次
(mkdir 这次没有被调用,因为目录已经存在)

五、串成链:一步失败,后面全部跳过#

&& 可以一路串下去,做成”任何一步失败就整条链停下”的效果——CI 脚本、部署脚本里到处都是这种写法:

Terminal window
$ echo "步骤1" && false && echo "步骤2(不会执行)" && echo "步骤3(也不会执行)"
步骤1
$ echo $?
1

false 那一步失败了,后面两个 echo 全部被跳过,链条提前终止,像多米诺骨牌少了中间一块,后面的牌全都倒不下去了。现实中你会写成类似 make build && make test && make deploy 这样——任何一步失败,后面更危险的步骤(比如部署)根本不会被尝试。

对比一下 ;——它只是”依次执行”,完全不看前一条成不成功:

Terminal window
$ echo "步骤1"; false; echo "步骤2(用分号,即使前面失败也会执行)"
步骤1
步骤2(用分号,即使前面失败也会执行)

;&& 长得像,但语义完全不同:; 是”不管怎样都继续”,&& 是”上一步必须成功才继续”。选哪个,取决于你要不要在意前一步的死活。

六、[[ ]] 的加强版,治好了 [ 的老毛病#

第一节里那些因为漏个空格就报错的坑,bash 专门出了个加强版来解决——[[ ]]:

Terminal window
$ [[ -d /tmp/mydir ]]
$ echo $?
0

看着和 [ ] 长得很像,但 [[ ]] 根本不是一个命令,而是 bash/zsh 内置的语法关键字(keyword),跟 iffor 是一类东西,不需要经过”分词、找可执行文件”这一整套普通命令的流程。可以把 [ 想象成手动挡——离合、油门、换挡全靠自己精确配合,漏一步就熄火(漏个空格就报错);[[ ]] 更像自动挡——很多容易踩坑的细节,shell 解析这个关键字的时候就替你处理好了:

  • 不怕变量展开后带空格没加引号:[ $var = "a b" ] 如果 $var 展开出多个词,[ 会因为参数个数对不上而报错;[[ $var = "a b" ]] 不会,[[ ]] 内部不走普通的单词展开+分词那条路。
  • 可以直接用 &&||<> 做逻辑和字符串比较,不需要 [ 里那种别扭的 -a/-o(逻辑与/或)写法。
  • 支持 =~ 做正则匹配,[ 完全没有这个能力。

代价是 [[ ]] 不可移植——只有 bash、zsh 这类”高级” shell 才认识这个语法,如果脚本第一行是 #!/bin/sh(某些系统上 sh 是不认识 [[ ]] 的精简 shell),用了 [[ ]] 就会直接报语法错误。写日常交互命令、写明确跑在 bash/zsh 下的脚本,[[ ]] 基本没有理由不用;写要求严格 POSIX 兼容的脚本,才需要老老实实退回 [ ]

七、$:取值到底是怎么回事#

前面一直在用 $?,但 $ 这个符号本身做的事,比 $? 这一个特例要大得多——$变量名 是”去把这个变量存的值取出来”这个动作本身,$? 只是恰好去取了一个名字叫 ? 的特殊变量而已。

可以把每个变量想象成一个贴着编号标签的取件柜——$PATH$HOME(第一篇讲过)都是柜子的编号,$ 就是”凭编号去开柜子取东西”这个动作:

Terminal window
$ name="Sean"
$ echo $name
Sean
$ echo name
name

name="Sean" 是把 “Sean” 存进那个叫 name 的柜子;$name 才是”打开 name 号柜子,把里面的东西倒出来”——少了 $,shell 只会把 name 当成一段普通的字面文本,原样打印。$? 也是同一套机制 每次执行完一条命令,都会自动把退出状态存进一个专门保留、名字就叫 ? 的柜子里,你要看这个值,一样得用 $? 去取。

一个常和取值绑在一起出现的坑——不加引号取值,可能被重新拆词:

Terminal window
$ file="my notes.txt"
$ ls $file
ls: my: No such file or directory
ls: notes.txt: No such file or directory
$ ls "$file"
my notes.txt

$file 展开成 my notes.txt 以后,如果没加引号,shell 会把这段文本按空格重新拆成 mynotes.txt 两个词交给 ls,而不是当成一个完整的文件名。加上双引号 "$file",shell 就会把展开结果当成一整块,不再拆词。这个坑在下一篇讲 $() 命令替换的时候还会再遇到一次——是同一个机制在作怪。

八、这不是管道的变体#

上一篇讲的管道 |,连接的是数据——前一个命令的 stdout,接到下一个命令的 stdin,两边几乎同时跑,谁都不管对方成功还是失败,数据照样往下流。

&&|| 连接的是信号——前一个命令的退出状态。它们不传递任何数据内容,只关心”成功还是失败”这一个二元结果,而且前一条命令必须先跑完、退出状态确定了,才轮到决定后一条要不要跑。

|(管道)&& / ||
传递的是什么数据(stdout → stdin)信号(退出状态 $?)
两边命令的时序(近似)同时跑前一条跑完才轮到下一条
关心内容吗关心,数据真的流过去了不关心,只看 0 还是非零

这也是为什么很多命令里两者会混着出现,比如 grep ERROR log.txt | wc -l || echo "没找到"——| 负责把数据从 grep 递给 wc -l,|| 负责在这整条管道失败时才触发兜底动作。它们各管各的,不是同一套机制的两种写法。

一句话总结#

[ 其实是 test 命令的化名,结尾那个 ] 是它要求的最后一个参数,不是语法糖,而且对空格、引号异常挑剔;[[ ]] 是 bash 内置的加强版,自动挡般处理掉了 [ 那些坑,代价是不可移植。$变量名 是通用的取值动作,$? 只是恰好取了个名字叫 ? 的特殊柜子,记录上一条命令是成功(0)还是失败(非零)。&& 在成功时继续,|| 在失败时补救,; 不管三七二十一都往下走。它们和管道 | 经常出现在同一行命令里,但传递的东西完全不同——管道传数据,这套机制传的是”成功还是失败”这一个信号,而 [/[[/$ 讲的是这个信号本身是怎么产生和读取的。