「速通Shell」Shell 循环与遍历

写脚本这件事,本质上就是让机器替我们"重复劳动"。前面我们聊过变量、条件判断、函数,这些东西单独拿出来都只是工具,而真正让脚本从"一次性"变成"自动化"的关键,是循环。换句话说,如果没有循环,再智能的判断也只能跑一次,再花哨的函数也只能处理一条数据。循环是脚本的发动机,而遍历则是这台发动机最常见的应用场景。

这一篇,我们就把 shell 里常见的几种循环结构和遍历过一遍。

一、循环和遍历的概念

这两个词经常一起出现,但其实是两件事。循环是一种控制流程 ,它的作用是让一段代码重复执行;遍历是一种数据操作,它的作用是把一个集合里的元素逐个拿出来看一眼。在写 shell 脚本的时候,我们更多时候做的是"在循环里去遍历",所以很多人把这两个概念混着说,也就不奇怪了。

举个最简单的例子:批量把一个目录里的 .log 文件改成 .log.bak。一个文件的话,你写一行 mv 就完了;一百个文件呢?一个个写 mv 显然不现实,这时候你就需要循环了。你让 shell 在这一百个文件上"循环"地执行 mv,每一次循环处理一个文件------这个"每一次处理一个文件"的过程,就是遍历。

所以循环是手段,遍历是目的之一。在 shell 里,最常用的循环有三个:forwhileuntil。我们一个一个来看。

二、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,每次都执行 dodone 之间的 echoin 后面就是"列表",列表里的元素用空格隔开,每次循环取一个赋给 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 用得不多,但偶尔能让代码读起来更自然,知道有这个东西就行。

五、循环控制

写循环的时候经常遇到这种情况:循环到一半想提前退出,或者想跳过某次循环直接进入下一轮。breakcontinue 就是干这个的。

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跳出整个循环

breakcontinue 更狠------它直接结束整个循环

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。这个题用 forwhile 都能写,但用 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 readIFS 修改、continue 都串起来了,是日常脚本里非常常见的模式。

七、总结

到这里 shell 里最常用的循环和遍历就讲完了。我们回顾一下:

  • for 变量 in 列表:适合已知列表已知次数的场景,可以遍历数字、文件、命令输出、数组。
  • while 条件:适合按条件循环 的场景,处理文件按行读用 while read
  • until 条件:语义上跟 while 相反,适合"等待某事发生"的场景。
  • breakcontinue 控制循环流程,多层循环里可以用 break n

掌握这几个,写日常的运维脚本、数据处理脚本基本够用了。下一篇文章我们继续往下走,看看 shell 里的字符串处理和正则匹配,这才是真正能体现 shell 生产力的地方。


文中所有脚本都在 bash 4.x 环境下测试通过,理论上兼容 bash 3.2+。如果你的环境是 dash、ash 或者 ksh,部分语法可能需要微调,建议先 bash script.sh 跑一下。

相关推荐
十里春风_jzh15 小时前
Tabby 修改版:更美观的现代终端
shell·tabby
茶本无香17 小时前
Java调用Shell脚本执行SQL数据库操作:从入门到实战
java·sql·shell
郝学胜_神的一滴2 天前
C++20 高级编程 002:夯实现代C++底层基本功
c++·编程语言
柒号华仔3 天前
「速通Shell」织线为面,Shell条件测试和判断
shell
渣男教父3 天前
Python游戏自动化实战案例
python·编程语言
junuo4 天前
10_C++内存分区与动态内存管理
c++·编程语言
程序员天天困4 天前
Spring Boot i18n 国际化实战:从资源文件到多语言接口完整指南
spring boot·后端·编程语言
苏灿烤鱼4 天前
把 Agent 做成一家公司,真比通用提示词好用吗?
python·agent·shell
寺中人5 天前
Linux 基础命令入门实战教程:从零掌握常用操作,新手快速上手
linux·运维·服务器·shell·linux 命令·linux 基础教程·linux 入门