羽夏 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。
- 可以使用数组的索引来访问数组的各个元素。
小试牛刀
- 编写一个 bash 脚本,在脚本中定义一个数组,脚本的第一个参数指示运行脚本时打印到控制台的数组元素的索引。
- 编写一个 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
命令。
小试牛刀
- 使用大括号展开创建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
。循环的每次迭代中执行的代码都是在do
和done
之间编写的。在循环的第一次迭代中,变量$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
后面都会声明一个变量名,而变量的值在循环的每次迭代中都会发生变化,直到相应的序列用完为止。现在你应该花点时间自己写几个FO
R循环,用我们已经讨论过的所有方法生成序列,只是为了加强你对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
,那么程序将永远运行,这可能不是您为程序计划的行为。
就像for
和while
循环的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
循环,或嵌套组合中的FOR
和WHILE
循环。
除了在彼此之间嵌套循环之外,还可以在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
超出3
到8
的范围时,才会运行echo
命令。
嵌套IF
语句和循环有无数种组合,但有一个好的经验法则是,嵌套深度不应超过两层或三层。如果你发现自己写的代码有很多嵌套,你应该考虑重组你的程序。深度嵌套的代码很难阅读,如果您的程序包含错误,则更难调试。
内容总结
- 循环允许你重复程序的各个部分。
FOR
循环在一个序列中迭代,这样,在循环的每次迭代中,指定的变量都会取序列中每个元素的值,而WHILE
循环则在每次迭代开始时检查条件语句。- 如果条件等价于
true
,则执行循环的一次迭代,然后再次检查条件语句。否则循环就结束了。 IF
语句和循环可以嵌套,以形成更强大的编程结构。
小试牛刀
- 编写几个具有三级嵌套的程序,包括
FOR
循环、WHILE
循环和IF
语句。在运行程序之前,请尝试预测程序将要打印的内容。如果结果与你的预测不同,试着找出原因。 - 在控制台中输入
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 提供了两个内部命令break
和continue
,用来在循环内部跳出循环。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
依次进行下面的处理。
select
生成一个菜单,内容是列表list
的每一项,并且每一项前面还有一个数字编号。- Bash 提示用户选择一项,输入它的编号。
- 用户输入以后,Bash 会将该项的内容存在变量
name
,该项的编号存入环境变量REPLY
。如果用户没有输入,就按回车键,Bash 会重新输出菜单,让用户选择。 - 执行命令体
commands
。 - 执行结束后,回到第一步,重复这个过程。
下面是一个例子:
#!/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[@]} # 错误!
是的,如果您可以将ls
或find
的输出视为一个文件名列表并对其进行迭代,那就太好了,但你不能。整个方法都有致命的缺陷,没有任何技巧可以让它发挥作用。你必须使用完全不同的方法。
这至少有6个问题:
- 如果文件名包含空格(或当前值
$IFS
中的任何字符),它将进行分词。假设我们有一个名为01 - Don't Eat the Yellow Snow.mp3
的文件。在当前目录中,for
循环将迭代生成的文件名中的每个单词:01
、-
、Don't
、Eat
等等。 - 如果文件名包含
glob
字符,它将进行文件名扩展。如果ls
生成任何包含*
字符的输出,则包含该字符的单词将被识别为一个模式,并替换为与之匹配的所有文件名的列表。 - 如果命令替换返回多个文件名,则无法判断第一个文件名从何处结束,第二个文件名从何处开始。路径名可以包含除
NUL
以外的任何字符。是的,这包括新行。 ls
实用程序可能会损坏文件名。根据您所在的平台、使用(或未使用)的参数,以及其标准输出是否指向终端,ls
可能会随机决定将文件名中的某些字符替换为?
,或者干脆不打印。永远不要试图解析ls
的输出。ls
完全没有必要。它是一个外部命令,其输出专门由人读取,而不是由脚本解析。- 命令替代会从输出中删除所有尾随的换行符。这似乎是可取的,因为
ls
添加了一个换行符,但如果列表中的最后一个文件名以换行符结尾,…
或$()
也将删除该文件名。 - 在
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 +H
或set +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 脚本了。一定要多加练习,光学不练假把式。当然仅仅学这两篇顶多是入门,还需要之后的练习和经验来提升这方面的水平。
本文来自博客园,作者:寂静的羽夏 ,一个热爱计算机技术的菜鸟
转载请注明原文链接:https://www.cnblogs.com/wingsummer/p/16269499.html