学习bash第二版-第六章 命令行选项和有类型变量

  通过前面几章的介绍,你应该对shell编程技术有了基本的了解。所学的内容已经使你可以编写许多完整且有用的shell脚本和函数。
  但你也许会注意到相比于常用的UNIX命令,你的shell代码仍有一定的差距。特别是,如果你是资深UNIX用户,就会注意到目前编写的所有实例脚本都还不具备处理命令行上短划线后选项的能力。如果你曾用常规语言C或Pascal编程,就会注意到我们在shell变量中遇到的唯一数据类型是字符串。也就是说,我们还没有接触算术。
  这些功能对作为实用UNIX编程语言的shell的函数功能来说当然很重要。本章将介绍bash如何对其及相关特性予以支持。
**命令行选项
  前面介绍了许多位置参数的例子(称为1,2,3等的变量),shell使用它们在运行时将命令行参数存储到shell脚本或函数。还有一些相关变量如*(所有参数的字符串)和#(参数个数)。
  实际上,这些变量保存用户命令行上的所有信息。但要考虑到涉及选项时发生的操作。典型UNIX命令格式为:命令 [-选项] 参数,表示它有0或多个选项。如果一个shell脚本处理命令teatime alice hatter,那么$1就是“alice”,$2就是“hatter”。但如果命令为teatime -o alice hatter,则$1就是-o,$2为“alice”,$3为“hatter”。
  可能你会认为要编写以下代码来处理参数:
  if [ $1 = -o ]; then
      处理-o选项的代码
      1=$2
      2=$3
  fi
  对$1和$2的正常处理...
  但此代码有些问题。首先,声明1=$2是非法的,因为位置参数是只读的。即使它们合法,出现的另一问题是这样的代码对脚本可以处理的参数个数强加了限制——这样做是不明智的。另外,如果此命令有几个可选项,处理它们的代码将很快变得很混乱。
**shift
  幸好shell提供了解决此问题的方式。命令shift执行功能:
    1=$2
    2=$3
    ...
  对于每个参数,无论有多少。如果你向shift给出数值参数,它可以重复移位(shift)参数。例如,shift 3产生的效果是:
    1=$4
    2=$5
    ...
  结果导致可以编写处理单个选项的代码(称为-o)以及任意多的参数:
  if [ $1 = -o ]; then
      处理-o选项
      shift
  fi
  正常参数处理...
  在if结构后,$1,$2等均被设置为正确的参数。
  可以将shift结合前面介绍过的编程特性以实现简单的选项处理策略。然而,当事情变得复杂时就需要额外的帮助。后面将介绍的getopts内置命令就有此功能。
  shift本身对实现第四章中(任务4-1)介绍的highest脚本的-N选项提供足够的支持。该脚本接受艺术家及其签名数的一个输入文件,该脚本将列表排序,并打印N个签名数最多的人选,按降序排列。执行实际数据处理的代码如下:
  filename=$1
  howmany=${2:-10}
  sort -nr $filename | head -$howmany
  调用该脚本的最初语法为highest filename [-N]。这里N如果省略则默认为10.下面将之改成更一般的UNIX语法,选项在参数前被给出:highest [-N] filename。下面给出使用此语法编写该脚本的方式:
  if [ -n "$(echo $1 | grep '^-[0-9][0-9]*$')" ]; then
      howmany=$1
      shift
  elif [ -n "$(echo $1 | grep '^-')" ]; then
      print 'usage: highest [-N] filename'
      exit 1
  else
      howmany="-10"
  fi
   
  filename=$1
  sort -nr $filename | head $howmany
  代码使用grep搜索功能测试$1是否匹配适当模式。为此对grep给出正则表达式^-[0-9][0-9]*$。它被解释为“一个短划线后跟一个数字,后面可再跟一个或多个数字”。如果找到匹配,则grep返回匹配值,测试返回真,否则grep返回空,执行elif中的测试。注意,这里将正则表达式放在单引号内以防止shell解释$和*,兵将其原封不动的传递给grep。
  如果$1不匹配,测试其是否为一选项,即是否匹配模式-,后跟其他内容。如果是,则其无效,打印错误信息并退出,且带有错误退出状态。如果代码执行到(else)最后部分,则假定$1是一文件名,代码执行无误。脚本的其余部分像前面一样处理数据。
  可以将所学过的内容扩展为一种处理多选项的通用技术。为安全起见,假定脚本为alice,要处理的选项为-a,-b和-c:
  while [ -n "$(echo $1 | grep '-')" ]; do
      case $1 in 
         -a ) process option -a ;;
         -b ) process option -b ;;
         -c ) process option -c ;;
         *  ) echo 'usage: alice [-a] [-b] [-c] args...'
              exit 1
      esac
      shift
  done
  normal processing of arguments...
  只要$1以短划线开始,此代码就重复检查$1。然后case结构依据选项$1运行适当代码。如果选项无效——即以短划线开始,但不是-a,-b或-c——则脚本打印可用信息,返回错误状态且退出。
  处理完选项后,参数被移位。结果是当while循环结束时,位置参数被设置为实际参数。
  注意,此代码有能力处理任意长度的选项,不止是一个字母长(例如,-adventure,而不是-a)。
