Case 语句把一个值依次与一组 shell 模式比较,适合命令分发、有限答案选择或文件名后缀分类。每个分支明确自己处理哪些输入,多个模式也可以共享同一个动作。分支顺序和默认行为都是接口的一部分:输入同时属于多个类别时谁先处理,完全不属于已知类别时是否报错,都需要明确规定。不能只看到成功输入能跑通,就认为整个分发器已经可靠。

模式匹配整个值

Case 使用的是 shell 模式,不是正则表达式。星号匹配任意长度字符串,问号匹配一个字符,方括号描述字符集合或范围。因此,[0-9]* 表示一个数字后面可以跟任意内容,并不表示整串都是数字。若要验证非空十进制文本,可以先拒绝空字符串,以及包含任意非数字字符的字符串,而不必把输入放进算术求值环境。

start|up|run) 这样的多个模式可以共用动作。*.log) 等后缀规则则只是在分类字符串,并不会打开文件或搜索目录。因此,匹配成功不能证明对应文件存在。与普通文件名展开不同,字符串匹配中的斜杠和开头的点也没有相同的特殊排除作用。分类、文件存在性和文件类型是不同判断,真正操作文件前仍应根据需求单独验证。

Bash 会对被测试的词执行文档规定的展开,但不会像普通命令参数那样对它分词或执行文件名展开。因此,这个位置的未引用空变量不会删掉语法上的词,进而把 case 变成语法错误。引用被测试值仍然是清楚的书写习惯。若开启 nounset,则要另外处理未提供的位置参数,或先验证参数数量;引号本身不能让未设置参数变得可用。

实际执行某个分支后,case 返回该分支最后执行命令的状态。因此,匹配成功与动作成功是两件事。下面的实验也让一个已经选中的分支返回六,并检查失败继续向外传播,避免把“找到了正确分支”误报成“分支里的操作已经成功”。

坏掉的样子

使用通常的双分号结束符时,首个匹配分支执行后,case 就结束。精确值应放在宽泛类别前,兜底星号通常放在最后,否则通用选项分支会吞掉后面的精确状态命令。语法检查只能证明结构可解析,不能证明顺序符合业务优先级。应该构造同时匹配两个规则的输入,并断言实际执行哪个动作,才能发现被遮蔽的分支。

如果没有任何模式匹配,case 不执行动作,却仍然返回零。因此,没有默认分支不等于会自动报错。命令分发器若要求拒绝未知命令,就必须在默认分支明确报告并返回失败;可选分类逻辑则可能有意允许不匹配。这个策略来自调用契约,不能假设 case 自带完整输入校验,更不能依赖自动退出选项补足业务含义。

模式上的引号也会改变含义。"*.log" 匹配字面字符,而不引用的写法匹配整个后缀类别。变量提供模式时同样需要区分:它究竟是受控的匹配表达式,还是应该精确比较的普通文本。不要让一个本来只是标识符的值意外获得通配能力,也不要为了“所有东西都加引号”而破坏本来需要的模式语义。

选项和版本还会改变行为。Nocasematch 控制是否忽略大小写;扩展模式要求在相关代码被解析之前开启 extglob。较新 Bash 还提供继续执行或继续匹配的其他分支结束符,它们不同于双分号,也不一定能在旧 shell 上运行。下面实验刻意只使用普通模式和双分号,保持 Bash 三点二兼容语法,本轮实际使用 Bash 五点二运行,避免把可选语法混入基本规则。

快速验证

下面完整代码包含精确命令、别名组、通配类别、空输入错误和未知输入错误。第二个函数验证十进制文本,不会把数字开头但后面含字母的值放过。断言同时检查正常输出与错误码,而不是仅观察整个脚本最后有没有非零退出。

bash --noprofile --norc <<'BASH'
set -e
export LC_ALL=C
shopt -u nocasematch
route() {
  case "${1-}" in
    status|--status) printf '%s' status ;;
    start|up|run) printf '%s' start ;;
    '') return 2 ;;
    --*) return 3 ;;
    *.log) printf '%s' log ;;
    *) return 4 ;;
  esac
}
[[ $(route --status) == status ]]
[[ $(route up) == start ]]
[[ $(route 'report final.log') == log ]]
expect_error() {
  local expected=$1
  shift
  if route "$@"; then
    return 1
  else
    local actual=$?
    [[ $actual -eq $expected ]]
  fi
}
expect_error 2 ''
expect_error 2
expect_error 3 --unknown
expect_error 4 STATUS
digits() {
  case "${1-}" in
    ''|*[!0-9]*) return 1 ;;
    *) return 0 ;;
  esac
}
digits 007
if digits 7abc; then exit 1; fi
if digits ''; then exit 1; fi
empty=''
matched=no
case $empty in '') matched=yes ;; esac
[[ $matched == yes ]]
case unmatched in known) exit 1 ;; esac
[[ $? -eq 0 ]]
if case match in match) (exit 6) ;; esac; then
  exit 1
else
  [[ $? -eq 6 ]]
fi
printf 'PASS: precedence, aliases, empty input, validation, no match\n'
BASH

预期只输出最后的通过提示,没有其他正文。精确的状态选项必须优先于通用未知选项分支;由于实验关闭了忽略大小写,大写 STATUS 应当被拒绝。末尾的检查还证明,展开为空的被测试词语法有效,以及完全没有模式匹配时 case 会成功返回。这些断言只验证分发选择;把无害打印换成真实动作后,还需要独立检查参数、外部副作用和失败传播,不能把选中了正确分支等同于操作已经成功。

来源