阶段六 · Shell 脚本基础

变量,条件判断与循环

一句话总结

Shell 脚本就是把平时一条条敲的命令写进文件,让它们按顺序自动执行.
这一篇打好编程基础:变量怎么存,引号有什么坑,条件怎么判断,循环怎么写--掌握这四样,就能把重复操作变成一个可复用的脚本.

从手敲命令到写脚本

前面学的命令都是"敲一条执行一条".但当一组操作需要反复执行(每天清理日志),或步骤很多容易敲错(部署流程)时,就该把它们写进一个文件--这就是 Shell 脚本.

手敲命令像每次做菜都临时翻菜谱,逐步操作;写脚本则是把整套流程录成一份"自动食谱",以后喊一声就能从头跑到尾,不会漏步,不会敲错.

第一行:shebang

脚本文件的第一行通常是 #! 开头的一行,称为 shebang.它告诉系统"用哪个解释器来跑这个文件".

#!/usr/bin/env bash
echo "Hello, Shell!"
写法 说明
#!/bin/bash 写死 bash 的绝对路径,简单直接
#!/usr/bin/env bash 推荐--通过 env 在 PATH 里找 bash,跨系统更通用(macOS 的新版 bash 常不在 /bin)
#!/bin/sh 用系统默认 sh,语法更受限,只在需要极致可移植时用
$ chmod +x hello.sh          # 加执行权限(回顾第 9 篇)
$ ./hello.sh # 运行
Hello, Shell!

两种运行方式的区别

./hello.sh 会读 shebang 启动一个新的子 Shell 执行;bash hello.sh 则显式指定解释器,此时 shebang 被忽略.

还有第三种 source hello.sh(或 . hello.sh),它在当前 Shell 里执行,脚本里设的变量会留在会话中--这正是修改 .zshrc 后用 source 生效的原理(第 5 篇).

变量:存取数据

变量是脚本里临时存数据的盒子.Shell 变量有几条和其他语言很不一样的规矩,踩坑全在这里:

name="alice"            # 赋值:等号两边绝对不能有空格!
age=25

echo $name # 取值:变量名前加 $
echo ${name} # 推荐用 ${} 包裹,边界清晰
echo "Hi, ${name}!" # 在字符串里嵌入

等号两边不能有空格

name = "alice"(带空格)会被 Shell 理解成"运行名为 name 的命令,参数是 =alice",直接报错.赋值时等号紧贴两边:name="alice".这是初学者第一个高频错误.

变量的几种来源

类型 例子 说明
普通变量 x="hello" 只在当前 Shell/脚本有效
环境变量 export PATH=... export 后子进程也能继承(第 5 篇)
位置参数 $1 $2 ... 运行脚本时传进来的参数
特殊变量 $? $# $$ Shell 内置,记录退出码,参数个数等

命令替换:把命令输出存进变量

$(...) 把一条命令的输出抓出来赋给变量,这是脚本里极其常用的操作:

today=$(date +%Y-%m-%d)         # 把日期命令的输出存起来
file_count=$(ls | wc -l) # 统计当前目录文件数
echo "今天是 $today,共有 $file_count 个文件"

用 $() 不要用反引号

老教程里会看到 today=`date`(反引号).它和 $() 等价,但反引号不能嵌套,容易和单引号看混.现代写法一律用 $(),可读,可嵌套.

引号:脚本里最大的坑

Shell 的单引号,双引号,不加引号,行为完全不同.搞混引号是脚本 bug 的头号来源,务必理清:

形式 变量 $var 会展开吗? 用途
双引号 "$var" 展开 默认就用它--既展开变量,又防止空格被拆分
单引号 '$var' 展开,原样输出 需要字面量,不想让 $ 起作用时
不加引号 $var 会展开,但会按空格拆分 危险,变量含空格时出 bug
name="alice smith"

echo "$name" # alice smith ← 完整保留
echo '$name' # $name ← 原样输出,不展开
echo $name # alice smith ← 看着对,但其实被拆成了两个参数!

# 不加引号的真实危害:
file="my file.txt"
rm $file # 灾难!被理解成 rm my 和 file.txt 两个文件
rm "$file" # 正确:当作一个含空格的文件名

黄金法则:变量引用永远加双引号

除非明确需要按空格拆分,否则所有 $var 都写成 "$var".这一条习惯能消灭脚本里大半的诡异 bug--尤其是文件名带空格,变量为空的情况.

条件测试:[[ ]] 与退出码

判断的基础是退出码(exit code):每条命令执行完都会返回一个数字,0 表示成功,非 0 表示失败.可以用特殊变量 $? 查看上一条命令的退出码.

$ ls /tmp
$ echo $? # 0 -- 成功
$ ls /nonexistent
$ echo $? # 非 0 -- 失败

条件判断用 [[ ... ]](bash 推荐)或 [ ... ](更老,更通用).判断成立时整体退出码为 0.方括号内侧必须有空格:

字符串与数值比较

比较 字符串 数值
相等 = / == -eq
不等 != -ne
大于 > -gt
小于 < -lt
大于等于 - -ge
小于等于 - -le

数值比较别用 > <

数值大小一定要用 -gt / -lt 这类字母运算符.在 [ ] 里写 > 会被当成重定向(把输出写到名为后面那个值的文件!),写 = 则是字符串比较--[ 10 = 10.0 ] 为假,但 [ 10 -eq 10 ] 为真.

记住:数字用字母,字符串用符号.