**带参数的选项
  还需要加入一个功能以使选项处理更加实用。许多命令都带有可接受其自身参数的选项。例如,cut命令,我们在第四章曾重点介绍,可接受选项-d,其所带的参数可判断域分隔符(如果没有,默认为TAB)。要处理此类选项,可使用另一种shift。
  假定alice脚本中,选项-b需要自己的参数。下面是可处理该情况的修改过的代码:
  while [ -n "$(echo $1 | grep '-')" ]; do
      case $1 in 
         -a ) process option -a ;;
         -b ) process option -b 
              $2 is the option's argument
              shift ;;
         -c ) process option -c ;;
         *  ) echo 'usage: alice [-a] [-b barg] [-c] args...'
              exit 1
      esac
      shift
  done
   
  normal processing of arguments...
**getopts
  到目前为止,我们介绍了完整但受限制的处理命令行选项的方法。上述代码不允许用户将多个参数和单个短划线结合,即不允许用-abc表示-a -b -c。也不允许指定在选项和参数间没有空格的情况,即不允许-barg,而必须是-b arg。
  shell提供了内置方式处理多个复杂且没有这些限制的选项。内置命令getopts可用做选项处理循环中while的条件。给定选项有效及其是否需要参数的规范,它就可以建立循环体依次处理每个选项。
  getopts带有两个参数。第一个是包含字母和冒号的字符串。每个字母均为有效选项;如果字母后跟一个冒号,则选项需要一个参数。getopts从命令行中抽出选项,将其(不带前面的短划线)赋给一个变量,变量名是getopts的第二个参数。只要还有选项需要处理,getopts就返回退出状态0,一旦选项处理完毕,则其返回退出状态1,使得while循环退出。
  getopts有些功能可使选项处理更加容易。在检验该例中如何使用getopts时会涉及到这一点:
  while getopts ":ab:c" opt; do
      case $opt in 
         a  ) process option -a ;;
         b  ) process option -b 
              $OPTARG is the option's argument ;;
         c  ) process option -c ;;
         \? ) echo 'usage: alice [-a] [-b barg] [-c] args...'
              exit 1
      esac
  done
  shift $(($OPTIND - 1))
  normal processing of arguments...
  while条件下对getopts的调用建立循环以接受选项-a,-b和-c,并指定-b带有一个参数(稍后将解释位于选项字符开始处的:)。每次执行循环体时,代码都会使opt中不带短划线的最新选项可利用。
  如果用户键入无效选项,getopts正常情况下会打印一个错误信息(针对格式cmd: getopts: illegal option -o),并将opt设置为?。然而,如果选项字母字符串起始处是一个冒号,getopts就不会打印该信息。这里推荐你指定该冒号,并在处理?的情况时给出自己的错误信息,如上所述。
  这里修改了case结构中的代码以反映出getopts的功能。但注意,在while循环中不再有shift语句:getopts不依赖shift以了解其位置。在getopts完成前,即while循环退出前,移动参数是不必要的。
  如果选项带一个参数,getopts将其保存在变量OPTARG中,它可用于处理选项的代码中。
  最后一个shift语句在while之后。getopts在变量OPTIND中保存要处理的下一参数的位置数。这里即指第一个(非选项)命令行参数的位置数。例如,如果命令行为alice -ab rabbit,则$OPTIND为“3”。如果命令行为alice -a -b rabbit,则$OPTIND为“4”。
  表达式$(($OPTIND - 1))为数字表达式(本章后面介绍),其值为$OPTIND-1。该值用做shift的参数,结果是正确数量的参数被移出,只保留“真正”参数为$1,$2等。
  下面总结了getopts的功能:
  1.它的第一个参数是包含所有有效选项字母的字符串。如果选项需要一个参数,则字符串中冒号位于该字母后。初始冒号使得用户给出一个无效选项时getopts不打印错误信息。
  2.第二个参数为一个变量名,保存每次处理的选项字母(不带前导短划线)。
  3.如果选项带一个参数,则该参数被保存到变量OPTARG。
  4.变量OPTIND包含要处理的下一命令行参数的编号。getopts完成后,它等于第一个“真正”参数的号码。
  getopts的优点是它最小化了处理选项所需的代码,并完全支持标准UNIX选项语法(关于这一点可参看用户的帮助页)。
  下面给出一个更具体的例子,以第四章中的任务4-2为例。到现在为止,脚本已经具有了处理各种类型图形文件的能力,如PCX文件(以.pcx结尾)、JPEG文件(.jpg)和XPM文件(.xpm)等。简单回忆一下脚本代码:
  filename=$1
   
  if [ -z $filename ]; then
      echo "procfile: No file specified"
      exit 1
  fi
   
  for filename in "$@"; do
      ppmfile=${filename%.*}.ppm
   
      case $filename in
          *.gif ) exit 0 ;;
   
          *.tga ) tgatoppm $filename > $ppmfile ;;
   
          *.xpm ) xpmtoppm $filename > $ppmfile ;;
   
          *.pcx ) pcxtoppm $filename > $ppmfile ;;
   
          *.tif ) tifftopnm $filename > $ppmfile ;;
   
          *.jpg ) djpeg $filename > $ppmfile ;;
   
              * ) echo "procfile: $filename is an unknown graphics file."
                  exit 1 ;;
      esac
   
      outfile=${ppmfile%.ppm}.new.gif
   
      ppmquant -quiet 256 $ppmfile | ppmtogif -quiet > $outfile
      rm $ppmfile
   
  done
  该脚本正常工作,它将我们给出的各种不同的图形文件转换成适合Web页面的GIF文件。然而,NetPBM除了文件转换还可进行其他有关图形的操作。它们都可在我们的脚本中实现。
  对Web页面上图形所做的操作包括改变大小,给其加上边框等。我们应该使脚本尽可能的灵活;可能只需要改变结果图形的大小,而不加边框。因此,需要向脚本指定其实现的功能。这样就需要用到命令行选项处理过程。
  可以通过使用NetPBM实用程序pnmscale改变图形的大小。上一章讲过,NetPBM包有自己的格式PNM,我们将使用这种实用程序改变PNM图形的大小并给它加上边框。幸好,我们的脚本可以将各种格式的图形转换成PNM格式(实际上在脚本中是PPM,PNM的全色彩实例)。除了PNM文件,pnmscale还需要一些参数以说明如何改变图形大小。有各种不同的方式,但我们选择的是xysize,它以像素形式给出最后图形的水平和垂直大小。
  需要的另一种实用程序是pnmmargin。它给图形加上有色的边框。其参数为边界的像素点宽度以及边界的颜色。
  我们的图形实用程序需要一些选项以映射上述功能:-s size指定最后的图形适合的大小(减去边界),-w width指定图形边界的宽度,-c color-name指定边界的颜色。
  以下脚本procimage的代码包含了选项处理过程:
  # 设置默认值
  size=320
  width=1
  colour="-color black"
  usage="Usage: $0 [-s N] [-w N] [-c S] imagefile..."
   
  while getopts ":s:w:c:" opt; do
      case $opt in
        s  ) size=$OPTARG ;;
        w  ) width=$OPTARG ;;
        c  ) colour="-color $OPTARG" ;;
        \? ) echo $usage
             exit 1 ;;
      esac
  done
   
  shift $(($OPTIND - 1))
   
  if [ -z "$@" ]; then
      echo $usage
      exit 1
  fi
   
  # 处理输入文件
  for filename in "$*"; do
      ppmfile=${filename%.*}.ppm
   
      case $filename in
          *.gif ) giftopnm $filename > $ppmfile ;;
   
          *.tga ) tgatoppm $filename > $ppmfile ;;
   
          *.xpm ) xpmtoppm $filename > $ppmfile ;;
   
          *.pcx ) pcxtoppm $filename > $ppmfile ;;
   
          *.tif ) tifftopnm $filename > $ppmfile ;;
   
          *.jpg ) djpeg $filename > $ppmfile ;;
   
              * ) echo "$0: Unknown filetype '${filename##*.}'"
                  exit 1;;
      esac
   
      outfile=${ppmfile%.ppm}.new.gif
      pnmscale -quiet -xysize $size $size $ppmfile |
          pnmmargin $colour $width |
          ppmquant -quiet 256 | ppmtogif -quiet > $outfile
   
      rm $ppmfile
   
  done
  脚本的头几行将所用的变量初始化为默认设置。默认设置的图形大小为320像素,黑色边界,宽一个像素。
  while、getopts和case结构处理选项的方式与上个例子相同。头三个选项的代码将各自的参数声明为一个变量(替换其默认值),最后一个选项是对其他无效选项的处理过程。
  代码其余部分工作方式与上个例子相同,只是对处理过程加入了pnmscale和pnmmargin实用程序。
  该脚本还生成了一个不同的文件名;在基本文件名后附加了.new.gif。这样就允许我们将GIF文件作为输入处理。对其应用大小和边界设置,并在不覆盖最初文件的情况下将其写出。
  此版本没有解决的一个问题是,如果我们不要求执行缩放功能该如何处理?下一章将继续开发此脚本。
