羽夏 Bash 简明教程(下)

写在前面

  该文章根据 the unix workbench 中的 Bash Programming 进行汉化处理并作出自己的整理,并参考 Bash 脚本教程BashPitfalls 相关内容进行补充修正。一是我对 Bash 的学习记录,二是对大家学习 Bash 有更好的帮助。如对该博文有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我。本篇博文可能比较冗长,请耐心阅读和学习。

数组

内容讲解

  Bash 中的数组是有序的值列表。通过将列表指定给变量名,可以从头开始创建列表。列表是用括号(())创建的,括号中用空格分隔列表中的每个元素。让我们列出几个动物的数组:

animals=(cat dog butterfly fish bird goose cow chick goat pig)

  要检索数组,需要使用参数展开,其中包括美元符号和花括号${}。数组中元素的位置从零开始编号。要获取此数组的第一个元素,请使用${animals[0]},如下所示:

wingsummer@wingsummer-PC ~ → echo ${animals[0]}
cat

  请注意,第一个元素的索引为0。可以通过这种方式获取任意元素,例如第四个元素:

wingsummer@wingsummer-PC ~ → echo ${animals[3]}
fish

  要获得改动物列表的所有元素,请在方括号之间使用星号*

wingsummer@wingsummer-PC ~ → echo ${animals[*]}
cat dog butterfly fish bird goose cow chick goat pig

  还可以通过使用方括号指定其索引来更改数组中的单个元素:

wingsummer@wingsummer-PC ~ → echo ${animals[*]}
cat dog butterfly fish bird goose cow chick goat pig
wingsummer@wingsummer-PC ~ → animals[4]=ant
wingsummer@wingsummer-PC ~ → echo ${animals[*]}
cat dog butterfly fish ant goose cow chick goat pig

  要仅获取数组的一部分,必须指定要从中开始的索引,后跟要从数组中检索的元素数,以冒号分隔:

wingsummer@wingsummer-PC ~ → echo ${animals[*]:5:3}
goose cow chick

  上面的查询本质上是这样的:从数组的第六个元素开始获取3个数组元素(记住第六个元素的索引为5)。
  可以使用#来获取数组的长度:

