Shell编程实例——逻辑与算术(二)
2026/9/16 23:31:21 网站建设 项目流程

逻辑与算术

    • 11、在循环中使用read
    • 12、循环若干次
    • 13、在循环中使用浮点值
    • 14、多路分支
    • 15、解析命令行参数
    • 16、创建简单的菜单
    • 17、修改简单菜单的提示符
    • 18、创建简单的RPN计算器
    • 19、创建命令行计算器

11、在循环中使用read

你正在使用 Subversion 版本控制系统,其对应的可执行文件是 svn。​(这个例子对于 CVS 来说也是大同小异。​)检查目录子树状态,查看是否有文件被改动时,你会看到如下显示:

$ svn status bcb M bcb/amin.c ? bcb/dmin.c ? bcb/mdiv.tmp A bcb/optrn.c M bcb/optson.c ? bcb/prtbout.4161 ? bcb/rideaslist.odt ? bcb/x.maxc $

以 ? 起始的行代表 Subversion 尚不知晓的文件,通常是草稿文件或文件的临时副本。以 A 起始的行代表新添加的文件,以 M 起始的行代表自上次提交后又发生改动的文件。

要想清理该目录,最好是删除所有的草稿文件。

while 循环的一种常见用法是读取文件和之前命令的输出。尝试下列命令:

svn status mysrc|grep'^?'|cut-c8-|whilereadFN;doecho"$FN";rm-rf"$FN";done

或者:

svn status mysrc|whilereadTAG FNdoif[[$TAG==\?]]thenecho$FNrm-rf"$FN"fidone

这两个脚本的效果相同:删除 svn 报告其中带有 ? 标记的文件。同样的解决方案也适用于其他版本控制系统。

第一种方法是用多个子程序来实现(在如今处理器以 GHz为单位进行计算的时代,这算不上什么大事)​,在典型的终端窗口中够写满一行。先用 grep 选择以 ? 起始(由 ^表示)的行。将 ^? 放进单引号中(‘^?’)以避免 bash 解释其中具有特殊含义的字符。接着用 cut 提取从第 8 列开始(一直到行尾)的字符串。

如果没有输入,read 会返回非 0 值并停止循环。在此之前,它每次都会将读取到的文本行(其实也就是要删除的文件名)赋给变量 $FN。-rf 选项是以防待删除的未知文件是目录或只读文件。如果不希望删除文件时表现得如此极端,可以去掉这些选项。

第二个脚本更有 shell 的范儿,既不用 grep 进行搜索(使用的是 if 语句)​,也没用 cut 剪切(使用的是 read 语句)​。我们还像在脚本文件中那样对其做了格式化。如果 是在命令提示符下输入这些代码,你可以省略缩进,但作为书中的示例,可读性远比少敲几个键更重要。

第二个脚本中的 read 将内容读入两个变量,看清楚,这次可不是一个。我们也要让 bash 将每行分成两部分:为首的字符和文件名。read 语句将输入行解析成若干单词(和 shell 命令行中的类似)​。输入行的第一个单词赋给read 语句中的第一个变量,第二个单词赋给第二个变量,以此类推。最后一个变量获得输入行中剩余的所有内容,即便其中不止一个单词。在这个例子中,$TAG 得到的第一个单词是字符(M、A 或 ?)​;空白字符用于分隔单词。变量 $FN 得到了行中剩余部分,也就是文件名,这一点在文件名中包含空白字符时尤为重要。​(我们想要的可不是文件名中的第一个单词。​)脚本接下来会删除相应的文件并进入下一次循环。

12、循环若干次

你需要循环够一定次数。可以使用 while 循环,在计数时进行测试,不过编程语言中的 for 循环正是针对这种情况设计的。那么,如何在 bash 中实现呢?

使用 for 循环语法的一种特例,看起来和 C 语言中的差不多,但使用的是双括号。

for((i=0;i<10;i++));doecho$i;done

在早期的 shell 版本中,for 循环只能按照固定的列表项进行迭代。和文件名之类的打交道时,shell 脚本是面向单词的,就此而言,这算得上是一个不错的创新。但如果需要计数,用户会发现自己可能写出了如下代码。

