概览
问题
怎样对许多不同的文件执行相同操作?
目标
- 编写循环,对一组文件中的每个文件分别执行一个或多个命令。
- 跟踪循环执行时循环变量依次取得的值。
- 解释变量名和变量值的区别。
- 解释为什么文件名不宜使用空格和某些标点符号。
- 演示如何查看最近执行的命令。
- 不用重新输入,就能再次执行最近的命令。
循环是一种编程结构,可针对列表中的每个元素重复执行一个命令或一组命令。因此,它是利用自动化提升工作效率的重要工具。与通配符和 Tab 补全一样,循环也能减少输入量,进而减少输入错误。假设我们有数百份基因组数据文件,名称包括 basilisk.dat、minotaur.dat 和 unicorn.dat。本例使用 exercise-data/creatures 目录,它只有三个示例文件,但同样的原则可以一次用于更多文件。
这些文件的结构相同:前三行依次为通用名称、分类和更新日期,之后是 DNA 序列。先查看文件:
$ head -n 5 basilisk.dat minotaur.dat unicorn.dat
我们希望打印每个物种的分类,它位于各文件的第二行。对每个文件,都需要执行 head -n 2,再通过管道传给 tail -n 1。先用下面的伪代码看看循环的一般形式:
# The word "for" indicates the start of a "For-loop" command
for thing in list_of_things
#The word "do" indicates the start of job execution list
do
# Indentation within the loop is not required, but aids legibility
operation_using/command $thing
# The word "done" indicates the end of a loop
done
将它应用于本例:
$ for filename in basilisk.dat minotaur.dat unicorn.dat
> do
> echo $filename
> head -n 2 $filename | tail -n 1
> done
basilisk.dat
CLASSIFICATION: basiliscus vulgaris
minotaur.dat
CLASSIFICATION: bos hominus
unicorn.dat
CLASSIFICATION: equus monoceros
注意提示符
输入循环时,shell 提示符会从 $ 变为 >,然后再变回去。第二种提示符提醒我们:还没有输入一个完整的命令。分号 ; 可以分隔写在同一行上的两条命令。
shell 遇到 for 关键字,就知道要对列表中的每个元素重复执行一个命令或一组命令。每次循环运行称为一次迭代;列表中的元素依次赋给变量,先执行循环内的命令,再处理下一个元素。循环内,在变量名前加 $ 就能取得变量的值。$ 告诉 shell 解释器把后面的内容视为变量名,用它的值进行替换,而不是把它当成普通文本或外部命令。
本例的列表包含三个文件名。每次迭代先用 echo 打印 $filename 当前的值。这不是取得结果的必要步骤,但能帮助我们跟踪过程。第一次迭代时,$filename 是 basilisk.dat,head 读取它的前两行并传给 tail,后者打印第二行。第二次迭代处理 minotaur.dat,第三次处理 unicorn.dat。列表只有三项,因此随后退出循环。
相同符号,不同含义
> 在这里是 shell 提示符,也可用于输出重定向。同样,$ 可以是提示符,也能让 shell 取得变量值。如果符号由 shell 打印,就是等待你输入的提示符;如果由你输入,就是重定向输出或取变量值的指令。
变量名也可放在花括号内,明确变量名的边界:$filename 等同于 ${filename},却不同于 ${file}name。你可能会在别人的程序里见到这种写法。我们把变量命名为 filename,是为了让人更容易理解用途。shell 本身不在乎名称;以下两种写法的运行效果相同:
$ for x in basilisk.dat minotaur.dat unicorn.dat
> do
> head -n 2 $x | tail -n 1
> done
或者:
$ for temperature in basilisk.dat minotaur.dat unicorn.dat
> do
> head -n 2 $temperature | tail -n 1
> done
但不要这样命名。程序只有在人们能理解时才有用。x 这样的无意义名称,以及 temperature 这样的误导性名称,都更容易让程序的实际行为偏离读者的理解。上例的变量名都可以替换,只要对编写者和读者有意义即可。循环还可以处理数字列表或数据子集,不限于文件名。
练习:编写自己的循环
怎样编写一个循环,回显从 0 到 9 的全部十个数字?
答案
$ for loop_variable in 0 1 2 3 4 5 6 7 8 9
> do
> echo $loop_variable
> done
0
1
2
3
4
5
6
7
8
9
也可以把整数枚举 0 1 2 3 4 5 6 7 8 9 替换为 {0..9},得到相同输出。
练习:循环中的变量
本练习使用 shell-lesson-data/exercise-data/alkanes 目录。ls *.pdb 的输出如下:
cubane.pdb ethane.pdb methane.pdb octane.pdb pentane.pdb propane.pdb
以下代码会输出什么?
$ for datafile in *.pdb
> do
> ls *.pdb
> done
接下来这段代码呢?为什么两个循环的输出不同?
$ for datafile in *.pdb
> do
> ls $datafile
> done
答案
第一段代码在每次迭代中都输出相同内容。Bash 不仅在循环开始前展开 *.pdb,也在循环体内展开它,匹配全部以 .pdb 结尾的文件,再用 ls 列出。展开后相当于:
$ for datafile in cubane.pdb ethane.pdb methane.pdb octane.pdb pentane.pdb propane.pdb
> do
> ls cubane.pdb ethane.pdb methane.pdb octane.pdb pentane.pdb propane.pdb
> done
cubane.pdb ethane.pdb methane.pdb octane.pdb pentane.pdb propane.pdb
cubane.pdb ethane.pdb methane.pdb octane.pdb pentane.pdb propane.pdb
cubane.pdb ethane.pdb methane.pdb octane.pdb pentane.pdb propane.pdb
cubane.pdb ethane.pdb methane.pdb octane.pdb pentane.pdb propane.pdb
cubane.pdb ethane.pdb methane.pdb octane.pdb pentane.pdb propane.pdb
cubane.pdb ethane.pdb methane.pdb octane.pdb pentane.pdb propane.pdb
第二段代码每次迭代列出一个不同的文件。通过 $datafile 取得变量 datafile 的值,然后用 ls 列出该文件。
cubane.pdb
ethane.pdb
methane.pdb
octane.pdb
pentane.pdb
propane.pdb
练习:限制文件集合
在 shell-lesson-data/exercise-data/alkanes 目录运行以下循环,结果是什么?
$ for filename in c*
> do
> ls $filename
> done
- 1. 不列出任何文件。
- 2. 列出所有文件。
- 3. 只列出 cubane.pdb、octane.pdb 和 pentane.pdb。
- 4. 只列出 cubane.pdb。
答案
正确选项是 4。* 匹配零个或多个字符,因此匹配以 c 开头、后面跟零个或多个其他字符的文件名。
练习:限制文件集合(续)
若改用以下命令,输出会有什么不同?
$ for filename in *c*
> do
> ls $filename
> done
- 1. 列出的文件相同。
- 2. 这次列出所有文件。
- 3. 这次不列出任何文件。
- 4. 列出 cubane.pdb 和 octane.pdb。
- 5. 只列出 octane.pdb。
答案
正确选项是 4。* 匹配零个或多个字符,因此可以匹配 c 前后都有任意数量字符的文件名。
练习:在循环中保存到文件(第一部分)
在 shell-lesson-data/exercise-data/alkanes 目录中,这个循环有什么效果?
for alkanes in *.pdb
do
echo $alkanes
cat $alkanes > alkanes.pdb
done
- 1. 打印 cubane.pdb、ethane.pdb、methane.pdb、octane.pdb、pentane.pdb 和 propane.pdb;propane.pdb 的文本保存到 alkanes.pdb。
- 2. 打印 cubane.pdb、ethane.pdb 和 methane.pdb;三个文件的全部文本拼接后保存到 alkanes.pdb。
- 3. 打印 cubane.pdb、ethane.pdb、methane.pdb、octane.pdb 和 pentane.pdb;propane.pdb 的文本保存到 alkanes.pdb。
- 4. 以上都不对。
答案
正确选项是 1。各文件的文本依次写入 alkanes.pdb,但每次迭代都会覆盖它,因此最终只保留 propane.pdb 的文本。
练习:在循环中保存到文件(第二部分)
仍在上述 alkanes 目录中,以下循环的结果是什么?
for datafile in *.pdb
do
cat $datafile >> all.pdb
done
- 1. 将 cubane.pdb、ethane.pdb、methane.pdb、octane.pdb 和 pentane.pdb 的文本拼接后保存到 all.pdb。
- 2. 将 ethane.pdb 的文本保存到 all.pdb。
- 3. 将 cubane.pdb、ethane.pdb、methane.pdb、octane.pdb、pentane.pdb 和 propane.pdb 的全部文本拼接后保存到 all.pdb。
- 4. 将上述六份文件的文本打印到屏幕,同时保存到 all.pdb。
答案
正确选项是 3。>> 向文件追加内容,而不会像 > 那样覆盖。cat 的输出被重定向,因此屏幕上没有输出。
继续处理文件
回到 shell-lesson-data/exercise-data/creatures 目录,再看一个例子:
$ for filename in *.dat
> do
> echo $filename
> head -n 100 $filename | tail -n 20
> done
shell 首先展开 *.dat,建立要处理的文件列表。循环体对每个文件执行两个命令:先展开 $filename,用 echo 打印文件名;再通过 head 和 tail 的组合选取第 81 至 100 行,前提是该文件至少有 100 行。
文件名中的空格
空格用于分隔循环列表的元素。如果元素本身含空格,就要用引号包住该元素,并在引用循环变量时同样加引号。假设数据文件名如下:
red dragon.dat
purple unicorn.dat
循环应写为:
$ for filename in "red dragon.dat" "purple unicorn.dat"
> do
> head -n 100 "$filename" | tail -n 20
> done
避免在文件名中使用空格或其他特殊字符,会更简单。上面的两个文件其实并不存在,所以运行后 head 找不到它们,但错误消息会显示预期的文件名:
head: cannot open ‘red dragon.dat' for reading: No such file or directory
head: cannot open ‘purple unicorn.dat' for reading: No such file or directory
试着去掉 $filename 两侧的引号,观察空格处理的变化。在 creatures 目录运行时,会从 unicorn.dat 得到部分输出:
head: cannot open ‘red' for reading: No such file or directory
head: cannot open ‘dragon.dat' for reading: No such file or directory
head: cannot open ‘purple' for reading: No such file or directory
CGGTACCGAA
AAGGGTCGCG
CAAGTGTTCC
...
我们想修改 creatures 目录中的每个文件,同时保留原文件的副本。新副本应命名为 original-basilisk.dat、original-unicorn.dat 等。不能这样做:
$ cp *.dat original-*.dat
因为它会展开为:
$ cp basilisk.dat minotaur.dat unicorn.dat original-*.dat
这不会备份文件,而会报错:
cp: target `original-*.dat' is not a directory
cp 收到两个以上参数时,会把最后一个参数当成目标目录,把前面的文件复制进去。creatures 中没有 original-*.dat 这个目录,因此出错。可以改用循环:
$ for filename in *.dat
> do
> cp $filename original-$filename
> done
循环为每个文件名分别执行一次 cp。第一次 $filename 展开为 basilisk.dat,执行:
cp basilisk.dat original-basilisk.dat
第二次执行:
cp minotaur.dat original-minotaur.dat
第三次,也就是最后一次执行:
cp unicorn.dat original-unicorn.dat
cp 通常不输出任何内容,因此不容易确认循环是否正确。可以利用先前学到的 echo,把命令打印出来而不实际执行,检查原循环将运行哪些命令。下图说明修改后循环的过程,也展示了合理使用 echo 是一种有效的调试方法。
Nelle 的流水线:处理文件
Nelle 准备用导师写的 shell 脚本 goostats.sh 处理数据。该脚本计算蛋白质样本文件的统计值,需要两个参数:原始数据输入文件,以及保存统计结果的输出文件。她还在学习 shell,因此逐步构建命令。先确认输入文件选择正确:应选择名称以 A 或 B 结尾的文件,不选 Z。进入 north-pacific-gyre 目录:
$ cd
$ cd Desktop/shell-lesson-data/north-pacific-gyre
$ for datafile in NENE*A.txt NENE*B.txt
> do
> echo $datafile
> done
NENE01729A.txt
NENE01736A.txt
NENE01751A.txt
...
NENE02040B.txt
NENE02043B.txt
接下来决定输出文件名。在每个输入文件名前加 stats 前缀似乎很方便,因此修改循环:
$ for datafile in NENE*A.txt NENE*B.txt
> do
> echo $datafile stats-$datafile
> done
NENE01729A.txt stats-NENE01729A.txt
NENE01736A.txt stats-NENE01729A.txt
NENE01751A.txt stats-NENE01729A.txt
...
NENE02040B.txt stats-NENE02040B.txt
NENE02043B.txt stats-NENE02043B.txt
她还没有真正运行 goostats.sh,但已经确认能选择输入文件并生成输出名称。反复输入很繁琐,也容易出错。按 ↑ 后,shell 用分号把整个循环显示为一行:
$ for datafile in NENE*A.txt NENE*B.txt; do echo $datafile stats-$datafile; done
用 ← 移动到 echo,把它改为 bash goostats.sh:
$ for datafile in NENE*A.txt NENE*B.txt; do bash goostats.sh $datafile stats-$datafile; done
按 Enter 后,shell 执行修改后的命令。但屏幕没有任何输出,看起来似乎什么也没发生。她意识到脚本不再打印内容,因此不知道是否还在运行,也不知道速度。按 Ctrl+C 终止命令,再用 ↑ 调出并修改为:
$ for datafile in NENE*A.txt NENE*B.txt; do echo $datafile;
bash goostats.sh $datafile stats-$datafile; done
行首与行尾
Ctrl+A 可移动到命令行开头,Ctrl+E 可移动到末尾。
再次运行后,大约每五秒打印一行:
NENE01729A.txt
NENE01736A.txt
NENE01751A.txt
...
此处缩小后的样本数据集包含 17 个文件,因此预计需要 17 × 5 = 85 秒。完整数据集有 1520 个文件,预计需要约两小时。最后,她打开另一个终端,进入 north-pacific-gyre,用 cat stats-NENE01729B.txt 查看一份输出。结果正常,她便在等待期间浏览社交媒体。
知道历史的人可以选择重复它
history 可以列出最近执行的几百条命令,再用 !123 重复编号 123 的命令。例如:
$ history | tail -n 5
456 for datafile in NENE*A.txt NENE*B.txt; do echo $datafile stats-$datafile; done
457 for datafile in NENE*A.txt NENE*B.txt; do echo $datafile stats-$datafile; done
458 for datafile in NENE*A.txt NENE*B.txt; do bash goostats.sh $datafile stats-$datafile; done
459 for datafile in NENE*A.txt NENE*B.txt; do echo $datafile; bash goostats.sh $datafile
stats-$datafile; done
460 history | tail -n 5
输入 !459,就可以在这些文件上重新运行 goostats.sh。
其他历史命令
- Ctrl+R 进入历史反向搜索模式 reverse-i-search,寻找历史中最近匹配随后输入文本的命令。再次按 Ctrl+R 可继续向前查找。用左右方向键选中并编辑,再按 Return 执行。
- !! 调出上一条命令;你可能觉得它比 ↑ 更方便,也可能不觉得。
- !$ 调出上一条命令的最后一个词。例如执行 bash goostats.sh NENE01729B.txt stats-NENE01729B.txt 后,可用 less !$ 查看 stats-NENE01729B.txt,比用 ↑ 调出并修改整行更快。
练习:预演
循环能一次完成许多操作;如果写错,也能一次造成许多错误。检查方法之一,是用 echo 打印将要运行的命令,而不真正执行。我们想预览以下循环的命令:
$ for datafile in *.pdb
> do
> cat $datafile >> all.pdb
> done
下面两个版本有什么区别?应该使用哪个?
# Version 1
$ for datafile in *.pdb
> do
> echo cat $datafile >> all.pdb
> done
# Version 2
$ for datafile in *.pdb
> do
> echo "cat $datafile >> all.pdb"
> done
答案
应使用第二个版本。它把引号内的内容全部打印到屏幕;变量名前有 $,因此仍会展开变量。>> 被当作字符串中的字面内容,不再是重定向,所以不会创建或修改 all.pdb。第一版则把 echo cat $datafile 的输出追加到 all.pdb,该文件只会包含 cat cubane.pdb、cat ethane.pdb、cat methane.pdb 等命令文本。请分别尝试,并打开 all.pdb 查看内容。
练习:嵌套循环
假设要建立目录结构,组织不同化合物在不同温度下测量反应速率常数的实验。以下代码会产生什么结果?
$ for species in cubane ethane methane
> do
> for temperature in 25 30 37 40
> do
> mkdir $species-$temperature
> done
> done
答案
这是嵌套循环,也就是一个循环包含另一个循环。外层每取一种化合物,内层都遍历全部温度,为每个组合创建一个目录。请尝试运行,查看创建了哪些目录。
要点
- for 循环对列表中的每个元素重复执行一次命令。
- 每个 for 循环都需要一个变量,指代当前正在处理的元素。
- 用 $name 展开变量,也就是取得它的值;也可写作 ${name}。
- 文件名应避免空格、引号、* 和 ? 等通配字符,以免使变量展开变复杂。
- 使用一致且易于通配符匹配的文件命名规则,方便筛选循环处理的文件。
- 用上箭头浏览、编辑并再次执行先前的命令。
- 用 Ctrl+R 搜索此前输入的命令。
- 用 history 显示最近命令,用 ![number] 按编号再次执行。











暂无评论内容