前言

会写脚本和写好脚本之间,隔着无数深夜的坑。这篇把 Bash 的函数进阶用法和那些"约定俗成但没人明说"的陷阱一次讲清。

一、函数的正确写法

#!/usr/bin/env bash

# 定义(function 关键字可省, 括号必须有)
function log() {
    # local 变量: 不污染全局 —— 函数内赋值一律 local!
    local level=$1 msg=$2
    printf '[%s] [%s] %s\n' "$(date '+%F %T')" "$level" "$msg"
}

# 调用(参数像脚本参数一样传)
log INFO "服务启动"
log ERROR "连接超时"

参数与返回值:

get_status() {
    local svc=$1
    # 函数的"返回数据"用 stdout
    systemctl is-active "$svc"
}

# 捕获: 命令替换
status=$(get_status nginx)
echo "nginx 状态: $status"

# 返回码用 return(0成功/非0失败, 给 if 用)
is_alive() {
    pgrep -x "$1" &>/dev/null
}

if is_alive nginx; then
    echo "nginx 在跑"
fi

黄金法则:

输出通道 用途
stdout 函数的"数据返回"(被 $(…) 捕获)
stderr 日志/调试信息(不污染数据流)
return 退出码 成功与否(给 if/&& 判断)
fetch_data() {
    echo "正在查询..." >&2          # 日志走 stderr
    echo '{"ok":true}'             # 数据走 stdout
}

data=$(fetch_data 2>/dev/null)      # 拿到纯数据

二、陷阱一:管道中的子 shell

#!/usr/bin/env bash
count=0
cat /etc/passwd | while read -r line; do
    count=$((count + 1))
done
echo "$count"          # 0 !! 炸裂

原因:管道右侧的 while 跑在子 shell 里,count 的修改留在子进程,退出即丢。

三种解法:

# 解法1: 重定向代替管道(同一进程)
count=0
while read -r line; do
    count=$((count + 1))
done < /etc/passwd
echo "$count"          # ✅ 正确

# 解法2: 进程替换
while read -r line; do ... done < <(some_command)

# 解法3: bash 4.2+ 打开 lastpipe(需关闭 job control)
shopt -s lastpipe
cat file | while read -r line; do ...; done

三、陷阱二:没引号的变量

file="my document.txt"

rm $file          # ❌ 实际执行: rm my document.txt → 删了两个不存在的文件
rm "$file"        # ✅

# 单词拆分还能引发注入:
user_input="; rm -rf /"
eval "echo $user_input"     # ❌ 灾难

三条铁律:

  1. 变量展开永远双引号:"$var"
  2. eval 是恶魔,能不用就不用(要用则严控输入)
  3. 数组展开用 "${arr[@]}"(每元素独立引号)

四、陷阱三:set 选项的细节

set -e          # 出错退出 —— 但有豁免区!
true            # 在 if 条件、&& || 链、管道(见下)中不触发
cmd || true     # 手动豁免(明示"允许失败")

set -e 与管道的恩怨:默认只看管道最后一个命令的退出码:

set -e
false | echo "hi"       # 打印 hi, 脚本继续! false 的失败被吞了

解法:set -o pipefail(与 -e 联手,这也是骨架里 set -euo pipefail 三件套的由来)。

# -u: 未定义变量报错
set -u
echo "$TYPO_VAR"        # 直接报错退出, 而不是静默空串
echo "${SAFE_VAR:-默认值}"   # 显式给默认值

# -x: 调试模式(排查脚本神器)
set -x
cp "$src" "$dst"
# + cp /data/a.tar /backup/a.tar   ← 每步实际执行什么都看到了

五、陷阱四:cd 与临时目录

#!/usr/bin/env bash
set -euo pipefail

# 脚本中途 cd 后, 相对路径全变 —— 处理完要回去
cleanup() { cd - >/dev/null; rm -rf "$TMP"; }
trap cleanup EXIT

TMP=$(mktemp -d)                 # 用 mktemp 而不是 /tmp/xxx 固定名
cd "$TMP"

# trap ... EXIT: 无论正常/报错/被 Ctrl+C 都执行清理
# 常见配对: 临时文件、锁文件、中断恢复

六、陷阱五:数值与字符串比较

[ "10" -gt "9" ]      # ✅ -gt 数值比较: true
[ "10" \> "9" ]       # 字符串比较: "10" < "9"(字典序!)
[[ 10 > 9 ]]          # 双括号里 > 不用转义, 但仍是字符串比较

[[ ]] 与 [ ] 的区别:
- [[ $a == $b ]]      # 不发生单词拆分, 变量可不加引号(还是建议加)
- [[ $a =~ ^[0-9]+$ ]]# 支持正则 =~(很实用)
- 算术: (( count >= 10 ))

if (( count >= 10 )); then echo "够了"; fi
if [[ $input =~ ^[0-9]+$ ]]; then echo "是纯数字"; fi

七、陷阱六:空格与换行

# 命令替换会吃掉尾部换行
out=$(echo "hello
")
echo "[$out]"         # [hello]  —— 尾换行没了, 数长度要小心

# 文件名带横杠开头
cat -options.txt      # 被当选项
cat ./-options.txt    # ✅ 加路径
cat -- -options.txt   # ✅ 或用 --

# 文件名含空格的遍历(错误 vs 正确)
for f in $(ls); do ... done          # ❌ 空格文件名被拆碎
for f in ./*; do ... done            # ✅ glob 展开, 认引号
find . -name "*.log" -print0 | while IFS= read -r -d '' f; do ...; done   # ✅✅ 终极

八、一个"防御性"的完整示例

#!/usr/bin/env bash
# 可靠的日志归档脚本
set -euo pipefail
IFS=$'\n\t'                                  # 收紧默认分词(防御性编程)

readonly LOG_DIR="/var/log/myapp"
readonly KEEP_DAYS=14
readonly ARCHIVE="/backup/myapp-$(date +%F).tar.gz"

log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; }
die() { log "FATAL: $*"; exit 1; }

usage() { echo "用法: $0 [-k 保留天数]"; exit 2; }

while getopts ":k:h" opt; do
    case $opt in
        k) KEEP_DAYS=$OPTARG ;;
        h) usage ;;
        :) die "选项 -$OPTARG 缺参数" ;;
        \?) die "未知选项 -$OPTARG" ;;
    esac
done

[[ -d $LOG_DIR ]] || die "日志目录不存在: $LOG_DIR"

trap 'log "归档中断, 临时文件已清理"; rm -f "$ARCHIVE.tmp"' ERR

log "开始归档 $LOG_DIR"
tar -czf "$ARCHIVE.tmp" -C "$LOG_DIR" .
mv "$ARCHIVE.tmp" "$ARCHIVE"                 # 原子替换
log "归档完成: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"

find "$LOG_DIR" -name "*.log" -mtime +"$KEEP_DAYS" -delete
log "已清理 ${KEEP_DAYS} 天前的旧日志"

亮点:>&2 日志分流、readonly、die 模式、trap ERR、mv tmp 原子化、getopts 严格报错。

小结

陷阱 解法
管道子 shell 变量丢失 done < file / < <(cmd)
空格拆分 永远 "$var"
管道错误被吞 set -o pipefail
静默失败 set -eu
中断留下垃圾 trap ... EXIT/ERR
魔法数字/路径 readonly 常量
固定临时文件名 mktemp

本文是「Linux 基础」系列第 9 篇。