wingsummer@wingsummer-PC ~ → echo ${#animals[*]}
10

  可以使用加号等于运算符+=将数组添加到数组的末尾:

animals=(cat dog fish)
echo ${animals[*]}
animals+=(cow chick goat)
echo ${animals[*]}

内容总结

  • 数组是一种线性数据结构,具有可存储在变量中的有序元素。
  • 数组的每个元素都有一个索引,第一个索引是0。
  • 可以使用数组的索引来访问数组的各个元素。

小试牛刀

  1. 编写一个 bash 脚本,在脚本中定义一个数组,脚本的第一个参数指示运行脚本时打印到控制台的数组元素的索引。
  2. 编写一个 bash 脚本,在脚本中定义两个数组,当脚本运行时,数组长度的总和将打印到控制台。
🔒 点击查看答案 🔒
#1
index=$1
animals=(cat dog butterfly fish bird goose cow chick goat pig)
echo "${animals[$index]}"

#2
animals1=(cat dog butterfly fish bird)
animals2=(goose cow chick goat pig)
echo $((${#animals1[*]}+${#animals2[*]}))

花括号扩展

内容介绍

  Bash 有一个非常方便的工具,用于从序列中创建字符串,称为大括号扩展。大括号展开使用花括号和两个句点{..}创建一系列字母或数字。例如,要创建一个所有数字都在0到9之间的字符串,可以执行以下操作:

echo {0..9}

  除了数字,您还可以创建字母序列:

echo {a..e}
echo {W..Z}

  您可以将字符串放在花括号的任一侧,它们将“粘贴”到序列的相应端:

echo a{0..4}
echo b{0..4}c

  还可以组合序列,以便将两个或多个序列连接在一起:

echo {1..3}{A..C}

  如果要使用变量来定义序列,则需要使用eval命令来创建序列:

wingsummer@wingsummer-PC ~ → start=4
wingsummer@wingsummer-PC ~ → end=9
wingsummer@wingsummer-PC ~ → echo {$start..$end}
{4..9}
wingsummer@wingsummer-PC ~ → eval echo {$start..$end}
4 5 6 7 8 9

  可以在括号{,}之间用逗号组合序列:

wingsummer@wingsummer-PC ~ → echo {{1..3},{a..c}}
1 2 3 a b c

  你还可以使用任意数量的字符串来执行此操作:

wingsummer@wingsummer-PC ~ → echo {Who,What,Why,When,How}?
Who? What? Why? When? How?

内容总结

  • 大括号允许创建字符串序列和展开。
  • 要使用带大括号的变量,需要使用eval命令。

小试牛刀

  1. 使用大括号展开创建100个文本文件。
🔒 点击查看答案 🔒
wingsummer@wingsummer-PC txts → eval touch {0..100}.txt
wingsummer@wingsummer-PC txts → ls
0.txt    16.txt  23.txt  30.txt  38.txt  45.txt  52.txt  5.txt   67.txt  74.txt  81.txt  89.txt  96.txt
100.txt  17.txt  24.txt  31.txt  39.txt  46.txt  53.txt  60.txt  68.txt  75.txt  82.txt  8.txt   97.txt
10.txt   18.txt  25.txt  32.txt  3.txt   47.txt  54.txt  61.txt  69.txt  76.txt  83.txt  90.txt  98.txt
11.txt   19.txt  26.txt  33.txt  40.txt  48.txt  55.txt  62.txt  6.txt   77.txt  84.txt  91.txt  99.txt
12.txt   1.txt   27.txt  34.txt  41.txt  49.txt  56.txt  63.txt  70.txt  78.txt  85.txt  92.txt  9.txt
13.txt   20.txt  28.txt  35.txt  42.txt  4.txt   57.txt  64.txt  71.txt  79.txt  86.txt  93.txt
14.txt   21.txt  29.txt  36.txt  43.txt  50.txt  58.txt  65.txt  72.txt  7.txt   87.txt  94.txt
15.txt   22.txt  2.txt   37.txt  44.txt  51.txt  59.txt  66.txt  73.txt  80.txt  88.txt  95.txt

循环

  循环是 Bash 语言中最重要的编程结构之一。到目前为止,我们编写的所有程序都是从脚本的第一行执行到最后一行,但循环允许您根据逻辑条件或按照顺序重复代码行。我们要讨论的第一种循环是FOR循环。FOR循环遍历指定序列的每个元素。让我们看一下循环的一个小例子:

#!/usr/bin/env bash
# File: forloop.sh

echo "Before Loop"

for i in {1..3}
do
    echo "i is equal to $i"
done

echo "After Loop"

  现在让我们执行脚本,结果如下:

Before Loop
i is equal to 1
i is equal to 2
i is equal to 3
After Loop

  让我们逐行分析forloop.sh。首先,在FOR循环之前打印Before Loop,然后循环开始。FOR循环以for [variable name] in [sequence]的语法开头,然后是下一行的do。在for之后立即定义的变量名将在循环内部接受一个值,该值对应于在in之后提供的序列中的一个元素,从序列的第一个元素开始,然后是每个后续元素。有效序列包括大括号展开、字符串的显式列表、数组和命令替换。在这个例子中,我们使用了大括号扩展{1..3},我们知道它会扩展到字符串1 2 3。循环的每次迭代中执行的代码都是在dodone之间编写的。在循环的第一次迭代中,变量$i包含值1。字符串i is equal to 1被打印到控制台。在1之后的大括号扩展中有更多元素,因此在第一次到达完成位置后,程序开始在do语句处执行。第二次循环变量$i包含值2。字符串i is equal to 2被打印到控制台,然后循环返回do语句,因为序列中仍有元素。$i变量现在等于3,因此i is equal to 3会打印到控制台。序列中没有剩余的元素,因此程序将超出FOR循环,并最终打印After Loop
  一旦你做了一些实验,看看这个例子,看看其他几种序列生成策略:

#!/usr/bin/env bash
# File: manyloops.sh

echo "Explicit list:"

for picture in img001.jpg img002.jpg img451.jpg
do
    echo "picture is equal to $picture"
done

echo ""
echo "Array:"

stooges=(curly larry moe)

for stooge in ${stooges[*]}
do
    echo "Current stooge: $stooge"
done

echo ""
echo "Command substitution:"

for code in $(ls)
do
    echo "$code is a bash script"
done

  然后执行代码:

Explicit list:
picture is equal to img001.jpg
picture is equal to img002.jpg
picture is equal to img451.jpg

Array:
Current stooge: curly
Current stooge: larry
Current stooge: moe

Command substitution:
bigmath.sh is a bash script
condexif.sh is a bash script
forloop.sh is a bash script
letsread.sh is a bash script
manyloops.sh is a bash script
math.sh is a bash script
nested.sh is a bash script
simpleelif.sh is a bash script
simpleif.sh is a bash script
simpleifelse.sh is a bash script
vars.sh is a bash script

  上面的示例演示了为for循环创建序列的其他三种方法:键入显式列表、使用数组和获取命令替换的结果。在每种情况下,for后面都会声明一个变量名,而变量的值在循环的每次迭代中都会发生变化,直到相应的序列用完为止。现在你应该花点时间自己写几个FOR循环,用我们已经讨论过的所有方法生成序列,只是为了加强你对FOR循环如何工作的理解。循环和条件语句是程序员可以使用的两种最重要的结构。
  现在我们已经有了一些FOR循环基础,让我们继续讨论WHILE循环。让我们看一个WHILE循环的例子:

#!/usr/bin/env bash
# File: whileloop.sh

count=3

while [[ $count -gt 0 ]]
do
  echo "count is equal to $count"
  let count=$count-1
done

  WHILE循环首先以while关键字开头,然后是一个条件表达式。只要循环迭代开始时条件表达式等价于true,那么WHILE循环中的代码将继续执行。当我们运行这个脚本时,你认为控制台会打印什么?让我们看看结果:

count is equal to 3
count is equal to 2
count is equal to 1

  在WHILE之前,count变量设置为3,但每次执行WHILE循环时,count的值都会减去1。然后循环再次从顶部开始,并重新检查条件表达式,看它是否仍然等效于true。经过三次迭代后,循环计数等于0,因为每次迭代的计数都会减去1。因此,逻辑表达式[[ $count -gt 0]]不再等于true,循环结束。通过改变循环内部逻辑表达式中变量的值,我们可以确保逻辑表达式最终等价于false,因此循环最终将结束。
  如果逻辑表达式永远不等于false,那么我们就创建了一个无限循环,因此循环永远不会结束,程序永远运行。显然,我们希望我们的程序最终结束,因此创建无限循环是不可取的。然而,让我们创建一个无限循环,这样我们就知道如果我们的程序无法终止该怎么办。通过一个简单的“错误输入”,我们可以改变上面的程序,使其永远运行,但用加号+替换减号,这样每次迭代后计数总是大于零(并不断增长):

#!/usr/bin/env bash
# File: foreverloop.sh

count=3

while [[ $count -gt 0 ]]
do
  echo "count is equal to $count"
  let count=$count+1              # We only changed this line!
done

  如下是部分运行结果:


...
count is equal to 29026
count is equal to 29027
count is equal to 29028
count is equal to 29029
count is equal to 29030
...

  如果程序正在运行,那么计数会快速增加,你会看到数字在你的终端中飞驰而过!不要担心,你可以使用Control+C终止任何陷入无限循环的程序。使用Control+C返回终端,这样我们就可以继续其他操作。
  在构造WHILE循环时,一定要确保你已经构建了程序,这样循环才会终止!如果while之后的逻辑表达式从未变为false,那么程序将永远运行,这可能不是您为程序计划的行为。
  就像forwhile循环的IF语句可以相互嵌套一样。在下面的示例中,一个FOR循环嵌套在另一个FOR循环中:

#!/usr/bin/env bash
# File: nestedloops.sh

for number in {1..3}
do
  for letter in a b
  do
    echo "number is $number, letter is $letter"
  done
done

  根据我们对FOR循环的了解,尝试在运行程序之前预测该程序将打印出什么。现在你已经写下或打印出你的预测,让我们运行它:

number is 1, letter is a
number is 1, letter is b
number is 2, letter is a
number is 2, letter is b
number is 3, letter is a
number is 3, letter is b

  让我们仔细看看这里发生了什么。最外层的FOR循环开始遍历{1..3}生成的序列。在第一次通过循环时,内循环通过序列a b进行迭代,首先打印数字为1,字母为a,然后数字为1,字母为b。然后完成外循环的第一次迭代,整个过程以数字为2的值重新开始。这个过程将继续通过内循环,直到外循环的顺序耗尽。我再次强烈建议您暂停片刻,根据上面的代码编写一些自己的嵌套循环。在运行程序之前,尝试预测嵌套循环程序将打印什么。如果打印的结果与您的预测不符,请在程序中查找原因。不要只局限于嵌套FOR循环,使用嵌套WHILE循环,或嵌套组合中的FORWHILE循环。
  除了在彼此之间嵌套循环之外,还可以在IF语句中嵌套循环,在循环中嵌套IF语句。让我们看一个例子:

#!/usr/bin/env bash
# File: ifloop.sh

for number in {1..10}
do
  if [[ $number -lt 3 ]] || [[ $number -gt 8 ]]
  then
    echo $number
  fi
done

  在我们运行这个示例之前,请再次尝试猜测输出将是什么:

1
2
9
10

  对于上面循环的每次迭代,都会在IF语句中检查number的值,只有当number超出38的范围时,才会运行echo命令。
  嵌套IF语句和循环有无数种组合,但有一个好的经验法则是,嵌套深度不应超过两层或三层。如果你发现自己写的代码有很多嵌套,你应该考虑重组你的程序。深度嵌套的代码很难阅读,如果您的程序包含错误,则更难调试。

内容总结

  • 循环允许你重复程序的各个部分。
  • FOR循环在一个序列中迭代,这样,在循环的每次迭代中,指定的变量都会取序列中每个元素的值,而WHILE循环则在每次迭代开始时检查条件语句。
  • 如果条件等价于true,则执行循环的一次迭代,然后再次检查条件语句。否则循环就结束了。
  • IF语句和循环可以嵌套,以形成更强大的编程结构。

小试牛刀

  1. 编写几个具有三级嵌套的程序,包括FOR循环、WHILE循环和IF语句。在运行程序之前,请尝试预测程序将要打印的内容。如果结果与你的预测不同,试着找出原因。
  2. 在控制台中输入yes命令,然后停止程序运行。查看yes的手册页,了解更多有关该程序的信息。
🔒 点击查看答案 🔒
# 略

拓展

  上面的循环是用的比较常见的几种,还有until循环和类似C语言的for循环。我们既要有写循环的能力,我们还要有操纵循环的能力,本部分扩展将会介绍。
  until循环与while循环恰好相反,只要不符合判断条件(判断条件失败),就不断循环执行指定的语句。一旦符合判断条件,就退出循环。

until condition; do
  commands
done

  关键字do可以与until不写在同一行,这时两者之间不需要分号分隔。

until condition
do
  commands
done

  下面是一个例子:

$ until false; do echo 'Hi, until looping ...'; done
Hi, until looping ...
Hi, until looping ...
Hi, until looping ...
^C

  上面代码中,until的部分一直为false,导致命令无限运行,必须按下Ctrl + C终止。

#!/bin/bash

number=0
until [ "$number" -ge 10 ]; do
  echo "Number = $number"
  number=$((number + 1))
done

  上面例子中,只要变量number小于10,就会不断加1,直到number大于等于10,就退出循环。
  until的条件部分也可以是一个命令,表示在这个命令执行成功之前,不断重复尝试。

until cp $1 $2; do
  echo 'Attempt to copy failed. waiting...'
  sleep 5
done

上面例子表示,只要cp $1 $2这个命令执行不成功,就5秒钟后再尝试一次,直到成功为止。

until循环都可以转为while循环,只要把条件设为否定即可。上面这个例子可以改写如下。

while ! cp $1 $2; do
  echo 'Attempt to copy failed. waiting...'
  sleep 5
done

  一般来说,until用得比较少,完全可以统一都使用while
  for循环还支持C语言的循环语法。

for (( expression1; expression2; expression3 )); do
  commands
done

  上面代码中,expression1用来初始化循环条件,expression2用来决定循环结束的条件,expression3在每次循环迭代的末尾执行,用于更新值。注意,循环条件放在双重圆括号之中。另外,圆括号之中使用变量,不必加上美元符号$。它等同于下面的while循环。

(( expression1 ))
while (( expression2 )); do
  commands
  (( expression3 ))
done

  下面是一个例子:

for (( i=0; i<5; i=i+1 )); do
  echo $i
done

  上面代码中,初始化变量i的值为0,循环执行的条件是i小于5。每次循环迭代结束时,i的值加1
  for条件部分的三个语句,都可以省略。

for ((;;))
do
  read var
  if [ "$var" = "." ]; then
    break
  fi
done

  上面脚本会反复读取命令行输入,直到用户输入了一个点.为止,才会跳出循环。
  Bash 提供了两个内部命令breakcontinue,用来在循环内部跳出循环。break命令立即终止循环,程序继续执行循环块之后的语句,即不再执行剩下的循环。

#!/bin/bash

for number in 1 2 3 4 5 6
do
  echo "number is $number"
  if [ "$number" = "3" ]; then
    break
  fi
done

  上面例子只会打印3行结果。一旦变量$number等于3,就会跳出循环,不再继续执行。
  continue命令立即终止本轮循环,开始执行下一轮循环。

#!/bin/bash

while read -p "What file do you want to test?" filename
do
  if [ ! -e "$filename" ]; then
    echo "The file does not exist."
    continue
  fi

  echo "You entered a valid file.."
done

  上面例子中,只要用户输入的文件不存在,continue命令就会生效,直接进入下一轮循环(让用户重新输入文件名),不再执行后面的打印语句。
  Bash 还提供了一个比较独特的指令:select。该结构主要用来生成简单的菜单。它的语法与for...in循环基本一致。

select name
[in list]
do
  commands
done

  Bash 会对select依次进行下面的处理。

  1. select生成一个菜单,内容是列表list的每一项,并且每一项前面还有一个数字编号。
  2. Bash 提示用户选择一项,输入它的编号。
  3. 用户输入以后,Bash 会将该项的内容存在变量name,该项的编号存入环境变量REPLY。如果用户没有输入,就按回车键,Bash 会重新输出菜单,让用户选择。
  4. 执行命令体commands
  5. 执行结束后,回到第一步,重复这个过程。

  下面是一个例子:

#!/bin/bash
# select.sh

select brand in Samsung Sony iphone symphony Walton
do
  echo "You have chosen $brand"
done

  执行上面的脚本,Bash 会输出一个品牌的列表,让用户选择:

wingsummer@wingsummer-PC ~ → ./select.sh
1) Samsung
2) Sony
3) iphone
4) symphony
5) Walton
#?

  如果用户没有输入编号,直接按回车键。Bash 就会重新输出一遍这个菜单,直到用户按下Ctrl + C,退出执行。select可以与case结合,针对不同项,执行不同的命令。

