写shell脚本的人,八成都有过这种经历:脚本越写越长,到处是重复代码,改一个逻辑要全局搜替换,最后自己都看不懂自己写的什么东西。等你开始用函数,才算是从“写命令”进入了“写程序”的门槛。这篇文章就把shell编程函数这件事从头到尾拆透,从语法细节到企业级脚本里的实战模板,再到那些坑了你无数次的隐蔽问题,一次性讲清楚。
函数这个概念,在任何语言里都是核心中的核心。Shell虽然是个解释型脚本环境,语法看起来松散,但函数的设计和使用逻辑,跟Python、Java几乎没有区别——都是为了复用、抽象、隔离复杂度。区别只在于Shell的坑更隐蔽,写法更灵活,出错时的提示更让人摸不着头脑。所以这篇文章不是把man手册里的语法复读一遍,而是结合真实场景讲清楚:函数到底解决什么问题、怎么写才对、为什么你写出来的函数总是不按预期跑。
适合看这篇文章的,是那些已经在写shell脚本、但还停留在“把命令一个个排下去”阶段的人,以及被变量作用域、返回值、子shell这些概念折磨过的人。新手也能看,但建议先去搞清楚for循环和基础变量再读,否则部分内容会有些吃力。
1. 先搞清楚:shell函数到底解决什么问题
1.1 没有函数之前,脚本是怎么写烂的
不写函数的脚本,最典型的问题是重复代码失控。比如你的部署脚本里,需要检查某个端口是否在监听、检查某个配置文件是否存在、打印带时间戳的日志,这些操作往往会在脚本里出现三五次甚至更多。第一次写的时候,直接复制粘贴一段检查逻辑;第二次要用,再粘贴一次;第三次改需求,要加一个判断条件,你得全文搜同样的代码段,一个个改。稍有不慎漏改一处,线上就会出现诡异的问题。
这类脚本还有个通病:主流程完全被细节淹没。你打开一个部署脚本,看到的全是grep、awk、sed这些底层操作,想找“启动nginx”这一步在哪,得顺着代码流走半天。等脚本到500行的时候,维护成本已经不是线性增长,而是指数级增长。
函数就是用来解决这两个问题的。把一段逻辑封装成函数,起一个清晰的名字,比如check_port、log_info、backup_config,主流程就变成了一连串语义明确的调用。读脚本的人不用关心check_port内部怎么实现,只要知道它检查端口、异常时退出,就够了。这跟写文章用标题、写代码用注释是一个道理——都是在降低阅读成本。
1.2 函数式思维带来的三个转变
从“写命令”到“写函数”,表面上是语法变化,实际上有三个思维层面的转变。
第一个转变是从“复制粘贴”到“抽象复用”。不写函数的人,脑子里想的是“这段代码我再用一次”;写函数的人,脑子里想的是“这段逻辑的核心是什么,输入是什么,输出是什么”。前者是遇到一个问题解决一个问题,后者是把一类问题归纳成一个解决方案。
第二个转变是从“线性执行”到“模块化组装”。脚本不再是自上而下的一条线,而是一个个可独立测试的模块。你可以单独跑bash script.sh --test来验证某个函数,也可以把常用函数提取到独立文件,在多个脚本里source复用。这一点在后面讲“函数分文件”的时候会细说。
第三个转变是从“能跑就行”到“可控可查”。函数化的脚本,出错时定位更快。bash的set -x配合函数调用栈,能直接看到是哪个函数、哪一行出了问题。而不是像一坨面条代码那样,报错行号指向一个完全看不懂的位置。
1.3 函数和“直接写”的性能与可读性权衡
有人会问:函数调用有额外开销,会不会影响性能?直接回答:有,但极小。bash里函数调用大约比直接执行命令多几毫秒的开销。如果你的脚本里一次循环跑十万次、每次都调用函数,那确实能感觉到慢。但绝大多数运维、部署、数据处理脚本,瓶颈根本不在函数调用这点开销上,而在网络IO、磁盘IO、进程启动这些真正慢的操作上。
所以结论很明确:在可读性和维护性面前,那点微乎其微的性能损失根本不值得考虑。如果哪天你真的碰到了十万次循环的性能问题,最优解不是把函数拆掉,而是换更合适的工具,比如awk或者干脆用Python。咱们得先分清什么场景用什么工具,而不是本末倒置。
2. 函数语法拆解:从定义到调用
2.1 两种函数定义方式,怎么选
Shell函数的定义有两种写法:
# 方式一:POSIX兼容写法 say_hello() { echo "Hello, $1" } # 方式二:bash关键字写法 function say_hello { echo "Hello, $1" }两种方式在bash里效果几乎一样。区别在哪儿?
方式一是POSIX sh标准里的写法,dash、ash、busybox sh这些精简shell都认。如果你的脚本可能要跑在嵌入式设备、Alpine容器的默认shell、或者系统/bin/sh指向dash的环境下,用方式一最稳妥。方式二是bash的扩展关键字,写起来更直白、语义更强(看到function就知道是函数),但不被POSIX shell识别。
我的建议是:如果你确定脚本只跑在bash环境,两种都行,按团队风格选一种统一用;如果脚本可能跨shell跑,一律用方式一。还有个细节,function say_hello后面没有(),有的人会写成function say_hello(),这种混合写法在bash里也支持,但不要用——不伦不类,而且部分老版本bash对它的解析有歧义。
定义函数的时候,{和函数名之间必须有空格,右括号}前面建议加上分号或者换行,这些都是shell语法的硬性要求。初次写的人容易踩这个坑:say_hello(){...}连写在一起,语法解析出错。
2.2 参数传递:你传进来的,和我拿到的
函数内部的参数,跟脚本本身的参数用的是同一套机制:$1、$2……一直到$9是位置参数,$0是脚本名(不是函数名),$#是参数个数,$@是所有参数(每个参数独立),$*是所有参数(所有参数连成一个字符串)。
有一个经典误区必须提:函数里用的`$1`、`$2`,是函数的参数,不是脚本的参数。但如果你在调用函数之前,脚本已经把`$1`当作自己的参数用过了,函数里再写`$1`,取到的还是脚本的第一个参数吗?不是。函数在被调用时,**函数内部的`$1`会重设为该函数的第一个参数**。函数调用结束后,`$1`不会自动恢复为脚本原来的参数。 ```bash #!/bin/bash # 测试参数传递 show_args() { echo "函数内 \$0=$0" echo "函数内 \$1=$1" echo "函数内 \$#=$#" } echo "脚本外 \$1=$1" echo "脚本外 \$#=$#" show_args "函数参数甲" "函数参数乙" echo "函数结束后 \$1=$1"运行./test.sh 脚本参数A,输出结果会让你清楚地看到参数传递的全过程。实际上,函数调用结束后脚本原来的位置参数不会恢复这个说法,在bash里需要验证——实际上bash中函数执行后$1等位置参数会恢复到函数调用前的值,但不同shell行为可能有差异,这里描述需谨慎。不过有个细节值得注意,函数内部用shift处理参数时,也不会影响脚本外层的参数。
2.3 函数的“返回值”:你真的懂什么是返回吗
这是shell函数新手最容易搞混的地方。C语言、Python里,return是函数执行完回传一个计算结果。但shell里,return只用来传退出状态码,范围是0-255。0代表成功,非0代表失败。
is_even() { local num=$1 if (( num % 2 == 0 )); then return 0 else return 1 fi } if is_even 4; then echo "4是偶数" fi这里if is_even 4的写法,等价于先执行is_even 4,再检查它的退出码。如果你想拿到函数的计算结果而不是退出状态,就不能用return,而是用echo配合命令替换:
get_name() { echo "zhangsan" } name=$(get_name) echo "得到的名字: $name"这个模式可以说是shell里最实用、也最常用的函数用法了——函数通过echo/printf把结果输出到标准输出,调用方用$(...)把结果捕获到变量里。
值得注意的是,函数里所有echo输出都会被捕获。如果你在函数里既打印了日志又打印了结果,调用方用$(...)捕获时会把日志一起捕获进去。这就是为什么前面要重点讲日志函数——日志要写到标准错误(stderr),不能写到标准输出(stdout),否则会污染函数的返回值。
2.4 函数分文件:复用不再靠复制
热词里出现了“shell脚本”“函数分文件”,这其实是个很实用的工程化技巧。当脚本越来越复杂,把所有函数塞在一个文件里,还是会乱。最佳实践是把函数按模块拆分配置到独立文件,再通过source引入。
# lib/log.sh LOG_LEVEL="INFO" log_info() { echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*" } log_error() { echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2 }# lib/net.sh check_port() { local port=$1 if ss -tlnp | grep -q ":$port "; then return 0 else return 1 fi }# main.sh #!/bin/bash SCRIPT_DIR=$(dirname "$(readlink -f "$0")") source "$SCRIPT_DIR/lib/log.sh" source "$SCRIPT_DIR/lib/net.sh" log_info "脚本开始" if check_port 8080; then log_info "端口8080已占用" else log_info "端口8080空闲" fi这样拆分的好处是:lib目录下的函数文件可以被多个不同脚本复用,主脚本的逻辑变得非常薄,一眼扫过去就知道整个流程发生了什么。用readlink -f而不是直接用$0,是为了解决“脚本被软链接调用”时$0路径不准的问题,这个细节后面还会提到。
拆分函数文件时,要留意source的路径问题。最稳妥的方式是使用$(dirname "$(readlink -f "$0")")获取脚本真实所在目录,再基于它拼接lib目录的路径。
3. 核心实操:变量作用域、局部变量与子shell的坑
3.1 全局变量与局部变量:不写local,早晚出事
Shell函数的变量默认是全局的,这是个非常重要的认知。你在函数里写的var=123,默认会修改到全局变量。看这个例子:
check_file() { exists=0 [ -f "/tmp/test.txt" ] && exists=1 } exists=1 check_file echo "exists的值: $exists"check_file里给exists赋值,要么改成0要么保持1,但这个赋值直接影响了外层的exists变量。如果函数逻辑复杂、嵌套调用复杂,这种隐式修改很容易造成“幽灵变量”——你根本不知道某个变量什么时候被谁改了。
解决办法只有一个:函数内部变量一律加local修饰符。
check_file() { local exists=0 [ -f "/tmp/test.txt" ] && exists=1 echo "$exists" }local声明的变量只在当前函数内有效,函数返回后自动销毁,不影响同名全局变量。这个习惯一定要养成,越早越好。等你写几百行的脚本时,你会感谢当初给自己定的这条规矩。
3.2 子shell和函数:管道、$()里的诡异行为
子shell是bash里特别容易让人困惑的机制,因为它发生在你完全察觉不到的地方。最常见的触发场景有两个:管道|和命令替换$(...)。
在管道里调用函数:
count_lines() { local total=0 while read -r line; do total=$((total + 1)) done echo "$total" } cat somefile.txt | count_lines看起来没有问题,但如果函数内部试图修改外部变量:
count=0 count_lines() { while read -r line; do count=$((count + 1)) done } cat somefile.txt | count_lines echo "总行数: $count"这个脚本跑完,$count大概率还是0。为什么?因为管道左边的命令和右边的命令分别运行在独立的子shell中。cat somefile.txt | count_lines,count_lines所在的子shell里对count的修改,不会传递到父shell。等管道执行完,子shell销毁,count恢复原值。
同理,$(...)内部是子shell,里面的变量改动同样不会传出来。这类问题排查起来非常隐蔽,因为代码看起来完全正常。
解决办法有几种。一种是让函数把结果echo出来,用命令替换接收结果:
count=$(count_lines < somefile.txt)另一种是用进程替换或者重定向避免管道:
while read -r line; do count=$((count + 1)) done < <(cat somefile.txt)< <(...)是进程替换,左侧的while在父shell里执行,对count的修改能保留到父shell。在实际写脚本时,我会尽量少用管道处理循环,改为重定向,这个习惯能避开一大堆子shell相关的问题。具体原理我后面在“当前路径与进程环境”那一节还会再提到。
3.3 函数里的cd:一个导致路径错乱的经典操作
函数内部执行cd,会改变调用者的当前工作目录吗?答案是会。函数不是在子shell里运行的,它只是命令的组合,cd的变化会直接影响父shell。
这个特性在写脚本时很方便——比如一个函数负责进入项目目录并做初始化:
init_project() { cd /opt/myapp ./configure }但如果不小心,它也会引出一个经典问题:脚本前半部分在某个目录下操作,调用了某个含cd的函数后,后续代码就在另一个目录执行了,导致相对路径全部失效。
更隐蔽的是在函数里用$(cd /path && pwd)这种写法,它是在子shell里执行cd,不会影响父shell路径。但有人会误以为cd真的跑到了别的目录,于是后续内容就开始诡异起来。
我的经验是:如果你的脚本里大量使用相对路径,尽量在脚本开头用cd "$(dirname "$0")"固定工作目录,函数内部如需切换目录,用完再切回来,或者干脆放在子shell里执行:
run_in_dir() { local target=$1 shift ( cd "$target" || return 1 "$@" ) }这段代码中用()包起来的子shell切换目录后执行命令,再退出子shell,父目录不受影响。这是编写库函数时非常安全的姿势。
3.4 set -e 与函数返回值:一个让人抓狂的组合
很多脚本会在开头写set -e,意思是“任何命令返回非0时立即退出脚本”。这个设定在简单脚本里很有效,但一旦和函数一起用,坑就来了。
#!/bin/bash set -e check_config() { grep "key" /etc/myapp.conf } if check_config; then echo "找到了配置项" else echo "没有找到配置项" fi这段脚本看起来没问题:grep找不到内容时返回非0,check_config也返回非0,应该走进else分支打印提示。但实际上……在set -e下,check_config在if判断里执行时,返回非0不会触发退出,所以这段代码能正常运行。问题出在别的地方。如果在普通的上下文里调用呢?
#!/bin/bash set -e check_config() { grep "key" /etc/myapp.conf } check_config echo "这行还会执行吗"check_config返回非0时,set -e会让整个脚本直接退出。但还有一个更隐蔽的情况:
#!/bin/bash set -e my_func() { false echo "这个echo还会输出吗" } my_func echo "脚本结束"运行结果?“这个echo还会输出吗”这行根本不会输出。因为函数里的false触发了set -e,脚本直接退出了。很多人刚接触这个组合时,会以为只有函数外层的命令才受set -e影响,实际上函数体内的每条命令也受它管控。
解决方案也很明确:函数里明确要处理“失败”的语句,要么加|| true,要么放在if判断里,要么临时关闭set -e。
my_func() { false || true echo "这行可以正常输出" } my_func echo "脚本结束"4. 真实项目里最常用的几类函数模板
4.1 日志函数:稳定输出带时间戳的日志
写脚本不写日志,等于裸奔。生产线上的脚本出问题时,第一件事就是翻日志——没有日志就全靠猜。所以一个像样的脚本一定会有统一的日志函数。
#!/bin/bash LOG_LEVEL="INFO" LOG_FILE="/var/log/myapp/script.log" log() { local level=$1 shift local msg="$*" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] [$level] $msg" >> "$LOG_FILE" if [[ "$level" == "ERROR" ]]; then echo "[$timestamp] [$level] $msg" >&2 else echo "[$timestamp] [$level] $msg" fi } log_info() { log "INFO" "$@" } log_error() { log "ERROR" "$@" }那个[[ "$level" == "ERROR" ]]的判断,是我踩过坑后加上去的:函数里的echo会被命令替换捕获,如果日志函数把日志写到stdout,而你在函数里用$(...)捕获返回值时,日志和返回值就会混在一起。所以日志函数要么写文件,要么错误日志走>&2,别往stdout里掺和。
如果需要更精细的日志控制,可以加个级别过滤:
log_debug() { if [[ "$LOG_LEVEL" == "DEBUG" ]]; then log "DEBUG" "$@" fi }这样调试时调高日志级别,上线时切换级别,不用删代码。
4.2 检查环境的函数:命令、文件、端口、权限一套带走
部署脚本最先做的往往是环境检查。没有这些检查,脚本跑到一半才发现命令不存在、目录没权限,留下一堆半成品状态,清理起来比重新部署还麻烦。
require_command() { local cmd=$1 if ! command -v "$cmd" >/dev/null 2>&1; then echo "缺少必要的命令: $cmd" >&2 exit 1 fi } require_file() { local file=$1 if [[ ! -f "$file" ]]; then echo "缺少必要的文件: $file" >&2 exit 1 fi } require_root() { if [[ $EUID -ne 0 ]]; then echo "必须使用root权限运行此脚本" >&2 exit 1 fi } # 使用示例 require_command docker require_command jq require_file /etc/myapp/config.yaml require_root这里有个小技巧:command -v比which更可靠,因为which在某些精简环境里可能不存在。EUID变量直接拿到当前用户的有效用户ID,不管是直接root还是sudo,只要UID是0就是root权限。
4.3 批量处理函数:for循环与函数协作的完整范例
热词里有“shell脚本for循环”,这是shell里最常用的结构。配合函数使用,批量处理场景会变得非常清晰。以批量压缩日志文件为例:
compress_log() { local file=$1 if [[ ! -f "$file" ]]; then echo "文件不存在: $file" >&2 return 1 fi gzip "$file" && echo "已压缩: ${file}.gz" } process_all_logs() { local dir=$1 local pattern=$2 local count=0 local file for file in "$dir"/$pattern; do [[ -f "$file" ]] || continue compress_log "$file" count=$((count + 1)) done echo "共处理 $count 个日志文件" } process_all_logs "/var/log/myapp" "*.log"for file in "$dir"/$pattern这里有个细节:如果通配符没有匹配到任何文件,"$dir"/$pattern会原样保留(比如"/var/log/myapp/*.log"作为一个字符串),导致循环器执行一次且拿到的是个不存在的路径。所以我把通配符放在引号外,让通配符展开生效。然后循环体内[[ -f "$file" ]] || continue过滤掉“原样保留”的情况。这个组合是shell里批量处理文件的标准写法,写多了就成肌肉记忆了。
4.4 对话交互函数:read参数处理其实有个大坑
有的脚本需要交互式输入,比如让用户选择部署环境、输入密码。用函数组织交互逻辑,可以让主流程非常清爽:
confirm() { local prompt=$1 local answer while true; do read -r -p "$prompt [y/n]: " answer case "$answer" in [Yy]|[Yy][Ee][Ss]) return 0 ;; [Nn]|[Nn][Oo]) return 1 ;; *) echo "请输入 y 或 n" ;; esac done } select_env() { local choice echo "请选择部署环境:" echo "1) dev" echo "2) staging" echo "3) prod" read -r -p "输入序号 [1-3]: " choice case "$choice" in 1) echo "dev" ;; 2) echo "staging" ;; 3) echo "prod" ;; *) echo "dev" ;; esac } if confirm "确认要部署到生产环境吗?"; then env=$(select_env) echo "开始部署到 $env 环境" fiselect_env最后通过echo输出结果,调用方用$(select_env)拿到环境名。这个模式比select_env内部改全局变量要干净得多,因为函数返回字符串的正确姿势就是echo加命令替换,再次强调。
5. 常见坑与排查技巧实录
5.1 return和echo混用:函数“返回”到底哪个生效
前面说过return传的是退出状态码,echo传的是标准输出。如果你在函数里既用了echo输出数据、又用return返回状态码,调用方要注意两件事都拿到:
get_config_value() { local key=$1 local file=$2 local value value=$(grep "^${key}=" "$file" | cut -d'=' -f2) if [[ -n "$value" ]]; then echo "$value" return 0 else echo "" return 1 fi } # 调用时 value=$(get_config_value "port" "/etc/myapp.conf") status=$? if [[ $status -eq 0 ]]; then echo "配置值: $value" else echo "没找到配置项" fi这里的关键是value=$(get_config_value ...)之后立刻用$?捕获退出状态。如果你在中间穿插了别的命令,$?就会被覆盖。所以捕获状态的时机要敏感,马上用,或者用一个变量存下来。
5.2 参数为空和参数带空格:为什么我的参数分了家
函数调用时,参数之间的空格就是分隔符。如果你的参数本身包含空格,必须用引号包起来,否则会被拆成多个参数。
say_hello() { echo "你好, $1" } # 错误示范:$1 会变成 "你好," say_hello 你好, 世界 # 正确姿势 say_hello "你好, 世界"反向的坑是:函数想接收多个参数,结果调用方忘记加引号,长参数被拆分,$#比预期的多。排查这类问题的办法很简单——在函数第一行打印$#和$@看看实际收到了什么:
say_hello() { echo "收到 $# 个参数" echo "内容: $@" }5.3 引号、${}和$()的区别:再捋一遍
热词里专门有“shell ${}和$() 区别”,这两个符号在函数里也经常出现。简单说:
${var}是变量扩展,取变量var的值。复杂写法如${var:-default}表示变量为空时给默认值,${#var}是取字符串长度。$(command)是命令替换,把命令的输出作为值使用。等价于反引号,但反引号嵌套时很难处理,所以推荐一律用$(...)。
函数里最常见的组合是$(command),比如date=$(date +%Y%m%d)。而${}更多用在变量修饰上,比如路径解析、默认值、字符串截取。
写函数时有个建议:能用${VAR}的不要用$VAR。因为${VAR}xxx这种写法可以明确变量边界,避免变量名拼接的歧义。
prefix="prod" # 错误:试图获取变量 prefix_app 的值 name="${prefix}_app" # 正确:用 ${} 明确变量边界 name="${prefix}_app"# 错误示范:不加花括号会导致变量名拼接 value1="hello" value2="world" echo "$value1_value2" # 试图查找变量 value1_value2 # 正确示范:用 ${} 明确变量边界 echo "${value1}_value2" # 输出 hello_value25.4 shift命令:批量处理参数的利器
热词里有“shell的shift命令”,这玩意儿在函数处理不定长度参数时非常有用。shift命令会把位置参数向左移动一位:$2变成$1,$3变成$2,同时$#减1。
process_args() { while [[ $# -gt 0 ]]; do case "$1" in --host) HOST=$2 shift 2 ;; --port) PORT=$2 shift 2 ;; --verbose) VERBOSE=true shift ;; *) echo "未知参数: $1" >&2 shift ;; esac done } process_args --host 127.0.0.1 --port 8080 --verbose echo "host=$HOST, port=$PORT, verbose=$VERBOSE"这个模式就是标准的“手写getopt”,写一遍就能理解shift的精髓:它让你能逐个消费位置参数,配合case分支实现参数解析。注意shift 2是一次跳两个位置,因为--host和它的值各占一位。
5.5 排查函数问题最实用的几个调试手段
写函数脚本时,遇到诡异行为不要瞎猜,按照有序排查法一步步来:
用bash -x跟踪执行:
bash -x ./script.sh执行时每一行命令都会带上+前缀打印到终端,能清楚看到变量展开成了什么值、函数进入哪个分支、哪个if判断短路了。这是shell调试的第一神器。
局部开启set -x和set +x:
my_func() { set -x # 这里只跟踪函数的内部命令 set +x }在函数内部临时打开调试开关,日志量可控,定位问题精准。
函数名输出调试:在关键函数入口打印一行调试信息:
my_func() { echo "[DEBUG] 进入 my_func, 参数: $*" >&2 }记住输出到>&2,避免污染正常的stdout数据流。
检查语法更苛刻:
bash -n script.sh只检查语法不执行,适合写完脚本、落地前先跑一遍,很多低级语法错误一眼就暴露。
排查函数问题时,一定要养成**“先怀疑子shell,再怀疑变量作用域,最后才是语法”**的习惯。这三个维度覆盖了我日常遇到九成以上的shell函数问题。
5.6 我在实际项目中遇到的一个典型问题:函数里的cd引发的连锁反应
讲一个我印象很深的案例。之前写过一个备份脚本,里面有个函数负责切到数据目录做增量备份。函数是这么写的:
do_backup() { cd /var/lib/mysql || exit 1 tar czf /backup/mysql_${date}.tar.gz . }脚本前面部分在/root/scripts下生成配置文件、检查磁盘空间,一切正常。但一旦执行到do_backup,整个脚本的工作目录就永久切到了/var/lib/mysql,后面的日志归档、清理操作因为相对路径全乱了。最坑的是,脚本逻辑看起来每一步都对——生成配置文件、备份数据库、清理旧归档,但清理旧归档的时候找不到文件,因为工作目录已经变了。
后来我把do_backup改成了子shell执行:
do_backup() { ( cd /var/lib/mysql || exit 1 tar czf /backup/mysql_$(date +%Y%m%d).tar.gz . ) }然后所有路径从相对路径改成绝对路径或者基于SCRIPT_DIR拼接的路径。从那以后,这类路径错乱问题再也没出现过。经验总结下来就一句:函数里不要轻易改全局工作目录,要改就放子shell里改,或者改完立刻切回来。
6. 最后一类特殊函数类型:递归函数
Shell不像其他语言那样有独立的函数栈管理,但它同样支持递归调用。最经典的例子就是递归遍历目录:
list_files_recursive() { local dir=$1 local file for file in "$dir"/*; do if [[ -d "$file" ]]; then list_files_recursive "$file" elif [[ -f "$file" ]]; then echo "$file" fi done } list_files_recursive "/etc/myapp"这个函数的关键在于,每次递归调用需要新的局部变量。file必须加local,否则同名的变量会在递归层次间互相覆盖,导致遍历结果错乱。递归深度很大的话,虽然不常见(几百上千层目录),但遇到极端情况可能会栈溢出,不过日常完全够用。
递归函数写的核心是:每次都传明确的入参,用local声明中间变量,给定明确的退出条件。有了这三点,shell写递归不比别的语言难。
写shell编程函数,说难不难,说简单也不简单。难就难在它跟其他语言一样,变量作用域、子shell、返回值这些机制都是需要真正理解才能驾驭的;简单则是因为shell函数就那么几个语法点,写过几十个函数之后,很多问题已经内化成直觉了。这篇文章里讲到的每个函数模式和坑,都是我在实际写脚本、跑自动化任务时踩过或者反复验证过的。你要是能把日志函数、环境检查、参数解析这几类函数模板熟练用起来,再掌握最常见的避坑手段,那写出来的脚本维护成本会和质量会有一个非常明显的改观。后面再遇到函数行为诡异的时候,可以用bash -x一行一行查,按照本文提到的“子shell、变量作用域、语法”三个维度走一遍排查流程,大部分问题都能自己找到答案。