学习bash第二版-第七章 输入/输出和命令行处理

  前几章详细介绍了各种shell编程技术,大部分集中于数据流以及对shell程序的控制。本章将介绍这两个相关主题。第一个是shell执行面向文件的输入和输出的机制。这里会对已经介绍过的shell基本I/O重定向符作出补充介绍。
  第二,广泛的讨论行和单词层次上的I/O。这是一个与前面的内容完全不同的主题,因为它涉及了在文件/终端和shell变量之间信息的移动。echo和命令替换是已介绍过的具有此功能的两种方式。
  通过对行和单词I/O的讨论,我们对shell如何处理命令行进行了更深层次的解释。该是必要的,这样你才可以精确理解shell是如何处理quotation的,并因此可以清楚高级命令eval的功能。在本章结尾会讲到eval。
**I/O重定向符
  在第一章中,我们介绍了shell的基本重定向符:>,<和|。虽然它们在你使用UNIX的大部分过程中已经足够了,但还需要知道bash对其他重定向符的支持。表7-1给出了列表,其中包括已经介绍的3个。虽然其余的一些也在广泛使用,但其他部分则主要应用于系统程序员。
  
  表7-1  I/O重定向符
  重定向符    功能
  cmd1 | cmd2 管道;接受cmd1的标准输出,作为cmd2的标准输入
  > file      将标准输出定向到file
  < file      从file接受标准输入
  >> file     将标准输出定向到file;如果file存在则附加到后面
  >| file     即使设置了noclobber仍强制标准输出到file
  n>| file    即使设置了noclobber仍强制从文件描述符n中输出到file
  <> file     使用file同时作为输入和输出
  n<> file    使用file同时作为文件描述符n的输入和输出
  << label    here-document,见文本
  n> file     将文件描述符n定向到file
  n< file     从file中接受文件描述符n
  n>> file    将文件描述符n定向到file,如果file存在则附加到后面
  n>&         将标准输出复制到文件描述符n
  n<&         将文件描述符n复制成标准输出
  n>&m        使文件描述符n成为输出文件描述符的副本
  n<&m        使文件描述符n成为输入文件描述符的副本
  &>file      定向标准输出和标准错误到file
  <&-         关闭标准输入
  >&-         关闭标准输出
  n>&-        关闭从文件描述符n的输出
  n<&-        关闭从文件描述符n的输入
  
  注意,表7-1中的一些重定向符包含数字n,其描述符包含术语文件描述符;后面会介绍详细内容。
  头两个新的重定向符>>和>|是标准输出重定向符>的简单变体。如果输出文件存在>>将输出附加到文件末尾(而不是重写);如果不存在,其行为类似>。当你不想用文本编辑器时,常用>>在初始化文件(例如,.bashrc或.mailrc)中加入一行。例如:
  $ cat >> .bashrc
    alias cdmnt='mount -t iso9660 /dev/sbpcd /cdrom'
    ^D
  第一章曾介绍过,不带参数的cat使用标准输入作为其输入。它允许你键入输入并在该行上用CTRL-D结束。如果.bashrc存在,alias行将被附加到该文件中,如果其不存在,该文件创建的内容则只有这一行。
  第三章中介绍过,可以通过键入set -o noclobber使>file防止shell重写一个文件,>|重载noclobber——它表示“放手干吧”。
  重定向符<>主要用于设备文件(在/dev目录下),也就是对应诸如终端和通信线路的硬件设备。底层系统程序员可以使用它测试设备驱动器;否则,它不是很有用。
