第34章:字符串和数字
计算机程序总是和数据一起工作。在上一章中,我们聚焦在文件级别的数据处理。然而,很多程序问题需要在更小单位的数据上处理,如字符串和数字。
本章中,我们要学习几个操纵字符串和数字的 shell 功能。shell 提供了多种参数扩展来执行对字符串的操作。除了算术扩展(我们在第 7 章「 如 Shell 般看世界 」中接触过),还有一个广为人知的命令行程序 bc
,用来执行高级别数学计算。
参数扩展
尽管在第 7 章里已经学习了参数扩展,但因为大多数参数扩展是用在脚本中而非命令行中的,所以,还有很多细节没有接触到。我们已经用过一些形式的参数扩展了,例如,shell 变量。shell 还提供了更多形式。
注意:永远要记得把参数扩展放在双引号内,以防不需要的分词,除非有特别的缘由不这么做。特别是在处理文件名的时候,因为文件名经常会包含空格和其它杂七杂八的符号。
基本参数
最简单的参数扩展形式体现在普通的变量的使用上。例如:
$a
当其展开时,成为变量 a
所包含的内容。简单参数可能还会被括在花括号内。
${a}
这不影响扩展,但是如果在该变量和其它文本连在一起、可能会使 shell 困惑的情况下,就是必须的了。在这个例子中,我们尝试用字符串 _file
附加到变量 a
的内容后,以创建一个文件名。
如果我们执行该命令序列,结果为空,因为 shell 会尝试扩展一个名为 a_file
的变量,而非 a
变量。这就可以通过把「真的」变量名括在花括号中来解决该问题。
我们也已经看到大于 9 的位置参数可以通过括号内的数字来访问。例如要访问第 11 个位置参数,可以这么做:
${11}
管理空变量的扩展
有几个位置参数是用来处理不存在的和空的变量的。这些扩展便于处理缺失的位置参数和分配默认值给参数。
${parameter:-word}
${parameter:-word}
如果 parameter 未设置(即,不存在)或为空,则该扩展的结果就是 word。如果 parameter 不为空,则扩展的结果就是 parameter 的值。
${parameter:=word}
${parameter:=word}
如果 parameter 未设置或为空,则该扩展的结果就是 word。另外,word 的值被赋予给 parameter。如果 parameter 不为空,则扩展的结果就是 parameter 的值。
注意:位置和其它特殊参数不能用此方式赋值。
${parameter:?word}
${parameter:?word}
如果 parameter 未设置或为空,则该扩展导致脚本出错并退出,word 的内容被发送到标准错误。如果 parameter 不为空,则扩展的结果就是 parameter 的值。
${parameter:+word}
${parameter:+word}
如果 parameter 未设置或为空,则该扩展的结果为空。如果 parameter 不为空,则 word 的值被用来替代 parameter;不过,parameter 的值不会被改变。
返回变量名的扩展
shell 具有返回变量名的能力。该功能用于一些相当奇特的场景中。
${!prefix*}
${!prefix@}
该扩展返回名称中以 prefix
开头的现有变量。根据 bash
文档,这两种形式的扩展执行效果完全一致。这里,我们列出了系统环境中以 BASH
开头的所有变量名:
字符串操作符
可以用来操作字符串的扩展有一大堆。许多是特别适合路径名的扩展。
$
$
扩展为包含在 parameter
中的字符串的长度。通常,parameter
是一个字符串,然后如果 parameter
是 @
或 *
,则扩展结果是位置参数的个数。
${parameter:offset}
${parameter:offset:length}
${parameter:offset}
${parameter:offset:length}
这两个扩展用来提取包含在 parameter
内的一部分字符串。从 offset
字符开始,一直到字符串的末尾,除非指定了 length
。
如果 offset
是负数,意味着从字符串的末尾而不是从开头开始计算。注意负数必须用一个空格开始,以防止和 ${parameter:-word}
扩展相混淆。length
,如果有的话,一定不能小于零。
如果 parameter
是 @
,则扩展的结果为从 offset
开始的位置参数长度。
${parameter#pattern}
${parameter##pattern}
${parameter#pattern}
${parameter##pattern}
这两个扩展会移除 parameter
字符串中由 pattern
定义的前半部分。pattern
是一个通配符模式,就像那些用在路径名扩展中的那样。上面两种形式的差别在于 #
只删除最短匹配,而 ##
则删除最长匹配。
${parameter%pattern}
${parameter%%pattern}
${parameter%pattern}
${parameter%%pattern}
这两个扩展和前面那两个 #
和 ##
扩展一样,不过这两个是从字符串后面开始移除字符,而非从开头开始移除。
${parameter/pattern/string}
${parameter//pattern/string}
${parameter/#pattern/string}
${parameter/%pattern/string}
${parameter/pattern/string}
${parameter//pattern/string}
${parameter/#pattern/string}
${parameter/%pattern/string}
这组扩展根据 parameter
的内容执行一次搜索替换操作。如果找到匹配通配符 pattern
的文本,就会用 string
中的内容替换。在普通形式中,只替换第一次发现的 pattern
。在 //
形式中,会替换所有发现的 pattern
。/#
形式要求该匹配必须发生在字符串开端处,/%
形式则要求匹配必须发生在字符串末尾。在每一种形式中,都可以省略 /string
,结果就会导致匹配于 pattern
的文本会被删除。
参数扩展是值得去理解的。字符操作扩展可以用来替换其它命令,如 sed
和 cut
。扩展可以避免使用外部程序,从而提高脚本的效率。作为一个示例,我们将修改上一章中讨论过的 longest-word
程序,使用参数扩展 ${#j}
以取代命令替换 $(echo -n $j | wc -c)
,并产生子 shell,如下:
随后,我们用 time
命令比较两个版本的效率。
原始版本的脚本使用 3.618 秒扫描文本文件,而使用参数扩展的新版本,仅使用了 0.06 秒——一个显著的改进。
Case 转换
bash
有四种参数扩展和两种 declare
命令选项支持字符串的大小写转换。
那么,大小写转换有什么用呢?除了显而易见的美观价值之外,在编程中具有重要的作用。让我们考虑数据库查找的一个案例。想象有个用户,已经在要在数据库中查找的数据输入字段中输入了字符串。可能该用户会全用大写字母或小写字母键入,也可能是二者的组合。我们当然不会用每种可能的大小写排列去填充数据库。要做什么?
该问题常见的解决方法是归一化(normalize)用户的输入。即,在尝试数据库检索前,将用户的输入转换为标准形式。我们将用户输入的所有字符转换为大小写中的任何一种,并保证数据库条目也是以同样的方式归一化。
declare
命令可以用来将字符串归一化为大小写中的任一形式。使用 declare
,我们可以强制一个变量总是包含想要的格式,无论其被赋值的是什么。
在上面的脚本中,我们用 declare
创建了两个变量,upper
和 lower
。我们将第一个命令行参数(位置参数 1)的值分配给每个变量,然后在屏幕上显示它们。
可以看到,命令行参数(aBc
)已经得到了归一化。
除了 declare
,有四个参数扩展也可以执行大小写转换的,如表 34-1 所示。
表 34-1:大小写转换参数扩展
格式
结果
${parameter,,pattern}
将 parameter
的值全部扩展为小写。pattern
是一个可选的 shell 模式(例如 [A-F]),可以限制哪些字符被转换。请查阅 bash
手册页以获取完整的 pattern
说明。
${parameter,pattern}
将 parameter
值中的首字母扩展为小写。
${parameter^^pattern}
将 parameter
的值全部扩展为大写字母。
${parameter^pattern}
将 parameter
值中的首字母扩展为大写。
下面的这个脚本演示了这几个扩展:
脚本运行结果如下:
我们又一次处理了命令行中第一个参数,并输出了四种由参数扩展支持的变形。这个脚本使用了第一个位置参数,parameter
可以是任意字符串、变量或字符串表达式。
算术评估和扩展
我们曾在第 7 章中学习过算术扩展。用来对整数执行各种算术操作。其基本形式如下:
$((expression))
其中 expression
是一个合法的算术表达式。
这就联系到第 27 章中学过的用来做算术评估(真值测试)的复合命令 (( ))
。
在前面几章中,我们学习了一些常见类型的表达式和操作符。这里,我们将学习一个更完整的列表。
基数
在第 9 章中,我们学习了八进制(基数为 8)和十六进制(基数为 16)。在算术表达式中,shell 支持任意基数的整数常量。表 34-2 中列出了用指定基数的计数法。
表 34-2:指定不同的基数
记号
描述
number
默认情况下,不带任何记号的数字被看作是十进制(基数是 10)整数。
0number
在算术表达式中,带一个前置零的被认为是八进制。
0xnumber
十六进制表示法。
base#number
基数是 base
的数字。
这里有些示例:
在上例中,我们打印了十六进制数字 ff
(最大的两位数)和最大的八位数二进制(基数为 2)数字。
单目运算符
有两个单目运算符,+
和 -
,分别用来指示一个数字是正值还是负值。例如 -5
。
简单算术
普通的算术运算符罗列在表 34-3 中。
表 34-3:算术运算符
运算符
描述
+
加法
-
减法
*
乘法
/
整除
**
乘方
%
模(取余)
大多数的运算符都是不言而喻的,不过整除和模还需要更进一步讨论。
由于 shell 算术运算符仅计算整数,所以除法的结果也永远是整数。
这使得确定除法运算中的余数更为重要。
使用整除和模运算符,我们可以确定 5 除以 2 的结果是 2,余数为 1。
计算余数在循环中很有用。它允许一个运算符在循环执行期间按指定间隔被执行。在下面的示例中,我们显示了一行数字,高亮显示了 5 的倍数:
当执行时,结果如下:
赋值
尽管其用途可能不是立即显而易见的,但算术表达式可以执行赋值。我们已经执行了许多赋值,尽管在不同的上下文中。每次,我们给一个变量一个值,我们正在执行赋值。我们还可以在算术表达式中赋值。
上面这个例子中,首先给变量 foo
赋了一个空值,并验证其确实为空。然后执行了一个带复合命令 (( foo = 5 ))
的复合命令。该进程做了两件有趣的事情:它将 5
分配到了变量 foo
,并评估为真,因为 foo
已经被分配了一个非零的数值。
注意:记住上面这个表达式中
=
的确切意义非常重要。单个的=
执行赋值。foo = 5
的意思是「使foo
等于5
」,而==
评估相等性。foo == 5
的意思是「foo
等于5
吗?」这是许多编程语言中都共有的特性。在 shell 中,这会导致一点小小的困惑,因为test
命令接收单个的=
作为字符串等价判断。这也是使用更现代的[[ ]]
和(( ))
复合命令以取代test
的另一个理由。
除了 =
表示法,shell 还提供了一些表示法,以执行非常有用的赋值,如表 34-4 所示。
表 34-4:赋值运算符
记号
描述
parameter = value
简单的赋值。将 value
赋值给 parameter
。
parameter += value
加法。使 parameter
等于 parameter
+ value
。
parameter -= value
减法。使 parameter
等于 parameter
- value
。
parameter *= value
乘法。使 parameter
等于 parameter
* value
。
parameter /= value
整除。使 parameter
等于 parameter
/ value
。
parameter %= value
取模。使 parameter
等于 parameter
% value
。
parameter++
变量递增。使 parameter
= parameter
+ 1(参看下方讨论)。
parameter--
变量递减。使 parameter
= parameter
- 1。
++parameter
变量递增。使 parameter
= parameter
+ 1。
--parameter
变量递减。使 parameter
= parameter
- 1。
这些赋值运算符为许多常见的算术任务提供了方便的简写。特别有趣的是递增(++
)和递减(--
)运算符,可以使它们参数的值自增一或自减一。这种风格的表示法来自 C 编程语言,且已经被整合到许多程序语言之中,包括 bash
。
递增递减运算符可以出现在一个参数的前面或后面。无论前后,都会使得该参数递增或递减,不过这前后位置有微妙的差别。如果运算符在参数之前,在返回参数之前,该参数会被自增(或自减)。如果在参数之后,则该操作会在参数被返回后被执行。这一点相当奇怪,然而确是预期的行为。这里有一个演示:
如果我们把 1
赋值给变量 foo
,然后在参数名后用 ++
运算符自增,foo
返回的值为 1
。然后如果我们第二次查看该变量的值,就会看到其已经被自增了。如果将 ++
运算符放在参数前面,则会得到更多预期的行为。
对大多数 shell 应用程序,前置运算符最有用。
++
和 --
运算符常与循环一起使用。我们来改进一下取模的脚本,使其更紧凑一点。
位运算
有一类运算符以一种不寻常的途径操纵数字。这类运算符在位级别(bit level)工作。它们被用在某种低级别任务中,经常参与设置和读取位标识(bit flags)。这些位运算符见表 34-5。
表 34-5:位运算符
操作
描述
~
按位求反。对数字中的所有位取反。
<<
左移。将数字中的所有位向左移动。
>>
右移。将数字中的所有位向右移动。
&
位与。对两个数字上的所有位执行与运算。
|
位或。对两个数字上的所有位执行或运算。
^
位异或。对两个数字上的所有位执行异或运算。
注意还可以对上面所有的运算符做相应的赋值运算,除了按位取反以外。
下面,我们来演示使用左移运算符,来产生一个 2 的幂的列表。
逻辑
如我们在第 27 章中学到的,(( ))
复合命令支持各种比较运算符。还有更多可以用在评估逻辑的运算符。表 34-6 提供了一个完整列表。
表 34-6:比较运算符
运算符
描述
<=
小于等于。
>=
大于等于。
<
小于。
>
大于。
==
等于。
!=
不等于。
&&
逻辑与。
||
逻辑或。
expr1?expr2:expr3
比较(三元)运算符。如果表达式 expr1
的评估结果位非零(算术真),则执行 expr2
;否则执行 expr3
。
当用在逻辑运算符时,表达式遵循如下算术逻辑准则,即,表达式评估为零的,被认为是假,非零的,则为真。(( ))
复合命令将结果映射到 shell 的正常的退出代码。
最奇怪的逻辑运算符就是三元运算符(ternary operator)。该运算符(模仿 C 程序语言中的一个)执行一个独立的逻辑测试。它可以用作一种 if/then/else
语句。它作用于三个算术表达式(字符串不起作用),如果第一个表达式的结果为真(或非零),就执行第二个表达式。否则就执行第三个表达式。可以在命令行上尝试一下:
这里我们看到一个三元运算符在工作。该示例实施了一个拨动效果。每次执行这个运算,变量 a
的值就从 0
变为 1
,或者相反变动。
请注意在表达式中赋值,不那么简单明了。当尝试时,bash
会报告一个错误。
这个问题可以通过在赋值表达式周围使用括号来解决。
接下来,是一个更完整的使用算术运算符的示例脚本,用来生成一个简单的数字表格。
在脚本中,实施了一个基于 finished
变量的值的 until
循环。首先,变量被设置为零(算术的假),随后开始循环,直到其变为非零。在循环中,我们计算了计数变量 a
的平方和立方。在循环末,会评估计数变量的值,如果小于 10(迭代的最大数),则会自增一,否则就给 finished
变量赋值为 1
,使得 finished
成为算术的真,从而终止循环。运行该脚本得到如下结果:
bc 一款任意精确的计算语言
我们已经看到,shell 是如何处理各类整数计算的了,但是如果我们需要执行更高级点的数学或者仅仅是使用浮点数呢?答案是,不能。起码不能用 shell 直接计算。要做的话,就需要使用一款外部程序。我们可以用的办法有好几种。嵌入式的 Perl 或者 AWK 程序是一个可能的解决方案,不过不幸的是,这超出了本书的范畴。
另一个途径是使用特制的计算程序。在众多 Linux 系统中可以找到的一款程序,叫 bc
。
bc
程序读取一份由其自身类 C 语言写成的文件并执行。一份 bc
脚本可以是一个独立的文件,或从标准输入读取。bc
语言支持许多特性,包括变量、循环和程序员定义的函数。这里,我们不会完整地学习 bc
,仅仅是浅尝一二。bc
自己的手册页就很完善了。
让我们用一个简单的示例开始。来写一个 2 加 2 的 bc
脚本。
脚本的第一行是一条注释。bc
使用和 C 语言一样的注释句法。注释,可以跨越多行,以 /*
开始,以 */
结束。
使用 bc
如果我们将之前的那个 bc
脚本保存为 foo.bc
,可以这样运行:
如果仔细查看,可以看到结果在底部,在版权消息之后。版权消息可以用 -q
(quiet 安静)选项抑制。
bc
还可以交互式使用。
当交互式使用 bc
时,可以简单的键入我们想要执行的计算,而结果即刻就会被显示。bc
命令 quit
结束了交互式会话。
还可以将一个脚本通过标准输入传递给 bc
。
获取标准输入的能力意味着我们可以使用 here 文档、here 字符串和管道来传递脚本。这里是一个 here 字符串示例:
一个示例脚本
作为一个真实世界的示例,我们将构建一个执行常见计算的脚本,月度贷款支付。在下面的脚本中,我们使用了一个 here 文档将一个脚本传递给 bc
:
当执行时,结果如下:
这个示例计算了对一笔 $135,000 贷款的月度还款,年利率 7.75%,分 180 期(15 年)还清。注意该答案的精度。这取决于 bc
脚本中特殊的 scale
变量。bc
手册页提供了一份完整的 bc
脚本语言的描述。除了有一些数学表示法会略微不同于 shell(bc
更类似于 C),大多数的用法基于我们目前所学过的,所以会相当熟悉。
总结
本章中,我们了解了许多可用于通过脚本完成「实际工作」的小事情。随着我们在脚本编写方面的经验的增长,有效地操作字符串和数字的能力将变得极为宝贵。loan-calc
脚本就演示了即便是简单的脚本也可以用来做一些实际有用的事务。
额外学分
loan-calc
脚本的基本功能已经完成了,不过其距离完成还很远。作为额外学分,尝试改进 loan-calc
脚本,使其具有如下功能:
完整的命令行参数的验证
用于实现「交互」模式的命令行选项,该模式将提示用户输入贷款的本金,利率和期限
一个更好的输出格式
扩展阅读
Last updated
Was this helpful?