foriin12345678910doecho$idone

看起来还行,尤其是循环次数不多时。可是说实话,换成500 次循环可就不好使了。​(没错,你可以写成 5×10 的嵌套循环,不过还是不要这样做了吧!)你真正需要的是能够计数的 for 循环。

bash 2.04 版开始引入一种 for 循环的变体,语法与 C 语言类似。其一般形式如下所示。

for((expr1;expr2;expr3));dolist;done

双括号表明这是算术表达式,在其中引用变量时,不用加$(但 $1 等位置参数除外)​,只要是 bash 中出现双括号的地方,均是如此。该表达式是整数表达式,可以使用包括逗号(用于在一个表达式中放入多个操作)在内的大量运算符。

for((i=0,j=0;i+j<10;i++,j++))doecho$((i*j))done

for 循环先初始化了两个变量($i 和 $j)​,然后在第二个更复杂的子表达式中对 $i 和 $j 求和,接着判断是否小于10。第三个子表达式再次用逗号运算符累加这两个变量。

13、在循环中使用浮点值

带有算术表达式的 for 循环只能执行整数运算。如果是浮点值,该怎么办呢?

如果系统提供了 seq 命令,则可以用它来生成浮点值。

forfpin$(seq1.0.011.1)doecho$fp;other stuff toodone

或者:

seq1.0.011.1|whilereadfpdoecho$fp;other stuff toodone

seq 命令会生成一系列浮点值,每行一个。该命令的参数依次是起始值、增量、结束值。如果习惯了 C 语言中的for 循环或学过 BASIC 中的循环(如 FOR I=4 TO 10 STEP2)​,你可能会觉得这种次序不符合直觉。seq 使用中间那个参数作为增量。

在第一个例子中,$() 在子 shell 中执行命令,返回结果中 的换行符会被空白字符替换,因此,就 for 循环而言,每个值都是字符串。

在第二个例子中,seq 作为单独的命令运行,通过管道将其输出传入 while 循环,后者读取其中的每一行并做相应处理。如果数值序列特别长,这种方法更可取,因为 seq命令是与 while 并行运行的。在第一个例子的 for 循环中,seq 必须先运行完毕,然后用全部的输出结果替换掉$()。对于冗长的数值序列来说,这种方法的时间开销和内存开销都不小。

14、多路分支

你需要进行一系列比较,但使用 if/then/else 显得又长又啰唆。有没有更简单的方法?

可以用 case 语句实现多路分支。

case$FNin*.gif)gif2png$FN;;*.png)pngOK$FN;;*.jpg)jpg2gif$FN;;*.tif|*.TIFF)tif2jpg$FN;;*)printf"File not supported: %s"$FN;;esac

与其等价的 if/then/else 形式如下所示。

if[[$FN==*.gif]]thengif2png$FNelif[[$FN==*.png]]thenpngOK$FNelif[[$FN==*.jpg]]thenjpg2gif$FNelif[[$FN==*.tif||$FN==*.TIFF]]thentif2jpg$FNelseprintf"File not supported: %s"$FNfi

case 语句会扩展 case 与 in 之间的单词(包括参数替换)​。然后依次尝试匹配各个模式。这可是 shell 了不得的一项特性。它不是简单地比对值,而是进行字符串模式匹配(尽管并非正则表达式)​。示例中用到的模式并不复杂:*.gif匹配以普通字符.gif结尾的任意字符串(由 *表示)​。

代表逻辑 OR 的 | 用于分隔不同模式,无论匹配到其中哪个模式,都会执行相同的处理。在我们给出的例子中,如果 $FN 以 .tif 或 .TIFF 结尾,则匹配模式并执行(假想的) tif2jpg 命令。

case 中并没有 else 或 default 关键词来指示未匹配到任何模式时该执行哪些语句。它使用 * 作为最后的模式,因为该模式什么都能匹配。将其放在最后以作为默认分支,匹配之前尚未被匹配到的内容。

双分号(;;)用于结束与某个模式关联的一组语句。从bash 4 开始,还可以用另外两种写法结束一组语句。;;&表示,即便找到匹配,依然尝试匹配下一个模式,如果发现该模式也匹配,则执行与之关联的语句。;& 表示执行流程会“直落而下”​,不管模式是否匹配,都执行下一组语句。下面这个多少有些无聊的例子演示了上述特性的用法。

