shell变量
- 14、设置默认值
- 15、使用空值作为有效的默认值
- 16、不只使用字符串常量作为默认值
- 17、对不存在的参数输出错误消息
- 18、修改部分字符串
- 19、获得某个数的绝对值
- 20、用bash实现basename
- 21、用bash实现dirname
- 22、选取CSV的替换值
- 23、使用数组变量
- 24、转换大小写
- 25、转换为驼峰命名法
14、设置默认值
你的脚本依赖于某些常用(如 $USER)或业务特定的环境变量。要想构建一个稳健的 shell 脚本,就得确保这些变量都有合理的默认值。那么该如何确保呢?
首次引用 shell 变量时,如果该变量没有值,则使用赋值运算符为其赋值。
cd${HOME:=/tmp}示例中所引用的$HOME会返回其当前值,除非它为空或者压根就没设置。对于后两种情况(为空或没有设置),返回 /tmp,该值还会被赋给$HOME,随后再引用$HOME的话,返回的就是这个新值。
我们来看一下实际应用。
$echo${HOME:=/tmp}/home/uid002 $unsetHOME# 这么做通常并非明智之举$echo${HOME:=/tmp}/tmp $echo$HOME/tmp $cd;pwd/tmp $一旦删除该变量,它就不再具有任何值。然后,用 := 运算符作为变量引用的一部分时,$HOME 被替换成了新值(/tmp)。接下来引用 $HOME 返回的都是这个新值。
记住,赋值运算符有一个重要的例外:不能对位置参数(如$1或$*)赋值。在这种情况下,可以使用:-(如${1:-default}),该表达式只返回值,但不进行赋值。
顺便说一下,注意${VAR:=value}和${VAR:-value}在形式上的差异,也许可以帮助你记忆这两种让人抓狂的符号。:= 执行赋值操作,同时返回运算符右侧的值。:- 只做了前者一半的工作:返回值,但不赋值。因此,它的符号也只有等号的一半(一个横杠,而不是两个)。
15、使用空值作为有效的默认值
你需要设置默认值,但希望空字符串也能作为有效值。你只想在变量不存在时才替换默认值。
${:=}运算符在两种情况下会使用新值:一种是指定的shell 变量不存在(或被明确删除);另一种是变量存在,但值为空,就像HOME=""或HOME=$OTHER($OTHER没有值)这样。
shell 能够区分这两种情况,忽略冒号(:)表示只希望在变量不存在时才进行替换。如果写成不带冒号的${HOME=/tmp},那么赋值操作仅会在该变量不存在的情况下(从未设置或已明确删除)发生。
我们再用 $HOME 变量演示一次,但这次不使用冒号。
$echo${HOME=/tmp}# 不会被替换/home/uid002 $HOME=""# 通常并非明智之举$echo${HOME=/tmp}# 不会被替换$unsetHOME# 通常并非明智之举$echo${HOME=/tmp}# 会被替换/tmp $echo$HOME/tmp $这个示例将 $HOME 设置为空字符串,虽然为空,但毕竟也是有效值,因此 = 运算符并不会替换 $HOME 的内容。但如果删除该变量,则会发生替换。要想允许出现空字符串,使用不带冒号的 = 即可。但大部分时候是使用 :=,因为无论你是否有意,空值基本上没什么用处。
16、不只使用字符串常量作为默认值
你不想只使用字符串常量作为变量默认值。
能出现在 shell 变量引用右侧的内容并不局限于字符串常量。例如:
cd${BASE:="$(pwd)"}如上所示,用于替换的值并不一定非得是字符串常量。它可以是更为复杂的 shell 表达式的结果,其中包括在子shell 中运行的命令(如上述示例所示)。如果 $BASE 不存在,那么 shell 会运行内建命令 pwd(以获得当前目录),并使用其返回的字符串。
那么,我们可以在该运算符(以及其他类似运算符)右侧做些什么?bash 手册页对于出现在运算符右侧的内容是这么表述的:“……要经过波浪号扩展、参数扩展、命令替换以及算术扩展”。
具体含义如下。
- 参数扩展意味着可以使用其他变量,如
${BASE:=${HOME}}。 - 波浪号扩展意味着可以使用
~bob这样的表达式,它会扩展成用户 bob 的主目录。可以通过${BASE:=~uid17}将默认值设置为用户 uid17 的主目录,但注意不要给~uid17加引号,因为波浪号扩展不会在引号中执行。 - 该示例用到的就是命令替换,它将命令的输出结果作为变量的值,其语法为
$(cmds)。 - 算术扩展意味着可以用
$(( ))语法执行整数算术运算。例如:echo${BASE:=/home/uid$((ID+1))}
17、对不存在的参数输出错误消息
这些能够设置默认值的便捷写法都挺酷,但有时你需要强制用户指定某个值,否则就无法继续往下进行。用户有可能会遗漏某个参数,因为他们确实不知道该怎样调用脚本。你希望能给用户点提示,省得他们自己瞎猜。相较于堆砌成堆的 if 语句,有没有更简洁的方法来检查各个参数?
引用参数时使用${:?}语法,如例所示。如果指定参数不存在或为空,那么 bash 会输出错误消息并退出。
#!/usr/bin/env bash# 实例文件:check_unset_parms#USAGE="usage: myscript scratchdir sourcefile conversion"FILEDIR=${1:?"Error. You must supply a scratch directory."}FILESRC=${2:?"Error. You must supply a source file."}CVTTYPE=${3:?"Error. ${USAGE}"}如果执行脚本时没有指定足够的参数,则会出现下列结果。
$ ./myscript /tmp /dev/null ./myscript: line7:3: Error. usage: myscript scratchdir sourcefile conversion $bash 会测试各个参数,如果参数不存在或为空,则输出错误信息并退出。
$3 所对应的错误消息中使用了另一个 shell 变量。你甚至可以在其中执行其他命令。
CVTTYPE=${3:?"Error. $USAGE. $(rm $SCRATCHFILE)"}如果$3不存在,则错误消息中会包含短语 “Error.”、变量$USAGE 的值以及 rm $SCRATCHFILE 命令的输出。我们先说到这里。你可以让自己的 shell 脚本紧凑得吓人,没错,我们说的就是吓人(awfully)。最好还是多加些空白字符,让代码逻辑更加清晰可读。
if[-z"$3"]thenecho"Error.$USAGE"rm$SCRATCHFILEfi另一方面的考虑:${:?}生成的错误信息包含 shell 脚本文件名和行号。例如,以下是示例产生的运行结果片段。
$ ./check_unset_parms ./check_unset_parms: line5:1: Error. You must supply a scratch directory. $ ./check_unset_parms somedir /tmp/check_unset_parms: line6:2: Error. You must supply asourcefile. $ ./check_unset_parms somedir somefile ./check_unset_parms: line7:3: Error. usage: myscript scratchdir sourcefile\conversion因为无法控制错误消息的这部分内容,而且这看起来好像是 shell 脚本自身出现的错误,再加上可读性的问题,该技术在商业级的 shell 脚本中并不多见。(不过确实有助于调试。)
要想所有变量都具备这种行为,同时又不想逐个改动,可以使用 set -u 命令,“在变量替换时,将不存在的变量视为一种错误”。
$echo"$foo"➊ $set-u➋ $echo"$foo"bash: foo: unbound variable ➌ $echo$?# 退出码1$set+u ➍ $echo"$foo"➎ $echo$?# 退出码0$- ❶ 一开始是正常行为
- ❷ 启用 nounset(-u)
- ❸ 现在看到了错误信息和故障退出码
- ❹ 再次关闭 nounset(-u)
- ❺ 恢复正常行为
18、修改部分字符串
你想要重命名一批文件。这些文件的名称基本上正确,就是后缀不对。
使用 bash 的参数扩展特性来删除匹配模式的文本,如例所示。
#!/usr/bin/env bash# 实例文件: suffixer## 将文件结尾的.bad修改为.bashforFNin*.baddomv"${FN}""${FN%bad}bash"donefor 循环迭代当前目录中所有以 .bad 结尾的文件名。变量$FN 每次都会获得其中一个文件名。在该循环中,mv 命令负责重命名文件(将旧名称转变成新名称)。为了避免文件名中包含空格,我们要将其放入引号。
重命名的关键之处是,所引用的F N 包含自动删除结尾字符串 b a d 的操作。 FN 包含自动删除结尾字符串 bad 的操作。FN包含自动删除结尾字符串bad的操作。{} 对该引用做了界定,以便与之相邻的字符串 bash 正好追加到已经过删除的文件名结尾。
以下是分解步骤。
NOBAD="${FN%bad}"NEWNAME="${NOBAD}bash"mv"${FN}""${NEWNAME}"这样你就能从中看出删除无用后缀、创建新文件名、重命名文件的各个步骤。只要习惯了那些特殊运算符,将这些步骤合并成一行也不算糟糕。
因为我们不仅从变量中删除了子串,还将 bad 替换为bash,所以也可以将替换运算符 / 用于变量引用。类似于那些用 / 进行搜索替换的编辑器命令(vi 和 sed 中就有),我们也可以按以下方式这么写:
# 没有锚定好位置,可别这么做mv"${FN}""${FN/bad/bash}"(和编辑器命令不一样的是,我们没有在结尾使用 /,右侧的花括号起到了相同的作用。)
但这种写法有一个问题:没有锚定替换操作的位置,因此可以在变量中的任意位置执行。例如,名为subaddon.bad 的文件经过替换后就会变成subashdon.bad,这可不是我们想要的结果。如果将第一个 / 换成 //,则会替换变量中所有指定的内容,最后就成了 subashdon.bash,这也并非我们所愿。最好按以下方式这么写:
# 添加"."来"锚定"替换模式的位置。这种写法更好,但也不是万无一失mv"${FN}""${FN/.bad/.bash}"解决方案中给出的 ${FN%bad}bash 已经锚定了位置,只删除字符串尾部的文本,在本例中,这正是我们想要的结果。
对变量中的字符串值执行各种操作的运算符有很多种。下表总结了这些运算符。
你可以挨个试试。用起来都非常方便。
19、获得某个数的绝对值
变量中的数值可能是负数,也可能是零或正数。你想得到它的大小(也就是绝对值),但 bash 似乎没有求绝对值的功能。
利用字符串操作。
${MYVAR#-}这是一种简单的字符串操作。在本例中,# 从字符串起始位置开始搜索负号(-)。如果找到,则将其删除;如果没有找到,就保留原值。不管是哪种情况,最后得到的都是不包含负号的绝对值。
也可以使用 if/then/else 按照数学方法来实现。
# 何必费事呢?if((MYVAR<0))thenletMYVAR=MYVAR*-1fi但就像注释中所说,何必呢?字符串操作简洁有力。但考虑到可读性,也可以加上注释。
MYVAR=${MYVAR#-}# ABS(MYVAR)20、用bash实现basename
basename 命令可以做到你想要的,但是能够在不调用外部命令的情况下得到相同的结果吗?bash 的字符串操作能不能做到?
是的,bash 能够从 shell 变量中剥离路径中的目录,只留下最后一部分(文件名)。当你想按以下方式写时:
FILE=$(basename$FULLPATHTOFILE)其实只需要写成:
FILE=${FULLPATHTOFILE##*/}这两种写法的不同之处在于花括号。第一种写法使用的是括号(旧式写法是 ``),它会生成一个子 shell 来执行参数值为 $FULLPATHTOFILE 的命令 basename。第二种写法使用的花括号只是 shell 变量替换语法的一部分,并不会生成子 shell,也不会执行命令。它从字符串起始位置开始(因为 #)查找并删除匹配模式*/的最长子串(因为##)。*匹配任意数量的字符,/ 仅代表其字面含义。对于路径 /usr/local/bin/mycmd,指定模式会匹配(并删除)/usr/local/bin/,将剩下的 mycmd 赋给变量 $FILE。
因为 basename 命令会忽略路径结尾的 /,所以 $(basename /usr/local/bin/) 会返回 bin,而我们用 bash 所实现的版本则返回空串(因为模式*/ 所能匹配的最长内容就是整个字符串)。为了与basename 兼容,第一件事就是删除路径结尾的/。
真正的 basename 命令还可以将要删除的后缀作为第二个可接受的参数。bash 也可以做到,只是需要额外多加一步。所以,对于:
FILE=$(basename$MYIMAGEFILE .jpg)更完整的替代方案是:
FILE=${MYIMAGEFILE%/}# 删除结尾的/FILE=${FILE##*/}# 删除结尾/之前的所有字符FILE=${FILE%.jpg}# 如果存在,删除后缀.jpg21、用bash实现dirname
dirname 命令的作用和你预想的一样,但就像basename,它也是一个单独的可执行文件,需要在子shell 中运行。能不能用字符串操作来代替?
当然能。用字符串操作删除文件名(路径中的最后一部分),尽可能多地保留路径中的目录部分。
DIR=${MYPATHTOFILE%/*}如果变量中保存的是 /usr/local/bin/mycmd,我们希望得到的是 /usr/local/bin,同时丢弃最后一部分(文件名)。由于路径中的各个部分是由斜线分隔的,从右侧开始(因为 %)删除匹配模式“斜线之后跟着任意数量字符(/*)”的最短子串(因为只有一个 %,而不是两个)即可。
这个示例演示了如何对 shell 变量进行字符串操作。其中并未给出完整的、可兼容的 dirname命令替代方案,尤其是针对一些路径名以斜线结尾的边缘情况。
22、选取CSV的替换值
你想制作一个由逗号分隔的值列表,但不希望开头或结尾处出现逗号。
如果在循环内部用 LIST=“L I S T , {LIST},LIST,{NEWVAL}” 构建该列表,那么第一次循环(此时 LIST 为空)过后会得到一个前导逗号。你可以对 LIST 进行特殊的初始化处理,以便它在进入循环前就先得到第一个值,但如果觉得这种方法不实用,或是为了避免重复代码(用于得到新值),你可以改用 bash 中的 ${:+} 语法。
LIST="${LIST}${LIST:+,}${NEWVAL}"如果 {LIST} 为空或不存在,那么 $LIST 的两个表达式不会产生任何内容。这就意味着,第一次循环过后,LIST 中保存的只有 NEWVAL 的值。如果 LIST 不为空,则第二个表达式 ${LIST:+,} 会被替换为逗号,将先前的值与新值分隔开来。
下面的代码片段用于读取并构建 CSV 列表。
## 一次读取一个值,构建以逗号分隔的值列表#whilereadNEWVALdoLIST="${LIST}${LIST:+,}${NEWVAL}"doneecho$LIST23、使用数组变量
到目前为止,你已经见识了不少使用变量的脚本,但是bash 能不能处理数组呢?
当然能。bash 有专门的一维数组语法。
如果编写脚本时已经知道具体的值,则初始化数组就很容易了。格式如下:
MYRA=(first second third home)括号内列表的每个单词都对应着一个数组元素。你可以像下面这样引用各个元素:
echorunners on${MYRA[0]}and${MYRA[2]}输出结果如下:
runners on first and third如果只写$MYRA,那么只会得到第一个数组元素,相当于${MYRA[0]}。
24、转换大小写
数码相机里全是一堆大写字母命名的文件,如IMG0001.JPG。你想将文件名改成小写,但又不愿意挨个重新输入。
bash 4.0 中的几个运算符可以在引用变量名时转换其大小写。如果变量 $FN 中包含一个需要转换成小写的文件名(字符串),那么F N , , 会返回全部是小写形式的字符串。与此类似, {FN,,} 会返回全部是小写形式的字符串。与此类似,FN,,会返回全部是小写形式的字符串。与此类似,{FN^^} 会返回全部是大写形式的字符串。甚至还有 ${FN~~},它可以切换大小写,将所有的小写字母转换成大写,大写字母转换成小写。
以下的 for 循环会将所有 .JPG 类型的文件名更改成小写字母。
forFNin*.JPGdomv"$FN""${FN,,}"done或者写成单行脚本:
forFNin*.JPG;domv"$FN""${FN,,}";donebash 4 或更高版本中还有另外一种方法:你可以将变量声明成始终为小写字母的类型。赋给该变量的任何文本都会转换成小写。要想在 for 循环中使用这种方法来重命名文件,使用简单的赋值即可,无须任何字符串操作。
declare-llcfn# 变量内容会转换成小写字母forFNin*.JPGdolcfn="$FN"mv"$FN""$lcfn"done还有一些类似的变量声明,它们可以更改所有字母或首字母的大小写。以下是一个简单的演示程序,其中展示了这类声明的用法。
declare-uUP# 全部大写declare-ldn# 全部小写declare-cCa# 仅首字母大写whilereadTXTdoUP="${TXT}"dn="${TXT}"Ca="${TXT}"echo$TXT$UP$dn$Cadone如果变量是通过 -c 声明的,即便字符串中包含多个单词,也只有首字母大写。你可以尝试运行并观察其如何工作。
25、转换为驼峰命名法
你希望字符串中每个单词的首字符都是大写,而不仅限于首字母。
结合使用数组与大小写转换运算符。
whilereadTXTdoRA=($TXT)# 一定得是($,不要写成$(echo${RA[@]^}done$TXT 周围的括号令其被视为数组初始化。文本中分隔单词的空白字符划分了数组元素。[@] 语法一次性引用数组中的所有元素,^ 操作符将每个元素的首字符转换为大写。