文件测试

脚本里经常要先判断"文件在不在,是不是目录"再操作:

测试 含义
-e 路径 存在(exists)
-f 路径 是普通文件(file)
-d 路径 是目录(directory)
-r / -w / -x 可读 / 可写 / 可执行
-z 字符串 字符串长度为零(空)
-n 字符串 字符串非空

if 条件语句

把退出码和 [[ ]] 组合起来,就是分支:

#!/usr/bin/env bash
file="config.yaml"

if [[ -f "$file" ]]; then
echo "$file 存在,开始处理"
elif [[ -d "$file" ]]; then
echo "$file 是个目录"
else
echo "$file 不存在"
fi
# ↑ fi 是 if 倒过来写,表示语句块结束

多个条件可以用逻辑运算符组合:

# && 与,|| 或,! 非
if [[ -f "$file" && -r "$file" ]]; then
echo "文件存在且可读"
fi

# 实用模式:检查参数是否为空
if [[ -z "$1" ]]; then
echo "错误:请提供一个参数"
exit 1 # 非 0 退出码表示出错
fi

if 后面可以直接跟任何命令,它判断的是该命令的退出码是否为 0.所以 if grep -q "error" log.txt; then ... 完全合法--grep 找到就退出 0,走 then 分支.[[ ]] 只是其中一种"返回退出码的命令"而已.

case 多分支

当要对同一个变量匹配多个值时,case 比一长串 elif 清爽得多,常用于解析菜单选项或命令参数:

#!/usr/bin/env bash
case "$1" in
start)
echo "启动服务"
;; # ;; 表示该分支结束
stop)
echo "停止服务"
;;
restart)
echo "重启服务"
;;
*) # * 是兜底,匹配所有其他情况
echo "用法: $0 {start|stop|restart}"
exit 1
;;
esac
# ↑ esac 是 case 倒过来写

case 的匹配模式支持通配符,比如 *.txt) 匹配所有 txt 文件,[0-9]*) 匹配数字开头--比逐个 if 灵活.

循环:for 与 while

for:遍历列表

Shell 的 for 本质是遍历一组词,这组词可以来自字面量,通配符展开,命令输出:

# 遍历字面量列表
for color in red green blue; do
echo "颜色: $color"
done

# 遍历当前目录所有 .log 文件(通配符展开)
for file in *.log; do
echo "处理 $file"
done

# 遍历命令的输出(注意:行内有空格会出问题,见下方提示)
for user in $(cat users.txt); do
echo "用户: $user"
done

# C 风格的数字循环
for ((i=1; i<=5; i++)); do
echo "第 $i 次"
done

while:条件循环

while 在条件成立时反复执行,适合"不知道要循环几次,靠条件决定何时停"的场景:

# 计数器循环
count=1
while [[ $count -le 5 ]]; do
echo "count = $count"
((count++)) # 算术运算用 (( ))
done

# 逐行读文件(处理文本的标准姿势)
while IFS= read -r line; do
echo "读到: $line"
done < input.txt
# ↑ 把文件重定向给 while 的 stdin(回顾第 10 篇)

逐行读文件要用 while read,不要用 for

for line in $(cat file) 逐行读是经典错误:它按空格和换行一起拆分,一行里有空格就会被拆成多个词.正确做法是 while IFS= read -r line--IFS= 保留行内空格,-r 防止反斜杠被转义.这是处理文本文件的标准模板,背下来.

break 与 continue

和其他语言一致:break 跳出整个循环,continue 跳过本次进入下一轮.常配合 if 在循环里做提前退出或跳过.

快速回顾

  • shebang(#!/usr/bin/env bash)决定用哪个解释器;chmod +x 后用 ./脚本 运行
  • 变量赋值等号两边不能有空格;取值用 ${var};命令替换用 $(命令) 把输出存进变量
  • 引号黄金法则:变量引用永远加双引号 "$var"--单引号不展开,不加引号会按空格拆分酿成 bug
  • 退出码:0 为成功,非 0 为失败,$? 查看;if 判断的就是退出码
  • 条件测试:用 [[ ]],内侧要有空格;数值比较用 -eq/-gt/-lt,字符串用 =/!=,别用错
  • 文件测试:-e 存在 · -f 文件 · -d 目录 · -z 空串 · -n 非空
  • if/elif/else/fi 做分支,case/esac 做多值匹配(支持通配符)
  • for 遍历列表/通配符,while 条件循环;逐行读文件用 while IFS= read -r line

动手练习

  1. shebang 与 source:写一个 hello.sh,加 shebang,chmod +x,分别用 ./hello.shsource hello.sh 运行,观察脚本里设的变量是否留在当前 Shell
  2. 引号差异:定义 name="alice smith",分别用双引号,单引号,不加引号 echo 它,观察三者输出差异
  3. 文件判断:写一个脚本,用 $1 接收一个文件名,用 if [[ -f ]] 判断它是否存在并打印不同提示;不传参数时报错退出
  4. case 分支:用 case 写一个 service.sh {start|stop|restart} 的骨架,兜底分支打印用法
  5. for 遍历:用 for 循环遍历当前目录所有 .txt 文件并打印文件名
  6. while read 对比:用 while IFS= read -r line 逐行读取一个文本文件并加行号打印,对比用 for line in $(cat file) 在含空格行上的区别
  7. 数值判断:写一个 count=$(ls | wc -l),用 if 判断文件数是否大于 10(用 -gt)并给出不同提示