很多写Bash脚本的朋友,大概率都遇到过这么个事。你写了一个很好用的函数,希望在调用它的时候,既能拿到它计算出来的结果,又想判断一下它到底执行成功没有。于是很自然地,在函数里用输出命令打印了结果,然后在外面用赋值语句把函数的标准输出抓到一个变量里,紧接着再查看一下退出码。结果发现,这个退出码根本不是函数自身的执行结果,有时候永远是0,有时候又乱得离谱。逻辑判断直接失效,整个脚本的行为就变得莫名其妙。

这其实不是遇到了什么灵异BUG,而是我们对Bash函数“输出”和“返回值”这两件事的理解还停留在想当然的层面。今天就把这层窗户纸捅破,让大家以后写脚本的时候心里明明白白的。

一、问题是怎么冒出来的

很多人在写Bash函数时,喜欢把函数当成一个小黑盒:给它输入,让它干点活,然后通过输出命令把结果打印出来,再去外面接住这个结果。这种做法本身没错,但问题在于,大家往往忽略了函数还有另外一个“出口”,那就是退出码。

在Bash里,函数和命令一样,执行完之后都会留下一个退出码,这个码用于告诉调用方“我到底是成功还是失败”。我们通常用0表示成功,非0表示失败。但是如果函数在结束之前执行了输出命令,而这个输出命令本身是成功的,那么函数的退出码就会被这个输出命令的退出码覆盖掉。这样一来,不管你在函数里怎么用返回语句去表示失败,外面看到的退出码却依然是0。脚本中的判断就会把失败当成成功,把成功当成失败,整个流程瞬间乱套。

其实这个问题的根源,就是把“数据输出”和“退出状态”这两条通道混在了一起。下面我们就从最基础的概念开始,把这两样东西彻底掰开揉碎。

二、先搞明白函数返回值的正确概念

2.1 什么是返回码

Bash函数的返回码,实际上就是函数体内最后一条命令的退出码。如果你在函数里显式使用了返回语句,那么这条返回语句本身就会设置退出码,并且作为函数的最终退出码。注意,这个返回码是一个0到255之间的整数,它只能表达“状态”,比如0代表成功,1代表文件不存在,2代表参数错误,等等。

这里要特别强调:函数里的返回语句和输出命令完全是两回事。返回语句是设置状态,输出命令是往标准输出里写内容。举个例子,函数里写一句输出命令“打印成功”,这句输出命令执行完会返回0,因为它成功打印了东西。但这个0并不能代表你业务上是否成功,它只代表“打印这件事成功了”。

2.2 echo到底干了啥

echo 是一个输出命令,它会把后面的内容写到标准输出上。标准输出就是你终端屏幕上显示的那部分内容,也是你用赋值语句抓取函数结果时,会被捕获的那部分内容。很多人以为“函数返回结果”就是echo出来的东西,实际上函数真正的返回结果是那串退出码,而echo出来的只是“数据”。

当你写 结果=$(你的函数) 时,Bash会启动一个子shell去执行这个函数,然后把函数标准输出里的所有内容作为“结果”赋值给变量。与此同时,函数自己的退出码被丢在了变量 $? 里,你可以用它来查看。问题就出在:如果函数里既有echo又有return,那么在命令替换这个执行机制下,最后一步执行的是谁,谁的退出码就会被留下。而如果你在函数末尾才return,return确实能覆盖之前的退出码。但如果你把return写在某个分支里,而分支外还有echo?咱们看看实际案例。

三、一个经典翻车现场

来,先看一段非常典型的代码。很多老手可能都写过类似的东西:

#!/usr/bin/env bash
# 技术栈:Bash Shell

# 检查文件是否存在,存在就输出文件大小,不存在就输出错误信息
check_file() {
    local file="$1"

    if [[ -f "$file" ]]; then
        # 文件存在,输出文件大小到标准输出
        local size
        size=$(wc -c < "$file")
        echo "文件大小是:$size"
        return 0
    else
        # 文件不存在,输出错误提示到标准输出
        echo "文件不存在"
        return 1
    fi
}

# 调用函数,把标准输出捕获到变量里
result=$(check_file "/tmp/abc.txt")

# 尝试通过退出码判断函数是否成功
echo "退出码是:$?"
echo "标准输出内容是:$result"

