65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (27投票s)

2023年2月28日

CPOL

8分钟阅读

viewsIcon

44184

关于那些容易被遗忘或忽略的、奇怪但至关重要的 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

Test expression

if 语句评估命令并检查它们的退出值。它不评估表达式的真假。

你需要记住的是,if 语句并不是在寻找布尔值 truefalse

这是否意味着 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/[ 的程序,它的参数是:-fthe-file.ext]

Programs used in if statements

看似关键字或编程结构的东西,实际上是程序。

为了确保 [ 命令被正确执行,在开括号后面和闭括号前面必须有一个空格。如果省略前者,你将无法调用正确的程序。如果省略后者,你未能以正确的闭合参数终止命令。

注意字符串比较中的空格

当你给 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 年重新发布在 上。
  • 我将《Linux Command-Line Tips & Tricks》的电子书版本免费提供给许多电子书商店。本文摘自其中。
© . All rights reserved.