**here-document
  <<label重定向符强制一个命令的输入使用shell的标准输入,并一直读到仅含label的行。期间的输入称为here-document。here-document在提示符下不是很有用,实际上,除了标签外其使用方式和标准输入没有差别。我们可以使用一个here-document模拟mail工具。当你使用mail工具给某人发消息时,使用点(.)符号结束消息。消息体被保存在文件msgfile中:
  $ cat >> msgfile << .
    > this is the text of
    > our message.
    > .
  here-document常用于shell脚本,它们使你可以指定对程序的批量输入。here-document通常用于简单的文本编辑器,如ed。任务7-1就是一个以该方式使用here-document的编程任务。
  
  任务7-1
  mail中的s file命令把当前消息保存在file中。如果消息来自网络(如Internet),则它可能包含关于网络路由信息的几个头标行。编写一个shell脚本从文件中删除这些头标行。
  
  可以使用ed删除头标行。为此,需要知道mail消息的语法,特别是要知道在头标行和消息文本之间总是有一个空行。ed命令1,/^[]*$/d可以执行该功能,表示“从第一行删除至第一个空行”。这里还需要ed命令w(写入已修改文件)和q(退出)。下面是具体实现任务的代码:
  ed $1 << EOF
  1,/^[ ]*$/d
  w
  q
  EOF
  shell在一个here-document中的文本上进行参数(变量)和命令替换,这样你可以使用shell变量和命令定制文本。例如bashbug脚本。它向bash维护者发送一个故障报告(见第十一章)。以下为精简的版本:
  MACHINE="i586"
  OS="linux-gnu"
  CC="gcc"
  CFLAGS=" -DPROGRAM='bash' -DHOSTTYPE='i586' -DOSTYPE='linux-gnu' \
      -DMACHTYPE='i586-pc-linux-gnu' -DSHELL -DHAVE_CONFIG_H   -I. \
      -I. -I./lib -g -O2"
  RELEASE="2.01"
  PATCHLEVEL="0"
  RELSTATUS="release"
  MACHTYPE="i586-pc-linux-gnu"
   
  TEMP=/tmp/bbug.$$
   
  case "$RELSTATUS" in
  alpha*|beta*)   [email protected] ;;
  *)              [email protected] ;;
  esac
   
  BUGADDR="${1-$BUGBASH}"
   
  UN=
  if (uname) >/dev/null 2>&1; then
          UN=`uname -a`
  fi
   
  cat > $TEMP <<EOF
  From: ${USER}
  To: ${BUGADDR}
  Subject: [50 character or so descriptive subject here (for reference)]
   
  Configuration Information [Automatically generated, do not change]:
  Machine: $MACHINE
  OS: $OS
  Compiler: $CC
  Compilation CFLAGS: $CFLAGS
  uname output: $UN
  Machine Type: $MACHTYPE
   
  bash Version: $RELEASE
  Patch Level: $PATCHLEVEL
  Release Status: $RELSTATUS
   
  Description:
          [Detailed description of the problem, suggestion, or complaint.]
   
  Repeat-By:
          [Describe the sequence of events that causes the problem
          to occur.]
   
  Fix:
          [Description of how to fix the problem.  If you don't know a
          fix for the problem, don't include this section.]
  EOF
   
  vi $TEMP
   
  mail $BUGADDR < $TEMP
  前8行在bashbug安装时生成。然后shell当脚本运行时在文本中用适当的值替换变量。
  重定向符<<有两个变体。首先,你可以通过把label括在单引号或双引号内防止shell进行参数和命令替换。在上面例子中,如果你使用行cat > $TEMP <<'EOF',则类似$USER和$MACHINE的文本保持不变(该脚本失效)。
  第二个变体是<<-,它从here-document和标签行中删除前导TAB(但不删空格)。它允许你缩进here-document的文本,使shell脚本的可读性增强:
  cat > $TEMP <<-EOF
          From: ${USER}
          To: ${BUGADDR}
          Subject: [50 character or so descriptive subject here]
   
          Configuration Information [Automatically generated,
              do not change]:
          Machine: $MACHINE
          OS: $OS
          Compiler: $CC
          Compilation CFLAGS: $CFLAGS
          ...
  EOF
  在选择label时要小心,应使其不作为实际输入行出现。
**文件描述符
  表7-1中的下几个重定向符要依靠一个文件描述符实现。像用在<>的设备文件一样,它是一个底层的UNIX I/O概念,只有系统程序员才会感兴趣——但也是偶尔。你只要明白关于它们的几个基本事实即可。整体思路请见UNIX指南的第二节中的read(),write(),fcntl()及其他内容,也可以参考《UNIX Power Tools》一书,作者是Jerry Peek、Tim O'Reilly和Mike Loukides(O'Reilly & Associates出版)。
  文件描述符是从0开始的整数,指向与进程相关的特定数据流。当进程启动时,通常打开三个文件描述符,分别对应三种标准的I/O:标准输入(文件描述符0),标准输入(1),标准输出(2)。如果进程打开了额外的文件进行输入和输出,则其被设置为下一个可用的文件描述符,并从3开始。
  目前文件描述符在bash中最常用的地方是在一个文件中保存标准错误。如果你要保存一个长运行作业的错误信息到一个文件里,使其不在屏幕上滚动,可在该命令后添加2>file。如果还要保存标准输出,则添加> file1 2> file2。
  这就引出了另一任务。
  
  任务7-2
  在后台启动一个长运行作业(这样使终端可以空闲下来),并把标准输出和标准错误保存在一个长文件中。编写脚本实现。
  
  该脚本为start,代码很简单:
  "$@" > logfile 2>&1 &
  该行跟在start命令的参数后运行(命令不能包含管道或输出重定向符)。它把命令的标准输出发送到logfile。
  然后,重定向符2>&1“将标准错误(文件描述符2)发送到与标准输出相同的位置(文件描述符1)”。因为标准输出被重定向到logfile,标准错误也在该文件里。最后的&把作业放在后台,以便你可以立即返回shell提示符。
  可以对该方法稍加改动,把标准输出和标准错误发送到一个管道而非一个文件中:command 2>&1 | ...(确信理解了这个代码的含义)。以下脚本将标准输出和标准错误发送到logfile文件中,并同时发送到终端:
  "$@" 2>&1 | tee logfile &
  命令tee接受其标准输入,并将其复制到标准输出和作为参数给定的文件中。
  这些脚本有一个缺点:你必须一直保持登录状态直到作业完成。虽然你可以键入jobs(见第一章)对正在运行的进程进行检验,但你不能离开终端,直到作业完成。除非你要冒安全风险。下一章将介绍如何解决该问题。
  其他面向文件描述符的重定向符(例如,<&n)常用于同时从多个文件中读取输入。本章后面会介绍这样的一个例子。另外,它们主要还是被系统程序员使用,<&-(强制标准输入关闭)和>&-(强制标准输出关闭)也是如此。
  1>和>是一样的,0<和<是一样的。如果明白了这一点,你就可能了解了文件描述符相关的全部内容。
**字符串I/O
  现在,再退回到字符串I/O层次,检验echo和read语句。它们赋予了shell I/O与传统编程语言的相关功能类似的能力。
**echo
  本书介绍过无数次,echo将其参数简单打印到标准输出上。下面将详细讨论该命令。
**echo选项
  echo接受许多短划线选项,如表7-2所示。
  
  表7-2  echo选项
  选项   功能
  -e     打开反斜线转义字符的解释
  -E     关闭系统上反斜线转义字符的解释,此为默然模式
  -n     省略最后的换行(与\c转义序列一样)
  
**echo转义序列
  echo接受许多以反斜线开始的转义序列。它们类似于由echo和C语言所识别的转义序列,在表7-3中列出。
  这些序列的行为基本是稳定的,除了\f:它在一些显示设备上会清屏,而在另一些上会换行;它在大多数打印机上会走纸。\v有点过时,它通常产生退行。
  
  表7-3 echo转义序列
  序列  被打印的字符
  \a    ALERT或CTRL-G(响铃)
  \b    BACKSPACE或CTRL-H
  \c    省略最后的NEWLINE
  \E    转义字符
  \f    FORMFEED或CTRL-L
  \n    NEWLINE(不在命令结尾)或CTRL-J
  \r    RETURN(ENTER)或CTRL-M
  \t    TAB或CTRL-I
  \v    VERTICAL TAB或CTRL-K
  \n    取值为8进制数n的ASCII字符,这里n为1到3个数字
  \\    单个反斜线
  
  \n序列设备无关性更强,可用于复杂的I/O。例如,光标控制和特殊字型字符。