#!/bin/bash

echo "Which Operating System do you like?"

select os in Ubuntu LinuxMint Windows8 Windows10 WindowsXP
do
  case $os in
    "Ubuntu"|"LinuxMint")
      echo "I also use $os."
    ;;
    "Windows8" | "Windows10" | "WindowsXP")
      echo "Why don't you try Linux?"
    ;;
    *)
      echo "Invalid entry."
      break
    ;;
  esac
done

  上面例子中,case针对用户选择的不同项,执行不同的命令。

函数

  函数是可以重复使用的代码片段,有利于代码的复用。函数总是在当前 Shell 执行,这是跟脚本的一个重大区别,Bash 会新建一个子 Shell 执行脚本。如果函数与脚本同名,函数会优先执行。但是,函数的优先级不如别名,即如果函数与别名同名,那么别名优先执行。

  Bash 函数定义的语法有两种:

# 第一种
fn() {
  # codes
}

# 第二种
function fn() {
  # codes
}

  上面代码中,fn是自定义的函数名,函数代码就写在大括号之中。这两种写法是等价的。下面是一个简单函数的例子:

hello() {
  echo "Hello $1"
}

  上面代码中,函数体里面的$1表示函数调用时的第一个参数。
  调用函数时,就直接写函数名,参数跟在函数名后面。