**有类型变量
  到此为止我们已经介绍了如何将bash变量设置为文本值。变量还有其他属性,包括成为只读的和整数类型。
  可以使用declare内置命令设置变量属性。表6-1总结了可用的declare选项。-打开选项,+关闭选项。
  
  表6-1  declare选项
  选项  含义
  -a    将变量看作数组
  -f    只使用函数名
  -F    显示未定义的函数名
  -i    将变量看作整数
  -r    使变量只读
  -x    标记变量未通过环境导出
  
  在当前提示符下键入declare会显示环境中所有变量值。-f选项将显示内容限定为当前环境下的函数名和定义。-F进一步将显示内容限定为只有函数名。
  -i选项用于创建一个整数变量。整数变量可用来保存数字值,可用在算术操作中并加以修改。考虑下面的例子:
  $ val1=12 val2=5
  $ result1=val*val2
  $ echo $result1
  val1*val2
  $
  $ declare -i val3=12 val4=5
  $ declare -i result2
  $ result2=val3*val4
  $ echo $result2
  60
  第一个例子中,变量是一般的shell变量,结果为字符串“val1*val2”。第二个例子中,所有变量均被声明为integer类型,变量result2包含12乘5的算术运算结果。实际上,不需要将val3和val4声明为整数。任何给result2赋值的东西将被解释成算术的而被求值。
  declare的-x选项操作方式与第三章中介绍的export内置命令一样。它允许列出的变量被导出到当前shell环境。
  -r选项创建一个只读变量,其值不能被后续赋值语句改变。
  相关的内置命令是readonly name ...,其操作方式与declare -r一样。readonly有4个选项:-f,使readonly将参数名解释为函数名而非变量名;-n,删除该名字的只读属性;-p,使内置命令打印所有只读名称的列表;-a,将名字参数解释为数组。
  最后,函数内使用declare声明的变量对于函数是局部的,就像使用local声明的一样。