你先猜猜,当 /tmp/abc.txt 不存在时,上面的脚本会输出什么?很多人以为退出码会是1,因为函数里明明写了 return 1。但实际上,函数在 else 分支里先执行了 echo "文件不存在",这条echo成功了,退出码变成了0,然后才执行 return 1。不过因为 return 1 是函数体中的最后一条命令,所以最终退出码应该是1?等一等,这里要仔细看:在 if 语句中,return 1 确实是 else 分支里的最后一条命令,所以 $? 应该是1。为什么我刚才说“永远0”?需要再理一下。

事实上,在标准的Bash中,函数内执行 return 1 后,函数立即终止,所以 $? 应该是1。但为什么很多人的实践中取得的是0?原因在于,他们往往把 return 写在了 echo 之前,或者函数里还有其他echo,或者他们在调用时使用了管道,导致 $? 变成管道中最后一个命令的退出码。比如:

result=$(check_file "/tmp/abc.txt" | cat)

这时候 $? 是cat的退出码,它通常是0。另外,如果你在函数里最后执行的是 echo,而不是显式 return,那么退出码就是echo的0。还有一种情况是你在 if 条件判断里使用了命令替换,而不是先调用再查 $?。所以问题远不止一种。

为了严谨,我们修改上面的翻车案例,去掉显式return,让它更贴近最常见的错误:

#!/usr/bin/env bash
# 技术栈:Bash Shell

# 检查文件是否存在,存在就输出大小,不存在就输出错误提示
check_file() {
    local file="$1"

    if [[ -f "$file" ]]; then
        local size
        size=$(wc -c < "$file")
        echo "文件大小是:$size"
    else
        echo "文件不存在"
    fi
}

# 调用函数,捕获标准输出
result=$(check_file "/tmp/abc.txt")

# 直接判断退出码,你会发现它永远是0
echo "退出码是:$?"

在这个版本里,函数压根没有return语句,那么函数的退出码就跟随着if分支最后一条命令的退出码。如果文件不存在,分支里最后执行的是echo "文件不存在",echo成功了,退出码就是0。这样一来,外部看到的$?一定是0,即使业务逻辑上这是一个“失败”的结果。这才是真正的翻车现场。

理解了这一点,你就会明白,想同时拿结果和判断状态,绝对不能把echo和return混在一个通道里。

四、把输出和返回码分开拿的正确姿势

既然知道了问题,那有什么好办法?有,而且不止一种。下面给出三种常用方案,每种都配合完整示例。

4.1 方法一:用全局变量传结果

这个方法最简单粗暴:函数内部不要用echo输出结果,而是把结果写进一个全局变量;函数只负责用return设置退出码。调用的时候,先查退出码,再读全局变量。

#!/usr/bin/env bash
# 技术栈:Bash Shell

# 全局变量,用来存放函数的结果
FILE_INFO=""

check_file() {
    local file="$1"

    if [[ -f "$file" ]]; then
        local size
        size=$(wc -c < "$file")
        # 结果写入全局变量,不打印到标准输出
        FILE_INFO="文件大小是:$size"
        return 0
    else
        FILE_INFO="文件不存在"
        return 1
    fi
}

# 普通调用,不使用命令替换
check_file "/tmp/abc.txt"
status=$?

# 读取全局变量
result="$FILE_INFO"

echo "退出码是:$status"
echo "结果内容是:$result"

# 放心用退出码做逻辑判断
if [[ $status -eq 0 ]]; then
    echo "[OK] 文件存在,大小已经拿到。"
else
    echo "[FAIL] 出错了,原因:$result"
fi

这种方式的优点是简单直接,代码可读性好。缺点也很明显:全局变量容易被其他地方意外修改,而且如果函数内部有嵌套调用,内层函数可能会把同一个全局变量覆盖掉。所以用的时候,尽量给变量起个不容易重名的名字。

4.2 方法二:结果输出到标准输出,错误信息输出到标准错误

这个方法更贴近Linux命令的经典做法:标准输出只放真正的数据,错误提示放到标准错误,返回码用return设置。外部用命令替换时,只会捕获标准输出,标准错误会直接显示到终端,不会污染数据。

#!/usr/bin/env bash
# 技术栈:Bash Shell

# 检查文件并输出大小,错误信息写到标准错误
check_file() {
    local file="$1"

    if [[ -f "$file" ]]; then
        local size
        size=$(wc -c < "$file")
        # 真正的结果写到标准输出
        echo "文件大小是:$size"
        return 0
    else
        # 错误提示写到标准错误
        echo "文件不存在" >&2
        return 1
    fi
}