wingsummer@wingsummer-PC ~ → hello world
Hello world

  下面是一个多行函数的例子,显示当前日期时间。

today() {
  echo -n "Today's date is: "
  date +"%A, %B %-d, %Y"
}

  删除一个函数,可以使用unset命令。
  函数体内可以使用参数变量,获取函数参数。函数的参数变量,与脚本参数变量是一致的。

  • $1~$9 :函数的第一个到第9个的参数。
  • $0 :函数所在的脚本名。
  • $# :函数的参数总数。
  • $@ :函数的全部参数,参数之间使用空格分隔。
  • $* :函数的全部参数,参数之间使用变量$IFS值的第一个字符分隔,默认为空格,但是可以自定义。

  如果函数的参数多于9个,那么第10个参数可以用${10}的形式引用,以此类推。下面是一个示例脚本test.sh

#!/bin/bash
# test.sh

function alice {
  echo "alice: $@"
  echo "$0: $1 $2 $3 $4"
  echo "$# arguments"

}

alice in wonderland

  运行该脚本,结果如下:

alice: in wonderland
test.sh: in wonderland
2 arguments

  上面例子中,由于函数alice只有第一个和第二个参数,所以第三个和第四个参数为空。下面是一个日志函数的例子:

function log_msg {
  echo "[`date '+ %F %T'` ]: $@"
}