**整数变量和运算
  最后一个图形工具例子中的表达式$(($OPTIND - 1))显示了shell进行整数运算的另一种方式。正如所见,shell将$((和))包围的单词解释为算术表达式。算术表达式内的变量前面不需要加美元标记,虽然加上也没错。
  算术表达式在双引号内求值,这一点~、变量和命令替换相同。我们最后一次给出关于引用字符串的规则:如果不确定,将字符串置入单引号,除非它包含~或任何涉及美元标记的表达式,这种情况下就得使用双引号。
  例如,UNIX系统V版本的date命令接受参数告诉它如何对输出进行格式化。参数+%j告诉它打印该年中已过的天数,亦即自从去年12月31号以来到现在的天数。
  可以使用+%j打印节日倒计时信息:
  echo "Only $(( (365-$(date +%j)) / 7 )) weeks until the New Year"
  我们会在第七章中说明它的语法是符合命令行处理的总策略的。
  算术表达式特性被内置到bash语法中,在Bourne shell(大部分版本中)中通过外部命令expr才可使用。因而它是将外部命令所提供的有用功能集成到shell内的一个例子。我们介绍过的getopts是这种设计趋势的另一个例子。
  bash算术表达式与其在C语言中的类似命令等价。优先级和组合性也与C中的相同。表6-2给出了bash支持的算术操作符,因为它们在$((...))语法中。
  
  表6-2  算术操作符
  操作符  含义
  +       加
  -       减
  *       乘
  /       除(取整)
  %       取余
  <<      左移位
  >>      右移位
  &       位与
  |       位或
  ~       位非
  !       位非
  ^       位异或
  
  圆括号可用于组成子表达式。算术表达式语法(C中相同)也支持关系操作符,“真值”为1,“假值”为0。表6-3给出关系操作符和用于结合关系表达式的逻辑操作符。
  
  表6-3  关系操作符
  操作符  含义
  <       小于
  >       大于
  <=      小于等于
  >=      大于等于
  ==      等于
  !=      不等于
  &&      逻辑与
  ||      逻辑或
  
  例如,$((3 > 2))值为1;$(( (3 > 2) || (4 <= 1) ))值也为1。因为两个表达式至少有一个为真。
  shell还支持N进制数,这里N从2到36.表达式B#N表示“B进制数N”,如果省略B#,则默认为10进制数。