**read
  shell字符串I/O的另一半功能是read命令。它允许你将值读到shell变量中。基本语法如下:
  read var1 var2...
  该语句从标准输入中接受一行,将其分开形成单词,单词间用环境变量IFS(见第四章;通常为空格、TAB或NEWLINE)的值中的任意字符分隔。单词然后被赋值到变量var1,var2,var3中。例如:
  $ read character1 character2
  alice duchess
  $ echo $character1
  alice
  $ echo $character2
  duchess
  如果单词数比变量多,则额外的单词被赋值到最后的变量中。如果你省略了变量,则整个输入被赋值到变量REPLY中。
  可以将之看做前面介绍过的shell编程功能中的“遗漏部分”。它类似于传统语言中的输入语句,像在Pascal中的同名语句。为什么要这时候介绍它呢?
  实际上,read是传统shell编程思想的一种“应急出口”。传统shell编程的思想指出最重要的数据处理单位是文本文件,UNIX工具如cut、grep、sort等都被用作编写程序所需的构建模块。
  从另一方面说,read暗指按行进行处理。可以使用它编写一个shell脚本实现管道的正常功能。但这样的脚本就会像下面的代码:
  while (read a line) do
      process the line
      print the processed line
  end
  这类脚本通常比管道慢很多,另外,其风格与某人用C(或类似语言)编写的程序是一样的,但后者速度更快。换句话说,如果你要以这种按行的方式来编写它,就不必编写一个shell脚本。
**从文件中读取行
  然而,带有read的shell脚本对某类任务是有用的。当你从一个很小的文件中读取数据时,效率并不是你最关心的问题(大约几百行或更少些),就可以将一些输入放入shell变量中。
  考虑一个UNIX机器,其终端被硬件连接到该机器的终端线。用户登录时最好把TERM变量设置为正确的终端类型。
  实现的一种方式是编写代码设置用户登录时的终端信息。该代码永久贮存在/etc/profile中。它是系统级的初始化文件,bash在运行用户自己的.bash_profile前会运行它。如果系统上的终端经常改变——也要确保同时改变该文件代码。最好把该信息保存在一个文件中,以后改变文件即可。
  假定把信息放在格式为典型的UNIX“系统配置”文件的文件中,每行包括一个设备名、一个TAB、一个TERM取值。
  称此文件为/etc/terms。代码如下:
  console       console
  tty01  wy60
  tty03  vt100
  tty04  vt100
  tty07  wy85
  tty08  vt100
  左边的值为终端行,右边的是TERM被设置的终端类型。与系统相连的终端为一个Wyse 60(wy60)、三个VT 100(vt100)和一个Wyse 85(wy85)。机器的主终端是控制台,TERM值为console。
  可以使用read从该文件中取得数据,但首先需要知道如何测试文件尾。简单的说:当没有内容被读入时,read的退出状态为1(亦即非0)。代码在一个清晰的while循环内:
  TERM=vt100       # 假设这为默认值
  line=$(tty)
  while read dev termtype; do
      if [ $dev = $line ]; then
          TERM=$termtype
          echo "TERM set to $TERM."
          break
      fi
  done
  while循环将输入的每行读到变量dev和termtype中。循环的每个步骤中,if查找$dev和用户终端之间的匹配($line,通过tty命令的命令替换得到)。如果找到相应匹配,设置TERM。打印消息,循环退出。否则TERM保持默认设置vt100。
  但还没有完成。该代码从标准输入中读取数据,而不是/etc/terms。我们需要知道如何将输入重定向到多重命令中。有许多方式可以实现。
**I/O重定向和多重命令
  解决该问题的一种方式是使用一个子shell,下一章会介绍它。其中包括创建一个单独的进程进行读取。然而,在相同进程中完成通常更有效。bash给出4种实现方式。
  第一个已经介绍过,是使用一个函数:
  findterm () {
      TERM=vt100       # 假设这为默认值
      line=$(tty)
      while read dev termtype; do
          if [ $dev = $line ]; then
              TERM=$termtype
              echo "TERM set to $TERM."
              break;
          fi
      done
  }
   
  findterm < /etc/terms
  该函数功能类似于一个脚本,有其自己的标准I/O描述符集合,它们可在包含该函数的代码行中被重定向。换句话说,可以认为findterm是一个脚本,你在命令行上键入了findterm < /etc/terms。read语句一次从/etc/terms中接受一行输入。该函数运行正常。
  第二种方式是稍微简化一下第一种方式,将重定向符放在函数结尾:
  findterm () {
      TERM=vt100       # 假设这为默认值
      line=$(tty)
      while read dev termtype; do
          if [ $dev = $line ]; then
              TERM=$termtype
              echo "TERM set to $TERM."
              break;
          fi
      done
  } < /etc/terms
  当findterm被调用时,它从/etc/terms中接受输入。
  第三种方式是将I/O重定向符放在循环末尾,如下:
  TERM=vt100       # 假设这为默认值
  line=$(tty)
  while read dev termtype; do
      if [ $dev = $line ]; then
          TERM=$termtype
          echo "TERM set to $TERM."
          break;
      fi
  done < /etc/terms
  可以对任何流控制结构使用该技术,包括if...fi,case...esac,select...done 和until...done。这样做很有意义,因为它们都是复合语句,但shell实现时将其看做单个命令。这种技术效率很好,read命令一次读取一行,直到读完复合语句内的所有输入。
