大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
函数与脚本结构
这一课你将能做到
- 定义和调用带参数的函数,说清 return 与 echo 两种「返回数据」方式的区别
- 用 local、source、
set -euo pipefail搭出可维护的生产级脚本骨架 - 写一个包含日志函数、错误处理、main 入口的部署/检查类脚本,结构对标公司运维脚本规范
为什么要学
脚本一超过 50 行,没有函数就是灾难:同一段「检查进程在不在」的逻辑复制五遍,改一处漏四处。函数让脚本获得和 Java 一样的能力——封装、复用、单一职责。面试官看你的脚本,先看有没有 set -euo pipefail、有没有 main 函数、错误怎么处理,这三点直接暴露工程素养,比背多少命令都有说服力。
人话:函数就像 Java 的方法:起个名字、收点参数、干完活给个交代。区别是 Shell 的 return 只能返 0-255 当状态码,要「返回数据」得靠 echo 输出 + 命令替换接住——这个差异是本课最重要的知识点。
先建立图像
生产级脚本的通用骨架(先看整体再拆解):
#!/bin/bash
─────────────────────────────
set -euo pipefail ← 第一层:全局安全带
─────────────────────────────
常量与默认配置 ← 第二层:可调参数集中放
─────────────────────────────
函数定义区 ← 第三层:log / die / 业务函数
log() 统一日志格式
die() 报错即退出
业务函数们
─────────────────────────────
main "$@" ← 第四层:唯一入口
对应到 Java 你已经熟悉的结构:
| Shell | Java 类比 |
|---|---|
| 函数 | 方法 |
$1 $2 | 方法参数 |
| return N | 返回状态码(≈ 抛异常与否) |
| echo 字符串 | return 返回值 |
| source xx.sh | import / include |
| set -euo pipefail | 全局 try-catch + fail-fast |
跟着做
步骤 1:定义与调用第一个函数
cat > func1.sh << 'EOF'
#!/bin/bash
# 定义:function 关键字可省略,推荐写法如下
greet() {
echo "你好, $1" # $1 是函数的第一个参数
}
greet "deploy" # 调用:函数名 + 参数,不是括号!
greet "reviewer"
EOF
bash func1.sh
注意:调用写 greet "deploy",写成 greet("deploy") 会报错——Shell 函数不是 C 系语法。
步骤 2:return 与 echo 的两种「返回」
cat > func2.sh << 'EOF'
#!/bin/bash
# 方式一:return 返回状态码(0 成功,非 0 失败)
file_exists() {
[[ -f "$1" ]]
# 函数最后一个命令的退出码自动成为 return 值,可省略 return
}
# 方式二:echo 输出数据,调用方用命令替换接住
today() {
date +%F
}
# 判断逻辑走 return
if file_exists /etc/hostname; then
echo "hostname 文件存在"
fi
# 取数据走 echo + $()
d=$(today)
echo "今天是 $d"
# 两者可结合:输出数据 + 退出码
get_error_count() {
grep -c ERROR /opt/myapp/logs/app.log 2>/dev/null || echo 0
}
echo "错误行数:$(get_error_count)"
EOF
bash func2.sh
背下来:return 传「成败」,echo 传「数据」。想 return 一个字符串是做不到的,用 echo。
步骤 3:local 变量与作用域
cat > func3.sh << 'EOF'
#!/bin/bash
name="全局"
rename() {
local name="局部" # 不加 local 会污染全局!
echo "函数内:$name"
}
rename
echo "函数外:$name" # 仍是 全局
EOF
bash func3.sh
函数里可变的临时变量一律 local,这是脚本能维护的前提,和 Java 方法内局部变量同理。
步骤 4:log 与 die:所有脚本的标配
cat > lib.sh << 'EOF'
#!/bin/bash
log() {
echo "[$(date '+%F %T')] $*"
}
die() {
log "ERROR: $*" >&2 # 错误走 stderr
exit 1
}
EOF
$* 把所有参数拼成一句话,>&2 把输出重定向到标准错误——日志和错误分流,采集系统才能正确归类。
步骤 5:source 与脚本模块化
cat > main.sh << 'EOF'
#!/bin/bash
source ./lib.sh # 加载函数库,当前 Shell 直接获得 log/die
TARGET=${1:?用法:$0 <目录>}
[[ -d "$TARGET" ]] || die "目录不存在:$TARGET"
log "开始检查 $TARGET"
log "文件数:$(find "$TARGET" -type f | wc -l)"
EOF
mkdir -p ~/func-lab/demo && touch ~/func-lab/demo/a.txt
cd ~/func-lab && bash main.sh demo
# 对比:source 与 bash 的区别
cat > env-demo.sh << 'EOF'
export DEMO_VAR=hello
myfunc() { echo "我是函数"; }
EOF
bash env-demo.sh; echo "bash 方式后:$DEMO_VAR" # 空:子进程里执行
source env-demo.sh; echo "source 方式后:$DEMO_VAR" # hello:当前 Shell 执行
myfunc # 函数也留在当前 Shell
bash xx.sh 开子进程跑完即焚;source xx.sh 在当前 Shell 里执行,变量函数全留下。.bashrc 之所以要 source,就是这个原理。
步骤 6:set -euo pipefail 与 main 收口
cat > deploy-demo.sh << 'EOF'
#!/bin/bash
set -euo pipefail
# -e:任何命令失败立即退出(不再默默往下跑)
# -u:引用未定义变量直接报错(拼错变量名当场暴露)
# -o pipefail:管道中任何一环失败,整条管道算失败
source ./lib.sh
APP_NAME="demo-app"
check() {
log "检查 $APP_NAME"
[[ -d "/opt/$APP_NAME" ]] || die "/opt/$APP_NAME 不存在"
}
backup() {
log "备份 $APP_NAME"
tar -czf "/tmp/$APP_NAME-$(date +%F).tar.gz" -C /opt "$APP_NAME"
}
main() {
check
backup
log "全部完成"
}
main "$@"
EOF
cd ~/func-lab && bash deploy-demo.sh demo-app && ls -lh /tmp/demo-app-*.tar.gz
main "$@" 把脚本参数转交给 main 函数,逻辑全在函数里,排查时一眼定位。这就是可以直接放进公司仓库的脚本形态。
讲清楚要点
- 函数先定义后调用:bash 逐行解释执行,调用写在定义前会报 command not found。所以约定「函数区在上,main 在下」。
- 函数内部
$1是函数自己的参数,与脚本参数$1是两个世界;$#同理在函数内变成函数的参数个数。想拿脚本参数,在 main 里显式传递main "$@"。 - return 数值上限 255:return 256 等于 return 0。所以「返回行数、大小」这类数据绝不能走 return,必须 echo +
$()。 set -e不是万能的:条件判断里的命令失败(if、&&、||右侧)不触发退出;管道默认只看最后一环(pipefail 补上)。理解边界才不会被「它怎么没退出」困惑。$?检查要在命令后立刻做:中间插入一条 echo,$?就变成 echo 的退出码(0)。要么紧跟检查,要么直接依赖set -e。- 库脚本只放函数和常量:库文件被 source,任何顶层执行语句都会在 source 时跑一遍。lib.sh 里出现 rm、启动服务这类语句是重大事故源。
你可能会懵的地方
-
$*和$@在函数里怎么用? 和脚本顶层规则一致:遍历用"$@"(保参数边界),拼一句话用"$*"。log 函数里echo "$*"就是把「多个参数拼成一条日志」。 -
函数名能不能和命令重名? 能,函数优先于外部命令。有人用
grep() { command grep --color=auto "$@"; }给 grep 加默认参数,command用于调原名避免无限递归。日常别滥用,坑队友。 -
如何让函数在每次开终端都可用? 写进
~/.bashrc,或单独建~/.bash_functions再在 .bashrc 里source它。改完source ~/.bashrc立即生效。 -
脚本退出码到底是什么在决定? 最后一条被执行命令的退出码;显式
exit N可随时指定;main "$@"收尾的脚本,main 里最后一条命令决定成败。crontab、CI 正是读这个码判断任务成败。 -
递归函数 Shell 支持吗? 支持,语法和 Java 方法递归一样。但 Shell 没有尾递归优化,深层递归性能差。需要递归遍历时优先考虑
find或tree这类现成命令。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
greet ("deploy") 调用带括号 | Shell 不认 C 系语法 | greet "deploy" |
| 函数内不 local 直接赋值 | 污染全局变量,连环事故 | 临时变量一律 local |
return "some string" | return 只接受 0-255 | 数据用 echo,调用方 $() 接 |
在 if 外写 [[ ... ]] 但依赖其失败 | set -e 下脚本直接退出 | 判断逻辑放进 if / && / || 语境 |
| lib.sh 顶层写了执行语句 | source 时被意外执行 | 库文件只放函数、常量定义 |
卡点急救
command not found调用函数时:检查函数是否在调用点之前定义;是否拼写不一致;是否忘了 source 库文件。unbound variable报错:这是set -u在保护你——引用了未定义变量。给变量补默认值${VAR:-},或修正拼写。上线脚本建议带着 -u 跑一遍测试。- 脚本中途失败但看不出哪行:加
PS4='+ $LINENO: ' set -x调试模式,每条命令执行前打印行号;或临时bash -x script.sh运行。排查完记得关掉,trace 日志量很大。
面试常问
-
Shell 函数如何返回字符串? 函数不能 return 字符串(return 仅 0-255 状态码)。标准做法是函数内 echo 输出,调用方用命令替换接收:
result=$(my_func)。副作用变量(需多处共享)也可用全局变量,但要控制数量。 -
set -euo pipefail 分别是什么?为什么生产脚本要加?
-e任何命令失败立即退出,避免「前一步备份失败还继续删除旧包」的灾难;-u禁止引用未定义变量,拦截拼写错误;-o pipefail让管道任一环节失败都判定失败,避免grep x file | wc -l在 grep 失败时返回假成功。三者合称脚本安全带。 -
source 和 bash 执行脚本的区别?
bash xx.sh创建子 Shell 执行,变量、函数、cd 都随子进程销毁;source xx.sh在当前 Shell 环境中逐行执行,变量函数直接生效。典型应用:.bashrc必须 source、配置文件加载、让cd/export影响当前终端。
自测三问
- 函数既要返回「是否成功」又要返回数据,怎么设计?
set -e在哪些情况下不会触发退出?- 为什么说 lib.sh 里不能有顶层执行语句?
小结一句话
函数 + local + source + set -euo pipefail + main 收口——这套组合拳让你的脚本从「一次性命令堆」升级为可测试、可维护、可进公司仓库的工程件。
延伸
- Google Shell 风格指南
- 下一课:正则表达式——给 grep/awk/sed 装上精准匹配的眼睛。