没人谈论的 Shell 编程秘诀(第一部分)





5.00/5 (27投票s)
关于那些容易被遗忘或忽略的、奇怪但至关重要的 Shell 编程细节
去年,我写了一本关于 Linux 命令行技巧的书(见下文),并对其进行了多次更新。令人恼火的是,我几乎每周都能发现关于 bash
Shell 程序的一些新的、重要的东西。我可不希望在我订购了作者副本后才发生这种情况。这些发现让我wonder,我这些年究竟是怎么过来的,竟然不知道这些 bash
秘密。
sh 和 bash 不同
Bourne Shell (sh
) 程序诞生于 70 年代的 Unix 操作系统。在 Ubuntu Linux 中,仍然保留着这个旧的 Shell 以及它新的化身——Bourne-Again Shell (bash
)。在命令行中尝试 bash -version
,它会显示版本号。尝试 sh -version
,你会得到一个错误。两者是不同的。虽然 sh
仍然是一个古老的遗迹,但 bash
仍在不断开发中,拥有更多功能。
我(在 90 年代末)习惯用 sh
命令在 SCO Unix 上运行我的 Shell 脚本。在 Ubuntu 中我也继续这样做,却发现很多在线脚本示例在它那里不起作用。(作为一种安全措施,我从不给我的脚本添加 .sh
扩展名或 +x
权限。我的脚本保持匿名,使用一个无害的 .txt
扩展名。)
意识到这个问题后,许多脚本作者会在第一行放置注释 #!/bin/bash
。这个注释可以确保即使脚本是通过 sh
调用,也会用 bash
来运行。
一些狂热分子则使用注释 #!/usr/bin/env bash
作为更保险的措施。他们说 bash
不一定总是在 /bin
目录下,所以最好让 env
来查找它。这样,他们就假设 env
总能在 /usr/bin
找到。对我来说,这有点夸张了。如果你像大多数人一样使用 Ubuntu,那么 #!/bin/bash
就足够了。
if 语句并非如你所见
Shell 的 if
语句非常不寻常。
if test-expression; then
statements;
else
statements;
fi
test-expression
需要返回 0 (零) 才被视为真,返回任何非零值才被视为假。在大多数语言中,1 (一) 表示真,0 (零) 表示假。为什么 bash
的行为不同?
这是因为 Shell 脚本经常需要确定其他程序是如何执行的。它们通过读取这些程序的退出值来做到这一点。按照惯例,当一个程序在没有错误的情况下退出时,它会以退出码 0 (零) 将控制权返回给调用程序(Shell 程序)。如果它在遇到错误后需要退出,它会以非零退出码返回。为了帮助故障排除,程序作者会为每个非零退出码赋予特殊的含义。
因此,在 if
语句中,test-expression
可以是一个程序。如果程序成功执行并向 Shell 返回 0,那么 if
语句的表现就像它被评估为 true
。如果程序以非零值退出,那么 if
语句的表现就像它被评估为 false
。
你需要记住的是,if
语句并不是在寻找布尔值 true
或 false
。
这是否意味着 if true; then
会被评估为 false
,因为它不是 0
(零)?不!
这就引出了 Shell 的另一个奇怪特性。true
实际上是一个程序!它不是 Shell 语言的一部分。在 Ubuntu 中,它位于 /usr/bin/true,并且以返回码 0
退出。还有一个 false
程序位于 /usr/bin/false,并以返回码 1
退出。
[ 是一个程序
要检查文件是否存在,可以使用 if [ -f the-file.ext ]; then
。这里的单个方括号 [
不是语言的一部分。它是一个位于 /usr/bin/[ 的程序,它的参数是:-f
、the-file.ext
和 ]
。
为了确保 [
命令被正确执行,在开括号后面和闭括号前面必须有一个空格。如果省略前者,你将无法调用正确的程序。如果省略后者,你未能以正确的闭合参数终止命令。
注意字符串比较中的空格
当你给 string
变量赋值时,不要在 =
号前后留有任何空格。否则,Shell 会认为该变量是一个命令,而 =
和试图赋给变量的值是它的参数。
当你检查两个 string
是否相等时,一定要在 =
号前后留有空格。如果不这样做,[
程序会认为你正在尝试赋值。这个赋值语句的退出值为 0 (零)。这意味着 if
语句将总是被强制评估为 true
!
# Causes an error because 'sTest' looks like a command
# and '=' and '"hello"' become its arguments
sTest = "hello"
# Assigns string variable correctly
sTest="hello"
# Temporary assignment evaluates to true whatever the value
if [ "$sTest"="hellooooooooo" ]; then
echo "Yep"
else
echo "Nope"
fi
# String comparison evaluates to true
if [ "$sTest" = "hello" ]; then
echo "Yep"
else
echo "Nope"
fi
[[ 不是 [ 的安全版本
与 [
(它是一个程序)不同,[[
构造是 Shell 语言的一部分。网上有一些误导性的说法建议你将所有的 [
评估替换为 [[
。请不要采信这些建议。
[[
用于更字面地评估文本 string
。你不必为所有内容加上引号。
- 单词和文件名不会被展开。但是,会执行其他形式的展开,例如参数扩展和命令替换。
=
操作符的行为与[
中的=
或==
操作符类似。!=
和==
操作符将左边的文本表达式与右边的模式进行比较。- 模式是包含至少一个通配符(
*
或?
)或方括号表达式[..]
的文本string
。方括号表达式包含一组字符或字符范围(由连字符 (-) 分隔),并用方括号([
和]
)括起来。
- 模式是包含至少一个通配符(
- 有一个新的
=~
操作符可用。(不能与[
一起使用。)它将左边的文本与右边的正则表达式进行比较。(如果正则表达式无效,它将以返回码 2 退出。)=~
操作符非常适合匹配子字符串。# Matches substring ell $ if [[ "Hello?" =~ ell ]]; then echo "Yes"; else echo "No"; fi Yes # Matches substring Hell at beginning $ if [[ "Hello?" =~ ^Hell ]]; then echo "Yes"; else echo "No"; fi Yes # Does not match substring ? (a regex special character) at the end $ if [[ "Hello?" =~ ?$ ]]; then echo "Yes"; else echo "No"; fi No # Matches substring ? at the end when quoted $ if [[ "Hello?" =~ "?"$ ]]; then echo "Yes"; else echo "No"; fi Yes
[
和 [[
的评估都有其合法的用例。不要混淆使用。
操作符用法 | 结果 |
---|---|
[ -f "$file" ] | 文件是否存在? |
[ -d "$file" ] | 目录是否存在? |
[ -h "$file" ] | 是否为符号链接? |
[ -r "$file" ] | 文件是否可读? |
[ -w "$file" ] | 文件是否可写? |
[ -x "$file" ] | 文件是否可执行? |
[ -z "$string" ] | 字符串是否为空? |
[ -n "$string" ] | 字符串是否非空? |
[ "$string1" = "$string2" ] | 字符串是否相同?= 等同于 == |
[ "$string1" != "$string2" ] | 字符串是否不同? |
[ "$string1" < "$string2" ] | 第一个字符串是否排在第二个字符串前面? |
[ "$string1" > "$string2" ] | 第一个字符串是否排在第二个字符串后面? |
[ n1 -eq n2 ] | 数字是否相同? |
[ n1 -ne n2 ] | 数字是否不同? |
[ n1 -le n2 ] | n1 是否小于或等于 n2 ? |
[ n1 -ge n2 ] | n1 是否大于或等于 n2 ? |
[ n1 -lt n2 ] | n1 是否小于 n2 ? |
[ n1 -gt n2 ] | n1 是否大于 n2 ? |
[ ! e ] | 表达式是否为假 |
[ e1 ] && [ e2 ] | 两个表达式是否都为真? |
[ e1 ] || [ e2 ] | 两个表达式是否有一个为真? |
不要使用 -a
和 -o
逻辑运算符。你会在读写它们时出错。它们是 sh
的做法。方括号和运算符 &&
和 ||
才是 bash
的风格。
算术运算并非直观
如果你设置 a=1
,然后尝试 a=a+1
,$a
会回显 2 还是 11?答案是 a+1
。直到几年前,我都不知道如何在 bash
中执行算术运算。我从未需要过,所以从未学过。我只是假设它必须和其他语言一样,但事实并非如此。要将一加一,你可以使用
let a=a+1
# or
a=$(( a+1 ))
数组操作可能令人费解
是不是每个语言都需要有完全不同的方法来创建和使用数组?谁这么邪恶?为什么?
# Creates an array
var=(hello world how are you)
# Displays hello
echo $var
# Displays how
echo ${var[2]}
# Changes hello to howdy
var[0]=howdy
# Displays howdy
echo ${var[0]}
# Displays values — howdy world how are you
echo ${var[@]}
# Displays values — howdy world how are you
echo ${var[*]}
# Displays indexes or keys — 0 1 2 3 4
echo ${!var[@]}
# Displays indexes or keys — 0 1 2 3 4
echo ${!var[*]}
# Displays dy
echo ${var:3:2}
# Displays rld
echo ${var[1]:2:3}
# Displays 5, the number of variables in the array
echo ${#var}
Bash 是粗心错误的雷区
Shell 脚本会像永远不会停止一样执行,无论它遇到什么错误。如果一个语句遇到错误并以非零退出码退出,bash
会很乐意显示任何它想显示的错误消息,但会若无其事地继续执行后续语句。
如果你试图使用一个未定义的变量,bash
不会将其视为错误。bash
会替换为空字符串并继续。如果你尝试 sudo rm -rf $non-existent-variable/
,该命令将被评估为 sudo rm -rf /
。我还没试过,所以无法告知 Linux 有哪些保护措施。
这些 Shell 的行为极其危险。为了尽早失败,请在脚本顶部添加以下语句。
set -eu
也就是说,在 #!/bin/bash
注释之后。
选项 -u
禁止使用未定义的变量。选项 -e
在遇到错误时停止脚本执行。这在构建脚本时很方便。它的缺点是你的代码将永远没有机会评估前一个语句的错误代码。如果你正在使用 if-else
结构来检查先前的错误代码,那么请使用 set -u
。还有一个用于详细错误信息的 -x
选项。
在一篇文章中不可能涵盖所有 bash
秘密。在下一篇文章中,我将介绍 bash
如何执行文本扩展、替换和删除。
注释
- 本文最初发表于 Open Source For You 杂志(2022 年)。我于 2023 年重新发布在 CodeProject 上。
- 我将《Linux Command-Line Tips & Tricks》的电子书版本免费提供给许多电子书商店。本文摘自其中。