**算术条件
  在第五章中介绍了如何使用[...]标记比较字符串(或使用test内置命令)。算术条件也可以这种方式测试。然而,测试语句必须使用自己的操作符执行。在表6-4中列出了这些操作符。
  
  表6-4  测试关系操作符
  操作符  含义
  -lt     小于
  -gt     大于
  -le     小于等于
  -ge     大于等于
  -eq     等于
  -ne     不等于
  
  像字符串比较一样,算术测试返回结果为真或假。如果为真则返回0,假则返回1。例如,[ 3 -gt 2 ]产生退出状态0,[ \( 3 -gt 2 \) || \( 4 -le 1 \) ]亦如此,但[ \( 3 -gt 2 \) && \( 4 -le 1 \) ]则为退出状态1,因为第二个子表达式为假。
  在这些例子中,必须对圆括号转义,将其传递给test作为单独参数。正如所见,如果有太多的圆括号,则可读性会很差。
  另一种进行算术测试的方法是使用$((...))形式来封装条件。例如,[ $(((3 > 2) && (4 <= 1))) = 1 ]。此表达式对条件进行求值,然后比较结果值为1(真)。
  执行算术测试还有更简洁有效的方法,即使用((...)),如果表达式为真则其返回退出状态0,假则为1。
  使用该结构的上述表达式变成:(( (3 > 2) && (4 <= 1) ))。此例返回退出状态1,因为第二个子表达式为假。
