Linux Shell 之 for 循环语句

  重复执行一系列命令在编程中很常见。通常你需要重复一组命令直至达到某个特定条件,比如处理某个目录下的所有文件、系统上的所有用户或是某个文本文件中的所有行。

  bash shell提供了for命令,允许你创建一个遍历一系列值的循环。每次迭代都使用其中一个值来执行已定义好的一组命令。下面是bash shell中for命令的基本格式。

1 for var in list
2 do
3   commands
4 done

  在list参数中,你需要提供迭代中要用到的一系列值。可以通过几种不同的方法指定列表中的值。

  在每次迭代中,变量var会包含列表中的当前值。第一次迭代会使用列表中的第一个值,第二次迭代使用第二个值,以此类推,直到列表中的所有值都过一遍。

  在do和done语句之间输入的命令可以是一条或多条标准的bash shell命令。在这些命令中,$var变量包含着这次迭代对应的当前列表项中的值。

说明 只要你愿意,也可以将do语句和for语句放在同一行,但必须用分号将其同列表中的值分开:for var in list; do。

1.1、读取列表中的值

  for命令最基本的用法就是遍历for命令自身所定义的一系列值。

 1 $ cat test1
 2 #!/bin/bash
 3 # basic for command
 4 for test in Alabama Alaska Arizona Arkansas California Colorado
 5 do
 6   echo The next state is $test
 7 done
 8 $ ./test1
 9 The next state is Alabama
10 The next state is Alaska
11 The next state is Arizona
12 The next state is Arkansas
13 The next state is California
14 The next state is Colorado
15 $

  每次for命令遍历值列表,它都会将列表中的下个值赋给$test变量。$test变量可以像for命令语句中的其他脚本变量一样使用。在最后一次迭代后,$test变量的值会在shell脚本的剩余部分一直保持有效。它会一直保持最后一次迭代的值(除非你修改了它)。

 1 $ cat test1b
 2 #!/bin/bash
 3 # testing the for variable after the looping
 4 for test in Alabama Alaska Arizona Arkansas California Colorado
 5 do
 6   echo "The next state is $test"
 7 done
 8   echo "The last state we visited was $test"
 9   test=Connecticut
10   echo "Wait, now we're visiting $test"
11 $ ./test1b
12 The next state is Alabama
13 The next state is Alaska
14 The next state is Arizona
15 The next state is Arkansas
16 The next state is California
17 The next state is Colorado
18 The last state we visited was Colorado
19 Wait, now we're visiting Connecticut
20 $

  $test变量保持了其值,也允许我们修改它的值,并在for命令循环之外跟其他变量一样使用。

1.2、读取列表中的复杂值

  事情并不会总像你在for循环中看到的那么简单。有时会遇到难处理的数据。下面是给shell脚本程序员带来麻烦的典型例子。

 1 $ cat badtest1
 2 #!/bin/bash
 3 # another example of how not to use the for command
 4 for test in I don't know if this'll work
 5 do
 6   echo "word:$test"
 7 done
 8 $ ./badtest1
 9 word:I
10 word:dont know if thisll
11 word:work
12 $

  真麻烦。shell看到了列表值中的单引号并尝试使用它们来定义一个单独的数据值,这真是把事情搞得一团糟。

  有两种办法可解决这个问题:

  •    使用转义字符(反斜线)来将单引号转义;
  •    使用双引号来定义用到单引号的值。

  这两种解决方法并没有什么出奇之处,但都能解决这个问题。

 1 $ cat test2
 2 #!/bin/bash
 3 # another example of how not to use the for command
 4 for test in I don\'t know if "this'll" work
 5 do
 6   echo "word:$test"
 7 done
 8 $ ./test2
 9 word:I
10 word:don't
11 word:know
12 word:if
13 word:this'll
14 word:work
15 $

  在第一个有问题的地方添加了反斜线字符来转义don't中的单引号。在第二个有问题的地方将this'll用双引号圈起来。两种方法都能正常辨别出这个值。

  你可能遇到的另一个问题是有多个词的值。记住,for循环假定每个值都是用空格分割的。如果有包含空格的数据值,你就陷入麻烦了。

 1 $ cat badtest2
 2 #!/bin/bash
 3 # another example of how not to use the for command
 4 for test in Nevada New Hampshire New Mexico New York North Carolina
 5 do
 6   echo "Now going to $test"
 7 done
 8 $ ./badtest1
 9 Now going to Nevada
