尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Shell函数从入门到实战:语法、作用域与避坑指南

Shell函数从入门到实战:语法、作用域与避坑指南 写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_LEVELINFO 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函数的变量默认是全局的这是个非常重要的认知。你在函数里写的var123默认会修改到全局变量。看这个例子check_file() { exists0 [ -f /tmp/test.txt ] exists1 } exists1 check_file echo exists的值: $existscheck_file里给exists赋值要么改成0要么保持1但这个赋值直接影响了外层的exists变量。如果函数逻辑复杂、嵌套调用复杂这种隐式修改很容易造成“幽灵变量”——你根本不知道某个变量什么时候被谁改了。解决办法只有一个函数内部变量一律加local修饰符。check_file() { local exists0 [ -f /tmp/test.txt ] exists1 echo $exists }local声明的变量只在当前函数内有效函数返回后自动销毁不影响同名全局变量。这个习惯一定要养成越早越好。等你写几百行的脚本时你会感谢当初给自己定的这条规矩。3.2 子shell和函数管道、$()里的诡异行为子shell是bash里特别容易让人困惑的机制因为它发生在你完全察觉不到的地方。最常见的触发场景有两个管道|和命令替换$(...)。在管道里调用函数count_lines() { local total0 while read -r line; do total$((total 1)) done echo $total } cat somefile.txt | count_lines看起来没有问题但如果函数内部试图修改外部变量count0 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_linescount_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找不到内容时返回非0check_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_LEVELINFO 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 21; 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 count0 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 *.logfor 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这种写法可以明确变量边界避免变量名拼接的歧义。prefixprod # 错误试图获取变量 prefix_app 的值 name${prefix}_app # 正确用 ${} 明确变量边界 name${prefix}_app# 错误示范不加花括号会导致变量名拼接 value1hello value2world 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) VERBOSEtrue 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 xmy_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、变量作用域、语法”三个维度走一遍排查流程大部分问题都能自己找到答案。
返回列表