fish-shell 中的 not 命令:翻转退出状态、`!` 别名与实现原理全解析

原创2026-09-30 23:51:451,445 阅读
文章标签:CLI开发工具

fish-shell 中的 not 命令:翻转退出状态、! 别名与实现原理全解析

本文以 fish-shell 官方命令手册 not.rst 为核心,系统讲解 not(及其等价别名 !)如何将一条命令或一个作业(job)的退出状态取反,包括语法格式、行为语义、if/and/or 等复合结构中的实战用法,并从 parser_keywords.rs、parse_execution.rs、proc.rs 等源码出发,剖析其作为保留关键字、AST 节点以及 JobFlags::negate 标志背后的完整实现链路。读完本文,你将掌握 not 的一切用法,并能理解它与 ! 别名、exec、管道、变量赋值组合时的真实行为。

概述:not 是什么

在 fish 中,not 是一个内建的保留关键字(reserved keyword),而不是一个普通的外部命令。它的作用只有一个:把紧跟在它后面的命令或作业的退出状态取反——命令成功(退出状态为 0)时 not 返回 1;命令失败(退出状态非 0)时 not 返回 0。

官方文档 not.rst 中的原话是:

not negates the exit status of another command. If the exit status is zero, not returns 1. Otherwise, not returns 0.

在多数其他 shell(如 bash)中只支持 ! 前缀写法,而 fish 同时提供 not 与 ! 两种形式,后者只是前者的别名(alias),语义完全一致。与 bash 的一个关键差异是:fish 的 not/! 不需要像 bash 那样依赖 set -o pipefail 之类的选项来控制取反粒度——它对整个作业(job)的最终状态取反,这会在下文「实现原理」一节中详细展开。

语法格式(Synopsis)

not 的完整语法为:

not COMMAND [OPTIONS ...]
! COMMAND [OPTIONS ...]

即 not 或 ! 后紧跟一条命令,命令可以带自己的参数。两条语法形式完全等价:

not grep -q needle haystack.txt
! grep -q needle haystack.txt

两点注意:

  • not 后跟的 COMMAND 可以是外部命令、内建命令(如 test、string、math)、函数,甚至可以是另一个复合结构(如 not begin ... end、not { ... }),它取反的是这条命令执行完毕后的最终退出状态。
  • 从测试用例 not.fish 中可以看到,! -h 在未重载的情况下会输出 ! 的文档(Documentation for !),说明 -h/--help 选项由 not 自身处理,用于显示帮助信息,并不会被当作后面命令的参数。

核心行为:退出状态的取反规则

取反规则非常朴素,可以用下表概括:

原命令退出状态 not 之后的退出状态 语义
0(成功) 1 取反为失败
非 0(失败,如 1、2、34) 0 取反为成功

该规则由源码直接印证。在 proc.rs 中,Job::statuses() 对作业最终状态的合成逻辑是:

st.status = if self.flags().negate {
    if laststatus == 0 { 1 } else { 0 }
} else {
    laststatus
};

也就是说,当作业带有取反标志(negate)时,只要最后一个进程的退出状态为 0,就输出 1;否则输出 0。这与文档描述逐字对应。

连续取反会互相抵消

not 可以连续堆叠,偶数次取反会互相抵消。这一点在 not.fish 测试中有明确验证:

# 一次取反:true 成功(0) -> 1
not true
echo $status
# CHECK: 1

# 两次取反:sh -c 'exit 34' 返回 34,取反两次后仍是 34
not not sh -c 'exit 34'
echo $status
# CHECK: 34

甚至可以在单行内混合使用 not 与 !。测试 andandoror.fish 中的用例:

not not not true   # 三次取反 = 一次取反,最终 1
not ! ! not true   # 四个前缀同样等价于一次取反
not ! echo not !   # echo 成功(0),取反后为 1

not 后的作业同时包含管道与多个进程

not 取反的是整个作业的最终状态,而不是管道中每一个进程的状态。例如:

not grep foo file.txt | head -n 1

这条命令中 not 作用于整个 grep ... | head ... 管道:管道最终状态由最后一个进程 head 决定,not 再对这个最终状态取反。其原理同样在 proc.rs 的 Job::statuses() 中体现——取反判断的是整个作业 laststatus(最后一个进程的退出状态),而 st.pipestatus 数组中仍然保留每个进程各自的状态,不会因 not 而改变。

实战场景:与 if / and / or 的组合用法

not 最常见的用途是作为条件判断的取反,出现在 if、and、or、while 等控制结构中。

在 if 条件中取反

文档 not.rst 给出了标准示例——当找不到名为 spoon 的文件时报错并退出:

if not test -f spoon
    echo There is no spoon
    exit 1
end

这里 test -f spoon 在文件存在时返回 0,not 将其取反为 1,于是 if 分支(echo There is no spoon; exit 1)仅在文件不存在时执行。这是「有则跳过、无则处理」类逻辑的惯用写法,等价于其他 shell 中的 if [ ! -f spoon ]; then ...; fi。

仓库内部也大量使用这一模式。例如 random.fish 中校验两个随机数相等的反例:

if not test "$run1" = "$run2"
    # 两个数不同,继续逻辑
end

以及 symlinks-not-overwritten.fish 中对 $fish_variables 是否为符号链接的检查:

if not test -L $fish_variables
    ...
end

与 and / or 组合构造复合条件

not 可以自由地与 and、or 组合,构成复合判断。测试 andandoror.fish 中的用例:

or not false

这条命令中 false 失败(状态 1),not false 成功(状态 0),整条 or 连接的作业因此成功。这类写法可用于「当某个条件不成立时才执行后续逻辑」的链式表达。

在 while 循环条件中取反

同理,not 也可以放在 while 条件里,实现「当某命令失败时继续循环」的语义:

