第 9 章

变量与控制流

第9章:Shell 变量与控制流

Shell 脚本的核心在于变量与控制流。掌握变量的作用域、类型声明、字符串展开技巧,以及 if/for/while/case/getopts 的正确用法,是从"写脚本"进化到"写可维护脚本"的关键一跃。本章系统梳理 bash 变量体系与所有控制结构,附带完整的 getopts 参数解析实战示例。

9.1 变量基础:赋值、引号与展开

bash 变量赋值的首要规则:等号两侧绝对不能有空格name=value 正确,name = value 会被解析为命令 name 加参数,报错。

# === 赋值规则 ===
name="Alice"          # 正确:字符串含空格时用引号
count=42              # 正确:纯数字无需引号
path=/usr/local/bin   # 正确:路径无特殊字符可不加引号
msg="hello world"     # 正确:含空格必须加引号

# 错误示例(等号旁有空格)
# name = "Alice"      ← 错误:bash 认为 name 是命令

# === 引号的区别 ===
var="world"

echo "Hello $var"     # 双引号:变量被展开 → Hello world
echo 'Hello $var'     # 单引号:完全字面量 → Hello $var
echo "Today: $(date)" # 双引号内 $() 仍被执行 → Today: Fri Apr 25 ...
echo 'Today: $(date)' # 单引号内 $() 不执行 → Today: $(date)

# 反引号(旧式命令替换,等价于 $(),但不可嵌套)
files=`ls /tmp`
# 推荐使用 $() 代替反引号,支持嵌套
files=$(ls /tmp)
nested=$(echo $(date +%Y))   # $() 可以嵌套

# === 变量展开 ===
echo $name            # 简单展开
echo ${name}          # 花括号展开(在紧跟其他字符时必须用)
echo "${name}s"       # 正确:展开后接 s → Alices
echo "$names"         # 错误:bash 查找变量 names(可能为空)

# 未定义变量默认为空字符串
echo "[$undefined]"   # → []

# 设置严格模式:未定义变量报错退出
set -u
# echo $undefined     # → bash: undefined: unbound variable

9.2 特殊变量

bash 内置了一系列只读或自动维护的特殊变量,熟练使用它们能大幅简化脚本逻辑:

#!/bin/bash
# 假设脚本以 ./myscript.sh -v foo bar 运行

echo $0       # 脚本名称:./myscript.sh
echo $1       # 第一个参数:-v
echo $2       # 第二个参数:foo
echo $9       # 第九个参数(超出时为空)
echo ${10}    # 第十个及以上参数必须用花括号

echo $#       # 参数个数:3(不含脚本名)
echo $@       # 所有参数列表(独立引用):"-v" "foo" "bar"
echo $*       # 所有参数合并为一个字符串:"-v foo bar"

# $@ 与 $* 的关键区别(在双引号内体现)
for arg in "$@"; do echo "arg: $arg"; done   # 每个参数独立
for arg in "$*"; do echo "arg: $arg"; done   # 所有参数合并为一个

echo $?       # 上一条命令的退出码(0 = 成功,非0 = 失败)
echo $$       # 当前 Shell 的 PID
echo $!       # 最近一个后台进程的 PID
echo $_       # 上一条命令的最后一个参数

# bash 特有
echo $BASHPID  # 当前进程 PID(子 Shell 中与 $$ 不同)
echo $PPID     # 父进程 PID
echo $BASH_VERSION  # bash 版本号
echo $LINENO   # 当前行号(调试用)
echo $FUNCNAME # 当前函数名(在函数内使用)

# 退出码判断示例
ls /nonexistent 2>/dev/null
if [ $? -ne 0 ]; then
    echo "命令失败"
fi
# 更简洁写法:
ls /nonexistent 2>/dev/null || echo "命令失败"

9.3 declare 类型声明

bash 默认所有变量为字符串类型。declare 内置命令可以为变量附加属性,实现类型约束:

# -i  整数属性(赋值时自动做算术)
declare -i count=10
count=count+5         # 不需要 $(()) ,直接算术赋值
echo $count           # → 15
count="abc"           # 非数字赋值结果为 0
echo $count           # → 0

# -r  只读(readonly 的等价写法)
declare -r PI=3.14159
# PI=3.14             # → bash: PI: readonly variable

# -l  值自动转为小写
declare -l username
username="ALICE"
echo $username        # → alice

# -u  值自动转为大写
declare -u STATUS
STATUS="running"
echo $STATUS          # → RUNNING