10 Now going to New
11 Now going to Hampshire
12 Now going to New
13 Now going to Mexico
14 Now going to New
15 Now going to York
16 Now going to North
17 Now going to Carolina
18 $

  这不是我们想要的结果。for命令用空格来划分列表中的每个值。如果在单独的数据值中有空格,就必须用双引号将这些值圈起来。

 1 $ cat test3
 2 #!/bin/bash
 3 # an example of how to properly define values
 4 for test in Nevada "New Hampshire" "New Mexico" "New York"
 5 do
 6   echo "Now going to $test"
 7 done
 8 $ ./test3
 9 Now going to Nevada
10 Now going to New Hampshire
11 Now going to New Mexico
12 Now going to New York
13 $

  现在for命令可以正确区分不同值了。另外要注意的是,在某个值两边使用双引号时,shell并不会将双引号当成值的一部分。

1.3、从变量读取列表

  通常shell脚本遇到的情况是,你将一系列值都集中存储在了一个变量中,然后需要遍历变量中的整个列表。也可以通过for命令完成这个任务。

 1 $ cat test4
 2 #!/bin/bash
 3 # using a variable to hold the list
 4 list="Alabama Alaska Arizona Arkansas Colorado"
 5 list=$list" Connecticut"
 6 for state in $list
 7 do
 8   echo "Have you ever visited $state?"
 9 done
10 $ ./test4
11 Have you ever visited Alabama?
12 Have you ever visited Alaska?
13 Have you ever visited Arizona?
14 Have you ever visited Arkansas?
15 Have you ever visited Colorado?
16 Have you ever visited Connecticut?
17 $

  $list 变量包含了用于迭代的标准文本值列表。注意,代码还是用了另一个赋值语句向 $list 变量包含的已有列表中添加(或者说是拼接)了一个值。这是向变量中存储的已有文本字符串尾部添加文本的一个常用方法。

1.4、从命令读取值

  生成列表中所需值的另外一个途径就是使用命令的输出。可以用命令替换来执行任何能产生输出的命令,然后在for命令中使用该命令的输出。

 1 $ cat test5
 2 #!/bin/bash
 3 # reading values from a file
 4 file="states"
 5 for state in $(cat $file)
 6 do
 7   echo "Visit beautiful $state"
 8 done
 9 $ cat states
10 Alabama
11 Alaska
12 Arizona
13 Arkansas
14 Colorado
15 Connecticut
16 Delaware
17 Florida
18 Georgia
19 $ ./test5
20 Visit beautiful Alabama
21 Visit beautiful Alaska
22 Visit beautiful Arizona
23 Visit beautiful Arkansas
24 Visit beautiful Colorado
25 Visit beautiful Connecticut
26 Visit beautiful Delaware
27 Visit beautiful Florida
28 Visit beautiful Georgia
29 $

  这个例子在命令替换中使用了cat命令来输出文件states的内容。你会注意到states文件中每一行有一个州,而不是通过空格分隔的。for命令仍然以每次一行的方式遍历了cat命令的输出,假定每个州都是在单独的一行上。但这并没有解决数据中有空格的问题。如果你列出了一个名字中有空格的州,for命令仍然会将每个单词当作单独的值。

说明 test5的代码范例将文件名赋给变量,文件名中没有加入路径。这要求文件和脚本位于同一个目录中。如果不是的话,你需要使用全路径名(不管是绝对路径还是相对路径)来引用文件位置。

1.5、更改字段分隔符

  造成这个问题的原因是特殊的环境变量IFS,叫作内部字段分隔符(internal field separator)。IFS环境变量定义了bash shell用作字段分隔符的一系列字符。默认情况下,bash shell会将下列字符当作字段分隔符:

  • 空格
  • 制表符
  • 换行符

  如果bash shell在数据中看到了这些字符中的任意一个,它就会假定这表明了列表中一个新数据字段的开始。在处理可能含有空格的数据(比如文件名)时,这会非常麻烦,就像你在上一个脚本示例中看到的。

  要解决这个问题,可以在shell脚本中临时更改IFS环境变量的值来限制被bash shell当作字段分隔符的字符。例如,如果你想修改IFS的值,使其只能识别换行符,那就必须这么做:

