写 Shell 的人大概都经历过这样的阶段:脚本刚开始只有十几行,写起来很爽;等需求一多,同一个参数校验逻辑复制了五六遍,日志打印散落四处,哪天想改个输出格式,得满文件找。这时候最自然的解法就是把重复逻辑抽成函数。Shell 编程里的函数没那么玄,但也有不少反直觉的细节。这篇文章我会从定义语法、参数传递、变量作用域到实战案例,再把常见坑整理一遍,希望你看完可以直接拿回去用,少走我当年走过的弯路。
1. 为什么要在 Shell 里写函数:解决的问题与适用场景
1.1 没有函数的脚本,维护起来有多痛苦
我见过不少生产用的部署脚本,两百多行,没有函数,全靠顺序执行+重复代码块。最典型的就是参数判断:
if [ -z "$1" ]; then echo "用法: $0 <环境名>" exit 1 fi这段代码在同一个脚本里出现了三次,每次环境名、配置目录的判断逻辑都差不多,但措辞略有差异。后来有人提了一个新需求:参数为空时不仅要打印用法,还要把当前支持的环境列表一起打出来。改起来就是满文件替换,漏掉一处,某个分支就静默出错。函数的意义不在于“代码量变少”,而在于把逻辑收敛到一处,后续只改一个地方。这是所有脚本语言通用的道理,Shell 也不例外。
1.2 函数、别名、独立脚本到底怎么选
Shell 里实现复用有三种常见手段:别名(alias)、函数、独立脚本。很多人分不清,我平时是这么判断的。
| 方式 | 能否传参 | 进程开销 | 复用范围 | 推荐时机 |
|---|---|---|---|---|
| alias | 只能简单拼接 | 无 | 当前用户交互环境 | 单纯为了少敲几个字母 |
| 函数 | 支持标准参数 | 无 | 当前 shell、被 source 的库 | 有逻辑判断、需要返回值,或要在多脚本间共享 |
| 独立脚本 | 支持标准参数 | 有(新进程) | 全局任何地方 | 跨项目独立工具,不需要回传变量给父进程 |
别名的问题在于它本质上是个“文本替换”,不适合在里面写 if/for,更没法按参数去分支。独立脚本的问题是每次执行都要启动一个新进程,虽然现代机器上性能影响可以忽略,但它拿不到父 shell 里的变量,想传回结果还得靠命令替换或者临时文件。函数在这两者之间取了平衡:没有额外进程,可以共享父 shell 的环境,同时又支持完整的参数体系和条件逻辑。
1.3 函数最常出现的三个位置
第一,个人环境配置文件,比如~/.bashrc或~/.bash_profile,这里放一些高频小工具函数,比如extract解压各种格式压缩包、mkcd创建目录并进入。这些函数只服务于当前用户的交互式终端。
第二,独立脚本内部。脚本几十行以内可以直接顺序写,一旦超过一百行,我会按职责拆成 init、check_env、do_work、cleanup 这几类函数,主流程保持简短。
第三,单独的函数库文件。比如项目里建一个lib/common.sh,专门放日志、备份、异常处理这类通用函数,其他脚本开头用source ./lib/common.sh导入。这种方式在多个脚本共享逻辑时非常省心,我下面第 4 节的实战案例会用这种结构。
2. 基础语法:定义、调用、参数传递
2.1 两种定义写法
Shell 函数有两种等价的定义方式:
# 写法一:function 关键字 function say_hello { echo "Hello, $1" } # 写法二:POSIX 风格,推荐 say_hello() { echo "Hello, $1" }两种写法在当前的主流 Bash 环境里行为完全一样。但如果你的脚本需要在不同的 sh 环境下跑,写法二的可移植性更好,因为function关键字是 Bash 的扩展,而不是 POSIX 标准。我在自己项目的代码规范里统一用第二种。
有个细节容易忽略:函数定义只是把函数名登记到当前 shell 环境里,并不会立刻执行。真正执行是在你写下say_hello "world"这一行的时候。所以哪怕函数定义在脚本靠后的位置,只要调用发生在定义之后就行,不必非得放在文件最前面。
2.2 调用方式和位置参数细节
函数调用和普通命令在形式上一样,直接写函数名加参数,中间用空格分隔。函数内部通过位置参数来访问这些参数:
print_args() { echo "函数名: $0" echo "参数个数: $#" for arg in "$@"; do echo "参数: $arg" done } print_args a "b c" d这里有几个重要语义:
$0在函数内部依然指向当前脚本的名字,而不是函数名。想拿到函数名本身,Bash 里可以用$FUNCNAME。$@表示所有参数,$*也表示所有参数。两者在加了双引号后区别就出来了:"$@"会把每个参数都当作独立的词,而"$*"会把所有参数拼成一个字符串。遍历参数时永远用"$@",这是最不容易出错的习惯。$#是参数个数,判断有没有传参、传参数量够不够时很常用。
在函数内部处理完一部分参数后,可以用shift把位置参数向左移动。比如第一个参数是命令名,处理完shift之后,剩下的$1就是命令的后续选项。这在写包装函数时特别有用。
2.3 return 到底能返回什么
Shell 函数的返回值是初学者最容易踩坑的地方,因为它的语义和别的语言不一样。return只能返回一个 0 到 255 之间的整数,用来表示状态码,而不是返回一段计算后的数据。
# 错误示范:想把字符串传出去 get_name() { return "abc" # 报错:数值太大 } # 正确做法:结果输出到标准输出 get_name() { echo "report_$(date +%F).log" } filename=$(get_name)大多数情况下,逻辑是这样的:需要给调用方传复杂数据时,用echo打到标准输出,调用方用$(函数名)接住;需要传状态时,才用return。比如:
check_config() { if [ -f "$1" ]; then return 0 else echo "找不到配置文件: $1" >&2 return 1 fi } if check_config "/etc/app.conf"; then echo "配置文件存在" else echo "检查失败" fireturn 0表示成功,任何非零表示失败。调用函数后,$?会记录最近一次函数调用的返回码,但这个变量很脆弱,任何一条命令执行都会覆盖它,所以需要立即判断或保存。
2.4 函数名与外部命令冲突
函数名是可以和外部命令同名的。比如你写了一个ls函数,在当前 shell 里执行ls,会优先走到你的函数,而不是系统的ls命令。利用这个特性可以做“增强版命令”,但一定要小心里面的递归问题:
ls() { command ls -lh "$@" }上面的command关键字是一条逃生通道,它告诉 Bash 后面的命令按“外部命令/内建命令”的正常路径去找,不要再触发同名函数。没有command的话,你的ls函数内部又调用ls,就会无限递归到栈溢出。这算是我见过的比较隐蔽的卡死场景。
3. 变量作用域与进阶技巧
3.1 local 与全局变量
Shell 函数里的变量默认是全局的。哪怕你没有显式声明,函数里随便写result="changed",函数结束后这个result还是存在,调用方能看到。这在某些场景是特性,但多数时候是隐患。
count_files() { local count=0 for f in "$dir"/*; do if [ -f "$f" ]; then count=$((count + 1)) fi done echo "$count" }用local声明局部变量,函数结束变量就消失,不会污染全局命名空间。我写函数的第一原则就是:所有临时变量一律local,需要对外暴露结果时用echo返回,除非调用双方有明确的约定要共享全局变量。
这里有个小知识:local只能在函数内部使用,在脚本顶层直接用会报错。如果你的 Bash 版本较新,也可以用declare -g var=value显式声明一个全局变量,但我在早期兼容性要求高的脚本里会尽量避免。
3.2 默认值参数与参数校验
Shell 的参数展开语法很强大,常用:-来设置默认值:
connect() { local host="${1:-127.0.0.1}" local port="${2:-3306}" echo "连接 ${host}:${port}" }${1:-默认值}的意思是:当$1未设置或者为空时,使用默认值。与之相似的${1-默认值}只在变量完全未设置时生效,$1传了空字符串会保留空字符串,这个细微差别我经常提醒自己。
想要强制必填参数,可以用${2:?错误信息}:
deploy() { local env_name="${1:?用法: deploy <环境名>}" echo "开始部署到 $env_name" }如果调用时没有传参数,Shell 会直接打印错误信息并从函数返回非零状态,省得你写一大段 if 判断。
参数校验除了数量,还要检查内容。比如端口号就得判断范围:
connect() { local port="${1:?必须提供端口号}" if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then echo "非法端口号: $port" >&2 return 1 fi echo "连接端口 $port" }[[ =~ ]]是 Bash 的正则匹配语法,比用grep判断更直接,也不产生额外的子进程。
3.3 递归函数与间接调用
Shell 函数支持递归,虽然是脚本语言,写递归时心里要惦记着性能,但简单场景完全可接受。一个比较实用的例子是递归列出目录树:
show_tree() { local prefix="$1" local path="$2" for item in "$path"/*; do [ -e "$item" ] || continue local name="${item##*/}" if [ -d "$item" ]; then echo "${prefix}${name}/" show_tree "${prefix} " "$item" else echo "${prefix}${name}" fi done } show_tree "" "/tmp/demo"递归的终止条件通常是路径不存在或目录内容为空。上面[ -e "$item" ] || continue是为了处理空目录的情况,否则"$path"/*展开成一个字面量通配符,会报错。
“函数名作为参数”也是常见需求。比如做一个菜单式的分发器:
do_start() { echo "启动服务"; } do_stop() { echo "停止服务"; } do_restart() { do_stop; do_start; } dispatch() { local action="$1" case "$action" in start) do_start ;; stop) do_stop ;; restart) do_restart ;; *) echo "未知操作: $action" >&2; return 1 ;; esac } dispatch restart这种写法让主流程变成一张清晰的分发表,加新命令只需要多写一个函数和一个 case 分支。
3.4 管道与子 shell 的怪癖
Shell 函数里最容易出现“变量没变”错觉的场景就是管道。看下面的例子:
count_lines() { local total=0 cat "$1" | while read -r line; do total=$((total + 1)) done echo "总行数: $total" } count_lines /tmp/data.txt你期待输出总行数,实际输出的很可能是 0。原因是管道的右侧(while循环部分)实际上运行在一个子 shell 里,total的修改发生在子 shell 内部,父 shell 里的同名变量完全没动。
解决方式有几种。最简单的是把整个过程放到一个命令组里,让计数逻辑和输出逻辑在同一个进程中完成:
count_lines() { local total=0 { while read -r line; do total=$((total + 1)) done < "$1" echo "总行数: $total" } }这里改成输入重定向[ -d "$item" ]而不是管道,让while循环在函数所在的进程里执行,变量修改才能保留下来。另一个做法是使用进程替换:
count_lines() { local total=0 while read -r line; do total=$((total + 1)) done < <(cat "$1") echo "总行数: $total" }进程替换<(...)把命令输出伪装成一个临时文件,循环在这个临时文件上读取,仍然在父 shell 进程里执行。这个语法初看有点怪,但用多了会非常顺手。
4. 实战案例:三组可以直接复用的函数
4.1 日志输出函数
写脚本没有标准库,日志打印是最容易“各写各的”的地方。我自己维护一个lib/log.sh,里面只有几行,但项目里所有脚本都靠它保持一致。
# 日志级别:0=DEBUG 1=INFO 2=WARN 3=ERROR LOG_LEVEL=${LOG_LEVEL:-1} log_debug() { [ "$LOG_LEVEL" -le 0 ] && echo "[DEBUG] $(date '+%F %T') $*" >&2; } log_info() { [ "$LOG_LEVEL" -le 1 ] && echo "[INFO ] $(date '+%F %T') $*" >&2; } log_warn() { [ "$LOG_LEVEL" -le 2 ] && echo "[WARN ] $(date '+%F %T') $*" >&2; } log_error() { [ "$LOG_LEVEL" -le 3 ] && echo "[ERROR] $(date '+%F %T') $*" >&2; }注意每个函数都把日志输出重定向到标准错误(>&2)。这个习惯非常关键:脚本的标准输出往往被用来传递数据,比如执行scripts/run.sh log_info "开始",如果把日志打到标准输出,日志内容就会掺进数据流里,污染命令替换的结果。凡是给人看的进度、警告、错误信息,一律走标准错误。
使用的时候先 source 再调用:
source /path/to/lib/log.sh deploy() { log_info "开始部署" if ! build_project; then log_error "构建失败" return 1 fi log_info "部署完成" }如果哪天想把日志同时写到文件和控制台,只需要把>(一个日志文件)的重定向路径设计好,或者用tee中间接一下,函数体的其他调用处都不用动。
4.2 带校验的环境准备函数
部署类脚本最常见的需求是:先检查依赖命令是否齐全,缺一个就立刻报错退出。我把检查逻辑抽成require_command,一劳永逸。
require_command() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then echo "缺少必需命令: $cmd" >&2 return 1 fi } prepare_env() { local commands=("git" "make" "gcc") local cmd for cmd in "${commands[@]}"; do require_command "$cmd" || return 1 done echo "环境检查通过" } if ! prepare_env; then echo "环境准备失败,请先安装依赖" >&2 exit 1 fi这个函数有几个值得注意的细节。command -v和which的区别在于:which是外部命令,在一些发行版上不存在,而且它不一定能识别 Shell 函数和内建命令;command -v是 Shell 内建的查找机制,能覆盖命令、函数、内建命令、别名。判断某个命令是否可用,command -v是最可靠的方式。
> /dev/null 2>&1把正常的路径输出和错误输出都丢弃,我们只关心退出码。这又是一个“输出和状态分离”的例子:要的是状态,输出就别留着。
4.3 配置文件备份与修改函数
凡是脚本里要动配置文件,我都强烈建议先备份。写一个通用的备份函数,返回备份文件的路径,调用方非常省心:
backup_file() { local src="$1" local suffix="$(date '+%Y%m%d%H%M%S')" local dst="${src}.${suffix}.bak" if [ ! -f "$src" ]; then echo "备份失败,文件不存在: $src" >&2 return 1 fi cp -a "$src" "$dst" echo "$dst" }使用方式:
modify_config() { local conf="/etc/app/config.ini" local bak bak=$(backup_file "$conf") || return 1 # 修改配置,这里模拟替换一行 sed -i "s/^timeout=.*/timeout=30/" "$conf" echo "已修改配置,备份位于: $bak" }这里又用到了命令替换捕获函数输出:bak=$(backup_file "$conf")。如果备份失败,函数返回非零,命令替换后立即|| return 1,整个操作不会往下继续。这种风格我写多了以后已经成了条件反射:凡是函数可能失败的,调用处就接||显式处理,避免脚本在出错后继续乱跑。
5. 常见问题排查与经验速查
5.1 排查工具箱
函数出问题的时候,先别急着改代码,用下面这些手段快速定位。
type 函数名:会显示函数的完整定义,也会告诉你它是别名、函数还是外部命令。这是我排查“为什么我定义的东西没生效”的第一把钥匙。declare -f 函数名:只输出指定函数的定义。配合declare -F可以列出当前 shell 里所有已定义的函数名。bash -n 脚本名:语法检查,不会真正执行,适合在跑之前先排除低级语法错误。set -x:在调用函数前开启,它会逐行打印会实际执行的命令及展开后的参数,看完再用set +x关掉。排查“参数传错了”或者“某个变量为空导致命令变形”时效果极好。set -u:变量未设置即报错。在函数里一旦写错变量名,它能第一时间暴露出来,避免把空值当成有效值往下传。
一个比较实用的组合是:
set -x deploy staging set +x输出会非常啰嗦,但往往一眼就能看到问题出在哪一行。排查完记得关掉,否则后面所有正常日志都会被淹没。
5.2 问题与解决对照表
我把实际工作中见过的、朋友问过的问题整理成了表格,按“现象”来找“原因”和“解法”。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 调用函数时报 command not found | 函数定义所在的文件没有被 source,或函数定义写在子进程里 | 先source 库文件,用type 函数名确认定义是否可见 |
return 1后调用方没拿到状态 | $?在函数调用后被其他命令覆盖 | 调用后立刻用if判断,或先local code=$?保存 |
echo输出的内容没被捕获 | 函数内部其他命令也往标准输出打印 | 把注释、提示信息改为>&2,标准输出只留给真正的结果 |
| 管道里的变量赋值后调用方看不到 | 管道各段运行在子 shell | 用进程替换< <(命令),或改为输入重定向 |
| 函数名和系统命令重名导致递归 | 函数优先级高于 PATH | 函数内部用到同名命令时加command前缀 |
set -e下函数一有非零状态脚本就退出 | set -e遇到未处理的失败状态直接退出 | 在函数内显式 ` |
| source 之后修改函数定义不生效 | 当前 shell 内存里还是旧定义 | 修改后重新source,新开终端也行 |
5.3 我踩过的几个坑
第一个坑,也是最经典的:echo和return混用。早期写函数时我想返回一个字符串,直接写了return "文本",结果报错。后来知道return只管状态码,数据走echo,两个通道要分开用。更深一层的坑是,如果函数里既想输出日志,又想输出真正的结果,日志会把结果搞脏。所以我现在只要有 getter 风格的函数,一律把日志重定向到>&2,标准输出干干净净只给结果。
第二个坑是 source 和执行的混淆。我一度直接在命令行跑bash mylib.sh测试库文件,结果函数定义全在子 shell 里,跑完就没了。库文件必须用source或.来加载,让定义落到当前 shell,后面才能直接用。
第三个坑和调试有关。开了set -e之后,函数里某个命令返回了非零状态,脚本会直接退出,而且退得很突然,往往看不出是哪个函数、哪一行。后来我养成了两个习惯:函数内部每个可能失败的关键命令都显式写清失败处理,比如cmd || return 1;调试阶段临时开set -x,看是哪一行触发的退出。这两个组合拳基本能解决绝大多数莫名其妙的提前退出问题。
第四个坑是空目录和通配符。在函数里写for item in "$path"/*,如果目录是空的,这个模式串不会展开成文件列表,而会原样保留一个带星号的字符串传给循环体。我在一个递归列目录函数里就是因为这个差点把星号当成了文件名处理。现在的习惯是循环体里先做[ -e "$item" ] || continue,把不存在的路径过滤掉,再继续处理。
我个人在实际操作中的体会是:Shell 函数不难写,难在把所有“进程边界”和“输出通道”想清楚。函数在当前 shell 执行还是子 shell 执行,标准输出是数据还是日志,return 是状态还是数据,这三件事想明白了,Shell 函数的七成坑就都绕开了。等这些细节成了肌肉记忆,你会发现自己写脚本的速度和信心会明显不一样。如果你在公司或项目里有自己积累的公共函数库,建议也整理成一篇文章或文档,方便团队其他人直接复用,而不是各自重写第三遍同样的逻辑。