# 使用其他结束标记case$FNin*.gif)gif2png$FN;;&# 继续向下查找匹配*.png)pngOK$FN;;&# 继续向下查找匹配*.jpg)jpg2gif$FN;;&# 继续向下查找匹配*.tif)tif2jpg$FN;&# 直落而下*.*)echo"two.words";;*)echo"oneword"esac

只要 $FN 匹配前 4 个模式中的任意一个,bash 都会执行(假想的)转换命令,并继续向下查找匹配。它会发现第5 个模式也能够匹配,因此还会显示短语 two.words。

同 C/C++ 和 Java 程序员谈一个题外话:bash 中的 case 类似于 switch 语句,每个模式对应一处分支。但要注意,switch/case 中使用的是 shell 变量(通常包含字符串值)​,分支中使用的是模式(不仅仅是常量值)​。模式以右括号(可不是冒号)作结。C/C++ 和 Java 的 switch 语句中的break 等同于 bash 中的双分号,default 关键词等同于 bash 中的 * 模式。

匹配过程区分大小写,但可以用 shopt -s nocasematch(bash 3.1 以上版本可用)改变此行为。该选项会影响到case 和 [​[。

我们用 esac(反过来拼写“c-a-s-e”​,这种写法源于Algol 68)来结束 case 语句。

15、解析命令行参数

你想编写一个简单的脚本,在屏幕上打印出一行连字符,但同时希望将该脚本参数化,以便指定不同的行长度以及除连字符之外的其他字符。其语法类似如下代码。

dashes# 打印出72个连字符dashes50# 打印出50个连字符dashes-c=50# 打印出50个等号dashes-cx# 打印出72个字符x

有什么易行的方法可以解析这些简单参数?

对于正式的脚本编程,应该使用 bash 内建的 getopts。但这里只是想向你展示 case 语句的实践用法,因此,对于这种简单的情况,我们选择用 case 来解析参数。

下例展示了该脚本的开头部分​。

#!/usr/bin/env bash# 实例文件: dashes## dashes - 打印出一行连字符## 选项:#指定字符数量(默认为72个)# -c X 使用字符X,不再使用默认的连字符#LEN=72CHAR='-'while(($#>0))docase$1in[0-9]*)LEN=$1;;-c)shift;CHAR=${1:--};;*)printf'usage: %s [-c X] [#]\n'${0##*/}>&2exit2;;esacshiftdone## 未完……

默认字符数量(72)和默认字符(-)是在脚本起始部分(位于几行注释之后)设置的。while 允许脚本解析多个参数。只要参数数量($#)不为 0,循环就会持续进行下去。

case 语句匹配 3 个模式。第一个模式 [0-9]* 能够匹配任意数位以及随后任意数量的字符。可以用更复杂的模式仅允许纯数字的出现,但我们假定任何以数位(digit)开头的参数都是数字(number)​。如果事实并非如此(例如,用户输入的是 1T4)​,那么脚本尝试使用 $LEN 时就会出错。我们暂时可以容忍这种情况。

第二个模式是普通的 -c。这其实算不上模式,只是严格的字面匹配。在此分支中,我们用内建命令 shift 丢弃这个参数(因为已经知道这个参数是什么了)并获取下一个参数(现在已变成第一个参数,因此用 $1 来引用它)​,同时将用户指定的新字符保存起来。在引用1 时,我们使用了 : − ( 1 时,我们使用了 :-(1时,我们使用了:{1:-x})​,以便在未指定新字符时设置默认字符。这样一来,如果用户只输入 -c,却未指定新字符,则使用紧随 :- 之后的默认字符。要是写成 ${1:-x},则默认字符为 x。脚本中出现的是 ${1:–}(注意,有两个减号)​,因此默认字符就是(第二个)减号。

第三个模式 * 可以匹配任意数量的字符,之前未匹配的参数都能够在此得以匹配。可以将其放在 case 语句的最后一个分支中来负责收尾工作,提醒用户发生了错误(出现了未知参数)​;打印提示信息,告知用户正确的用法。

如果刚开始接触 bash,可能需要向你解释一下 printf 输出的错误信息。该语句分为 4 部分。第一部分很简单,就是命令名 printf。第二部分是 printf 要用到的格式化字符串​。为了避免 shell 解释其中的内容,我们在字符串周围加上了单引号。最后一部分(>&2)告诉 shell 将 printf 的输出重定向到标准错误。因为属于错误信息,所以这种做法没毛病。不少脚本作者并没有将此放在心上,经常忽略错误信息的重定向。我们认为坚持将错误消息重定向到标准错误是一个很好的习惯。

第三部分对 $0 进行字符串操作。这是一种惯用写法,用于将调用命令时的前导路径部分剔除。如果我们只使用$0,会发生什么情况呢?以下是调用同一脚本时的两种错误方法。注意错误信息。

$ dashes-gusage: dashes[-c X][#]$ /usr/local/bin/dashes-gusage: /usr/local/bin/dashes[-c X][#]

在第二次调用时,我们使用的是完整路径,于是其也出现在了错误信息中。有人会觉得这样很烦。因此,我们剔除了 $0 中的部分内容,只留下脚本的基本名称(类似于使用 basename 命令)​。以后无论以何种方式调用脚本,错误信息看起来都是一模一样的。

$ dashes-gusage: dashes[-c X][#]$ /usr/local/bin/dashes-gusage: dashes[-c X][#]

相较于硬编码脚本名称或原封不动地使用 $0,这种方法肯定要多花点时间,但脚本的可移植性会更好,就算重命名脚本,也用不着再修改代码。如果你更喜欢在子 shell中使用 basename 命令,同样值得一试,多出的那点运行时间不值一提。输出错误信息后,就退出脚本。

我们用 esac 结束了 case 语句,然后通过 shift 丢弃刚刚在 case 语句中匹配到的参数。要是不这么做的话,就会深陷在 while 循环之中,一遍又一遍地解析同一个参数。shift 会使第 2 个参数($2)成为第 1 个参数($1)​,第 3个参数成为第 2 个参数,以此类推,同时还会将KaTeX parse error: Expected 'EOF', got '#' at position 1: #̲ 减1。经过几次循环后,# 最终会变成 0(表示此时已经没有任何参数了)​,循环随之终止。

这里并没有展示连字符(或者其他字符)的实际输出效果,因为我们希望将重点放在 case 语句及其相关处理上。你可以在节中找到完整的脚本以及用于输出用法信息的函数。

16、创建简单的菜单

你有一个简单的 SQL 脚本,希望能够针对不同的数据库运行,以便将其重置用于各种测试。可以直接在命令行上提供数据库名,但你想要拥有更多的交互性。如何编写一个可以从数据库名称列表中进行选择的 shell 脚本呢?

用 select 语句创建简单的字符型屏幕菜单,如下例所示。

#!/usr/bin/env bash# 实例文件:dbinit.1#DBLIST=$(sh./listdb|tail-n+2)selectDBin$DBLISTdoechoInitializing database:$DBmysql-uuser-p$DB<myinit.sqldone

暂时先忽略 $DBLIST 是如何得到值的,知道该值是一个单词列表即可(就像 ls 命令的输出)​。select 语句会显示出这些单词,每个单词前都有一个数字,然后由用户做出选择。用户只需要输入菜单项数字,对应的单词就会保存在 select 之后指定的变量(本例中是变量 DB)中。

以下是运行该脚本时的输出。

$ ./dbinit1)testDB2)simpleInventory3)masterInventory4)otherDB#? 2Initializing database: simpleInventory#?$

如果用户输入“2”​,则单词 simpleInventory 会保存在变量 DB 中。如果着实需要用户所选的菜单项编号,你可以在变量 $REPLY 中找到。本例中的这个值是 2。

select 语句其实就是一个循环。当用户输入选择时,执行循环体(位于 do 和 done 之间)​,然后再次提示用户输入下一次选择。

此外,菜单也不会对 in 之后的变量重新求值,也就是说,一旦 select 语句运行,就无法改动菜单了。就算你在循环内修改了 $DBLIST,菜单也不会出现变化。

如果碰到文件结束符,则停止循环,在交互式用法中,这意味着用户按下了 Ctrl-D。​(如果通过管道将一系列选择传给 select,那么输入结束时,循环也会随之结束)​。

尽管 select 确实会考虑 $COLUMNS 的值,但并没有什么办法能控制菜单的格式。要是你打算使用 select,那也只能坦然接受了。不过,你可以用变量 $PS3 来修改 select的提示符。

17、修改简单菜单的提示符

你不喜欢 select 所生成菜单的提示符。该怎么修改?

bash 环境变量 $PS3 就是 select 所使用的提示符。将其设置成别的值就可以得到一个新的提示符。

这是第三种 bash 提示符。第一种提示符($PS1)出现在大多数命令之前。​(我们给出的例子用 $ 作为提示符,但它可以加入用户 ID 或目录名来变得更为复杂。​)如果命令行需要续行,那么就得用到第二种提示符($PS2)了。

第三种提示符 $PS3 出现在 select 循环中。在执行 select语句前设置该变量,就可以得到你想要的提示符。甚至还可以在循环内部对其做出改动。

下例中的脚本和上一节中的类似,但是它会统计处理了多少次有效选择。

#!/usr/bin/env bash# 实例文件:dbinit.2#DBLIST=$(sh./listdb|tail-n+2)PS3="0 inits >"selectDBin$DBLISTdoif[$DB]thenechoInitializing database:$DBPS3="$((++i))inits> "mysql-uuser-p$DB<myinit.sqlfidone

我们额外加入了空行,这样做是为了让 $PS3 的设置部分更加显眼。if 语句确保我们只统计用户的有效选择。这种检查可以用在该例的上一个版本中,不过当时我们想要示例保持简单化。

18、创建简单的RPN计算器

你也许只用口算就能将二进制转换成十进制、八进制、十六进制,不过同样的法子对简单的算术运算似乎无能为力,而且计算器还总是在需要的时候不见踪影。这该怎么办?

像下例中那样,用 shell 算术和 RPN6 创建一个计算器。

6RPN(Reverse Polish notation,逆波兰表示法或逆波兰记法)是一种由波兰数学家扬 • 武卡谢维奇于 1920 年引入的数学表达式方式。在逆波兰记法中,所有运算符都置于操作数的后面,因此也被称为后缀表示法。逆波兰记法不需要括号来标识运算符的优先级。

#!/usr/bin/env bash# 实例文件:rpncalc## 简单的RPN命令行(整数)计算器## 获取用户提供的参数并计算# 参数形式为:a b运算符# 允许使用*x*代替*## 检查参数数量:if[\($#-lt3\)-o\($(($#%2))-eq0\)]thenecho"usage: calc number number op [ number op ] ..."echo"use x or '*' for multiplication"exit1fiANS=$(($1${3//x/*} $2))shift3while[$#-gt0]doANS=$((ANS ${2//x/*} $1))shift2doneecho$ANS

RPN(或者后缀)风格的表示法将操作数(数字)放在前面,然后是运算符。如果使用 RPN,那么表达式应该写成5 4 +,而不是 5 + 4。要是想将结果再乘以 2,将2 *写在后面即可,因此整个表达式就是5 4 + 2 *,这种写法在解析表达式时实在是再好不过了,因为可以从左向右处理,压根不需要括号。任何运算的结果都会变成下一个子表达式的第一个操作数。

在我们这个简单的 bash 计算器中,允许用小写字母x代替乘号,因为*对于 shell 而言具有特殊含义。但如果你写成'*'\*,也没问题。

如何检查参数错误?如果参数数量少于 3 个(我们需要 2个操作数和 1 个运算符,如 6 3 /)​,那么就认为出错了。参数可以多于 3 个,但总数始终应该是奇数(一开始是 3个参数,然后是下一个操作数和运算符,共计 2 个,以此类推,始终要再添加 2 个参数。有效的参数数量应该是3、5、7、9……)​。我们用下列语句检查参数数量,比较结果是否为 0。

$(($#%2))-eq0

$(( )) 用来执行 shell 算术运算。我们用 % 运算符(求余运算符)检查 $#(其中保存着参数数量)能否被 2 整除(通过 -eq 0 判断)​。

$(( )) 只能对其中的表达式执行整数运算。

现在我们知道了参数数量没问题,可以计算结果了。

ANS=$(($1${3//x/*} $2))

该语句将*替换成字母 x 并计算出最终结果。在命令行上调用脚本时,给出的是 RPN 表达式,但是 shell 算术运算所采用的语法还是正常的(中缀)写法。因此,必须交换一下参数位置才能在 $(( )) 中求值。暂时忽略用*替换 x这部分,前面的语句就变成了:

ANS=$(($1$3$2))

这里只是将运算符移到了两个操作数之间。在进行算术求值之前,bash 会替换掉这几个参数,因此,如果 $1 是5、$2 是 4、$3 是 +,那么参数替换后得到的就是:

ANS=$((5+4))

我们将求值结果 9 赋给变量 $ANS。处理完这 3 个参数后,用 shift 3 将其丢弃并获得新的参数,然后继续进行后续处理。由于之前已经确认了有奇数个参数,因此如果还有参数要处理,至少是 2 个(只有 1 个的话,参数总量就是偶数了,因为 3+1=4)​。

接下来进入 while 循环,每次处理 2 个参数。上一步的结果作为第一个操作数,下一个参数(执行完 shift 之后,现在变成了$1)作为第二个操作数,我们将保存在$2中的运算符放置在两个操作数之间,然后像先前那样对表达式求值。一旦处理完所有参数,$ANS中保存的就是最终结果。

最后再说一下替换操作。我们用${2}引用第二个参数。虽然通常用不着{},直接写成$2即可,但这里是为了让bash 对该参数执行额外的操作。我们将其写成${2//x/*}是想在返回$2的值之前先用*(在第 3 个/后指明)替换掉(//)x。多加一个变量可以将这一步拆成两小步。

OP=${2//x/*}ANS=$((ANS OP $1))

如果是刚接触 bash 的这些特性,这个额外变量有助于你加深理解,一旦熟悉了这种惯用写法,你会发现自己不由自主地就将它们写成了一行(尽管可读性不太好)​。

你是不是好奇我们对表达式求值时为什么不写成$ANS$OP ?$(( ))中,除了位置参数(如 $1、$2)​,变量名前面并不需要加 $。位置参数需要 $ 是为了和普通数字(如 1、2)区分开来。

19、创建命令行计算器

你需要的不仅仅是整数算术,而且压根也不喜欢 RPN 表示法。有没有别的方法可以实现命令行计算器?

可以用 awk 内建的浮点算术表达式创建简单的命令行计算器,如下例所示。

# 实例文件:func_calc# 简单的命令行计算器functioncalc{# 仅适用于整数!--> echo The answer is: $(( $* ))# 浮点数awk"BEGIN {print\"The answer is:\"$*}";}# 函数calc定义完毕

你可能想跳过 awk 命令,并尝试用echo The answer is:$$$*$$。如果是整数,这么做没问题,但如果是浮点数运算,结果会被截断。

因为别名不允许使用参数,所以我们使用了函数。

你可能会想将这个函数添加到全局配置文件 /etc/bashrc或本地配置文件~/.bashrc。

运算符和你期望中的一样,与 C 语言中的也无异。

$ calc2+3+4The answer is:9$ calc2+3+4.5The answer is:9.5

小心 shell 的元字符。例如:

$ calc(2+2-3)*4 -bash: syntax error near unexpected token `2+2-3'

你需要将括号转义,消除其特殊含义。可以将表达式放进 单引号,或在任何特殊字符(对 shell 而言)之前加上斜线。例如:

$ calc'(2+2-3)*4'The answer is:4$ calc\(2+2-3\)\*4 The answer is:4$ calc'(2+2-3)*4.5'The answer is:4.5

我们还得转义 *,因为它是文件名通配符,对 bash 具有特殊含义。如果你想在运算符周围加上空白字符,则更是如此,因为 * 会匹配当前目录下的所有文件名,bash 会在命令行上用这些文件名替换 *,这肯定不是你想要的结果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询