**命令块
  但如果你要将I/O重定向到一个命令的任意组,但并不创建一个单独的进程,就需要使用前面没有介绍过的一种结构。如果你将代码放在{和}内,代码行为就类似一个没有名字的函数。这是另一种复合语句。对应于C语句中的等价概念,我们称其为命令块。
  块有什么用呢?这里,它意味着大括号内({})的代码会接受前面代码中最后一块描述的标准I/O描述符。该结构对应前的例子很合适,因为该代码只需要被调用一次,并且整个脚本没有大到需要分成几个函数的情况。下面给出在例子中使用块的情况:
  {
      TERM=vt100       # 假设这为默认值
      line=$(tty)
      while read dev termtype; do
          if [ $dev = $line ]; then
              TERM=$termtype
              echo "TERM set to $TERM."
              break;
          fi
      done
  } < /etc/terms
  要理解其工作方式,可以把大括号和其中的代码看做是一个命令,即:
  { TERM=vt100; line=$(tty); while ... } < /etc/terms;
  用于系统管理的配置文件和它很像,一个很明显的例子是/etc/hosts,它列出了在一个TCP/IP网络中可访问的机器。通过允许在文件中加入以#开始的注释行,就像在shell脚本中一样,可以使/etc/terms更像一个这样的标准文件。这样改动后/etc/terms如下:
  #
  # 系统控制台是console
  console       console
  #
  # Cameron的行有一个Wyse 60
  tty01  wy60
  ...
  可以通过while循环处理注释行,这样它就会忽略以#开始的行。我们在测试里放入一个grep:
  if [ -z "$(echo $dev | grep ^#)" ]  && [ $dev = $line ]; then
    ...
  第五章中曾讲过,&&命令结合两个条件,使它们同时为真时整个命令才为真。
  给出命令块的另一例子。考虑创建一个面向dc命令的标准代数表示法的前端。dc是一个逆波兰表示法(Reverse Polish Notation,RPN)计算器的UNIX工具:
  { while read line; do
      echo "$(alg2rpn $line)"
    done
  } | dc
  假定从一种表示法到另一种表示法的实际转换由一个函数处理,名字为alg2rpn。它接受一行标准的代数符号作为一个参数,并在标准输出上打印等价的RPN。while循环读取每行并把它们传递到转换函数,该过程一直持续到键入EOF为止。所有功能均在一命令块内执行,输出通过管道送给dc命令求值。
**读取用户输入
  read适合的另一任务类型是提示用户输入。我们到目前为止几乎没有介绍过这样的脚本。实际上,唯一的一个是任务5-4的修改方案,其中包含了select。
  可能你已经看出,read可被用于取得用户输入,并将之放在shell变量中。
  可以使用echo提示用户,如下:
  echo -n 'terminal? '
  read TERM
  echo "TERM is $TERM"
  运行结果如下:
  terminal? wy60
  TERM is wy60
  然而,shell惯例指出,提示应输出到标准错误,而不是标准输出(以前讲过的select就是标准错误)。我们使用本章前面介绍过的带有输出重定向符的文件描述符2:
  echo -n 'terminal? ' >&2
  read TERM
  echo TERM is $TERM
  下面介绍一个复杂一点的例子,在select不存在的情况下实现任务5-5。将下面代码与第五章的代码比较:
  echo 'Select a directory:'
  done=false
   
  while [ $done = false ]; do
      do=true
      num=1
      for direc in $DIR_STACK; do
          echo $num) $direc 
          num=$((num+1))
      done
      echo -n 'directory? '
      read REPLY
   
      if [ $REPLY -lt $num ] && [ $REPLY -gt 0 ]; then
          set - $DIR_STACK
   
          # 对堆栈进行操作的语句...
   
          break
      else
          echo 'invalid selection.'
      fi
  done
  while是必须的,因为如果用户进行了无效的选择代码会重复。如果有多个选择,select包含了构建多列菜单的能力。它也更适合于处理空用户输入。
  在结束read前应注意,它有4个选项:-a,-e,-p和-r。第1个允许你将值读到一个数组中。每个连续的条目读取都被赋值到以下标0开始的给定数组中。例如:
  $ read -a people
  alice duchess dodo
  $ echo ${people[2]}
  dodo
  $
  这里数组people包含了条目alice,duchess和dodo。
  选项-e只用于从交互式shell启动的脚本。它使得readline被用来聚集输入行,意即你可以使用第二章介绍过的readline任何编辑特性。
  后跟字符串参数的-p选项在读取输入前打印该字符串。在前面的read的例子中曾使用过它。该例子中我们在进行读取前打印一个提示。例如,目录选择脚本可以使用read -p 'directory? ' REPLY。
  read使得你可以输入比显示宽度长的行,方式是使用一个反斜线做行继续字符,就像在shell脚本中一样。-r选项屏蔽此功能,使用它后用户的脚本可以读取行尾刚好为反斜线字符的文件。read -r也保留了输入可能包含的其他转义序列。例如,如果文件hatter包含如下行:
  A line with a\n escape sequence
  那么read -r aline在变量aline中将包含反斜线。而没有-r,read将隐藏反斜线,结果为:
  $ read -r aline < hatter
  $ echo -e "$aline"
  A line with a
   escape sequence
  $
  然而:
  $ read aline < hatter
  $ echo -e "$aline"
  A line with an escape sequence
  $