# 调用函数,命令替换只捕获标准输出
result=$(check_file "/tmp/abc.txt")
status=$?

echo "退出码是:$status"
echo "捕获到的结果是:$result"

if [[ $status -eq 0 ]]; then
    echo "[OK] 结果:$result"
else
    echo "[FAIL] 失败了,具体错误请看终端上面的输出。"
fi

这里关键点有两个:一是错误信息使用了 >&2,把内容导向标准错误;二是函数结束时显式写了return,确保退出码能正确反映业务状态。因为return是函数里最后执行的一条命令,所以命令替换后,$?拿到的正是return设置的值。

这种方式的优势在于函数调用方式非常自然,让函数用起来跟内置命令一样。缺点是如果你在脚本里重定向了标准错误,或者需要把错误信息也存到变量里,就要额外费点力气。最常见的做法是调用时加 2>&1,但那样会把错误和数据混在一起,需要再解析。所以最好让错误信息直接打印到终端,供人查看。

4.3 方法三:用eval给指定变量赋值

如果你希望函数既能返回数据,又不想用全局变量,也不想依赖标准输出,那么可以传入一个变量名作为参数,函数内部用eval来给这个变量赋值。

#!/usr/bin/env bash
# 技术栈:Bash Shell

# 第一个参数是文件路径,第二个参数是结果变量的名字
check_file() {
    local file="$1"
    local result_var="$2"
    local output=""

    if [[ -f "$file" ]]; then
        local size
        size=$(wc -c < "$file")
        output="文件大小是:$size"
        # 把output变量内容赋值给result_var所指向的变量名
        eval "$result_var=\"$output\""
        return 0
    else
        output="文件不存在"
        eval "$result_var=\"$output\""
        return 1
    fi
}

# 调用方式:先声明一个变量,再把变量名传进去
FILE_INFO=""
if check_file "/tmp/abc.txt" FILE_INFO; then
    echo "成功:$FILE_INFO"
else
    echo "失败:$FILE_INFO"
fi

注意,eval在安全要求严格的场景下要特别谨慎,尤其是变量内容中包含特殊字符时,可能会被当作命令执行。上面示例中output内容来自文件路径和系统命令,可控性还行,但如果输入来源不可信,建议不要用eval。这里只是展示一种思路,日常开发我更推荐前两种。

4.4 关联技术深挖:命令替换与子shell的隐藏关联

为什么要单独提这个?因为很多人用了方法一(全局变量),却依然习惯写成 result=$(函数),结果发现全局变量没有被修改,一头雾水。

命令替换 $( ) 会让函数在一个子shell中执行。子shell里的变量修改,是不会影响到父shell的。所以如果你用命令替换去调用一个依赖全局变量传递结果的函数,你拿不到任何东西,因为函数在子shell里修改的全局变量是子shell自己的副本。

来看一个演示:

#!/usr/bin/env bash
# 技术栈:Bash Shell

GLOBAL_INFO="原始值"

modify_info() {
    GLOBAL_INFO="我被修改了"
}

echo "普通调用之前:$GLOBAL_INFO"
modify_info
echo "普通调用之后:$GLOBAL_INFO"

GLOBAL_INFO="原始值"
result=$(modify_info)
echo "命令替换之后:$GLOBAL_INFO"
echo "命令替换得到的输出:$result"

运行结果会告诉你,普通调用能修改全局变量,但命令替换调用不能。因为 $(modify_info) 里的 modify_info 在子shell里跑,改的是子shell的变量。

所以结论是:如果你想使用全局变量方式,就必须用普通调用,不要用命令替换。而方法二因为依赖标准输出,用命令替换才自然,两者不要混用。

五、应用场景分析

现在把三种方案放到真实场景里看看。

第一种(全局变量)适合脚本内部的小型辅助函数。比如一个部署脚本里有几个私有函数,几个变量名自己掌握,不打算给外部复用,那么用全局变量最省事。缺点是如果脚本很长,变量名容易冲突,函数之间也容易互相踩到。

第二种(stderr分离)是推荐方案。它适合所有需要让函数像外部命令一样被调用的场景,尤其是你要写一套可以复用的函数库时,使用 result=$(你的函数) 来取数据、用 $? 来判定状态,这种做法最符合Bash使用者的直觉。外部程序也可以直接调用你的脚本,因为它遵循了“标准输出给数据、标准错误给提示、返回码给状态”的约定。