使用方法如下:

wingsummer@wingsummer-PC ~ → log_msg "This is sample log message"
[ 2020-05-13 17:52:34 ]: This is sample log message

  return命令用于从函数返回一个值。函数执行到这条命令,就不再往下执行了,直接返回了。

function func_return_value {
  return 10
}

  函数将返回值返回给调用者。如果命令行直接执行函数,下一个命令可以用$?拿到返回值。

wingsummer@wingsummer-PC ~ → func_return_value
wingsummer@wingsummer-PC ~ → echo "Value returned by function is: $?"
Value returned by function is: 10

  return后面不跟参数,只用于返回也是可以的。

function name {
  commands
  return
}

  Bash 函数体内直接声明的变量,属于全局变量,整个脚本都可以读取。这一点需要特别小心。

# 脚本 test.sh
fn () {
  foo=1
  echo "fn: foo = $foo"
}

fn
echo "global: foo = $foo"

  上面脚本的运行结果如下:

wingsummer@wingsummer-PC ~ → bash test.sh
fn: foo = 1
global: foo = 1

  上面例子中,变量$foo是在函数fn内部声明的,函数体外也可以读取。函数体内不仅可以声明全局变量,还可以修改全局变量。

#! /bin/bash
foo=1

fn () {
  foo=2
}

fn

echo $foo

  上面代码执行后,输出的变量$foo值为2。
  函数里面可以用local命令声明局部变量:

#! /bin/bash
# 脚本 test.sh
fn () {
  local foo
  foo=1
  echo "fn: foo = $foo"
}

fn
echo "global: foo = $foo"

  上面脚本的运行结果如下:

wingsummer@wingsummer-PC ~ → bash test.sh
fn: foo = 1
global: foo =

  上面例子中,local命令声明的$foo变量,只在函数体内有效,函数体外没有定义。

内容总结

  • 函数以function关键字开头,后跟函数名和花括号。
  • 函数是小的、可重用的代码片段,其行为与命令类似。可以使用$1$2$@等变量为函数提供参数,就像Bash脚本一样。
  • 使用local关键字可防止函数创建或修改全局变量。

Bash 陷阱

  我们在编写 Bash 脚本的时候总会犯一些错误。如下是常见的例子,每一个例子在某些方面都有缺陷。如果想看比较完整的,如果有英文能力,可以到 BashPitfalls 进行阅读。

for f in $(ls *.mp3)

  BASH 程序员最常见的错误之一是编写如下循环:

for f in $(ls *.mp3); do    # 错误!
    echo $f                 # 错误!
done

for f in $(ls)              # 错误!
for f in `ls`               # 错误!

for f in $(find . -type f)  # 错误!
for f in `find . -type f`   # 错误!

files=($(find . -type f))   # 错误!
for f in ${files[@]}        # 错误!

  是的,如果您可以将lsfind的输出视为一个文件名列表并对其进行迭代,那就太好了,但你不能。整个方法都有致命的缺陷,没有任何技巧可以让它发挥作用。你必须使用完全不同的方法。
  这至少有6个问题:

  1. 如果文件名包含空格(或当前值$IFS中的任何字符),它将进行分词。假设我们有一个名为01 - Don't Eat the Yellow Snow.mp3的文件。在当前目录中,for循环将迭代生成的文件名中的每个单词:01-Don'tEat等等。
  2. 如果文件名包含glob字符,它将进行文件名扩展。如果ls生成任何包含*字符的输出,则包含该字符的单词将被识别为一个模式,并替换为与之匹配的所有文件名的列表。
  3. 如果命令替换返回多个文件名,则无法判断第一个文件名从何处结束,第二个文件名从何处开始。路径名可以包含除NUL以外的任何字符。是的,这包括新行。
  4. ls实用程序可能会损坏文件名。根据您所在的平台、使用(或未使用)的参数,以及其标准输出是否指向终端,ls可能会随机决定将文件名中的某些字符替换为?,或者干脆不打印。永远不要试图解析ls的输出。ls完全没有必要。它是一个外部命令,其输出专门由人读取,而不是由脚本解析。
  5. 命令替代会从输出中删除所有尾随的换行符。这似乎是可取的,因为ls添加了一个换行符,但如果列表中的最后一个文件名以换行符结尾,$()也将删除该文件名。
  6. ls示例中,如果第一个文件名以连字符开头,可能会导致3号陷阱。

  你也不能简单地重复引用替换词:

for f in "$(ls *.mp3)"; do     # 错误!

  这会导致ls的整个输出被视为一个词。循环将只执行一次,而不是遍历每个文件名,将所有文件名拼凑在一起的字符串分配给f。你也不能简单地把IFS改成新行,文件名也可以包含换行符。
  另一个变体是滥用分词和for循环(错误地)读取文件的行。例如:

IFS=$'\n'
for line in $(cat file); do …     # 错误!

  这不管用,尤其是如果这些行是文件名。Bash就是不能这样工作。那么,正确的方法是什么?
  有几种方法,主要取决于是否需要递归扩展。如果不需要递归,可以使用简单的文件名扩展。代替ls

for file in ./*.mp3; do    # 更好! 并且…
    some command "$file"   # …一定要给扩展变量加双引号
done

  POSIX shell(如Bash)具有专门用于此目的的文件名扩展功能,允许 shell 将模式扩展为匹配文件名的列表。不需要解释外部效用的结果。因为文件名扩展是最后一个扩展步骤,所以每个匹配的./*.mp3正确地扩展,并且不受无引号扩展的影响。但问题是:如果当前目录中没有mp3文件会怎么样呢?然后使用file="./*.mp3"执行一次for循环,这不是预期的行为!解决方法是测试是否存在匹配的文件:

# POSIX
for file in ./*.mp3; do
    [ -e "$file" ] || continue
    some command "$file"
done

  另一个解决方案是使用 Bash 的shopt -s nullglob特性,不过这只能在阅读文档并仔细考虑此设置对脚本中所有其他文件名扩展的影响后才能完成。如果需要递归,标准解决方案是find。使用find时,请确保正确使用它。要实现POSIX sh的可移植性,请使用-exec选项:

find . -type f -name '*.mp3' -exec some command {} \;

# 或者如果命令要获取多个文件输入:

find . -type f -name '*.mp3' -exec some command {} +

  如果您使用的是 bash ,那么您还有两个额外的选项。一种是使用 GNU 或 BSD find-print0选项,以及 bash 的read -d选项和过程替代(ProcessSubstitution):

while IFS= read -r -d '' file; do
  some command "$file"
done < <(find . -type f -name '*.mp3' -print0)

  这里的优点是some command(实际上是整个while循环体)在当前shell中执行。您可以设置变量,并在循环结束后让它们保持不变。
  Bash 4.0及更高版本中提供的另一个选项是globstar,它允许递归地扩展glob

shopt -s globstar
for file in ./**/*.mp3; do
  some command "$file"
done

以破折号开头的文件名

  带前导破折号的文件名可能会导致许多问题,像*.mp3被分类到一个扩展列表中(根据您当前的语言环境),并且在大多数语言环境中,在字母之前排序。然后将列表传递给某个命令,该命令可能会错误地将-filename解释为一个选项。这有两个主要的解决方案。一种解决方案是在命令(如cp)及其参数之间插入。这告诉它停止扫描选项,一切都很好:

cp -- "$file" "$target"

  这种方法存在潜在的问题。您必须确保插入--在可能被解释为选项的上下文中,每次使用参数时都要插入--这很容易遗漏,并且可能涉及大量冗余。大多数编写良好的选项解析库都理解这一点,正确使用它们的程序应该自由继承该功能。然而,仍然要知道,最终由应用程序来识别结束选项。一些手动解析选项、错误解析选项或使用糟糕的第三方库的程序可能无法识别。除了 POSIX 指定的一些例外情况,比如echo
  另一个解决方案是通过使用相对或绝对路径名来确保文件名始终以目录开头:

for i in ./*.mp3; do
    cp "$i" /target
    …
done

  在这种情况下,即使我们有一个名称以-开头的文件,文件名扩展也将确保变量扩展为类似./-foo.mp3想形式。就cp而言,这是完全安全的。
  最后,如果可以保证所有结果都具有相同的前缀,并且在循环体中只使用变量几次,则可以简单地将前缀与扩展连接起来。这在理论上节省了为每个词生成和存储几个额外字符的时间。

for i in *.mp3; do
    cp "./$i" /target
    …
done

[ $foo = "bar" ]

  这种写法是有比较大的问题的,此示例可能因以下几个原因而中断出错:

  • 如果foo变量不存在或者是空的,最后结果就是这样的:

    [ = "bar" ] # 错误!
    

    这会抛出unary operator expected异常。

  • 如果foo变量中含有空格,结果会和下面的比较类似:

    [ multiple words here = "bar" ]
    

    这会导致语法错误,正确的写法应该是这样的:

    # POSIX
    [ "$foo" = bar ] # 正确!
    

    即使$foo-开头,这在符合POSIX的实现上也可以很好地工作,因为POSIX[根据传递给它的参数的数量来确定其操作。只有非常古老的shell才有这个问题,在编写新代码时,您不必担心它们。

  在 Bash 和许多其他类似 ksh 的 shell 中,有一个更好的选择,它使用[[]]关键字。

# Bash / Ksh
[[ $foo == bar ]] # 正确!

  您不需要在[[]]中的=左侧引用变量加双引号,因为它们不会进行分词或全局搜索,即使是空白变量也会得到正确处理。另一方面,引用它们也不会有什么坏处。与[test不同,你也可以使用功能相同的==。但是请注意,使用[]进行的比较会对右侧的字符串执行模式匹配,而不仅仅是简单的字符串比较。要使字符串位于正确的文字上,如果使用了在模式匹配上下文中具有特殊意义的任何字符,则必须给它加上双引号。

# Bash / Ksh
match=b*r
[[ $foo == "$match" ]] # 不错! 未加双引号也将与 b*r 匹配.

  你可能见过这样的代码:

# POSIX / Bourne
[ x"$foo" = xbar ] # 可以,但通常没必要.

  必须在非常古老的 shell 上运行的代码需要x"$foo"技巧,这些 shell 缺少[[并且有一个更原始的[,如果$foo-!(开头,则会产生混淆,在上述较旧的系统上,[只需要对=左侧的标记格外小心,这个技巧它能正确处理右侧的标记。

[ "$foo" = bar && "$bar" = foo ]

  不能在旧的test[]命令中使用&&命令。Bash 解析器会看到[[]](())之外的&&命令,并将命令分为两个命令,在&&命令之前和之后。请使用以下选项之一:

[ bar = "$foo" ] && [ foo = "$bar" ] # 正确! (POSIX)
[[ $foo = bar && $bar = foo ]]       # 正确! (Bash / Ksh)

[[ $foo > 7 ]]

  这里有很多问题。第一[[]]命令不应仅用于计算算术表达式。它应用于涉及受支持的test运算符之一的测试表达式。虽然从技术上讲,您可以使用[[]]的一些运算符进行数学运算,但只有与表达式中某个位置的非数学测试运算符结合使用才有意义。如果您只想进行数值比较(或任何其他shell算法),只使用(())要好得多:

# Bash / Ksh
((foo > 7))     # 正确!
[[ foo -gt 7 ]] # 能用,但不常用,建议用 ((…))

  如果在[[]]内使用>运算符,则会将其视为字符串比较(按区域设置测试排序顺序),而不是整数比较。这有时可能有效,但在你最意想不到的时候就会失败。如果在[]内使用>则更糟糕,这是一个输出重定向。您将在目录中获得一个名为7的文件,只要$foo不为空,test就会成功。
  如果严格的 POSIX 一致性是一项要求,并且(())不可用,则使用[]的正确替代方案是:

# POSIX
[ "$foo" -gt 7 ]       # 正确!
[ "$((foo > 7))" -ne 0 ] # 兼容 POSIX ,和 (()) 一样的功能,可以做更复杂的数学运算

  如果$foo的内容没有经过验证,并且超出了你的控制(例如,如果它们来自外部源),那么除了["$foo" -gt 7]之外的所有内容都构成了命令注入漏洞,因为$foo的内容被解释为算术表达式。例如,a[$(reboot)]的算术表达式在计算时会运行reboot命令。[]里面要求操作数为十进制整数,因此不受影响。但引用$foo非常关键,否则仍然会出现漏洞。
  如果无法保证任何算术上下文,包括变量定义、变量引用、数值比较的测试表达式的输入,则必须始终在计算表达式之前验证输入。

# POSIX
case $foo in
    ("" | *[!0123456789]*)
        printf '$foo is not a sequence of decimal digits: "%s"\n' "$foo" >&2
        exit 1
        ;;
    *)
        [ "$foo" -gt 7 ]
esac

if [bar="$foo"]; then …

[bar="$foo"]     # 错!
[ bar="$foo" ]   # 还错!
[bar = "$foo"]   # 也错了!
[[bar="$foo"]]   # 又错了!
[[ bar="$foo" ]] # 猜一猜?还是错了!
[[bar = "$foo"]] # 我还有必要说这个吗?

  正如前一个例子中所解释的,[是一个命令,可以用type -t [whence -v [来证明。就像其他任何简单的命令一样,Bash 希望该命令后面有一个空格,然后是第一个参数,然后是另一个空格等等。如果不把空格放进去,就不能把所有的东西都放在一起运行!以下是正确的方法:

if [ bar = "$foo" ]; then …

if [[ bar = "$foo" ]]; then …

read $foo

  在read命令中,变量名前不能使用$。如果要将数据放入名为foo的变量中,可以这样做:

read foo

  如果想更安全的写法:

IFS= read -r foo

  这将读取一行输入,并将其放入名为$foo的变量中。如果你真的想把foo作为对其他变量的引用,这可能会很有用;但在大多数情况下,这只是一个bug

cat file | sed s/foo/bar/ > file

  不能在同一管道中读取和写入文件。根据管道所做的工作,文件可能会被删除,或者它可能会增长,直到填满可用磁盘空间,或者达到操作系统的文件大小限制或配额,等等。
  如果希望安全地对文件进行更改,而不是附加到文件末尾,请使用文本编辑器。

printf %s\\n ',s/foo/bar/g' w q | ed -s file

  如果您正在做的事情无法用文本编辑器完成,则必须在某个点创建一个临时文件。例如,以下是完全可移植的:

sed 's/foo/bar/g' file > tmpfile && mv tmpfile file

  以下内容仅适用于GNU sed 4.x:

sed -i 's/foo/bar/g' file(s)

echo $foo

  这个看起来相对人畜无害的命令引起了巨大的混乱。因为$foo没有被引用,它不仅会被分词,还会被文件替换。这会误导 Bash 程序员,让他们认为自己的变量包含错误的值,而事实上变量是可以的,只是单词拆分或文件名扩展扰乱了他们对所发生事情的看法。

msg="Please enter a file name of the form *.zip"
echo $msg

  此消息被拆分为多个单词,任何文件名扩展都会展开:

Please enter a file name of the form freenfss.zip lw35nfss.zip

echo <<EOF

  <<是在脚本中嵌入大量文本数据的有用工具。它会将脚本中的文本行重定向到命令的标准输入。不幸的是,echo不是从stdin读取的命令。

# 如下是错误的示例:
wingsummer@wingsummer-PC ~ → echo <<EOF
Hello world
How's it going?
EOF

# 你试图这么做:
wingsummer@wingsummer-PC ~ → cat <<EOF
Hello world
How's it going?
EOF

# 或者使用内置命令 echo :
wingsummer@wingsummer-PC ~ → echo "Hello world
How's it going?"

  使用这样的引号很好,它在所有 shell 中都非常有效,但它不允许您只在脚本中插入一行代码。第一行和最后一行都有语法标记。如果你想让您的行不受 shell 语法的影响,并且不想生成cat命令,那么还有另一种选择:

 # 或者使用内置命令 printf :
  printf %s "\
  Hello world
  How's it going?
  "

cd /foo; bar

  如果不检查cd命令中的错误,可能会在错误的位置执行bar。例如,如果bar恰好是rm -f *,这可能是一场重大灾难。故必须始终检查cd命令中的错误,最简单的方法是:

cd /foo && bar

cmd1 && cmd2 || cmd3

  有些人试图使用&&||作为if…then…else…fi的快捷语法,可能是因为他们认为自己很聪明。例如:

# 错误!
[[ -s $errorlog ]] && echo "Uh oh, there were some errors." || echo "Successful."

  然而,在一般情况下,这种构造并不完全等同于if…fi&&之后的命令也会生成退出状态,如果退出状态不是true,那么||之后的命令也会被调用。例如:

 i=0
 true && ((i++)) || ((i--))  # 错!
 echo "$i"                   # 打印 0

echo "Hello World!"

  这里的问题是,在交互式 Bash shell(在4.3之前的版本中)中,您会看到如下错误:

bash: !": event not found

  这是因为,在交互式 shel l的默认设置中,Bash 使用感叹号执行csh风格的历史扩展。这在 shell 脚本中不是问题;只有在交互式shell中。不幸的是,显然试图“修复”这一问题是行不通的:

wingsummer@wingsummer-PC ~ → echo "hi\!"
hi\!

  最简单的解决方案是取消histexpand选项:这可以通过set +Hset +o histexpand完成。

for arg in $*

  Bash(和所有 Bourne Shell 一样)有一种特殊的语法,可以一次引用一个位置参数列表,而$*不是吗,$@也不是。这两个参数都会扩展到脚本参数中的单词列表,而不是作为单独的单词扩展到每个参数。正确的语法是:

for arg in "$@"

# 或者就:
for arg

  由于在脚本中循环位置参数是很常见的事情,所以for arg$@中默认为for arg"$@"是一种特殊的语法,它可以将每个参数用作单个单词(或单个循环迭代),这是你至少99%的时间应该使用的东西。
  如下是一个例子:

# 不正确的版本
for x in $*; do
  echo "parameter: '$x'"
done

wingsummer@wingsummer-PC ~ → ./myscript 'arg 1' arg2 arg3
parameter: 'arg'
parameter: '1'
parameter: 'arg2'
parameter: 'arg3'

  这个应该这样写:

# Correct version
for x in "$@"; do
 echo "parameter: '$x'"
done
# or better:
for x do
  echo "parameter: '$x'"
done

wingsummer@wingsummer-PC ~ → ./myscript 'arg 1' arg2 arg3
parameter: 'arg 1'
parameter: 'arg2'
parameter: 'arg3'

function foo()

  这在某些 Shell 中有效,但在其他 Shell 中无效。在定义函数时,永远不要将关键字function与括号()组合在一起。

printf "$foo"

  这不是因为引号错误,而是因为一个格式字符串漏洞。如果$foo不在你的严格控制之下,那么变量中的任何\%字符都可能导致不期望的行为。要始终提供自己的格式字符串:

printf %s "$foo"
printf '%s\n' "$foo"

小结

  如果认真学习玩这两篇,再加上基础的练习,就可以写一个 Bash 脚本了。一定要多加练习,光学不练假把式。当然仅仅学这两篇顶多是入门,还需要之后的练习和经验来提升这方面的水平。

posted @ 2022-05-14 11:32  寂静的羽夏  阅读(417)  评论(0编辑  收藏  举报