**命令行处理
  前面已经介绍了shell使用read处理输入行的方式:它处理单引号('')、双引号("")和反斜线;它依据环境变量IFS中的分隔符将行分成各个单词,并把单词赋值给shell变量。我们可以将该过程看作shell处理命令行的一个子集。
  已经介绍过了命令行处理过程,现在需要使整个事情明朗化。shell从标准输入或脚本中读取的每行称为一个管道行,它包含一或多个由0个或多个管道字符(|)分隔的命令。对其读取的每个管道行,执行下列操作:
  1.将命令分成由固定元字符集分隔的记号:SPACE、TAB、NEWLINE、;、(、)、<、>、|和&。记号类型包括单词、关键字、I/O重定向符和分号。
  2.检测每个命令的第一个记号,查看其是否为不带引号或反斜线的关键字。如果是一个开放的关键字,如if和其他控制结构起始字符串、function、{或(,则命令实际上为一复合命令。shell在内部对复合命令进行处理,读取下一命令,并重复这一过程。如果关键字不是复合命令起始字符串(例如,是一个控制结构中间出现的关键字,如then、else或do和类似于fi或done的结束关键字或逻辑操作符),则shell给出语法错误信号。
  3.依据别名列表检查每个命令的第一个关键字。如果找出相应匹配,则替换其别名定义,并退回到第1步;否则,进入第4步。该策略允许递归别名(见第三章)。它还允许定义关键字别名,例如alias aslongas=while或alias procedure=function。
  4.执行大括号扩展,例如:a{b,c}变成ab ac。
  5.如果~位于单词的开头,使用用户的主目录($HOME)替换~。使用user的主目录替换~user。
  6.对任何以符号$开头的表达式执行参数(变量)替换。
  7.对形式$(string)的表达式进行命令替换。
  8.评估形式为$((string))的算术表达式。
  9.把行的参数、命令和算术替换部分再次分成单词,这次它使用$IFS中的字符做分隔符而不是步骤1中的元字符集。
  10.对出现的*、?和[/]对执行路径名扩展,也称为通配符扩展。
  11.通过依据第四章列表中的其余部分查找其源码,使用第一个单词作为命令,首先是一个function命令,然后是一个内置命令,然后是$PATH内目录里的文件。
  12.设置完I/O重定向和其他操作后执行该命令。
  步骤很多但还不是全部。继续下面的讲解前,先给出一个例子。假定要运行下列命令:
  alias ll="ls -l"
  进一步假定文件.hist537存在于用户alice的主目录下/home/alice,有一个双美元符号变量$$,值为2537(在下一章会介绍该特殊变量)。
  下面给出shell处理下列命令的方式:
  ll $(type -path cc) ~alice/.*$(($$%1000))
  该行运行过程如下:
  1.ll $(type -path cc) ~alice/.*$(($$%1000))
    将输入分隔成单词。
  2.ll不是关键字,因此步骤2执行为空。
  3.ls -l $(type -path cc) ~alice/.*$(($$%1000))
    用ls -l替换其别名“ll”。然后shell重复步骤1至步骤3.步骤2将ls -l分成两个单词。
  4.ls -l $(type -path cc) ~alice/.*$(($$%1000))
    此步骤执行为空。
  5.ls -l $(type -path cc) /home/alice/.*$(($$%1000))
    将~alice扩展成/home/alice。
  6.ls -l $(type -path cc) /home/alice/.*$((2537%1000))
    将$$替换为2537。
  7.ls -l /usr/bin/cc /home/alice/.*$((2537%1000))
    对“type -path cc”执行命令替换。
  8.ls -l /usr/bin/cc /home/alice/.*537
    对算术表达式2537%1000求值。
  9.ls -l /usr/bin/cc /home/alice/.*537
    此步骤执行为空。
  10.ls -l /usr/bin/cc /home/alice/.hist537
    用文件名替换通配符扩展.*537。
  11.在/usr/bin中找到命令ls。
  12.运行/usr/bin/ls,带有选项-l和两个参数。
  虽然此步骤列表很直接,但还不是全部内容。有5种修改该过程的方式:使用command、builtin或enable,以及使用高级命令eval。
**引用
  可以将引用看作shell省略上面12个步骤种某些步骤的一种方式。特别是:
  ·单引号('')绕过了前10个步骤——包括别名。所有单引号内的字符均保持不动。不能把单引号放在单引号内——即使在前面加反斜线也不行。
  ·双引号("")绕过步骤1到步骤4、步骤9和步骤10.也就是,在双引号内忽略了管道字符、别名、~替换、通配符扩展和通过分隔符分裂成单词。双引号内的单引号没有作用。但双引号允许参数替换、命令替换和算术表达式求值。可以在双引号内包含双引号,方式是在前面加上反斜线字符。还必须使用反斜线转义$,顿号`(旧式命令替换分隔符)和\本身。
  表7-4给出具体实现的简单例子。这里假定运行了语句person=hatter,用户alice的根目录为/home/alice。
  如果你对在一个特定shell编程环境下使用单引号还是双引号有疑问,最好使用单引号,除非你特别需要参数、命令或算术替换。
  
  表7-4  引用规则的例子
  表达式      取值
  $person     hatter
  "$person"   hatter
  \$person    $person
  '$person'   $person
  "'$person'" 'hatter'
  ~alice      /home/alice
  "~alice"    ~alice
  '~alice'    ~alice
  
**command、builtin和enable
  在讲解命令行处理最后一部分前,先来看看第四章介绍的命令查找次序以及如何使用shell命令进行修改。
  命令查找的默认次序是函数、内部命令、脚本和可执行代码。有三个内置命令可以覆盖该次序:command、builtin和enable。
  command删除别名和函数查找。只有搜索路径中找到的内置命令和命令被执行。如果要在搜索路径下创建与内置命令或命令同名的函数,并需要从函数内调用最初的命令时,这样做很有用。例如,要创建函数cd替换标准的cd命令,用来先执行一些其他功能,然后再调用真正的内置命令cd:
  cd ()
  {
      #Some fancy things
      command cd
  }
  这里要避免使函数陷入递归循环中,方法是把command放在cd前。这样就确保内置命令cd被调用而不是函数被调用。
  command选项在表7-5中列出。
  
  表7-5  command选项
  选项   说明
  -p     使用PATH的默认值
  -v     打印用来调用command的命令或路径名
  -V     比使用-v选项描述的更详细
  -      关闭进一步选项检查
  
  -p选项是默认路径,它确保命令查找会找到所有的标准UNIX实用程序。这种情况下,command会忽略PATH下的目录。
  builtin类似于command,但更加严格。它只查找内置命令,忽略函数和PATH中的命令。上述例子中可以用builtin替换command。
  最后一个命令使shell内置命令可用或屏蔽——称为enable。屏蔽一个内置命令允许运行一个shell脚本或同名的可执行代码而不给出完全路径名。当初级UNIX shell编程者将其脚本命名为test时,经常会碰到这种问题。使其惊讶的是,执行test常会结果为空,因为shell执行的是命令test,而不是shell脚本。使用enable屏蔽命令可以实现其目的。
  表7-6列出了enable的可用选项。一些选项用于动态载入命令。这些选项的细节以及关于如何创建和载入用户自己的命令的详细内容请参见附录三。
  
  表7-6  enable选项
  选项        说明
  -a          显示所有命令以及其是否可用
  -d          删除使用-f载入的命令
  -f filename 从共享对像filename中载入一个新的命令
  -n          屏蔽一个命令或显示屏蔽的命令列表
  -p          显示所有命令列表
  -s          限制输出为POSIX“特定”命令
  
  这些选项中,-n最有用。它用于屏蔽一个命令。不带选项的enable使能一个命令。enable可带有多个命令参数,因此enable -n pushd popd dirs将屏蔽pushd、popd和dirs命令。
  可以使用该命令本身或带有-p选项的enable找到当前可用和屏蔽的命令。使用enable或enable -p可以列出所有可用的命令,使用enable -n可以列出所有被屏蔽的命令。要得到命令当前状态的完整列表,可以使用enable -a。
  -s选项限制输出为POSIX“特定”命令。它们是:,.,source,break,continue,eval,exec,exit,export,readonly,return,set,shift,trap,unset。
**eval
  前面曾介绍过使用引用可以跳过命令行处理的某些步骤。而eval命令则可以再次执行命令行处理。两次执行命令行处理也许有点奇怪,但实际上其功能很强大:它使你可以编写脚本随意创建命令字符串,然后把它们传递给shell执行。这意味着你可以赋予脚本“智能”,在运行时修改其自身行为。
  eval语句通知shell接受eval参数,并再次通过命令行处理的所有步骤运行它们。为理解eval的行为,先给出一个小例子,使你循序渐进的达到可以随意构建和运行命令的状态。
  eval ls把ls传递给shell执行,shell打印当前目录下文件列表。该代码很简单,ls不需要被两次发送到命令行处理步骤,但考虑:
  listpage="ls | more"
  $listpage
  不会显示出标有页数的文件列表,shell会把|和more看作ls的参数。ls会给出没有这些文件的错误信息。为什么呢?因为管道字符“出现”在shell对变量求值的步骤6,之后查找管道字符。直到步骤9,变量扩展仍没有被解析。结果是,shell把|和more看作ls的参数,试图在当前目录下查找名为|和more的文件。
  下面考虑eval $listpage而不是$listpage。shell到达最后一步时,运行带有参数ls,|和more的eval。这样shell就回到第1步,处理由这些参数组成的一行。在步骤2中它找到了|,将该行分隔成两个单词ls和more,每个命令都以正常方式处理,结果分页显示出当前目录下的文件列表。
  现在你该明白eval的强大功能了。它是一种高级特性,需要有效的使用考虑周密的编程技巧,它的设置有一点人工智能的味道。使用它你可以编写能够“编写”和执行其他程序的程序。在日常shell编程中你可能不会用到eval,但花一些时间理解其功能是值得的。
  下面给出一个更有趣的例子。再次使用本书最前面的任务4-1为例。该任务中,构建了一个简单的管道行,将文件分类并打印出前N行,这里N默认为10。最后的管道行为:
  sort -nr $1 | head -${2:-10}
  第一个参数指定要分类的文件;$2为要打印的行数。
  假定改变该任务,默认打印整个文件而不是10行。这意味着我们在默认情况下根本不会使用head,以下列方式实现:
  if [ -n "$2" ]; then
      sort -nr $1 | head -$2
  else
      sort -nr $1
  fi
  换句话说,这里要依据$2是否为null来决定要运行的管道行。但下面的方案更适合一些:
  eval sort -nr \$1 ${2:+"| head -\$2"}
  该行最后的表达式在$2存在时(非null)对字符串| head -\$2求值。如果$2为null,则表达式也为null。如果变量值包含特殊字符,如>或|,我们用反斜线屏蔽变量名前的美元符号(\$)以防止出现意料外结果。直到eval命令运行前,反斜线有效地推迟了变量求值。这样如果给出了$2,整个行变成:
  eval sort -nr \$1 | head -\$2
  如果$2为null,则为:
  eval sort -nr \$1
  不用eval仍不能运行该命令,因为shell试图将该行分隔成命令后,管道仍未被分离出来。eval使得在给出$2的情况下shell运行正确的管道行。
  下面,我们再回到本章前面的任务7-2,start脚本使你可以在后台启动一个命令并将其标准输出和标准错误保存到一个日志文件。前面曾说过,该任务的一行解决方案的限制是命令不能包含输出重定向符或管道。虽然考虑前者没有意义,但你当然希望能以这种方式启动管道行的功能。
  eval是解决该问题的方式:
  eval "$@" > logfile 2>&1 &
  唯一强加给用户的限制是管道和其他此类特殊字符要使用引号或在前面加反斜线加以引用。
  这是将eval和各种其他的shell编程概念结合起来的一种方式。
  
  任务7-3
  将make实用程序的核心实现为一个shell脚本。
  
  make是众所周知的编程人员的工具,但人们每天都会发现它的一些新用法。不介绍无关的细节,make基本用于跟踪一个特定项目里的多个文件,而这些文件间存在依赖关系(例如,一个文档依赖于其文字处理器输入文件)。它确保当你改变一个文件时,所有依赖它的其他文件也被处理。
  例如,假定你正在使用troff文字处理器编写一本书。书中各章的文件名为ch1.t,ch2.t等。troff对这些文件的输出为ch1.out,ch2.out等。运行命令troff chN.t > chN.out进行处理。对该书,你要一次性对几个文件进行改动。
  这里使用make跟踪需要被再次处理的文件,你所需做的就是键入make,它会指出所做的功能,你不必知道再次处理所需修改的文件。
  make如何完成呢?它比较输入和输出文件的修改时间(在make术语中称为源和目标),如果输入文件较新,则make对其再次进行处理。
  你要通知make所需检查的文件,方法是构建一个文件makefile,结构如下:
  target : source1 source2 ...
           commands to make target
  基本含义是:“对于最新的目标,它必须比所有源都要新,如果不是,运行commands将之更新”。commands是以TAB开头的一行或多行命令。例如,要对ch7.out更新:
  ch7.out : ch7.t
            troff ch7.t > ch7.out
  假定这里编写了一个shell函数makecmd,读取并执行该形式的一个结构,假定makefile从标准输入中读取,函数代码如下:
  makecmd ()
  {
      read target colon sources
      for src in $sources; do
          if [ $src -nt $target ]; then
              while read cmd && [ $(grep \t* $cmd) ]; do
                  echo "$cmd"
                  eval ${cmd#\t}
              done
              break
          fi
      done
  }
  该函数对目标和源都读取一行,变量colon是符号的占位符。然后使用第五章介绍的-nt文件属性测试操作符检测每个源,看其和目标相比是否较新。如果源较新,读取、打印并执行命令直到它找到不以TAB开头的一行或到达文件尾(实际的make执行更多的功能,见本章最后的练习)。运行命令后(从最初的TAB中抽取),它退出for循环,这样就不用再多次运行命令。
  下面给出eval的最后一个例子。再次以本书前面开发的图形工具procimage为例。该脚本表现出来的一个问题是无论是否想要,它都执行伸缩和边界处理。如果没有给出命令行选项,则使用默认大小、边界宽度和边界颜色。不使用if then结构解决该问题,这里要介绍如何在脚本中动态构建一个命令管道行。当要执行这些命令时,不需要的命令会消失。另外,我们还用它向脚本加入了另一种功能:图形增强。
  观察procimage脚本,你会注意到,NetPBM命令形成一个很好的管道行,一个操作的输出成为下一操作的输入,到最后一个图形时才结束。如果不需要使用特殊的转换工具,我们可以将脚本缩略成如下的管道行(现在忽略了选项):
  cat $filename | convertimage | pnmscale | pnmmargin | ppmquant | \
      ppmtogif > $outfile
  或者,下面这样更好:
  convertimage $filename | pnmscale | pnmmargin | ppmquant | ppmtogif \
      > $outfile
  正如我们已经看到的,这等同于:
  eval convertimage $filename | pnmscale | pnmmargin | ppmquant | \
      ppmtogif > $outfile
  了解了eval如何操作,可以将其变换为:
  eval "convertimage" $filename " | pnmscale" " | pnmmargin" \
      " | ppmquant" " | ppmtogif" > $outfile
  进一步:
  convert='convertimage'
  scale=' | pnmscale'
  border=' | pnmmargin'
  standardise=' | ppmquant | ppmtogif'
   
  eval $convert $filename $scale $border $standardise > $outfile
  现在考虑当我们不想改变图像大小时将发生的操作,我们执行以下代码:
  scale=""
   
  while getopts ":s:w:c:" opt; do
      case $opt in
        s  ) scale=' | pnmscale' ;;
   
   ...
   
  eval $convert $filename $scale $border $standardise > $outfile
  在此代码段中,scale被默认设置为空字符串,如果-s未在命令行上给出,则最后一行$scale求值为空字符串,管道行将最后变成:
  $convert $filename $border $standardise > $outfile
  使用该原则,可以修改前面的procimage脚本,给出一个管道行新版本。对每个输入文件,这里都需要基于命令行上给出的选项构建并运行一个管道行。新版本如下:
  # 设置默认值
  width=1
  colour='-color grey'
  usage="Usage: $0 [-s N] [-w N] [-c S] imagefile..."
   
  # 初始化管道行组件
  standardise=' | ppmquant -quiet 256 | ppmtogif -quiet'
   
  while getopts ":s:w:c:" opt; do
      case $opt in
        s  ) size=$OPTARG
             scale=' | pnmscale -quiet -xysize $size $size' ;;
        w  ) width=$OPTARG
             border=' | pnmmargin $colour $width' ;;
        c  ) colour="-color $OPTARG"
             border=' | pnmmargin $colour $width' ;;
        \? ) echo $usage
             exit 1 ;;
      esac
  done
   
  shift $(($OPTIND - 1))
   
  if [ -z "$@" ]; then
      echo $usage
      exit 1
  fi
   
  # 处理输入文件
  for filename in "$@"; do
      case $filename in
          *.gif ) convert=giftopnm  ;;
   
          *.tga ) convert=tgatoppm  ;;
   
          *.xpm ) convert=xpmtoppm  ;;
   
          *.pcx ) convert=pcxtoppm  ;;
   
          *.tif ) convert=tifftopnm  ;;
   
          *.jpg ) convert=djpeg ;;
   
              * ) echo "$0: Unknown filetype '${filename##*.}'"
                  exit 1;;
      esac
   
      outfile=${filename%.*}.new.gif
   
      eval $convert $filename $scale $border $standardise > $outfile
   
  done
  该版本和前面的比较已被简化,这里不再需要保存被转换文件的临时文件。阅读和理解它也比较容易。为显示可以很容易的对脚本加入进一步的处理,下面添加一个NetPBM实用程序。
  你可能注意到,当你缩小一个图形大小时,它会变得有点模糊,NetPBM给出一种实用程序增强了图形质量,使之锐化,即pnmnlfilt。该实用程序是一个对图形进行采样的图形过滤器,可以提高图形边界质量(如果给出适合的值,它还可以平滑图形)。它接受两个说明锐化程度的参数。对该脚本来说,这里选出某些可选值,并给出一个选项开关该脚本的这个功能。
  要加入新更能,这里还需要做的是将新选项(-S)添加到getopts case语句,修改用法行,并在管道行加入新的变量。新代码如下:
  # 设置默认值
  width=1
  colour='-color grey'
  usage="Usage: $0 [-S] [-s N] [-w N] [-c S] imagefile..."
   
  # 初始化管道行组件
  standardise=' | ppmquant -quiet 256 | ppmtogif -quiet'
   
  while getopts ":Ss:w:c:" opt; do
      case $opt in
        S  ) sharpness=' | pnmnlfilt -0.7 0.45' ;;
        s  ) size=$OPTARG
             scale=' | pnmscale -quiet -xysize $size $size' ;;
        w  ) width=$OPTARG
             border=' | pnmmargin $colour $width' ;;
        c  ) colour="-color $OPTARG"
             border=' | pnmmargin $colour $width' ;;
        \? ) echo $usage
             exit 1 ;;
      esac
  done
   
  shift $(($OPTIND - 1))
   
  if [ -z "$@" ]; then
      echo $usage
      exit 1
  fi
   
  # 处理输入文件
  for filename in "$@"; do
      case $filename in
          *.gif ) convert=giftopnm  ;;
   
          *.tga ) convert=tgatoppm  ;;
   
          *.xpm ) convert=xpmtoppm  ;;
   
          *.pcx ) convert=pcxtoppm  ;;
   
          *.tif ) convert=tifftopnm  ;;
   
          *.jpg ) convert=djpeg ;;
   
              * ) echo "$0: Unknown filetype '${filename##*.}'"
                  exit 1;;
      esac
   
      outfile=${filename%.*}.new.gif
   
      eval $convert $filename $scale $border $sharpness $standardise \
          > $outfile
   
  done
  我们可以继续给出更复杂的eval例子,但这里要给出一些练习结束本章了。练习3的问题实际上更类似事物菜单条目。
  1.下面是procimage图形工具的一些增强功能:
    a.加入一个选项-q,允许用户开关NetPBM实用程序诊断信息的打印。你需要将-q映射到该工具的-quiet选项。还要对不打印任何内容的实用程序如格式转换,加入自己的诊断输出。
    b.加入选项允许用户指定NetPBM处理发生的次序,即哪个图形功能先发生,边界操作还是调整大小。不要使用if结构在硬编码次序中进行选择,构建一个字符串动态实现,代码与下面的类似:
    "eval $convert $filename $scale $border $sharpness
      $standardise > $outfile"
    你需要使用eval来对该字符串求值。
  2.任务7-3中出现的函数makecmd表示了真实make工具的一种简化。make实际上递归检查文件的依赖性,即makefile中一行上的源可能是另一行上的目标。例如,例子中书的章节本身依赖于某些单独文件的图形,这些文件以图形包的形式存在。
    a.编写函数readtargets 遍历makefile,把所有目标保存在一个变量或临时文件中。
    b.makecmd检测是否有比给定目标更新的源。它实际上是一个递归过程,如下:
      function makecmd ()
      {
          target=$1
          get sources for $target
          for each source src; do
              if $src is also a target in this makefile then
                  makecmd $src
              fi
              if [ $src -nt $target ]; then
                  run commands to make target
                  return
              fi
          done
      }
    实现它。
    c.编写“驱动器”脚本,把makecmd函数转换成完全make程序。要求把给定目标作为参数,在没有给出参数的情况下,默认为makefile中列出的第一个目标。
    d.上述makecmd没有完成的make的一个重要功能是:允许不是文件的“象征性”目标。这使得make功能可应用于各种其他情况,象征性目标修改时间总是为0,因此make总是运行命令形成它们,修改makecmd使之允许象征性目标(提示:问题的关键是指出如何得到一个文件的修改时间,这是很难的)。
  3.下面习题测试你对eval和shell的命令行处理规则的掌握情况。解决这些问题后,你就是一个真正的bash高手了。
    a.高级shell程序员有时使用一些eval的秘诀:使用一个变量值作为另一个变量的名字。换句话说,你可以给定shell脚本对变量名赋值的控制能力。bash的最新版本给出这样的内置功能,形式为${!varname},这里varname包含将成为操作目标的另一变量名。我们称之为间接扩充。如何使用eval来实现它?
    (提示:如果$object等于“person”,$person等于“alice”,则你可以认为如果键入echo $object,得到的结果为alice。实际上不是这样,但这是正确的思路。)
    b.可以结合使用上述技术和eval技巧实现shell的新控制结构。例如,查看是否可以编写一个脚本模拟类似C或Pascal的传统语言中的一个for循环的行为,亦即遍历固定次数的一个循环,循环变量从1到循环固定数目(对C来说,是从0到循环数减1)。调用你的脚本loop以避免和关键字for和do发生冲突。
    c.前面章节建立的pushd、popd和dirs函数不能处理名字中带有空格的目录(因为DIR_STACK使用空格做分隔符)。使用eval克服此限制。
    (提示:使用eval实现一个数组。每个数组元素称为array1,array2,... arrayn,每个数组元素包含一个目录名)。
    d.(下面练习和本章内容关系不大,但却是典型的编程练习:)
    编写“命令块”一节中使用的函数alg2rpn。实现过程如下:代数表示法中的算术表达式形式为expr op expr,这里每个expr是一个数字或另一表达式(也许在圆括号内),op是+,-,*,/或%(取余)。在RPN中,表达式形式为expr expr op。例如,代数表达式2+3在RPN中为2 3 +;(2+3) * (9-5)的RPN等价为2 3 + 9 5 - *。RPN的主要好处是不需要使用圆括号和操作符优先级规则(例如,*优先于+)。dc程序接受标准的RPN,但每个表达式都在后面加上“p”,它通知dc打印其结果。例如,上面第一个例子在dc中为2 3 + p。
    e.需要编写一个过程把代数符号转换成RPN。该函数在遇到子表达式是应调用其本身(递归)。或是包含一个这样的函数。重要的一点是该函数需要跟踪其在输入字符串中的位置以及它处理过程中已经实施完操作的字符串数目(提示:利用第四章介绍的模式匹配操作符完成解析输入字符串的任务)。
    为简单起见,现在不要担心操作符优先级问题;只需要从左到右依此转换成RPN即可。例如,把3+4*5看成(3+4)*5,3*4+5看作(3*4)+5。这使你可以随意转换输入字符串,即不需要在做处理前读取全部内容。
    f.增强前面练习的功能,使之可以支持通常次序的操作符优先级:*,/,%,+,-。例如,将3+4*5看作3+(4*5),3*4+5看作(3*4)+5。
    g.下面是一些对技巧的其他测试:编写图形实用程序脚本index,缩减图形文件列表中图形的大小,并创建一个“索引”图形。一个索引图形由原始图形的缩减到极小后的版本组成,并按行和列整齐排列,在下面给出一个标题(通常是原始文件名)。
    除了文件列表,还需要一些选项,包括要创建的列数以及缩略图形的大小。还可能包括一个指定图形间距离的选项。
    所需的新NetPBM实用程序是pbmtext和pnmcat。还需要以前的pnmscale、ppmquant以及一个或多个转换实用程序,这取决于你是否决定接受各种格式(像对procimage一样)以及输出格式。
    pbmtext接受文本作为参数,并把文本转换成PNM点阵。pnmcat稍微复杂一点。像cat一样,它连接指定内容:这里为图形。你可以指定多个PNM文件为参数,pnmcat将把它们放在一个大图形中。使用-lr和-tb选项,可以指定是否要把图形按从左到右、从上到下的顺序排放。pnmcat的第一个选项是背景颜色。可为-black或-white,前者对应黑色背景,后者对应白色背景。这里建议使用-white,以一个白色背景上配合pbmtext黑色文本。
    接受每个文件,通过pbmtext运行该文件名,使用pnmcat把它放在原始图形的所略图形之下。然后对每个文件继续此过程,并使用pnmcat连接所有图形。另外,必须跟踪已完成的列数和启动新行的时间。注意,需要分别构建各行并使用pnmcat连接图形,pnmcat不会自动完成该功能。
 

猜你喜欢

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