写脚本这件事,本质上就是让机器替我们"重复劳动"。前面我们聊过变量、条件判断、函数,这些东西单独拿出来都只是工具,而真正让脚本从"一次性"变成"自动化"的关键,是循环。换句话说,如果没有循环,再智能的判断也只能跑一次,再花哨的函数也只能处理一条数据。循环是脚本的发动机,而遍历则是这台发动机最常见的应用场景。
这一篇,我们就把 shell 里常见的几种循环结构和遍历过一遍。
一、循环和遍历的概念
这两个词经常一起出现,但其实是两件事。循环是一种控制流程 ,它的作用是让一段代码重复执行;遍历是一种数据操作,它的作用是把一个集合里的元素逐个拿出来看一眼。在写 shell 脚本的时候,我们更多时候做的是"在循环里去遍历",所以很多人把这两个概念混着说,也就不奇怪了。
举个最简单的例子:批量把一个目录里的 .log 文件改成 .log.bak。一个文件的话,你写一行 mv 就完了;一百个文件呢?一个个写 mv 显然不现实,这时候你就需要循环了。你让 shell 在这一百个文件上"循环"地执行 mv,每一次循环处理一个文件------这个"每一次处理一个文件"的过程,就是遍历。
所以循环是手段,遍历是目的之一。在 shell 里,最常用的循环有三个:for、while、until。我们一个一个来看。
二、for 循环
for 循环是 shell 里最直白的一种循环。它的逻辑非常清楚:给定一个列表,对列表里的每一个元素都执行一次循环体。所以当你知道要循环多少次 、或者明确知道要处理哪些数据 的时候,for 是最顺手的选择。
2.1 最基本的写法
bash
#!/bin/bash
for i in 1 2 3 4 5
do
echo "当前数字是:$i"
done
执行结果:
当前数字是:1
当前数字是:2
当前数字是:3
当前数字是:4
当前数字是:5
这段代码把 i 依次赋值为 1、2、3、4、5,每次都执行 do 和 done 之间的 echo。in 后面就是"列表",列表里的元素用空格隔开,每次循环取一个赋给 i。这种写法的好处是直观,坏处也很明显------元素多了写起来就累。
2.2 用大括号生成数字序列
如果列表是连续的数字,没必要一个个手写,shell 提供了大括号展开:
bash
#!/bin/bash
for i in {1..5}
do
echo "数字:$i"
done
{1..5} 会自动展开成 1 2 3 4 5,效果和上一个例子一样,但更简洁。你甚至可以指定步长:
bash
for i in {0..10..2}
do
echo "i = $i"
done
这个 {0..10..2} 表示从 0 开始,每次加 2,到 10 结束,结果是 0、2、4、6、8、10。
不过大括号展开这种方式在某些老版本的 sh 里不支持,在 dash、busybox 这种精简环境里也可能失效。如果你要写兼容性强的脚本,建议用 seq。
2.3 用 seq 生成序列
seq 是 Linux 自带的小工具,专门用来生成数字序列:
bash
#!/bin/bash
for i in $(seq 1 5)
do
echo "i = $i"
done
seq 1 5 输出 1 2 3 4 5,等同于 {1..5}。但 seq 比大括号灵活,它支持负数和反向:
bash
for i in $(seq 10 -1 1)
do
echo "倒计时:$i"
done
seq 10 -1 1 表示从 10 开始,每次减 1,到 1 结束。这就是个简易倒计时。seq 还能指定数字宽度:
bash
for i in $(seq -w 1 10)
do
echo "编号:$i"
done
-w 会让所有数字用前导零补齐,输出 01 02 03 ... 10,适合用来生成对齐的编号。
2.4 类 C 风格 for
如果你写过 C、Java、JavaScript,对下面这种写法一定不陌生:
bash
#!/bin/bash
for ((i=1; i<=5; i++))
do
echo "i = $i"
done
这是 shell 提供的类 C 风格循环,注意是两层圆括号 ------不是 (()) 算术运算,而是 for ((...))。里面的 i=1 是初始条件,i<=5 是循环条件,i++ 是每次循环结束后的变化。这套语法适合循环次数由计数器决定的场景,比如遍历数组下标、做累加等等。
但说实话,shell 里这种写法用得不多,因为 shell 处理数字本来就不是强项,能用 for i in 解决的事情,一般不会去写 for ((...))。它存在的意义更多是给从 C 家族转过来的程序员一个熟悉的入口。
2.5 遍历文件
for 循环最强大的地方在于,"列表"这个东西可以是任意命令的输出。这意味着你可以让它自动找出所有要处理的文件:
bash
#!/bin/bash
for file in *.log
do
echo "正在处理:$file"
mv "$file" "$file.bak"
done
这段脚本会把当前目录下所有 .log 文件改成 .log.bak。* 是通配符,shell 在执行 for 之前会先把 *.log 展开成所有匹配的文件名。
注意文件名最好用引号包起来------"$file",别写成 $file。如果文件名里有空格,不加引号会出问题。
同理,你也可以用 find 找出符合条件的文件:
bash
#!/bin/bash
for file in $(find /var/log -type f -name "*.log")
do
echo "找到日志:$file"
done
不过这里有个坑:find 输出默认是用换行分隔的,shell 的 for 又会按 IFS 切分,如果文件名里有空格或者换行符,就会出错。生产环境里要处理任意文件名的场景,建议老老实实用 while read,我们后面会讲。
2.6 遍历数组
前面的文章我们讲过 shell 数组的用法,搭配 for 循环就能遍历数组的所有元素:
bash
#!/bin/bash
fruits=("苹果" "香蕉" "橘子" "葡萄")
for fruit in "${fruits[@]}"
do
echo "我喜欢吃:$fruit"
done
"${fruits[@]}" 是取数组所有元素的标准写法。这里必须加双引号------不加的话,如果某个元素本身带空格(比如"红 苹果"),就会被拆成两个元素。
如果你只想取部分元素,可以用下标切片:
bash
for fruit in "${fruits[@]:1:2}"
do
echo "切片:$fruit"
done
${fruits[@]:1:2} 表示从下标 1 开始,取 2 个元素,也就是"香蕉"和"橘子"。
如果数组是关联数组(key-value 形式),需要这样遍历:
bash
#!/bin/bash
declare -A user
user=([name]="张三" [age]=25 [city]="北京")
for key in "${!user[@]}"
do
echo "$key = ${user[$key]}"
done
"${!user[@]}" 中的 ! 是取所有 key,这是 shell 里的一个特殊写法,初次见到可能会觉得有点奇怪,记住就行。
三、while 循环
for 循环适合"已知列表"的场景,但很多时候我们事先不知道要循环多少次------比如"只要服务没启动就继续等"、"只要文件没下载完就继续重试"。这时候就要用 while。
3.1 基本语法
while 的逻辑是:只要条件为真,就一直循环。
bash
#!/bin/bash
count=1
while [ $count -le 5 ]
do
echo "count = $count"
count=$((count + 1))
done
执行结果:
ini
count = 1
count = 2
count = 3
count = 4
count = 5
条件 [ $count -le 5 ] 表示"count 小于等于 5",-le 是 less or equal 的缩写。shell 里这种单方括号的条件判断是经典的 test 命令写法,方括号两边必须有空格,里面的变量和符号之间也都要有空格。
循环体里最重要的那行是 count=$((count + 1)),它每次把 count 加 1。这步千万不能忘------没有这行,count 永远是 1,条件永远成立,循环就死在那了。这种"死循环"是写 while 循环最常犯的错误。
$((...)) 是 shell 的算术展开,$((count + 1)) 的意思就是算出 count + 1 的值。也可以写成 ((count++)),效果一样,但更紧凑。
3.2 死循环的几种写法
有时候我们就是要一个死循环,比如写一个守护进程让它一直跑。常见的写法有三种:
bash
# 写法一
while true
do
echo "一直跑"
sleep 1
done
# 写法二
while :
do
echo "一直跑"
sleep 1
done
# 写法三
while [ 1 -eq 1 ]
do
echo "一直跑"
sleep 1
done
true 和 : 是 shell 的内置命令,都是"永远返回真"的意思,等价。第三种写法 [ 1 -eq 1 ] 是 test 的形式,写得啰嗦但更直观。真正的死循环里一定要记得加 sleep 或者某种退出条件,否则会把 CPU 占满。
3.3 按行读取文件
while 循环的杀手级应用是按行读取文件 。比如我们有个名单文件 users.txt:
张三
李四
王五
现在要给每个人发一封欢迎邮件:
bash
#!/bin/bash
while read -r line
do
echo "发送欢迎邮件给:$line"
done < users.txt
read -r line 是从标准输入读一行赋给 line,-r 表示不要把反斜杠当转义字符,直接原样读。< users.txt 是把 users.txt 重定向到 while 的标准输入------这步很关键,没有它 read 就没东西可读。
这种写法的最大优点是不会因为文件名或内容里的空格而出问题 。我们前面讲过,for 循环用 find 找文件时遇到空格会出错,但 while read 是按行读的,每一行就是一条完整记录,不管内容里有什么字符都能稳稳处理。所以处理文件强烈建议用 while read 而不是 for。
如果你想顺便读多个字段,比如每行格式是 姓名 城市 年龄,可以这样:
bash
while read -r name city age
do
echo "$name 来自 $city,今年 $age 岁"
done < users.txt
read 会自动按 IFS 切分,把切出来的字段分别赋给后面的变量。
3.4 读文件时保留空格
read 默认会用 IFS 切分行内的字段,这意味着如果某行内容是"张三 北京 25"(中间多几个空格),name 拿到的就是"张三",但多余的空格就被吞了。
如果想保留原始格式,可以在循环前临时改一下 IFS:
bash
#!/bin/bash
OLD_IFS="$IFS"
IFS="|"
while read -r name city age
do
echo "name=[$name] city=[$city] age=[$age]"
done < users.txt
IFS="$OLD_IFS"
| 是字段分隔符(你也可以换成其他不会在文件里出现的字符)。这样每行就按 | 切分。循环结束后记得把 IFS 还原,不然会污染当前 shell。
四、until 循环
while 是条件为真就循环,until 反过来------条件为假才循环。这两个其实是镜像的,逻辑上能互相转换:
bash
# while 写法
while [ $count -le 5 ]
do
echo $count
count=$((count + 1))
done
# until 写法
until [ $count -gt 5 ]
do
echo $count
count=$((count + 1))
done
while 写的是"小于等于 5 时循环",until 写的是"大于 5 时停止"。两个条件刚好相反,所以输出是一样的。
那 until 存在的意义是什么?主要是语义上更贴切的场景。比如"等待服务启动":
bash
#!/bin/bash
until curl -s http://localhost:8080/health > /dev/null
do
echo "服务还没起来,等一秒再试"
sleep 1
done
echo "服务起来了"
这里 curl 失败时返回非 0(也就是"假"),until 条件成立,继续循环;curl 成功时返回 0,条件变成"假",循环结束。读起来比 while ! curl ... 更顺。
until 用得不多,但偶尔能让代码读起来更自然,知道有这个东西就行。
五、循环控制
写循环的时候经常遇到这种情况:循环到一半想提前退出,或者想跳过某次循环直接进入下一轮。break 和 continue 就是干这个的。
5.1 continue跳过本次
continue 的作用是结束当前这一轮循环,立刻开始下一轮。
bash
#!/bin/bash
for i in {1..5}
do
if [ $i -eq 3 ]
then
continue
fi
echo "i = $i"
done
输出:
ini
i = 1
i = 2
i = 4
i = 5
可以看到 i=3 那一轮什么都没输出,直接跳到下一轮。continue 后面如果还有代码也不会执行。
一个常见的用法是"跳过某些特殊值"。比如遍历目录时跳过隐藏文件:
bash
for file in *
do
if [[ "$file" == .* ]]
then
continue
fi
echo "处理:$file"
done
5.2 break跳出整个循环
break 比 continue 更狠------它直接结束整个循环。
bash
#!/bin/bash
for i in {1..5}
do
if [ $i -eq 3 ]
then
break
fi
echo "i = $i"
done
输出:
ini
i = 1
i = 2
i=3 触发 break,整个循环立刻结束,4 和 5 都没机会输出。
一个典型场景是"找到目标就退出":
bash
for file in *.log
do
if grep -q "ERROR" "$file"
then
echo "在 $file 里发现错误"
break
fi
done
这个脚本只关心第一个包含 ERROR 的日志文件,找到一个就退出,不浪费时间继续找。
5.3 多层循环中的 break n
如果循环是嵌套的,break 默认只跳出一层。有时候你想从内层直接跳出所有层,可以加数字:
bash
#!/bin/bash
for i in {1..3}
do
for j in {1..3}
do
if [ $j -eq 2 ]
then
break 2
fi
echo "i=$i, j=$j"
done
done
break 2 表示跳出 2 层循环。执行结果:
ini
i=1, j=1
只输出一行。因为 j=2 时直接跳出了外层,连外层都结束了。continue 2 同理,表示跳过外层那一轮剩下的部分。
六、几个实战例子
光看语法容易忘,下面几个例子把前面讲的几种循环都用上,建议自己敲一遍。
6.1 1 到 100 累加
bash
#!/bin/bash
sum=0
for i in $(seq 1 100)
do
sum=$((sum + i))
done
echo "1 到 100 的和是:$sum"
输出:5050。这个题用 for 或 while 都能写,但用 seq 配合 for 最干净。
6.2 99 乘法表
bash
#!/bin/bash
for i in $(seq 1 9)
do
for j in $(seq 1 $i)
do
echo -n "$j*$i=$((j*i)) "
done
echo
done
echo -n 不换行,内层循环结束后 echo 用来换行。这个例子主要是演示双层 for 循环的用法。
6.3 监控服务直到启动
bash
#!/bin/bash
max_wait=30
count=0
until curl -s http://localhost:8080/health > /dev/null
do
count=$((count + 1))
if [ $count -ge $max_wait ]
then
echo "等了 ${max_wait} 秒服务还没起来,放弃"
exit 1
fi
echo "服务未就绪,等待中...($count/$max_wait)"
sleep 1
done
echo "服务已就绪"
until + break(用 exit 代替)的组合,给等待加了超时上限,避免无限等下去。
6.4 批量重命名文件
bash
#!/bin/bash
for file in *.jpg
do
[ -e "$file" ] || continue # 没有 .jpg 文件时跳过
newname="${file%.jpg}_processed.jpg"
mv "$file" "$newname"
echo "重命名:$file -> $newname"
done
${file%.jpg} 是 shell 的字符串处理------把 $file 末尾的 .jpg 删掉,后面再拼上 _processed.jpg。这种写法比 sed 更快、也更稳。
6.5 读取 CSV 并统计
bash
#!/bin/bash
total=0
count=0
while IFS=, read -r name age city
do
# 跳过表头
if [ "$name" = "name" ]
then
continue
fi
total=$((total + age))
count=$((count + 1))
echo "$name 来自 $city"
done < users.csv
echo "总人数:$count,平均年龄:$((total / count))"
这个例子把 while read、IFS 修改、continue 都串起来了,是日常脚本里非常常见的模式。
七、总结
到这里 shell 里最常用的循环和遍历就讲完了。我们回顾一下:
for 变量 in 列表:适合已知列表 或已知次数的场景,可以遍历数字、文件、命令输出、数组。while 条件:适合按条件循环 的场景,处理文件按行读用while read。until 条件:语义上跟while相反,适合"等待某事发生"的场景。break和continue控制循环流程,多层循环里可以用break n。
掌握这几个,写日常的运维脚本、数据处理脚本基本够用了。下一篇文章我们继续往下走,看看 shell 里的字符串处理和正则匹配,这才是真正能体现 shell 生产力的地方。
文中所有脚本都在 bash 4.x 环境下测试通过,理论上兼容 bash 3.2+。如果你的环境是 dash、ash 或者 ksh,部分语法可能需要微调,建议先
bash script.sh跑一下。