1 IFS=$'\n'

  将这个语句加入到脚本中,告诉bash shell在数据值中忽略空格和制表符。对前一个脚本使用这种方法,将获得如下输出。

 1 $ cat test5b
 2 #!/bin/bash
 3 # reading values from a file
 4 file="states"
 5 IFS=$'\n'
 6 for state in $(cat $file)
 7 do
 8   echo "Visit beautiful $state"
 9 done
10 $ ./test5b
11 Visit beautiful Alabama
12 Visit beautiful Alaska
13 Visit beautiful Arizona
14 Visit beautiful Arkansas
15 Visit beautiful Colorado
16 Visit beautiful Connecticut
17 Visit beautiful Delaware
18 Visit beautiful Florida
19 Visit beautiful Georgia
20 Visit beautiful New York
21 Visit beautiful New Hampshire
22 Visit beautiful North Carolina
23 $

  现在,shell脚本旧能够使用列表中含有空格的值了。

警告 在处理代码量较大的脚本时,可能在一个地方需要修改IFS的值,然后忽略这次修改,在脚本的其他地方继续沿用IFS的默认值。一个可参考的安全实践是在改变IFS之前保存原来的IFS值,之后再恢复它。

  这种技术可以这样实现:

1 IFS.OLD=$IFS
2 IFS=$'\n'

  <在代码中使用新的IFS值>

IFS=$IFS.OLD

  这就保证了在脚本的后续操作中使用的是IFS的默认值。

  还有其他一些IFS环境变量的绝妙用法。假定你要遍历一个文件中用冒号分隔的值(比如在/etc/passwd文件中)。你要做的就是将IFS的值设为冒号。

1 IFS=:

  如果要指定多个IFS字符,只要将它们在赋值行串起来就行。

1 IFS=$'\n':;"

  这个赋值会将换行符、冒号、分号和双引号作为字段分隔符。如何使用IFS字符解析数据没有任何限制。