# -x  导出为环境变量(export 的等价写法)
declare -x MYAPP_ENV="production"
# 子进程可以读取 MYAPP_ENV

# -p  查看变量的当前属性和值
declare -p count      # → declare -i count="15"
declare -p PI         # → declare -r PI="3.14159"
declare -p MYAPP_ENV  # → declare -x MYAPP_ENV="production"

# -f  查看函数(不带参数列出所有函数)
declare -f myfunc     # 查看函数 myfunc 的定义

# -a  索引数组,-A  关联数组(第10章详解)
declare -a fruits=("apple" "banana" "cherry")
declare -A colors=([red]="#ff0000" [blue]="#0000ff")

9.4 字符串操作速查

bash 的参数展开(Parameter Expansion)提供了强大的字符串处理能力,无需外部工具即可完成大多数字符串操作:

语法 含义 示例(str="Hello World")
${#str} 字符串长度 11
${str:6} 从第6位到末尾 World
${str:0:5} 从第0位取5个字符 Hello
${str#H*o} 从头删除最短匹配 " World"
${str##H*o} 从头删除最长匹配 "rld"
${str%o*d} 从尾删除最短匹配 "Hello W"
${str%%o*d} 从尾删除最长匹配 "Hell"
${str/World/Shell} 替换第一次匹配 Hello Shell
${str//l/L} 替换全部匹配 HeLLo WorLd
${var:-default} var 为空则用 default(不赋值)
${var:=default} var 为空则赋值 default
${var:+alt} var 有值则用 alt,否则为空
${var:?msg} var 为空则打印 msg 并退出
str="Hello World"

# 长度与子串
echo ${#str}          # → 11
echo ${str:6}         # → World
echo ${str:0:5}       # → Hello
echo ${str: -5}       # → World (负数:从末尾数,注意空格)

# 前缀/后缀删除(常用于路径处理)
file="/var/log/nginx/access.log"
echo ${file##*/}      # → access.log (取文件名)
echo ${file%/*}       # → /var/log/nginx (取目录名)
echo ${file##*.}      # → log (取扩展名)
echo ${file%.*}       # → /var/log/nginx/access (去扩展名)

# 替换
echo ${str/World/Shell}   # → Hello Shell
echo ${str//l/L}          # → HeLLo WorLd
echo ${str/#Hello/Hi}     # → Hi World (只替换前缀)
echo ${str/%World/Shell}  # → Hello Shell (只替换后缀)

# 默认值展开
unset color
echo ${color:-blue}       # → blue (color 仍为空)
echo ${color:=green}      # → green (color 现在 = green)
echo ${color:+found}      # → found (color 有值)
echo ${color}             # → green

# 强制要求变量存在
: ${REQUIRED_VAR:?"ERROR: REQUIRED_VAR must be set"}
# 如果 REQUIRED_VAR 为空,脚本退出并打印错误信息

9.5 算术运算

# $(( )) — bash 内置算术(整数)
a=10; b=3
echo $((a + b))       # → 13
echo $((a - b))       # → 7
echo $((a * b))       # → 30
echo $((a / b))       # → 3 (整数除法,截断)
echo $((a % b))       # → 1 (取模/余数)
echo $((a ** b))      # → 1000 (幂运算)

# 自增/自减
i=5
echo $((i++))         # → 5 (先取值再加)
echo $((++i))         # → 7 (先加再取值)
echo $((i--))         # → 7 (先取值再减)
((i += 10))           # → i = 17
((i -= 3))            # → i = 14

# 比较(返回 0 或 1)
echo $((5 > 3))       # → 1
echo $((5 == 3))      # → 0

# let 命令(等价于 $(()),但不能用于赋值给变量)
let sum=a+b
let "product = a * b"
echo $sum $product    # → 13 30

# expr(外部命令,比 $(()) 慢,已过时)
result=$(expr $a + $b)
echo $result          # → 13

# bc — 支持浮点运算
echo "scale=4; 10 / 3" | bc    # → 3.3333
echo "scale=2; sqrt(2)"  | bc -l  # → 1.41
# -l 加载数学库(sin/cos/sqrt/log 等)
pi=$(echo "scale=10; 4*a(1)" | bc -l)
echo $pi              # → 3.1415926535

9.6 条件测试:test / [ ] / [[ ]]

bash 有三种条件测试语法:test 命令、[ ](test 的别名)、[[ ]](bash 关键字,功能更强)。现代脚本推荐始终使用 [[ ]]

# === 字符串测试 ===
str="hello"
[[ $str == "hello" ]]   && echo "相等"
[[ $str != "world" ]]   && echo "不等"
[[ -z $str ]]           && echo "空字符串"    # zero length
[[ -n $str ]]           && echo "非空字符串"  # non-zero length
[[ $str == h* ]]        && echo "通配符匹配([[ ]] 支持)"
[[ $str =~ ^h[aeiou]   ]] && echo "正则匹配([[ ]] 专有)"

# === 数字比较(必须用 -eq 等,不能用 == /  0)"
[[ -L /bin/sh ]] && echo "是符号链接"
[[ file1 -nt file2 ]] && echo "file1 比 file2 新"
[[ file1 -ot file2 ]] && echo "file1 比 file2 旧"

# === 逻辑组合 ===
# [[ ]] 内使用 && ||(推荐)
[[ -f $file && -r $file ]]  && echo "文件存在且可读"
[[ $a -eq 0 || $b -eq 0 ]]  && echo "有一个为零"
[[ ! -d /tmp/missing ]]     && echo "目录不存在"

# [ ] 内必须用 -a -o(容易出错,不推荐)
[ -f "$file" -a -r "$file" ] && echo "OK"

陷阱: 使用 [ ] 时,变量必须加双引号:[ "$var" = "hello" ]。如果 $var 为空或包含空格,不加引号会导致语法错误。[[ ]] 内变量展开更安全,通常不需要引号(但加上也无妨)。

9.7 if / elif / else

#!/bin/bash
# 基本 if-elif-else 结构
score=75

if [[ $score -ge 90 ]]; then
    echo "优秀"
elif [[ $score -ge 80 ]]; then
    echo "良好"
elif [[ $score -ge 60 ]]; then
    echo "及格"
else
    echo "不及格"
fi

# 单行写法(简单条件)
[[ -f /etc/hosts ]] && echo "hosts 文件存在" || echo "hosts 文件不存在"

# 检查命令是否成功(不用 $?,直接判断命令)
if grep -q "root" /etc/passwd; then
    echo "root 用户存在"
fi

# 多条件组合
user=$(whoami)
if [[ $user == "root" && $(id -u) -eq 0 ]]; then
    echo "以 root 运行"
fi

# 嵌套 if(建议使用 elif 代替深层嵌套)
if [[ -d "$1" ]]; then
    if [[ -r "$1" ]]; then
        echo "目录 $1 存在且可读"
    else
        echo "目录 $1 存在但不可读"
    fi
else
    echo "路径 $1 不是目录"
fi

# 条件为算术表达式
n=7
if (( n % 2 == 0 )); then
    echo "偶数"
else
    echo "奇数"
fi

9.8 for 循环

# 列表循环
for fruit in apple banana cherry; do
    echo "水果: $fruit"
done

# 遍历数组
fruits=("apple" "banana" "cherry")
for fruit in "${fruits[@]}"; do
    echo "$fruit"
done

# 遍历命令输出
for user in $(cut -d: -f1 /etc/passwd); do
    echo "用户: $user"
done

# 遍历文件(glob 展开,不用 ls)
for file in /var/log/*.log; do
    [[ -f "$file" ]] || continue
    echo "处理: $file ($(wc -l 
  
## 9.9 while / until 循环


  
```bash
# while — 条件为真时循环
count=0
while [[ $count -lt 5 ]]; do
    echo "count = $count"
    (( count++ ))
done

# while true — 无限循环(需要 break 退出)
while true; do
    read -p "输入命令(quit 退出): " cmd
    [[ $cmd == "quit" ]] && break
    echo "执行: $cmd"
    eval "$cmd" 2>&1
done

# 逐行读取文件(最常用模式)
while IFS= read -r line; do
    echo "行: $line"
done /dev/null; do
    (( elapsed >= timeout )) && { echo "超时"; exit 1; }
    sleep 1
    (( elapsed++ ))
    echo "等待服务启动... ${elapsed}s"
done
echo "服务已就绪"

9.10 case 模式匹配

#!/bin/bash
# 基本 case 结构
read -p "请输入操作(start/stop/restart/status): " action

case $action in
    start)
        echo "启动服务..."
        ;;
    stop)
        echo "停止服务..."
        ;;
    restart)
        echo "重启服务..."
        ;;
    status)
        echo "查看状态..."
        ;;
    *)
        echo "未知操作: $action"
        exit 1
        ;;
esac

# 多模式匹配(用 | 分隔)
read -p "继续?(y/n): " answer
case $answer in
    y|Y|yes|YES|true)
        echo "用户选择继续"
        ;;
    n|N|no|NO|false)
        echo "用户选择退出"
        ;;
    *)
        echo "无效输入"
        ;;
esac

# 通配符模式
file="photo.jpg"
case $file in
    *.jpg|*.jpeg|*.png|*.gif)
        echo "图片文件"
        ;;
    *.mp4|*.avi|*.mkv)
        echo "视频文件"
        ;;
    *.sh)
        echo "Shell 脚本"
        ;;
    [0-9]*)
        echo "以数字开头"
        ;;
    *)
        echo "其他文件"
        ;;
esac

# ;;& — 继续匹配下一个(bash 4+)
value="hello123"
case $value in
    *hello*)
        echo "包含 hello"
        ;;&    # 继续检查下面的分支
    *[0-9]*)
        echo "包含数字"
        ;;&
    hello*)
        echo "以 hello 开头"
        ;;
esac
# 输出:包含 hello / 包含数字 / 以 hello 开头(三个都匹配)

9.11 getopts 参数解析

getopts 是 bash 内置的标准参数解析器,正确处理 -f value-fvalue、组合参数 -vf 等多种写法:

#!/bin/bash
# 完整 getopts 示例脚本
# 用法: ./deploy.sh [-v] [-e env] [-o output] [-h] [target...]

VERBOSE=0
ENV="production"
OUTPUT="/tmp/deploy.log"

usage() {
    cat &2
            exit 1
            ;;
        \?)
            # 未知选项
            echo "错误: 未知选项 -$OPTARG" >&2
            exit 1
            ;;
    esac
done

# 移除已解析的选项,$@ 变为剩余的位置参数
shift $((OPTIND - 1))

# 此时 $@ 是非选项参数(target...)
TARGETS=("$@")

# 使用解析结果
[[ $VERBOSE -eq 1 ]] && echo "[VERBOSE] 模式已启用"
echo "环境: $ENV"
echo "日志: $OUTPUT"
echo "目标: ${TARGETS[*]:-(无)}"

# OPTIND — 下一个待处理参数的索引(getopts 自动维护)
# OPTARG — 当前选项的参数值

# ============================================================
# 嵌套 getopts(子命令模式)
# ============================================================
# 如果脚本有子命令(类似 git commit / git push),
# 通常在子命令处理函数中重置 OPTIND=1 后再次调用 getopts:
parse_commit_opts() {
    local OPTIND=1   # 局部 OPTIND,不影响外层
    local message=""
    while getopts ":m:" opt; do
        case $opt in
            m) message="$OPTARG" ;;
        esac
    done
    echo "Commit message: $message"
}

parse_commit_opts -m "fix: typo"

getopts vs getopt: bash 内置的 getopts 只支持短选项(-v),不支持长选项(--verbose)。如果需要长选项,可以使用外部命令 getopt(GNU 版本)或手动解析 $@。大多数脚本用短选项已经足够。

9.12 read 交互输入

# 基本读取
read name
echo "你好, $name"

# -p 提示符(prompt)
read -p "请输入用户名: " username

# -s 静默输入(密码)
read -s -p "请输入密码: " password
echo  # 换行(因为静默模式不会输出换行)

# -t 超时(秒)
if read -t 5 -p "5秒内按回车继续: " answer; then
    echo "用户响应: $answer"
else
    echo "超时,使用默认值"
fi

# -n 限制读取字符数(读到 n 个字符立即返回,无需回车)
read -n 1 -p "按任意键继续..." key
echo

# -r 原始模式(不处理反斜杠转义,读取文件时必须用)
read -r line  # 反斜杠不被解释为转义符

# -a 读取到数组
read -r -a items -p "输入多个值(空格分隔): "
echo "第一个: ${items[0]}, 共 ${#items[@]} 个"

# -d 自定义分隔符
read -d ':' -r field >> $line"
done  **章节总结:**          本章系统覆盖了 bash 变量体系的全部核心:从赋值规则、特殊变量、declare 类型声明,到字符串操作速查表、算术运算、条件测试,再到所有控制流(if/for/while/case)和实用工具(getopts/read)。下一章将深入函数定义、数组操作与高级字符串处理。


  
  
    
      上一章
      ← 第8章:存储与磁盘
    
    
      下一章
      第10章:函数与数组 →
本章评分
4.7  / 5  (35 评分)

💬 留言讨论