while not command -q python3
    # 等待 python3 出现或进行其他处理
end

别名 ! 与重载注意事项

! 是 not 的别名,二者在语法上完全等价:

! test -f spoon

需要注意:! 在 fish 中并不是历史展开符(bash 中 ! 用于历史扩展),因此可以放心在脚本中使用,不会有 bash 用户常遇到的引号转义问题。

另一个值得注意的细节来自测试 not.fish:! 是可以被函数重载的。测试中演示了:

function !
    echo overridden! $argv
end
! -h
# CHECK: overridden! -h

也就是说,如果你自定义了一个名为 ! 的函数,它会遮蔽内置的取反别名——这一点在 parser_keywords.rs 的保留字表中也有体现:! 被标记为 not reserved(不保留、允许重载),而 not 本身则是保留字(("not", [subcommand])),parser_keywords_is_reserved 返回 true,因此无法用同名函数或别名覆盖 not。

与其他关键字、命令的组合行为

not 作为保留关键字,还可以出现在一些更复杂的组合中,仓库测试提供了多个可验证的场景:

not time true      # time 显示执行耗时,true 成功(0),取反后为 1
not time a=b true  # 变量赋值 + time + 取反
not exec cat <nosuchfile

当 exec 因重定向失败而无法真正替换进程时,exec.rs 会按取反标志生成最终状态:let status = if job.flags().negate { 0 } else { 1 };——也就是说,exec 失败时 not exec ... 反而返回 0。

not a=b echo $a
not { false; true }   # 块内最后一条 true 成功,取反后为 1
  • 与 math 的错误输入配合(math.fish):not math '2 - '、not math 'sqrt(-1)' 等用例利用 math 对非法表达式返回非 0,再经 not 取反为 0,从而在测试中表达「该表达式应当报错」。

  • 与 -- 分隔符组合(decorator-double-dash.fish):

not time -- { false }

实现原理:从语法解析到退出状态合成

not 不是普通的可执行文件,而是被 fish 解析器内建处理的关键字。它在源码中贯穿三条主线:

1. 词法/关键字识别阶段

在 parser_keywords.rs 的保留字表 RESERVED_WORDS 中,not 与 ! 均被登记,其中 not 是 [subcommand] 保留字(保留、不可重载),! 则是 [subcommand] 但 not reserved(不保留、可重载)。

在词法层面,parse_constants.rs 把字符串 "not" 映射为 ParseKeyword::Not:

'n' if s == L!("not") => ParseKeyword::Not,

而 ! 对应的关键字枚举在 parse_constants.rs 中定义为 Exclam(同文件 L238 中 ParseKeyword::Not => L!("not") 表明其显示名称仍为 not)。

2. 语法树(AST)阶段

在 ast.rs 中,当语句开头关键字为 Not 或 Exclam 时,解析器构造一个专门的 NotStatement 节点:

match self.peek_token(0).keyword {
    ParseKeyword::Not | ParseKeyword::Exclam => {
        let embedded = self.allocate_boxed_visit::<NotStatement>();
        Statement::Not(embedded)
    }
    ...
}

NotStatement 内部嵌有真实的命令内容(contents)以及可选的变量赋值列表(variables),这解释了上文 not a=b echo $a 能够正常工作的原因——变量赋值是 NotStatement 结构的一部分,会先于命令执行。

3. 执行与状态合成阶段

执行时,parse_execution.rs 的 populate_not_process 函数负责把取反意图记录到作业标志上:

fn populate_not_process(
    &mut self,
    ctx: &mut OperationContext<'_>,
    job: &mut Job,
    proc: &mut Process,
    not_statement: &ast::NotStatement,
) -> EndExecutionReason {
    {
        let mut flags = job.flags_mut();
        flags.negate = !flags.negate;
    }
    self.populate_job_process(
        ctx, job, proc,
        &not_statement.contents,
        &not_statement.variables,
    )
}

注意这里用的是 flags.negate = !flags.negate——即翻转标志而非简单置位。这正是 not not cmd 两次取反互相抵消、not not not true 三次取反等效一次的根本原因:每层 not 都会把 JobFlags::negate 翻转一次。

JobFlags::negate 定义于 proc.rs。最终状态合成就发生在 proc.rs 的 Job::statuses():取反标志为真时,最后一个进程状态为 0 则输出 1,否则输出 0(详见上文「核心行为」一节)。此外 exec.rs 对 exec 失败但带取反标志的作业也有同样的处理分支。

语法高亮

highlight.rs 将 ParseKeyword::Not 与 and、or 等一起作为「命令修饰关键字」着色,因此交互式输入时 not/! 会以关键字颜色显示,方便识别。

小结

  • not COMMAND 与 ! COMMAND 完全等价,作用是把整个作业的退出状态取反:0 → 1,非 0 → 0。
  • 连续多个 not/! 会逐个翻转取反标志,偶数次相互抵消(源码依据:parse_execution.rs、not.fish)。
  • 最典型的使用场景是 if not test -f file ... 这类条件取反,也可与 and/or/while、time、exec、变量赋值、代码块自由组合(仓库测试均有覆盖)。
  • not 是保留关键字,不可重载;! 不保留,可被同名函数覆盖(parser_keywords.rs)。
  • 从源码看,not 的实现横跨关键字表(parser_keywords.rs)、词法映射(parse_constants.rs)、AST 节点(ast.rs)、作业标志翻转(parse_execution.rs)与最终状态合成(proc.rs)五个环节,最终统一落地为 JobFlags::negate 这一单一标志。

若需深入验证,可阅读仓库中的官方手册 not.rst 与回归测试 not.fish、andandoror.fish,后者覆盖了本文提及的绝大多数取反组合场景。

登录后查看全文
fish-shell