第三种(eval传变量)适合需要返回多个结果的复杂函数。比如一个函数既要返回文件名、又要返回大小、还要返回权限,用单个标准输出不好拆,用全局变量又太多,那就可以传多个变量名进去,函数内部统一赋值。但eval的注入风险必须重视,建议只把这种写法用在自己完全可信的脚本里,不要在从外部读取的字符串上直接eval。

还有一种场景,就是你觉得以上都不够优雅,非要在一个echo里同时带上状态和结果,比如输出“OK:大小”。这时你可以在外面用字符串分割来解析。但这种做法会让调用方很痛苦,一旦分隔符在内容里出现就会出错,而且还得自己维护一套格式协议。除非你是要写给人看的输出,否则非常不推荐。

六、技术优缺点对比

为了让你更直观地选择,我把几种方式摆在一起对比一下。

方式 优点 缺点
全局变量 简单直接,不涉及命令替换,性能好 命名容易冲突,嵌套调用可能被覆盖,不支持命令替换
stderr分离 符合Linux命令惯例,调用方式自然,数据与状态分开 错误信息会直接显示到终端,需要重定向才能捕获
eval传变量 可返回多个结果,函数接口灵活 存在代码注入风险,调试起来不直观
字符串拼接 不需要额外变量,写法上看着省事 解析麻烦,格式耦合严重,容易踩坑,不推荐

如果让我给建议,脚本里自用的函数选第一种,想写成可复用的函数库选第二种,第三种只在非常特殊的多返回场景下考虑。

七、注意事项和常见坑

第一,不要在函数里用echo去输出调试信息,然后还用命令替换抓结果。那些echo都会变成标准输出的一部分,把你真正要的数据给污染掉。调试信息请写到标准错误,或者用一个开关变量控制是否输出。

第二,区分return和exit。return是退出函数,设置函数退出码;exit是退出整个脚本进程。你不小心在函数里写了exit,那外面的代码根本来不及取退出码,因为脚本已经结束了。这是一个非常致命的坑,写的时候要记住。

第三,如果你开了“遇到错误即退出”的选项,那么函数返回非0时,调用方如果不做特殊处理,脚本会直接终止。你需要在调用函数时,把它放进if条件里,或者用“或”逻辑运算符来接管,例如:

#!/usr/bin/env bash
# 技术栈:Bash Shell
set -e

# 一个注定要失败的函数
fail_function() {
    echo "准备失败"
    return 1
}

# 如果直接调用,脚本会退出,所以用if包裹住
if fail_function; then
    echo "成功"
else
    echo "捕获到失败,脚本继续执行"
fi

第四,注意退出码的范围。函数返回码只能是0到255,如果你返回一个超过255的数字,它会溢出回绕。比如返回256,实际得到0。所以不要用返回码传递数值,只能用来表示状态。

第五,命令替换会去掉结果末尾的换行符。如果你用echo输出一行文本,外面的变量里不会保留结尾的换行。这个在涉及字符串比较时需要注意,但一般影响不大。

第六,子shell问题前面说过,但是值得再强调一次:在命令替换里修改的全局变量,不会影响父shell。如果你写的函数既有echo输出,又要设置全局变量,然后还希望外部能同时拿到,那几乎都会踩坑。请认准一个通道:要么用标准输出传数据,要么用变量传数据,不要并行。

第七,函数中的return语句只能写在函数体里,不能写在命令替换的子shell里?实质上函数是在子shell里的,但这种子shell是命令替换造成的,函数体内部没有额外限制。不过你还是要确保函数最后一个有意义的命令是return,这样$?才是可靠的。

八、文章总结

回到最初的问题:echo输出和返回码冲突,本质上是因为我们没有把“数据流”和“状态流”分开。函数的标准输出是数据通道,退出码是状态通道,两者互相独立。想同时拿结果又判断成败,最干净的方式是让标准输出只承载真正的数据,错误信息走标准错误,并且用显式return设置退出码。调用方用命令替换抓数据,用$?抓状态。如果你不想用命令替换,那也可以选择全局变量来传数据,但必须使用普通调用。

Bash脚本的很多坑,其实都源于对数据流和状态流的理解不够透彻。如果你能把这两条通道理顺,再遇到类似的“返回码失灵”“逻辑判断失效”问题,就能一眼看到底。以后写函数时,先想清楚:我的数据走哪条路?我的状态走哪条路?两者一旦分工明确,脚本自然又稳又清晰。