Shell 中的冒号(colon)无所作为。但尽管如此,还是用它吧

Shell 中的冒号(colon)无所作为。但尽管如此,还是用它吧

引言

Shell 中的冒号 (:) 是一个内置的空命令(null-command),它会评估其参数并丢弃结果。它本身不执行任何操作,但当它置于参数扩展(parameter expansion)之前时,它允许扩展发生,而不会让 Shell 尝试将生成的字符串作为命令执行。

验证必需参数

使用 : ${VAR:?diagnostic} 扩展提供了一种单行方式来确保变量已设置且非空。

: "${1:?missing argument, aborting.}"
echo "Hello $1!"

如果 $1 未设置或为空,Shell 会将诊断信息打印到 stderr 并以非零状态退出;否则,扩展会产生 $1 的值,脚本继续执行。相同的技术也适用于命名变量,例如 : "${GREET_NAME:?missing argument, aborting.}",它会在错误消息中包含变量名。

使用 := 设置默认值

${VAR:=default} 扩展在 VAR 未设置或为空时分配默认值。在前面加上 : 会在执行赋值的同时丢弃分配的值。

: "${DATA_DIR:=/var/data}"
: "${RETRIES:=3}"

执行这些行后,如果 DATA_DIRRETRIES 之前未设置,它们将持有默认值。

截断和测试文件

: 之后的重定向会将文件截断为零长度,因为空命令不产生任何输出。

: > error.log # 截断 error.log
: > error.log > access.log # 同时截断两个文件

类似地,仅包含重定向和 : 的子 shell 可用于测试可读性或可写性。

( : < dataset.json ) && echo YES # dataset.json 是否可读?
( : >> result.json ) && echo YES # result.json 是否可写?

之所以需要冒号,仅仅是因为子 shell 需要一个命令;重定向本身完成了工作。

在 trap 和条件语句中使用冒号

trap 内置命令需要一个命令;: 满足了这一要求且不执行任何操作。

trap : INT
sleep 60 # sleep 保持可中断状态

if-else 结构中,如果 then 分支必须包含一个命令,: 可以作为占位符。

if some-command; then : # 需要命令 else echo "command failed" fi

常见问题解答(摘自文章)

为什么我需要这个空命令?没有冒号扩展不也会发生吗?

参数扩展无论如何都会发生,但如果没有空命令或类似的结构,Shell 会将生成的字符串视为要运行的命令。例如:

% ${HELLO:=123} zsh: command not found: 123

在前面加上 : 会在执行扩展的同时丢弃结果:

% : ${HELLO:=123} % echo $HELLO 123

既然可以用 VAR=${VAR:-default-value},为什么要使用空命令?

两者达到相同的效果,但冒号形式只需提及一次变量,减少了拼写错误的概率:

: "${DATA_DIR:=/var/data}" # DATA_DIR 只出现一次 DATA_DIR="${DATA_DRI:-/var/data}" # 可能出现拼写错误 (DATA_DRI)

社区反应(精选 HN 评论)

  • @gpvos 回想起了 Larry Wall 的语言重新设计法则:“每个人都想要冒号。Larry 得到了冒号。”

  • @amiga386 注意到了 if some-command; then : else echo "command failed" fi 这一惯用法,并指出等效的 if! some-command; then echo "command failed" fi 也是符合 POSIX 标准的。

  • @rgrau 建议直接在命令参数中内联 :? 测试,例如 echo "${1:? first param required}",并描述了一种在循环体中使用冒号的 while 循环模式。

  • @fphilipe 将冒号用作 Git 的 EDITOR,以便在不打开编辑器的情况下执行带有 auto-squash 的交互式 rebase。

  • @archargelod 认为用 : "${1:?...}" 替换可读的 if 语句会损害可读性,并提出了另一种选择 [ -z "$1" ] && { echo "missing argument, aborting." 1>&2; exit 1 }

  • @kevincox 发现 :? 形式对于检查必需的环境变量很有用,但认为许多其他示例使用专门的命令会更清晰。

  • @kazinator 批评子 shell 测试 ( : < file ) && echo YES 是多余的,指出仅靠重定向就可以测试可读性/可写性,并且使用 : >> file 创建零长度文件可能并不理想。

  • @olexsmir 分享了一个个人使用案例:在 dotfiles bootstrap 脚本中使用 : "${DOTFILES_PATH:=$HOME/.dotfiles}" 为配置环境变量设置默认值。

  • @luciana1u 评论说冒号无所作为,这使得它成为“LLM 无法过度设计的唯一 Bash 命令”。

  • @m2f2 描述了在 getopts 风格的循环中使用冒号的情况:while : ; do case "$1" in... esac done

  • @jeffrallen 感谢了作者,但表示他不会使用这种惯用法,因为他更喜欢能向受众解释清楚的代码。

  • @lucideer 觉得这些示例只是将可读的多行代码变成了单行代码,他认为这在脚本中没有地位。

  • @normie3000 相比 if! x; then something; fi 更喜欢 if x then :; else something; fi

  • @PunchyHamster 说他会拒绝引入这种单行代码的 pull request,主张用 Python 或 Perl 重写较长的脚本。

  • @garethrowlands 将这种技巧的需求归因于 Shell 语法中古老的字符串替换本质。

  • @ocd 使用冒号创建“无操作”别名,例如 : alias f3probe,以便快速 grep 遗忘的工具。

  • @dominiwe 需要冒号来使一个 Shell 脚本同时成为一个有效的 YAML 文件。

  • @del 怀疑冒号是否是早期 Unix Shell 中少数几个特殊处理的代码路径之一。

  • @greatgib 说他第一次是在 Claude 生成的代码中看到这个结构,后来在文章中看到。

  • @search_facility 从 bash 转向了纯 Python 脚本以获得跨平台稳定性,并放弃了“bash zoo”。

Sources