如果你对前一章的定制技术很熟悉,可能你想要开始对环境进行各种改动,但有的现在还做不到这一点。shell编程会帮助你实现。
bash有它这一类命令解释器所应具有的一些高级编程功能。虽然其语法不是最好的,或者说不像大多数一流的编程语言那样好,但其功能和灵活性却可以与之媲美。实际上,bash可以作为编写软件原形的完整环境。
bash编程的某些方面实际上是前面介绍的定制技术的扩展,其他一些特性则类似于传统的编程语言特性。即使你不是一个程序员,也可以读读这里给出的内容,比起前几章的信息你可以用该章内容实现更多的功能。具有传统编程语言经验(比如Pascal或C)对理解后几章内容有益(但不是必须的)。在本书其余部分,我们将会遇到一些称为任务的编程问题,其解决方案就利用了这里介绍的概念。
**shell脚本和函数
一个脚本,就是包含shell命令的文件,它是一个shell程序。上一章讨论的.bash_profile和环境文件就是shell脚本。
可以使用自己选择的文本编辑器创建脚本。一旦创建,就有两种方式运行它们。一个已经介绍过,就是键入source scriptname。这使得脚本中的命令被读取并运行,就好像键入它们一样。
第2中运行脚本方式时简单键入其名字,再按RETURN,就像调用一个内置命令一样。当然,这种方式更方便一些。该方法使脚本看起来像任何其他UNIX命令一样,实际上几个“正规”的命令就是作为shell脚本被实现的(而不是最初用C或其他语言编写的程序),包括spell、某些系统上的man和各种系统管理员命令。“用户命令文件”和“内置命令”之间差异的缩小是UNIX不断扩展,并因此在编程者中广泛流行的原因之一。
只有当脚本所在目录在命令搜索路径中或.目录(当前目录)在命令搜索路径中时,才可以通过键入脚本名来运行脚本。例如,如果当前目录(第三章里讨论)不在你的搜索路径下,必须加入./scriptname,它等同于键入脚本的绝对路径名(见第一章)。
在通过名字调用shell脚本前,还必须给其“可执行”权限。如果你熟悉UNIX文件系统,就知道文件有三种权限(读、写、执行),这些权限应用于三类用户(文件所有者、用户组和其他)。正常情况下,当用文本编辑器创建一个文件时,用户具有读和写权限,其他所有人则具有只读权限。
因此必须显示给出脚本的可执行权限。方式是使用chmod命令。最简单的方式是键入:
$ chmod +x scriptname
如果你后来又对其做出改变,文本编辑器会保留其权限。如果没有向脚本加入可执行权限,当调用它时,shell会打印消息:
scriptname: Permission denied
但在运行脚本的两种方式之间还有更重要的差别。当使用source使得脚本里的命令被执行时,就好像它们是登录会话的一部分,而第二种方法使得shell做一系列操作。首先,它运行shell的另一副本作为一个子进程,称为子shell。然后该子shell从脚本中取得命令,运行它们,再中断,将控制权返回给父shell。
图4-1显示了shell执行脚本的方式。假定你有一个简单shell脚本alice,它包含了命令hatter和gryphon。在图4-1.a中,键入source alice使得两个方法运行在同一shell下,就像你自己键入时一样。图4-1.b显示当你只键入alice时,子shell内命令运行,同时父shell等待子shell完成。
将之和图4-1.c的情况比较你会发现很有趣的现象,在c中显示了当你键入alice &时所发生的情况。第一章介绍过,&使得命令运行在后台,它实际上只是“子进程”的另一称谓。它表明在图4-1.c和图4-1.b之间的唯一重要差别是当命令运行时你对终端或工作站的控制权不同——在输入进一步命令前不需要等到它完成。
图4-1 运行一个shell脚本的方式
a
Shell: source alice → hatter → gryphon →
b
Shell: alice ╭→
子shell: ╰→ hatter → gryphon
c
Shell: alice &┄┄┄┄┄┄┄┄┄┄┄╭→
子shell: ╰→ hatter → gryphon
有许多使用子shell的分支。重要的一点是上一章介绍的被导出的环境变量(如TERM、EDITOR、PWD)在子shell中为已知,而在用户的.bash_profile中定义的其他未使用export语句的shell变量则为未知。
子shell涉及的其他问题讨论起来很复杂;关于子shell I/O和进程特性将分别在第七章和第八章详细讲解。现在,只需要记住脚本正常情况运行于子shell。
**函数
bash的函数特性是系统V中Bourne shell和其他shell的类似功能的扩展版本。函数是一种脚本内脚本。你使用它通过名字来定义某些shell代码,并将其保存在shell内存中以便以后进行调用和运行。
函数大大增强了shell的编程能力。主要原因有两个,首先,当你调用一个函数时,它已经在shell的内存中;因此函数运行的很快。现在计算机拥有大量的内存,因此没有必要担心一个典型函数占用的空间大小。为此,大多数人定义尽可能多的常用函数而不是保留许多脚本。
函数的另一个好处是它们对将长的shell脚本组织成各种容易开发和维护的模块是最理想的。如果你不是一个程序员,当向一个程序员询问没有函数的生活是什么样的(在其他语言中也称为过程和子进程),你可能会得到令人吃惊的答案。
要定义一个函数,可以使用下述两种格式:
function functname{
shell commands
}
或
functname (){
shell commands
}
两者间没有功能上的区别。本书使用这两种格式。还可以使用命令unset -f functname删除一个函数定义。
定义一个函数,就是令shell在内存中保存其名字和定义(亦即其包含的shell命令)。如果你要在以后运行函数,只须键入其名字,后跟任意参数即可,就好像它是一个shell脚本。
可以通过键入declare -f找到登录会话里定义的函数。shell会依据函数名按字母次序打印出所有函数的名字和定义。此输出可能很长,你可能要通过more或者将其管道输出或重定向到一个文件以便使用文本编辑器查看。如果只想看看函数名,可以使用declare -F。关于declare的详细内容请参见第六章。
除了优势,函数和脚本有两个重要差别。首先,当通过名字调用时,函数不在单独进程里运行,而脚本却可以。运行一个函数的语义更类似于登录时.bash_profile中的命令或用source命令调用脚本时的情况。第二,如果一个函数与一个脚本或可执行程序有相同的名字,则函数优先。
现在可以讲解当在shell中键入一个命令时,各种资源的优先级次序:
1.别名
2.关键字。例如function,以及将在第五章介绍的if和for
3.函数
4.内置命令。如cd和type
5.脚本和可执行程序。shell按在PATH环境变量中列出的目录中对其进行搜索
因此,同名的情况下,别名优先于函数和脚本。然而,可以使用内置的command、builtin和enable改变优先级次序。它允许你将函数、别名和脚本文件定义为同样的名字,并选择要执行的一个。我们在第七章命令行处理一节中会详细介绍该过程。
如果需要知道命令的精确源。可参加第三章介绍的type内置命令的选项。type本身会打印bash按上面列出的搜索位置对命令进行的解释。如果有一个shell脚本、函数以及一个别名都称为dodo,当你键入dodo时,type会告诉你使用作为别名的那个dodo,如果给type多个参数,则会依次打印所有信息。
type有三个选项允许你查找一个命令的特定细节。如果你要找出dodo的所有定义,可以使用type -all。结果如下:
$ type -all dodo
dodo is aliased to `echo "Everybody has won, and all must have prizes"`
dodo is a function
dodo ()
{
echo "Everybody has won, and all must have prizes"
}
dodo is ./dodo
可以使用-path选项严格限制命令的搜索只对应可执行文件或shell脚本。如果在bash中被键入的命令执行一个文件或shell脚本,则返回文件的路径名;否则,输出为空。
type默认输出很长。它会给出一个别名或函数的所有定义。使用-type选项,可以限制输出为单个单词描述符:关键字、函数、内置命令或文件。例如:
$ type -type bash
file
$ type -type if
keyword
也可以通过-all使用-type选项。
本书其余部分我们主要引用脚本。除非特别注明,否则,可以假定我们所说的应用等同于函数。
**shell变量
bash从shell变量中继承了许多编程特性。我们已经介绍了变量的基本概念,简略地说:它们被命名用以存储数据,通常格式为字符串,其值可使用$符号加名字获得。某些变量,称为环境变量,按惯例以大写字母命名,其取值对子进程已知(使用export语句)。
如果你是程序员,可能知道所有主要编程语言都以某种方式使用变量,实际上语言间特性区分的最主要方式就是比较其变量的功能。
在bash的变量策略和常用语言中变量策略的基本差别是bash的特别强调的是字符串(因此它与具有特定用途的语言如SNOBOL的共同点比与一般语言如Pascal的共同点多)。在Bourne shell和C shell中也是如此,但bash中还有处理整数的额外机制。
**位置参数
可以使用格式varname=value的语句定义变量值,例如:
$ hatter=mad
$ echo "$hatter"
mad
某些环境变量在登录时由shell预定义。还有其他三种内置变量对shell编程很重要。现在介绍其中一些,其他的后面再介绍。
最重要的特定内置变量称为位置参数。当脚本被调用时,它们保存脚本的命令行参数。位置参数名为1,2,3等,其值由$1,$2,$3表示。还有一个位置参数0,其值为脚本名(亦即要被调用的键入的命令)。
两个特殊变量包含了所有的位置参数(除了位置参数0):*和@。它们的差别不明显,但很重要,并且只有在双引号内才体现出来。
"$*"是包含所有参数位置的单一字符串,由环境变量IFS(内部域分隔符,internal field separator)中第一个字符分隔。IFS默认为空格、TAB和NEWLINE。另一方面,"$@"等价于"$1""$2"..."$N",这里N为位置参数数目,等价于N个单独的由空格分隔的双引号字符串。如果没有位置参数,"$@"扩展为空。下面我们会介绍两者差别的细节。
变量#保存位置参数的数目(作为一个字符串)。所有这些变量都是只读的,不能在脚本内对其设置新值。
例如,假定有下列简单shell脚本:
echo "alice: $@"
echo "$0: $1 $2 $3 $4"
echo "$# arguments"
进一步假定脚本名为alice,如果键入了alice in wonderland,就会看到下列输出:
alice: in wonderland
alice: in wonderland
2 arguments
这里,$3和$4未设置,将对其替换以空字符串。
**函数内的位置参数
shell函数使用位置参数和特殊变量,如*和#,其方式与shell脚本一样。如果要将alice定义为函数,可以在.bash_profile或环境文件中放入如下内容:
function alice
{
echo "alice: $*"
echo "$0: $1 $2 $3 $4"
echo "$# arguments"
}
键入alice in wonderland结果是一样的。
典型情况下,在一个shell脚本里都要定义几个shell函数。因此,每个函数都需要处理自己的参数,这表明每个函数都需要分别跟踪位置参数。肯定的说,每个函数都有这些变量的副本(即使函数并不像脚本运行在其子shell里),我们称这些变量对函数是局部的。
然而,在函数内定义的其他变量则不是局部的(为全局变量),其取值在整个shell脚本中均为已知。例如,假定有一shell脚本称为ascript,内容包含:
function afunc
{
echo in function: $0 $1 $2
var1="in function"
echo var1: $var1
}
var1="outside function"
echo var1: $var1
echo $0: $1 $2
afunc funcarg1 funcarg2
echo var1: $var1
echo $0: $1 $2
如果通过键入ascript arg1 arg2调用该脚本,就会得到如下输出:
var1: outside function
ascript: arg1 arg2
in function: ascript funcarg1 funcarg2
var1: in function
var1: in function
ascript: arg1 arg2
换句话说,函数afunc将变量var1的值从“函数外”改变为“函数内”,而且这一改变对函数外已知。而$1,$2在函数内和主脚本中有不同的取值。注意,$0不改变,因为函数在shell脚本环境内执行,$0值为脚本名。图4-2显示了每个变量的范围。
图4.2 函数有自己的位置参数
┏━━━━━━━━━━┓
┃script ascript ┃ ┏━━━┓
┃ ┏━━━┓ ┃ ┃var .┃-只在脚本内已知
┃ ┃$var1 ┃ ┃ ┗━━━┛
┃ ┗━━━┛ ┃ ┏━━━┓
┃ ┏━━━┓ ┃ ┃var ..┃-只在函数内已知
┃ ┃$0 ┃ ┃ ┗━━━┛
┃ ┗━━━┛ ┃ ┏━━━┓
┃ ┏━━━┓ ┃ ┃var ┃-在脚本和函数内已知
┃ ┃$1 .┃ ┃ ┗━━━┛
┃ ┗━━━┛ ┃
┃ ┏━━━┓ ┃
┃ ┃$2 .┃ ┃
┃ ┗━━━┛ ┃
┃┏━━━━━━━┓ ┃
┃┃function afunc┃ ┃
┃┃ ┏━━━┓ ┃ ┃
┃┃ ┃$1 ..┃ ┃ ┃
┃┃ ┗━━━┛ ┃ ┃
┃┃ ┏━━━┓ ┃ ┃
┃┃ ┃$2 ..┃ ┃ ┃
┃┃ ┗━━━┛ ┃ ┃
┃┗━━━━━━━┛ ┃
┗━━━━━━━━━━┛
**函数内局部变量
函数定义中的local语句使所涉及的变量均为函数的局部变量。在“子程序”单元(过程、函数、子进程等)中定义局部变量对于编写大型程序是必须的,因为它有助于使子进程之间,以及子程序和主程序之间相对独立。
下面以上节中最后一个例子为例,这里将变量var1定义为局部变量:
function afunc
{
local var1
echo in function: $0 $1 $2
var1="in function"
echo var1: $var1
}
运行ascript arg1 arg2的结果如下:
var1: outside function
ascript: arg1 arg2
in function: ascript funcarg1 funcarg2
var1: in function
var1: outside function
ascript: arg1 arg2
图4-3显示了新脚本中每个变量的范围。注意,afunc现在有其自己的局部var1副本,虽然最初的var1仍可为ascript调用的任意其他函数所用。
图4-3 函数可以具有局部变量
┏━━━━━━━━━━┓
┃script ascript ┃ ┏━━━┓
┃ ┏━━━┓ ┃ ┃var .┃-只在脚本内已知
┃ ┃$var1 ┃ ┃ ┗━━━┛
┃ ┗━━━┛ ┃ ┏━━━┓
┃ ┏━━━┓ ┃ ┃var ..┃-只在函数内已知
┃ ┃$0 ┃ ┃ ┗━━━┛
┃ ┗━━━┛ ┃ ┏━━━┓
┃ ┏━━━┓ ┃ ┃var ┃-在脚本和函数内已知
┃ ┃$1 .┃ ┃ ┗━━━┛
┃ ┗━━━┛ ┃
┃ ┏━━━┓ ┃
┃ ┃$2 .┃ ┃
┃ ┗━━━┛ ┃
┃┏━━━━━━━┓ ┃
┃┃function afunc┃ ┃
┃┃ ┏━━━━┓ ┃ ┃
┃┃ ┃$var1 ..┃ ┃ ┃
┃┃ ┗━━━━┛ ┃ ┃
┃┃ ┏━━━┓ ┃ ┃
┃┃ ┃$1 ..┃ ┃ ┃
┃┃ ┗━━━┛ ┃ ┃
┃┃ ┏━━━┓ ┃ ┃
┃┃ ┃$2 ..┃ ┃ ┃
┃┃ ┗━━━┛ ┃ ┃
┃┗━━━━━━━┛ ┃
┗━━━━━━━━━━┛
**对$@和$*进行引用
有了上面的背景知识,下面将详细介绍“$@”he “$*”。这是shell最有特色的两个变量。因此这里将讨论它们最常用的方面。
·为什么元素“$*”用IFS的第一个字符而不是用空格分隔呢?这是为了输出的灵活性。下面是一个简单例子。要打印逗号分隔的位置参数列表,脚本为:
IFS=,
echo "$*"
在脚本里改变IFS是很危险的,但如果脚本里的其他内容和它无关就可能没问题。如果脚本为arglist,那么命令arglist alice dormouse hatter产生输出alice,dormouse,hatter。第五章和第十章给出了改变IFS的其他的例子。
·为什么“$@”用做N个独立的双引号引用字符串?目的是允许你再次分别使用它们。例如,要想在脚本里调用一个具有同样位置参数列表的函数,如:
function countargs
{
echo "$# args."
}
假定你的脚本调用使用与上述arglist一样的参数,那么如果它包含命令countargs "$*",则函数打印1 args。但如果命令为countargs "$@",函数打印3 args。
**变量语法详解
在介绍用shell变量实现的许多功能前,必须指出这里进行的一种简化:取一个变量值的$varname的语法实际上是常用语法${varname}的简化形式。
为什么有两种语法呢?一个原因是,如果代码中引用了多于9个位置参数,则必须使用常用语法${10}而不是$10。除此之外,还要考虑下列情况,如果要在用户ID后放入一个下划线:
echo $UID_
shell会试图使用UID_作为变量名。除非出现$UID_已经存在的偶然情况,否则该语句不会打印任何内容(值为null或空字符串"")。要得到预期结果,需要将该shell变量括在大括号内:
echo ${UID}_
如果变量名后跟一个非小写字符、数字或下划线,则省略大括号就没问题。
**字符串操作符
大括号语法允许你使用shell的字符串操作符。字符串操作符允许你以各种可用方式对变量值进行操作,却不必编写其固定程序或求助于外部UNIX功能。可以使用字符串处理操作符做很多操作,即使你不精通后面几章介绍的编程特性。
字符串操作符可以作如下操作:
·确保变量存在(亦即被定义且为非空值)
·设置变量的默认值
·捕获未设置变量导致的错误
·删除匹配模式的变量的值部分内容
**字符串操作符语法
字符串操作符的基本思想是将表示操作的特殊字符插入到变量名和右边的大括号之间。操作符需要的任何参数都被插入到操作符右边。
第一组字符串处理操作符用来测试变量的存在性以及允许在一定条件下对默认值进行替换,如表4-1所示。
表4-1 替换操作符
操作符 替换
${varname:-word} 如果varname存在且非null,返回其值,否则返回word
意图 如果变量未定义,返回一个默认值
例子 如果count未定义,则${count:-0}为0
${varname:=word} 如果varname存在且非null,返回其值,否则将其设置为word,然后返回其值。位置参数和特殊参数不能这样设置
意图 如果变量未定义,设置该变量为默认值
例子 如果count未定义,则${count:=0}设置count为0
${varname:?message} 如果varname存在且非null,返回其值,否则打印varname:后跟信息message,并退出当前命令或脚本(只对非交互式shell)。省略message则产生默认信息parameter null or not set
意图 捕获未定义变量导致的错误
例子 如果count未定义{count:?"undefined!"}则打印出"count: undefined!"并退出
${varname:+word} 如果varname存在且非null,返回word,否则返回null。
意图 测试一个变量的存在性
例子 如果count被定义,${count:+1}返回1(即为“true”)
${varname:offset}
${varname:offset:length} 执行子字符串扩展。返回$varname从offset开始,长度为length的子字符串。在$varname里第一个字符位置为0。如果长度省略,子字符串以offset开始,一直到$varname结束。如果offset小于0,则第一个字符位置为$varname结尾。如果varname为@,length则为从参数offset开始的位置参数的数目。
意图 返回字符串的一部分(子字符串或片段)
例子 如果count设置为frogfootman,${count:4}返回footman。${count:4:4}返回foot
第一个操作符最适合设置用户省略参数时命令行参数的默认值。本书中我们将对下面的第一个编程任务使用该技术。
任务4-1
假设有一个大的签名册,需要编写一个软件来跟踪它。假定有一个关于每个艺术家有多少签名的数据文件。文件中内容如下:
5 Depeche Mode
2 Split Enz
3 Simple Minds
1 Vivaldi, Antonio
编写一个程序打印N个数值最高的行,亦即N位签名最多的艺术家。N默认为10.改程序应带有一个表示输入文件名的参数,以及一个给出要打印的行数的可选参数。
目前此类脚本最好的实现方式是使用内置UNIX功能,将之与I/O重定向和管道结合。即典型的UNIX“构建模块”原理,这也是UNIX得以在程序员间广泛流行的另一原因。我们使用构建模块技术编写了该脚本的第一个版本,只有一行:
sort -nr $1 | head -${2:-10}
工作方式如下:sort程序将文件数据分类,文件名作为第一个参数($1)。-n选项令sort将每行的第一个单词解释为数字(而不是字符串)。-r选项使sort进行逆向比较,因此排序是降序排列。
sort的输出被管道输出到head实用程序。当给定参数N时,在标准输出上打印其输入的前N行。表达式-${2:-10}解释为一个短划线后跟第2个参数(如果给定),如果未给定则为-10.注意,此表达式中变量为2,它是第2个位置参数。
假定要编写的脚本称为highest,则如果用户键入highest myfile,实际运行的是:
sort -nr myfile | head -10
或者如果用户键入highest myfile 22,运行的行是:
sort -nr myfile | head -22
用户要确信理解:-字符串操作符提供默认值的方式。
这是一个完美的可运行脚本——但它有一些问题。首先,该行有点晦涩。这对这么小的脚本不算问题,但以这种方式编写长的复杂脚本就不很明智了。稍微改动一下将使脚本可读性增强。
首先,可以向代码加入注释。在#和行尾之间的内容为注释。一般情况,脚本应开始于注释行,以给出脚本的功能以及其接受的参数。第二,可以将位置参数赋值给名字,有助于记忆的变量来改进变量名。最后,可以加入空行将各个部分分开。像注释一样,空行也被忽略。以下为强可读性版本:
#
# highest filename [howmany]
#
# 打印filename文件中howmany个数值最高的行,
# 假设输入文件具有以数字开始的行。默认情况下howmany是10。
#
filename=$1
howmany=${2:-10}
sort -nr $filename | head -$howmany
注释里包围howmany的方括号符合UNIX文档的惯例,方括号表示可选参数。
我们只是增加了代码可读性,并未改变其运行方式。如果用户不带参数调用脚本会出现什么情况呢?记得位置参数如果未定义则默认为null,如果没有参数,则$1和$2均为null。变量howmany($2)被默认设置为10。但没有filename($1)的默认值。结果如下:
sort -nr | head -10
运行时,如果不带文件名参数调用sort,它希望输入来自标准输入。例如,一个管道(|)或用户终端。因为这里没有管道,则希望是终端。这意味着脚本将挂起。虽然总是可以用CTRL-D或CTRL-C退出该脚本,但初级用户可能不知道这一点。
因此需要确保用户至少给出一个参数。一种方式是调用另一字符串操作符。下面将行:
filename=$1
替换为:
filename=${1:?"filename missing."}
这样,当用户不带任何参数调用该脚本时将产生两个结果:首先shell打印一些错误消息:
highest: 1: filename missing.
到标准错误输出。第二,脚本退出,不再运行其余代码。做了下面的修改后,可以得到稍微好些的错误消息。
考虑代码:
filename=$1
filename=${filename:?"missing."}
结果消息为:
highest: filename: missing.
当然,还有许多其他方式可以打印所需的消息。第五章将详细介绍。
现在让我们详细看一下表4-1中剩下的3个操作符,并介绍如何将其嵌入我们的任务中。:=操作符的功能就像:-一样,但如果变量不存在,将变量值设置为给定单词会有副作用。
想要在上面的脚本中用:=替换:-是不行的。因为我们会试图设置位置参数值,而这是不被允许的。如果将行:
howmany=${2:-10}
替换为:
howmany=$2
并将替换结果放到实际命令行中(就像在开始时做的一样),则我们可以使用:=操作符:
sort -nr $filename | head -${howmany:=10}
使用:=将howmany值设置为10有好处,我们将在该脚本的后续版本中用到它。
如果给定变量存在且不为null,操作符:+将替换一个值。下面给出了在上例中使用它的方式:假定我们要给出一个可以让用户在脚本输出中加入头行的选项。如果他键入选项-h,则输出结果的最前面加入行:
ALBUMS ARTIST
进一步假定此选项最终赋给变量header,即如果选项被设置,则$header为-h。如果未设置,则为null(后面我们会看到如何在不打乱其他位置参数的同时完成此功能)。
如果变量header为null,则下述表达式输出为null,如果为非null,则为ALBUMSARTIST\n:
${header:+"ALBUMSARTIST\n"}
这表明我们可以刚好把行:
echo -e -n ${header:+"ALBUMSARTIST\n"}
放入执行实际操作的命令行前。echo的-n选项使其在打印参数后不打印LINEFEED。因此,如果header为null此echo语句将不打印任何内容——即使是一个空行也没有。否则,将打印头行和LINEFEED(\n)。-e选项使echo将\n解释为LINEFEED而非其字面意义。
最后一个操作符为子字符串扩展,用来返回字符串的一部分。我们可以使用它来从字符串中取出一部分。假定脚本可以一次一行的将排序列表中的行赋值给变量album_line。如果我们要打印签名并忽略签名数。可以使用子字符串扩展:
echo ${album_line:8}
结果从第8个字符的位置开始打印,该位置是每行签名的开始位置。
如果只想打印签名数,不打印签名,可以通过给出子字符串的长度实现:
echo ${album_line:0:7}
虽然这个例子看起来不是很有用,但它给出如何使用子字符串的粗浅经验。接合本书后面讨论的其他编程特性,子字符串可能就很有用了。
**模式和模式匹配
本章后面的内容将进一步细化任务4-1.下一类型字符串操作符是用模式来匹配变量字符串部分值的模式匹配操作符。像第一章介绍的那样,模式是可能包含任意字符的字符串(用于字符设置和范围的*,?和[])。
表4-2列出了bash的模式匹配操作符。
表4-2 模式匹配操作符
操作符 含义
${variable#pattern} 如果模式匹配变量取值的开头,删除最短的匹配部分,并返回其余部分
${variable##pattern} 如果模式匹配变量取值的开头,删除最长的匹配部分,并返回其余部分
${variable%pattern} 如果模式匹配变量取值的结尾,删除最短的匹配部分,并返回其余部分
${variable%%pattern} 如果模式匹配变量取值的结尾,删除最长的匹配部分,并返回其余部分
${variable/pattern/string}
${variable//pattern/string} 将variable中匹配模式的最长部分替换为string。第一种格式中,只有第一个匹配部分被替换,第二种格式中,所有匹配部分均被替换。如果模式以#开头,则必须匹配variable的开头。如果以%开头,则必须匹配variable的结尾。如果string为null,匹配部分被删除。如果variable为@或*,操作被依次应用于每个位置参数并且扩展为结果列表
这些很难记住,因此这里给出了易于记忆的方法:#匹配前面,因为#总是在数字前。%匹配结尾,因为%符号总是在数字后。
模式匹配操作符的典型用法是去除路径名的组成部分,如目录前缀和文件名后缀。下面给出一个例子,它显示了所有操作符的工作方式。假定变量path取值为/home/cam/book/long.file.name,则:
表达式 结果
${path##/*/} long.file.name
${path#/*/} cam/book/long.file.name
$path /home/cam/book/long.file.name
${path%.*} /home/cam/book/long.file
${path%%.*} /home/cam/book/long
这里用到的两个模式为/*/,它匹配两个斜线间的部分内容,而.*则匹配点号后的内容。
最长的和最短的模式匹配操作符产生同样的输出,除非它们使用了*通配符。例如,如果filename取值为alicece,则${filename%ce}和${filename%%ce}均产生结果alice。这是因为ce是精确匹配,对要进行的匹配来说,字符串ce必须出现在$filename结尾。最长和最短匹配将匹配最后一组ce,并删除它。然而,如果使用通配符*,则${filename%ce*}产生alice的结果,因为它匹配最短的ce取值,后面可跟任意内容。${filename%%ce*}返回ali,因为它匹配最长的ce取值,后跟任意内容,这里指第一个和第二个ce。
下一任务将把这些模式匹配操作符合并到一起。
任务4-2
假定你正在编写一个用于创建WWW主页的图形文件转换工具。你要接受一个PCX文件,并把它转换成GIF文件,进而在Web页面上使用。
图形文件转换工具很常见,因为当前存在大量不同的图形格式和文件类型。它们允许你指定一个输入文件,通常来自各种不同的格式,然后将之转换成一种不同格式的输出文件。这里,假定接受了一个PCX文件,它不能在WWW浏览器上显示,要将之转换成GIF文件。该过程一部分是接受PCX文件的文件名,它以.pcx结尾,并将之改变成以.gif结尾的输出文件。基本方法就是接受最初的文件名,去除.pcx,然后附加上.gif。这使用一条shell语句就可以实现:
outfile=${filename%.pcx}.gif
shell接受文件名并在字符串结尾处查找.pcx。找到后去除.pcx,返回其余部分。例如,如果filename值为alice.pcx,表达式${filename%.pcx}将返回alice。附加.gif形成预期的alice.gif,然后将其保存在变量outfile中。
如果filename值不对(没有.pcx),如alice.jpg,上述表达式将返回alice.jpg.gif。因为没有匹配,filename值的任何部分都没被删除,.gif随意附加到末尾。注意,无论filename包含多个点号(例如,如果为alice.1.pcx——表达式仍将产生预期结果alice.1.gif)。
下一任务使用最长的模式匹配操作符。
任务4-3
假定正在使用一个过滤器准备将一个文本文件输出到打印机。要把不带目录前缀的文件名放入“标志”页面。假定脚本中要打印的文件的路径名被保存在变量pathname中。
这里的目的很明确,就是要从路径名中删除目录前缀。下面的代码可实现此功能:
bannername=${pathname##*/}
该方案有点类似于前面例子的第一行。如果pathname只是一个文件名,模式*/(任意内容后跟一个斜线)将不匹配,表达式取值仍为pathname。如果pathname内容类似book/wonderland,则前缀book/将匹配模式并被删除,只留下wonderland作为表达式的值。如果pathname类似于/home/cam/book/wonderland,情况也一样:因为##将删除最长的匹配,所以将删除整个/home/cam/book/。
如果使用#*/而不是##*/,表达式将得到错误值home/cam/book/wonderland,因为“以斜线开始字符串”的最短实例只是一个斜线(/)。
结构${variable##*/}实际上等价于UNIX实用程序basename。basename将路径名作为参数,并只返回文件名。它常被shell的命令替换机制使用(解释见下面)。basename效果不如${variable##*/},因为后者运行于自己的单独进程中,而不是在shell里。另一实用程序dirname基本与basename相反,它只返回目录前缀。它等价于bash表达式${variable%/*},由于同样原因效率也没有后者好。
表中的最后一个操作符匹配模式并执行替换。任务4-4就是使用它的一个简单任务。
任务4-4
PATH打印出来时为一行,且各目录用冒号分隔很难区分。要想以一种简单方式来显示它们,一个目录对应一行。
因为目录名以冒号分隔,最容易的方式就是将冒号替换为LINEFEED。
$ echo -e ${PATH//:/'\n'}
/home/cam/bin
/usr/local/bin
/bin
/usr/bin
/usr/X11R6/bin
冒号的每次出现都被替换为\n。正如前面所见,-e选项允许echo将\n解释为一个LINEFEED。这里我们使用两种替换形式的第二种。如果使用第一种,则只将第一个冒号替换为\n。
**长度操作符
还有一个变量操作符是${#varname},返回变量字符串值的长度(在第六章将介绍如何将它和类似值看作真实数字,这样它们就可以被算术操作符所用了)。例如,如果filename取值为alice.c,则${#filename}取值为7。
**命令替换
讨论到现在,我们介绍了取得变量值的两种方式:通过赋值语句和通过用户将其作为命令行参数给出。还有一种方式:命令替换,它允许你使用命令的标准输出,就好像它是一个变量值一样。下面介绍了该特性的强大之处。
命令替换的语法是:
$(UNIX command)
运行圆括号内的命令,该命令写到标准输出的内容返回作为表达式值。该结构可被嵌套使用,即UNIX命令可包含命令替换。
下面是简单实例:
·$(pwd)值为当前目录(等同于环境变量$PWD)。
·$(ls $(HOME))为用户根目录下所有文件名。
·$(ls $(pwd))为当前目录下所有文件名。
·如果不知道一个命令文件的位置,要找出该命令的详细信息,可键入ls -l $(type -path -all command-name)。-all选项强制type执行路径名搜索,-path使其忽略关键字、内置命令等。
·如果要在bash上编辑(使用vi)书中包括短语“命令替换”的各节,假定你的章节文件都以ch开始,可以键入:
vi $(grep -l 'command substitution' ch*)
grep的-l选项只打印包含匹配的文件的名字。
像变量和~扩展一样,命令替换也要在双引号内执行。因此,第一章和第三章中关于字符串“如果它们不包含变量时则使用单引号”的规则可扩展为“不确定时,要使用单引号,除非字符串包含变量或命令替换。在后者情况下则使用双引号。”
命令替换有助于我们解决下一任务。该任务与任务4-1中的签名数据库有关。
任务4-5
任务4-1中使用的文件实际上是来自一个关于签名的更大数据列表的报告。该列表由列或域组成,用户通过名字如“艺术家”、“标题”、“年份”等来引用它们。这些列由符号|分隔(|,等同于UNIX管道操作符)。要处理表中的单个列,域名需要被转换成域编号。
假定有一shell函数为getfield,它接受域名为参数,在标准输出上写下相应的域(或列)编号。使用此函数可以帮助从数据表中抽取列。
cut功能正适合此任务。cut是一个数据过滤器:它从规则表格中抽取列。如果给出要从输入中抽取的列编号,cut就再标准输出上只打印这些列。列可为字符位置或由TAB字符或其他分隔符分隔的域(在本例中)。
假定任务中数据列表为文件albums,其内容如下:
Depeche Mode|Speak and Spell|Mute Records|1981
Depeche Mode|Some Great Reward|Mute Records|1984
Depeche Mode|101|Mute Records|1989
Depeche Mode|Violator|Mute Records|1990
Depeche Mode|Songs of Faith and Devotion|Mute Records|1993
...
下面是使用cut抽取第4列(年)的过程:
cut -f4 -d\| albums
-d参数用于指定域分隔符字符(TAB为默认值)。|符号必须用反斜线转义,这样shell不会试图将其解释为一个管道。
通过该行代码和getfield程序,我们可以轻易的解决该任务。假定getfield的第一个参数为用户要抽取的域的名字,则方案为:
fieldname=$1
cut -f$(getfield $fieldname) -d\| albums
如果调用该脚本,并给参数year,输出为:
1981
1984
1989
1990
1993
...
任务4-6给出使用cut的另一个小任务。
任务4-6
发送一个邮件信息给当前已登录的所有用户。
命令who给出了已经登录的用户(即他们的终端位置和登录时间)。输出内容如下:
root tty1 Oct 13 12:05
michael tty5 Oct 13 12:58
cam tty23 Oct 13 11:51
kilrath tty25 Oct 13 11:58
各域由空格分隔,而不是TAB。因为我们需要第一个域,可以在cut命令中使用空格作为域分隔符来处理(否则,就必须要用到使用字符列而不是域的cut选项)。要在命令行上给出空格字符作为参数,可用引号将其括起来:
$ who | cut -d' ' -f1
根据上述who输出,该命令的输出如下:
root
michael
cam
kilrath
这样直接引出了上述任务的解决方案。键入:
$ mail $(who | cut -d' ' -f1)
运行命令mail root michael cam kilrath,然后你就可以键入你的信息了。
任务4-7给出了另一任务,显示如何在命令替换中使用命令管道。
任务4-7
ls命令提供带有通配符的模式匹配功能,但它不允许你依据修改的日期选择文件。这里要设计一种实现该功能的机制。
下面函数使你可以列出最后修改日期为给定参数值的所有文件。为了加快运行速度,我们再次选择了函数,该函数名没有双关含义:
function lsd
{
date=$1
ls -l | grep -i "^.\{42\}$date" | cut -c55-
}
此函数依据ls -l命令的列布局。特别是,它依赖于开始于列42的日期和开始于列55的文件名。如果你的UNIX版本中不是这种情况,则需要对列编号做相应调整。
我们使用grep搜索功能匹配给定的日期值(形式为Mon DD,例如,Jan 15或Oct 6,后者有两个空格)作为ls -l输出的参数。结果给出一个日期匹配参数的长格式文件列表。grep的-i选项允许你在月份名字里使用小写字母。后面奇怪的参数表示“匹配41个字符后跟函数参数的任意行”。例如,键入lsd 'jan 15'使得grep搜索匹配41个任意字符后跟jan 15(或Jan 15)的行。
grep的输出结果被用管道输出到cut中进而只检索出文件名。cut的参数告诉它抽取从第55列到行尾的字符。
有了命令替换,你可以对接受文件名参数的任意命令使用该函数。例如,如果要打印当前目录下最后修改日期为今天的所有文件,如果今天是1月15日,则可以键入:
$ lp $(lsd 'jan 15')
lsd的输出在多行上(一次对应一个文件名),但LINEFEED是lp命令的合法域分隔符,因为环境变量IFS(本章前面介绍)默认包含LINEFEED。
**高级例子:pushd和popd
我们将以一对已嵌入到bash的函数来结束本章,它们对理解本章介绍的概念很有帮助。
任务4-8
函数pushd和popd实现了目录堆栈,使你可以暂时移动到另一目录,并让shell记得你以前的位置。将其实现为shell函数。
这里仅实现其功能的主要子集,完整的程序请参见第六章。
将堆栈看成自助餐厅里的盘子堆。最近被放上的盘子总是被想要找食物的人最先取用。因而,这种堆被称为“后进先出”或LIFO结构。把盘子放在栈上在计算机科学里称pushing(入栈),从栈上面取盘子则称poping(出栈)。
堆栈用于记忆目录很方便,它可以“记住你的位置”任意次数。cd命令的cd -形式功能也是如此,但是只有一层。例如,如果你当前在firstdir里,然后改变到seconddir中,可以键入cd -返回。但如果你从firstdir出发,然后改变到seconddir,再进入thirddir,使用cd -则只返回到seconddir。如果你再次键入cd -,则又回到了thirddir。因为它是前一个目录。
如果要实现可以返回到firstdir的“嵌套”记忆功能,需要pushd和popd,同时还要有一个目录堆栈。下面是其工作方式:
·第一次pushd dir被调用,pushd把当前目录放入堆栈。然后使用cd转移到dir,把它放入堆栈。
·随后调用pushd dir时,只使用cd转移到dir,并把dir放入堆栈。
·popd删除了堆栈中的最上层目录,把下面的一层放到最上面。然后使用cd转移到新的该层目录。
例如,考虑表4-3中的一系列操作。假定你已经登录,在自己的主目录下(/home/you)。
表4-3 pushd/popd实例
命令 堆栈内容 结果目录
pushd lizard /home/you/lizard /home/you /home/you/lizard
pushd /etc /etc /home/you/lizard /home/you /etc
popd /home/you/lizard /home/you /home/you/lizard
popd /home/you /home/you
popd 空 错误
我们把堆栈实现为包含空格分隔符的目录列表的环境变量。
登录时你的目录堆栈应被初始化为null字符串。为此,将其放入.bash_profile中:
DIR_STACK=""
export DIR_STACK
不要把它放入环境文件中。export语句确保了DIR_STACK对所有的子进程已知。只应对其初始化一次,如果该代码被放入一个环境文件,它将在每个子shell中被再次初始化,这是我们不想看见的。
接着,需要实现pushd和popd函数。以下为最初版本:
pushd ()
{
dirname=$1
DIR_STACK="$dirname ${DIR_STACK:-$PWD' '}"
cd ${dirname:?"missing directory name."}
echo "$DIR_STACK"
}
popd ()
{
DIR_STACK=${DIR_STACK#* }
cd ${DIR_STACK%% *}
echo "$PWD"
}
注意,代码到此为止了。下面分析一下这两个函数及其工作过程。从pushd开始,第一行只将第一个参数保存为变量dirname,目的是为了增加可读性。
函数第二行把新目录放入堆栈。栈非null时表达式${DIR_STACK:-$PWD' '}值为$DIR_STACK,如果为null,则为$PWD' '(当前目录和一个空格)。双引号内的表达式由给定参数、一个空格、一个DIR_STACK或当前目录和一个空格组成。当前目录后的空格被popd函数用于模式匹配。堆栈中每个目录都作为"dirname"形式。
语句中的双引号确保所有内容都被打包到一个字符串内以便赋值给DIR_STACK。因而,该代码行可处理特定的初始状态(堆栈为空)以及通常情况(非空状态)。
第3行的主要目的是改变到新目录。这里使用:?操作符处理参数遗漏时的错误:如果给定参数,则表达式${dirname:?"missing directory name."}值为$dirname。如果未给定参数,则shell打印信息pushd: dirname: missing directory name并从函数退出。
最后一行只打印堆栈的内容,暗示出最左边的目录既是当前目录,也位于堆栈的最上层(这也是我们选择空格分隔目录的原因,而不是使用在PATH和MAILPATH中更常用的冒号)。
popd函数仍使用shell的模式匹配操作符。第一行使用#操作符,它试图删除DIR_STACK取值中模式"* "(任意内容后跟一个空格)的最短匹配。结果最上层目录和其后面的空格从堆栈中删除。这也是我们需要在放入堆栈的第一个目录结尾后加入空格的原因。
popd函数的第二行使用模式匹配操作符%%删除DIR_STACK值中模式"*"(空格后跟任意内容)的最长匹配。它抽取最上层目录作为cd的参数,但并不影响到DIR_STACK的值,因为没有对其赋值。最后一行只打印确认信息。
该代码在4个方面还有不足。首先,它没有对错误进行防备。例如:
·如果用户试图在堆栈中放入一个不存在或无效的目录该如何处理?
·如果用户试图弹出,但堆栈未空该如何处理?
通过指出代码对这些错误情况的反应就可以测试你对代码的理解程序。第2个问题是如果在一个shell脚本中使用pushd,在没有给定参数的情况下,它将退出一切内容。${varname:?message}总是从非交互式shell中退出,但它不会从调用函数的交互式shell中退出。第3个缺点就是它只实现了bash的pushd和popd命令的一部分——不过是最重要的部分。在下一章,我们将介绍如何克服这些不足之处。
该段代码的第4个问题是,如果由于某原因目录名包含了空格,代码将不工作。该代码将空格看作分隔字符。我们这里暂时接受这些不足,但在下一章就会考虑如何克服它们了。