写在前面

  该文章根据 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 脚本了。一定要多加练习,光学不练假把式。当然仅仅学这两篇顶多是入门,还需要之后的练习和经验来提升这方面的水平。

羽夏 Bash 简明教程(下)的更多相关文章

  1. 羽夏 Bash 简明教程(上)

    写在前面   该文章根据 the unix workbench 中的 Bash Programming 进行汉化处理并作出自己的整理,并参考 Bash 脚本教程 和 BashPitfalls 相关内容 ...

  2. 羽夏 MakeFile 简明教程

    写在前面   此系列是本人一个字一个字码出来的,包括示例和实验截图.该文章根据 GNU Make Manual 进行汉化处理并作出自己的整理,一是我对 Make 的学习记录,二是对大家学习 MakeF ...

  3. Bash简明教程--变量

    1. 前言 Bash是一门流行在*nix系统下的脚本语言.作为一门脚本语言,变量是一门语言的基本要素,在这篇教程中,我们将学习Bash中的变量是怎么表示的,以及变量相关的一些语法规则. 2. Bash ...

  4. 羽夏看Linux内核——引导启动(下)

    写在前面   此系列是本人一个字一个字码出来的,包括示例和实验截图.如有好的建议,欢迎反馈.码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作.如想转载,请把我的转载信息附在文章后面,并 ...

  5. Qemu下安装Sun Solairs8简明教程 转

    http://blog.csdn.net/stonesharp/article/details/8928393 Qemu下安装Sun Solairs8简明教程(Centos6. / Win7) 作者: ...

  6. 羽夏看Linux内核——环境搭建

    写在前面   此系列是本人一个字一个字码出来的,包括示例和实验截图.如有好的建议,欢迎反馈.码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作.如想转载,请把我的转载信息附在文章后面,并 ...

  7. Docker简明教程

    Docker简明教程 [编者的话]使用Docker来写代码更高效并能有效提升自己的技能.Docker能打包你的开发环境,消除包的依赖冲突,并通过集装箱式的应用来减少开发时间和学习时间. Docker作 ...

  8. (五)羽夏看C语言——结构体与类

    写在前面   由于此系列是本人一个字一个字码出来的,包括示例和实验截图.本人非计算机专业,可能对本教程涉及的事物没有了解的足够深入,如有错误,欢迎批评指正. 如有好的建议,欢迎反馈.码字不易,如果本篇 ...

  9. (四)羽夏看C语言——循环与跳转

    写在前面   由于此系列是本人一个字一个字码出来的,包括示例和实验截图.本人非计算机专业,可能对本教程涉及的事物没有了解的足够深入,如有错误,欢迎批评指正. 如有好的建议,欢迎反馈.码字不易,如果本篇 ...

随机推荐

  1. 数据库遇到的问题之“datetime设置默认为CURRENT_TIMESTAMP时报无效默认问题”和“时区问题”

    一.问题1 问题描述: 今日加入创建时间和修改时间,并设置为默认CURRENT_TIMESTAMP时,出现错误,指向sql中的datetime字段,查了一下,发现是版本问题 立马查询自己的MySQL版 ...

  2. C++ | 程序编译连接原理

    文章目录 预编译(生成*.i文件) 编译(生成*.s文件) 汇编(生成*.o文件,也叫目标文件) 链接(生成*.exe文件,也叫可执行文件) 汇编--目标文件 查看文件头 查看符号表 查看 .o 文件 ...

  3. jupyter notebook使用技巧

    shift + tab 键可以查看对应源代码(注意:需要先将代码运行才能查看) Jupyter Notebook 的快捷键 Jupyter Notebook 有两种键盘输入模式:1.命令模式,键盘输入 ...

  4. 解决SVG animation 在IE中不起作用

    问题描述 CSS animation没办法解决SVG路径运动的问题,下图路径运动的过程,通过查资料发现所有的IE的版本都不支持SVG animation.在IE中没有水流动的效果. 主要代码 < ...

  5. 结合CSS3的布局新特征谈谈常见布局方法

    写在前面最近看到<图解CSS3>的布局部分,结合自己以前阅读过的一些布局方面的知识,这里进行一次基于CSS2.3的各种布局的方法总结. 常见的页面布局 在拿到设计稿时,作为一个前端人员,我 ...

  6. 五款轻量型bug管理工具横向测评

    五款轻量型bug管理工具横向测评 最近正在使用的本地bug管理软件又出问题了,已经记不清这是第几次了,每次出现问题都要耗费大量的时间精力去网上寻找解决方案,劳心劳力.为了避免再次出现这样的情况,我决定 ...

  7. 一块小饼干(Cookie)的故事-上篇

    cookie 如果非要用汉语理解的话应该是 一段小型文本文件,由网景的创始人之一的卢 蒙特利在93年发明. 上篇是熟悉一下注册的大致流程,下篇熟悉登录流程以及真正的Cookie 实现基本的注册功能 我 ...

  8. Spark入门之环境搭建

    本教程是虚拟机搭建Spark环境和用idea编写脚本 一.前提准备 需要已经有搭建好的虚拟机环境,具体见教程大数据学习之路又之从小白到用sqoop导出数据 - 我试试这个昵称好使不 - 博客园 (cn ...

  9. vue日历(纯 js,没用任何插件和组件)

    效果图: 代码:   <template> <div class="calender"> <div class="top"> ...

  10. css过渡效果和盒子缩放大小的结合

    给盒子一个鼠标经过时放大里面的图片效果在css中使用过渡效果transition结合 <html lang="en"> <head> <meta ch ...