1.6、用通配符读取目录

  最后,可以用for命令来自动遍历目录中的文件。进行此操作时,必须在文件名或路径名中使用通配符。它会强制shell使用文件扩展匹配。文件扩展匹配是生成匹配指定通配符的文件名或路径名的过程。

  如果不知道所有的文件名,这个特性在处理目录中的文件时就非常好用。

 1 $ cat test6
 2 #!/bin/bash
 3 # iterate through all the files in a directory
 4 for file in /home/rich/test/*
 5 do
 6   if [ -d "$file" ]
 7   then
 8     echo "$file is a directory"
 9   elif [ -f "$file" ]
10   then
11     echo "$file is a file"
12   fi
13 done
14 $ ./test6
15 /home/rich/test/dir1 is a directory
16 /home/rich/test/myprog.c is a file
17 /home/rich/test/myprog is a file
18 /home/rich/test/myscript is a file
19 /home/rich/test/newdir is a directory
20 /home/rich/test/newfile is a file
21 /home/rich/test/newfile2 is a file
22 /home/rich/test/testdir is a directory
23 /home/rich/test/testing is a file
24 /home/rich/test/testprog is a file
25 /home/rich/test/testprog.c is a file
26 $

  for命令会遍历/home/rich/test/*输出的结果。该代码用test命令测试了每个条目(使用方括号方法),以查看它是目录(通过-d参数)还是文件(通过-f参数)(参见上一篇博文)。

  注意,我们在这个例子的if语句中做了一些不同的处理:

1 if [ -d "$file" ]

  在Linux中,目录名和文件名中包含空格当然是合法的。要适应这种情况,应该将 $file 变量用双引号圈起来。如果不这么做,遇到含有空格的目录名或文件名时就会有错误产生。

1 ./test6: line 6: [: too many arguments
2 ./test6: line 9: [: too many arguments

  在test命令中,bash shell会将额外的单词当作参数,进而造成错误。

  也可以在for命令中列出多个目录通配符,将目录查找和列表合并进同一个for语句。

 1 $ cat test7
 2 #!/bin/bash
 3 # iterating through multiple directories
 4 for file in /home/rich/.b* /home/rich/badtest
 5 do
 6   if [ -d "$file" ]
 7   then
 8     echo "$file is a directory"
 9   elif [ -f "$file" ]
10   then
11     echo "$file is a file"
12   else
13     echo "$file doesn't exist"
14   fi
15 done
16 $ ./test7
17 /home/rich/.backup.timestamp is a file
18 /home/rich/.bash_history is a file
19 /home/rich/.bash_logout is a file
20 /home/rich/.bash_profile is a file
21 /home/rich/.bashrc is a file
22 /home/rich/badtest doesn't exist
23 $

  for语句首先使用了文件扩展匹配来遍历通配符生成的文件列表,然后它会遍历列表中的下一个文件。可以将任意多的通配符放进列表中。

警告 注意,你可以在数据列表中放入任何东西。即使文件或目录不存在,for语句也会尝试处理列表中的内容。在处理文件或目录时,这可能会是个问题。你无法知道你正在尝试遍历的目录是否存在:在处理之前测试一下文件或目录总是好的。

2、C 语言风格的for 命令

  如果你从事过C语言编程,可能会对bash shell中for命令的工作方式有点惊奇。在C语言中,for循环通常定义一个变量,然后这个变量会在每次迭代时自动改变。通常程序员会将这个变量用作计数器,并在每次迭代中让计数器增一或减一。bash的for命令也提供了这个功能。

2.1、C 语言的for 命令

  C语言的for命令有一个用来指明变量的特定方法,一个必须保持成立才能继续迭代的条件,以及另一个在每个迭代中改变变量的方法。当指定的条件不成立时,for循环就会停止。条件等式通过标准的数学符号定义。比如,考虑下面的C语言代码:

1 for (i = 0; i < 10; i++)
2 {
3 printf("The next number is %d\n", i);
4 }

  这段代码产生了一个简单的迭代循环,其中变量i作为计数器。第一部分将一个默认值赋给该变量。中间的部分定义了循环重复的条件。当定义的条件不成立时,for循环就停止迭代。最后一部分定义了迭代的过程。在每次迭代之后,最后一部分中定义的表达式会被执行。在本例中,i变量会在每次迭代后增一。

  bash shell也支持一种for循环,它看起来跟C语言风格的for循环类似,但有一些细微的不同,其中包括一些让shell脚本程序员困惑的东西。以下是bash中C语言风格的for循环的基本格式。

1 for (( variable assignment ; condition ; iteration process ))

  C语言风格的for循环的格式会让bash shell脚本程序员摸不着头脑,因为它使用了C语言风格的变量引用方式而不是shell风格的变量引用方式。C语言风格的for命令看起来如下。

1 for (( a = 1; a < 10; a++ ))

  注意,有些部分并没有遵循bash shell标准的for命令:

  • 变量赋值可以有空格;
  • 条件中的变量不以美元符开头;
  • 迭代过程的算式未用expr命令格式。

  shell开发人员创建了这种格式以更贴切地模仿C语言风格的for命令。这虽然对C语言程序员来说很好,但也会把专家级的shell程序员弄得一头雾水。在脚本中使用C语言风格的for循环时要小心。

  以下例子是在bash shell程序中使用C语言风格的for命令。

 1 $ cat test8
 2 #!/bin/bash
 3 # testing the C-style for loop
 4 for (( i=1; i <= 10; i++ ))
 5 do
 6   echo "The next number is $i"
 7 done
 8 $ ./test8
 9 The next number is 1
10 The next number is 2
11 The next number is 3
12 The next number is 4
13 The next number is 5
14 The next number is 6
15 The next number is 7
16 The next number is 8
17 The next number is 9
18 The next number is 10
19 $

  for 循环通过定义好的变量(本例中是变量 i )来迭代执行这些命令。在每次迭代中,$i 变量包含了 for 循环中赋予的值。在每次迭代后,循环的迭代过程会作用在变量上,在本例中,变量增一。

2.2、使用多个变量

  C语言风格的for命令也允许为迭代使用多个变量。循环会单独处理每个变量,你可以为每个变量定义不同的迭代过程。尽管可以使用多个变量,但你只能在for循环中定义一种条件。

 1 $ cat test9
 2 #!/bin/bash
 3 # multiple variables
 4 for (( a=1, b=10; a <= 10; a++, b-- ))
 5 do
 6   echo "$a - $b"
 7 done
 8 $ ./test9
 9 1 - 10
10 2 - 9
11 3 - 8
12 4 - 7
13 5 - 6
14 6 - 5
15 7 - 4
16 8 - 3
17 9 - 2
18 10 - 1
19 $

  变量a和b分别用不同的值来初始化并且定义了不同的迭代过程。循环的每次迭代在增加变量a的同时减小了变量b。