Shell 函数把一组命令组织到同一个名称之下,并接收本次调用自己的位置参数。普通调用时,函数体在当前 Shell 中执行,不会自动启动独立进程。这方便复用操作,也意味着赋值、切换目录及其他 Shell 状态变化可能影响调用方。函数接口除了规定输入,还应明确输出、退出状态和有意产生的副作用,不能只因为把几行命令包进了函数,就认为它们已经与外层隔离。

每次调用都有自己的参数列表

调用 demo_func first second 后,这次函数内部的前两个位置分别是传入的两个值,参数数量为二。再次调用其他函数,会暂时安装下一层自己的参数列表;返回后,调用者的列表重新可见。因此,在被调用函数中移位,不会消耗调用者的位置参数。Bash 的 $0 也不会因此变成当前函数名称,不能用它判断自己正在哪个函数里面。

使用 "$@" 转发整份列表,才能保留空参数、空格以及通配符字符各自的边界。应先检查数量,再读取必需的位置,同时区分省略参数与明确传入空字符串。Shell 函数没有自动执行这些规则的参数声明,调用时语法上能写出一串值,并不意味着这些值已经满足函数的业务约定。

局部变量使用动态作用域

Bash 的局部声明会创建一个变量,遮住外层的同名绑定;函数返回之后,原来的绑定再次可见。若修改调用者变量并不是接口的一部分,应为临时值、循环计数器等声明局部变量。面向多个解释器的可移植脚本,不能假设每个 Shell 都实现了 Bash 的这套局部变量设施,解释器要求必须与使用的语法保持一致。

但 Bash 采用动态作用域:被另一个函数调用的函数,可以看到调用者已经声明的局部变量。如果它没有声明自己的局部变量,就直接给同名变量赋值,修改的可能正是调用者的局部绑定。最外层的全局值依然受保护,但中间那层函数的值已经改变。因此,局部变量不是其他函数完全不能接触的词法私有对象,沿真实调用链测试才看得见这种影响。

普通的局部变量声明不会自动恢复工作目录、Shell 选项、陷阱,或函数体已经改写的文件。Bash 另外提供 local -,可以在函数返回时恢复通过 set 改动的选项;这是一项明确的选项作用域机制,与单纯声明临时变量不同。这些效果都需要各自的约定。如果把函数放进普通命令替换,它会在子 Shell 环境中运行,变量和目录变化不会回到外层,但文件写入仍可能保留。捕获输出改变的是执行边界,不是为任意操作提供一套安全沙箱。

状态与数据走不同通道

没有显式返回时,函数通常采用最后执行命令的状态。标准输出应承载约定的数据,诊断则写到标准错误。return 7 把一个状态交给调用者,不是返回文本数字作为数据;exit 会结束正在执行的 Shell,而不只是离开当前函数。设计辅助函数时,需要让调用者知道究竟应读取哪个通道,以及失败后还能不能使用已经打印出的部分结果。

当生产者退出状态重要时,把局部声明与命令替换赋值分开。写成 local result=$(producer) 时,观察到的是声明内建命令的状态,它可能掩盖生产者失败;先声明,再单独赋值,赋值语句才能反映命令替换的状态。之后仍需在日志或清理覆盖它之前保存。把两句合成一行虽然简短,却可能改变错误传播,不能当作纯粹的排版差异。

坏掉的样子

没有局部声明的计数器可能破坏调用者的循环,即便调用者已经把自己的计数器声明为局部也一样。测试辅助函数时,应既从顶层调用,也从另一层函数调用;只检查全局值是否恢复,会漏掉动态作用域影响。同样,标准输出上意外出现的进度消息,会污染原本只捕获一个路径或值的调用方,测试应检查实际捕获结果而不只是看屏幕。

不要依赖错误即退出选项,认为函数内每次失败都会可预测地停下。它受调用上下文影响:被用作条件的函数,体内某条命令失败之后仍可能继续执行。对于失败后必须停止后续工作的操作,应明确检查结果,并在诊断之后传播约定的状态。函数的返回值要代表其契约要求的工作结果,而不是最后一条成功打印语句的偶然状态。

快速验证

在 Bash 中运行下面完整代码块。它只在新 Shell 内修改变量和位置参数,不改变外层终端,也不操作文件。第一个工作函数有意改写调用者的局部变量;第二个函数验证空参数、带空格参数、移位和非零返回;最后两个函数比较合并声明与独立赋值的状态差异。这样可以分别观察作用域、参数边界和失败传播,而不把它们混成一次笼统的成功测试。

BASH_ENV= bash --noprofile --norc <<'BASH'
demo_value=global
demo_worker() { demo_value=changed; }
demo_scope() {
  local demo_value=inner
  printf 'local-before=%s\n' "$demo_value"
  demo_worker
  printf 'local-after=%s\n' "$demo_value"
}
demo_scope
printf 'global-after=%s\n' "$demo_value"
demo_args() {
  local demo_arg
  printf 'function-count=%s\n' "$#"
  for demo_arg in "$@"; do printf 'argument=<%s>\n' "$demo_arg"; done
  shift
  printf 'after-shift=%s\n' "$#"
  return 7
}
set -- outer-one outer-two
if demo_args '' 'two words'; then
  exit 99
else
  demo_status=$?
fi
printf 'function-status=%s outer-count=%s outer-first=%s\n' "$demo_status" "$#" "$1"
demo_masked() { local demo_result=$(return 9); }
demo_checked() {
  local demo_result
  demo_result=$(return 9)
}
demo_masked
printf 'combined-declaration=%s\n' "$?"
if demo_checked; then exit 99; else printf 'separate-assignment=%s\n' "$?"; fi
BASH

局部值应从 inner 变成 changed,但全局值仍是 global。两个传入参数应分别打印,其中一个为空;函数移位后返回七,外层依然持有两个参数,第一个仍是 outer-one。最后,合并声明报告零,独立赋值报告九。这些结果验证真实调用边界,也揭示局部声明能够保护什么、不能保护什么,避免把“变量叫局部”误当成完全隔离的证据。

来源