**算术变量和赋值
  像前面介绍的,你可以通过declare定义整数变量。也可以对算术表达式求值并使用let将之赋值给变量。语法为:
  let intvar=expression
  在let语句中没有必要将表达式括在$((和))中。let不创建整数类型变量,它只使得后跟在等号后面的表达式被解释为一个算术值。对于任何变量赋值,不必在等号两边加空格。将表达式用引号括起来是个很好的习惯,因为许多字符都被shell看做特殊字符(例如,*,#以及圆括号)。另外,必须将包含空白(空格或TAB)的表达式引起来。例子如表6-5所示。
  
  表6-5  整数表达式赋值示例
  赋值        取值
  let x=      $x
  1+4         5
  '1 + 4'     5
  '(2+3) * 5' 25
  '2 + 3 * 5' 17
  '17 / 3'    5
  '17 % 3'    2
  '1<<4'      16
  '48>>3'     6
  '17 & 3'    1
  '17 | 3'    19
  '17 ^ 3'    18
  
  任务6-1
  下面是使用整数运算的一个小例子。编写脚本ndu,打印每个目录参数(及子目录)的磁盘空间使用信息,同时要给出字节数以及适当的千字节数或兆字节数。
  
  代码如下:
  for dir in ${*:-.}; do
      if [ -e $dir ]; then
          result=$(du -s $dir | cut -f 1)
          let total=$result*1024
   
          echo -n "Total for $dir = $total bytes"
   
          if [ $total -ge 1048576 ]; then
                echo " ($((total/1048576)) Mb)"
          elif [ $total -ge 1024 ]; then
                echo " ($((total/1024)) Kb)"
          fi
      fi
  done
  要获得文件和目录的磁盘使用情况,可以使用UNIX实用程序du。du的默认输出为目录列表,带有目录的空间使用数目。内容如下:
  6       ./toc
  3       ./figlist
  6       ./tablist
  1       ./exlist
  1       ./index/idx
  22      ./index
  39      .
  如果不向du指定目录,则其使用当前目录(.)。每个目录和子目录都被列出,同时伴有其使用的空间大小。空间总和在最后一行给出。
  每个目录及其中所有文件所用的空间大小都以块为单位计算。依据用户运行的UNIX操作系统,一个块可能为512或1024字节。每个文件和目录至少使用一个块,即使该文件和目录为空,它仍然分配文件系统上的一块空间。
  在这里,我们只对总的使用情况感兴趣,亦即du输出的最后一行。要得到该行,可以使用du的-s选项。得到该行后,取出块数,去除目录名。这里我们使用熟悉的cut抽取第一个域。
  一旦得到该数字,将之乘以一块的字节数(这里为1024),并会打印出字节信息结果。然后测试总数是否大于一兆字节(1 048 576字节,即1024*1024)。如果大于,就可以通过除以该数字打印出兆字节数。如果不大于,查看其是否可以表示成千字节数,否则打印为空。
  需要确保指定的目录存在,否则du会打印错误信息,脚本产生错误。为此在调用du前应使用第五章讲过的文件或目录存在性(-e)测试。
  为使该脚本完备,应通过给出多个参数以尽可能地模仿du。为此,应将代码嵌入一个for循环。如果没有给出参数,要注意参数替换是如果被用来指定当前目录的。
  下面给出整数运算一个较大的例子。我们继续完成pushd和popd函数(任务4-8)。这些函数对DIR_STACK实施操作,DIR_STACK是一个以目录名字符串表示,以空格分隔的目录堆栈。bash的pushd和popd还带有另外的参数类型,包括:
  ·pushd+n接受堆栈中的第n个目录(开始为0),将之上移到顶部,并进入该目录。
  ·pushd不带参数,默认情况下交换堆栈中的上两层目录,并进入到新的顶层目录。
  ·popd+n接受堆栈中的第n个目录,并删除它。
  这些特性中最有用的是到达堆栈中第n层目录的功能。这两个函数的最新版本如下:
  pushd ()
  {
      dirname=$1   if [ -n $dirname ] && [ \( -d $dirname \) -a
             \( -x $dirname \) ]; then
          DIR_STACK="$dirname ${DIR_STACK:-$PWD' '}"
          cd $dirname
          echo "$DIR_STACK"
      else
          echo "still in $PWD."
      fi
  }
   
  popd ()
  {
      if [ -n "$DIR_STACK" ]; then
          DIR_STACK=${DIR_STACK#* }
   
          cd ${DIR_STACK%% *}
          echo "$PWD"
      else
          echo "stack empty, still in $PWD."
      fi
  }
  要得到第n个目录,使用while循环把其上层目录转移到一个暂时的堆栈副本中n次即可。我们把循环放在函数getNdirs内,如下:
  getNdirs ()
  {
      stackfront=''
      let count=0
      while [ $count -le $1 ]; do
          target=${DIR_STACK%${DIR_STACK#* }}
          stackfront="$stackfront$target"
          DIR_STACK=${DIR_STACK#$target}
          let count=count+1
      done
   
      stackfront=${stackfront%$target}
  }
  传递给getNdirs的参数为n,变量target包含当前从DIR_STACK移到临时堆栈stackfront中的目录。循环结束时target包含第n个目录,stackfront则包含target上(并包含该目录)的所有目录。stackfront开始时为null。count包含循环数,开始时为0。
  循环体的第1行将堆栈上的第1个目录复制到target。第二行将target附加到stackfront,下一行从堆栈中删除target。最后一行为下一循环增加循环计数。整个循环执行n+1次,count值从0增加到N。
  循环结束时,$target中的目录为第n个目录。表达式${stackfront%$target}从stackfront中删除该目录,这样stackfront将包含前n-1个目录。另外,DIR_STACK现在包含了堆栈的“后面部分”,亦即没有头n个目录的堆栈。这样,我们就可以编写pushd和popd增强版的代码了:
  pushd ()
  {
      if [ $(echo $1 | grep '^+[0-9][0-9]*$') ]; then
   
          # pushd +n的情况:将第n个目录循环到堆栈顶部
          let num=${1#+}
          getNdirs $num
   
   
          DIR_STACK="$target$stackfront$DIR_STACK"
          cd $target
          echo "$DIR_STACK"
   
      elif [ -z "$1" ]; then
          # pushd不带参数的情况;交换顶部的两个目录
          firstdir=${DIR_STACK%% *}
          DIR_STACK=${DIR_STACK#* }
          seconddir=${DIR_STACK%% *}
          DIR_STACK=${DIR_STACK#* }
          DIR_STACK="$seconddir $firstdir $DIR_STACK"
          cd $seconddir
   
      else
          # pushd dirname的正常情况
          dirname=$1
          if [ \( -d $dirname \) -a \( -x $dirname \) ]; then
              DIR_STACK="$dirname ${DIR_STACK:-$PWD" "}"
              cd $dirname
              echo "$DIR_STACK"
          else
              echo still in "$PWD."
          fi
      fi
  }
   
  popd ()
  {
      if [ $(echo $1 | grep '^+[0-9][0-9]*$') ]; then
   
          # popd +n的情况:从堆栈中删除第n个目录
          let num=${1#+}
          getNdirs $num
          DIR_STACK="$stackfront$DIR_STACK"
          cd ${DIR_STACK%% *}
          echo "$PWD"
   
      else
   
          # popd不带参数的正常情况
          if [ -n "$DIR_STACK" ]; then
              DIR_STACK=${DIR_STACK#* }
              cd ${DIR_STACK%% *}
              echo "$PWD"
          else
              echo "stack empty, still in $PWD."
          fi
      fi
  }
  这些函数变得越来越庞大,下面依次进行讨论。pushd开始时的if语句检验第一个参数是否为形式为+N的选项。如果是,代码的第一部分被执行。首先let从参数中抽取加号(+),并将其余部分赋值给(作为整数)变量num,该变量则被传递给函数getNdirs。
  下一个赋值语句将DIR_STACK设置为列表的新序列。然后函数进入新目录并打印当前目录堆栈。
  elif子句测试没有参数的情况。这种情况下,pushd应交换堆栈的上两层目录。子句里的头四行分别将上两层目录赋值给firstdir和seconddir,并从堆栈中将它们删除。然后,如上所述,代码把堆栈以新的次序放回到一起并使用cd进入到新的上层目录。
  else子句对应通常情况,这里用户给出目录名作为参数。
  popd工作方式类似,if子句检验+N选项,在这里即意味着“删除第n个目录”。let抽取N为整数。getNdirs函数把头n个目录放入stackfront。最后,以省略第n个目录的形式重新形成堆栈,执行cd命令以防被删除的目录是列表中的第一个目录。
  else子句对应通常情况,用户在这种情况下不给出参数。
  下面给出些练习以测试你对该代码的理解:
  1.实现bash的dirs命令以及选项+n和-l。dirs本身显示当前被记忆的目录列表(堆栈中的)。+n选项打印头n个目录(从0开始),-l选项给出长列表;~符号被全路径名替换掉。
  2.修改getNdirs函数,使得它检验N是否超出堆栈中的目录数,并如果为真,打印相应的错误信息并退出。
  3.修改pushd、popd和getNdirs,使得它们在算术表达式中使用integer类型的变量。
  4.修改getNdirs,使得cut(带有命令替换),而不是while循环来抽取头N个目录。这样代码量会减少,但因为要生成额外的进程,所以运行速度会减慢。
  5.bash的pushd和popd版本还有一个-N选项,两个函数内-N选项都对列表中右边的第n个目录执行操作。就像+N一样,它开始时为0.添加此功能。
  6.使用getNdirs重新实现上一章的selectd函数。
**数组
  pushd和popd函数使用一个字符串变量保存目录列表,并使用字符串模式匹配操作符对列表进行操作。虽然这对在字符串的开始和结尾加入或检索条目很有效,但当试图访问其他位置的条目时就显得很麻烦,例如,使用getNdirs函数获得条目N。最好能够指定条目的编号或索引并检索它。数组实现了该功能。
  数组类似于保存取值的一个排列。排列中每个位置称为元素。每个元素可通过数字下标从0开始,并可以持续到很大的数。例如,数组names的第5个元素为names[4]。下标可为任何有效的算术表达式,其值为大于或等于0的数。
  给数组赋值有几种方式。最直接的方式是像其他变量一样使用赋值语句:
  names[2]=alice
  names[0]=hatter
  names[1]=duchess
  代码将hatter赋值给数组names的元素0,duchess赋值给元素1,alice赋值给元素2.
  另一种复制方式是使用一个复合语句:
  names=([2]=alice [0]=hatter [1]=duchess)
  它与第一个例子等价,常用于用一组值初始化数组。注意,这里不必按数字次序指定下标。实际上,如果稍微重排一下次序,甚至不必给出下标:
  names=(hatter duchess alice)
  bash自动将取值赋给从0开始的顺序元素。如果在复合赋值语句中某处给出下标,则从该点连续设置取值,于是:
  names=(hatter [5]=duchess alice)
  将hatter赋值给元素0,duchess赋值给元素5,alice赋值给元素6。
  数组以这种赋值形式自动创建。要创建一个空数组,可以使用declare的-a选项。使用declare对数组设置的任何属性(例如,只读属性)均应用于数组中每个元素。例如,语句declare -ar names会创建一个名为names的只读数组。数组的每个元素均为只读的。
  数组中的一个元素可用语法${array[i]}引用。因此,对于上面的例子,语句echo ${names[5]}会打印字符串“duchess”。如果不给出下标,则假定为数组元素0。
  还可以使用特殊下标@和*。它们返回数组中所有元素取值,其工作方式与位置参数一样。当数组在双引号内被引用时,使用*将引用扩展到由被IFS变量第一个字符分隔的数组中的所有取值组成的一个单词中,而@将数组取值扩展成一组单独的单词。就像位置参数一样,这在使用for循环遍历取值时非常有用:
  for i in "${names[@]}"; do
      echo $i
  done
  未被赋值的任何数组元素均不存在;如果显式引用它们,则其默认为null字符串。因此,前面循环例子只打印数组names已赋值的元素。如果下标为1,45和1005有值,则只打印这三个值。
  对数组有用的一个操作符是#,即第四章见过的长度操作符。要找出一个数组中任意元素的长度,可以使用${#array[i]}。类似的,要找出数组中有多少取值,则使用*或@做下标。因此,对names=(hatter [5]=duchess alice), ${#names[5]}取值为7,${#names[@]}取值为3。
  使用一个复合语句对已有的数组重新赋值会将该数组替换为新值。所有的旧值均失效,即使它们和新元素下标不同。例如,如果将names重新赋值为([100]=tweedledee tweedledum),则取值hatter、duchess和alice将消失。
  可以使用unset内置命令销毁任意元素或整个数组。如果指定一个下标,该特定元素将被销毁。例如,unset names[100]将删除下标为100的元素取值,即上面例子中的tweedledee。然而,与重新赋值不同,如果不指定下标,则整个数组被销毁。不只是元素0。可以显示指定销毁整个数组方式是使用*或@做下标。
  下面介绍一个使用数组匹配系统上账号名称和用户ID的简单例子。代码接受用户ID作为参数,打印账号名以及在当前系统上的账号数目:
  for i in $(cut -f 1,3 -d: /etc/passwd) ; do
     array[${i#*:}]=${i%:*}
  done
   
  echo "User ID $1 is ${array[$1]}."
  echo "There are currently ${#array[@]} user accounts on the system."
  使用cut创建文件/etc/passwd第1域和第3域的列表,域1为账号名,域3为该账号的用户ID。脚本使用用户ID作为每个数组元素的下标遍历该列表,并赋值每个账户名给元素。然后脚本使用给出的参数作为数组的下标,打印出该下标上元素的取值以及数组中已赋值的元素数目。
  bash的某些环境变量就是数组。例如,充当pushd和popd内置命令中堆栈的DIRSTACK函数,BASH_VERSINFO是shell当前实例版本信息的数组,PIPESTATUS是被执行的上一前台管道的退出状态值数组。
  第九章构建bash调试器时会详细介绍数组的用法。
  本章最后给出几个和我们介绍的内容相关的问题:
  1.增强账号ID脚本功能使其检测参数是否为数字。另外,添加一个测试,如果用户ID不存在则打印出相应信息。
  2.使脚本同时打印用户名(域5)。提示:这不是很容易。用户名可能包含空格,使得for循环对该名字的每一部分进行遍历。
  3.如前所述,pushd和popd的内置命令版本使用数组实现堆栈。修改本章中的pushd、popd以及getNdirs代码,使其使用数组。
 

猜你喜欢

转载自blog.csdn.net/chenzhengfeng/article/details/81558804