Bash-编程高级教程-二-

102 阅读11分钟

Bash 编程高级教程(二)

原文:Pro Bash Programming

协议:CC BY-NC-SA 4.0

六、Shell 函数

一个Shell 函数是一个已经命名的复合命令。它存储了一系列命令供以后执行。该名称本身就是一个命令,可以像任何其他命令一样使用。它的参数在位置参数中可用,就像在任何其他脚本中一样。像其他命令一样,它设置一个返回代码。

函数与调用它的脚本在同一个进程中执行。这使得它很快,因为不需要创建新的进程。脚本的所有变量对它来说都是可用的,而不必导出,并且当函数更改这些变量时,调用脚本将会看到这些更改。也就是说,您可以使变量成为函数的局部变量,这样它们就不会影响调用脚本;选择权在你。

函数不仅将代码封装在一个脚本中重用,还可以让其他脚本也能使用它。它们使得自上而下的设计变得容易,并且提高了可读性。它们将脚本分成可管理的块,可以单独测试和调试。

在命令行,函数可以做外部脚本不能做的事情,比如改变目录。它们比别名更加灵活和强大,别名只是用不同的命令替换您键入的命令。第十一章介绍了一些使提示工作更有效率的功能。

定义语法

在 KornShell 中引入 shell 函数时,定义语法如下:

function name <compound command>

当 Bourne shell 在 1984 年添加函数时,语法(后来包含在ksh中并被 POSIX 标准采用)如下:

name() <compound command>

bash允许任一语法以及混合:

function name() <compound command>

下面是我几年前写的一个函数,我最近发现它作为一个例子包含在bash源代码包中。它检查点分四组互联网协议(IP) 地址是否有效。在本书中,我们总是使用 POSIX 语法进行函数定义:

isvalidip()

然后,函数体用大括号({ ... })括起来,后面是可选的重定向(参见本章后面的uinfo函数中的示例)。

第一组测试包含在case语句中:

case $1 in
  "" | *[!0-9.]* | *[!0-9]) return 1 ;;
esac

它检查空字符串、无效字符或不以数字结尾的地址。如果找到这些项目中的任何一个,将调用 shell 内置命令return,退出状态为1。这将退出函数,并将控制权返回给调用脚本。参数设置函数的返回代码;如果没有参数,函数的退出代码默认为最后执行的命令的代码。

下一个命令local 是一个内置的 shell,它将变量的范围限制在函数(及其子函数)内,但是该变量在父进程中不会改变。将IFS 设置为句点会导致在扩展参数时在句点处拆分单词,而不是空白。从bash-4.0开始,localdeclare有一个选项-A,用来声明一个关联数组。

local IFS=.

set内置用它的参数替换位置参数。由于$IFS是一个句点,IP 地址的每个元素被分配给一个不同的参数。

set -- $1

最后两行依次检查每个位置参数。如果它大于 255,则在点分四位的 IP 地址中无效。如果参数为空,它将被无效值 666 替换。如果所有测试都成功,则函数成功退出;如果没有,返回码是1,或者失败。

[ ${1:-666} -le 255 ] && [ ${2:-666} -le 255 ] &&
[ ${3:-666} -le 255 ] && [ ${4:-666} -le 255 ]

清单 6-1 显示了带有注释的完整函数。

清单 6-1isvalidip,检查有效点分四段 IP 地址的参数

isvalidip() #@ USAGE: isvalidip DOTTED-QUAD
{
  case $1 in
    ## reject the following:
    ##   empty string
    ##   anything other than digits and dots
    ##   anything not ending in a digit
    "" | *[!0-9.]* | *[!0-9]) return 1 ;;
  esac

  ## Change IFS to a dot, but only in this function
  local IFS=.

  ## Place the IP address into the positional parameters;
  ## after word splitting each element becomes a parameter
  set -- $1

  [ $# -eq 4 ] && ## must be four parameters
                  ## each must be less than 256
  ## A default of 666 (which is invalid) is used if a parameter is empty
  ## All four parameters must pass the test
  [ ${1:-666} -le 255 ] && [ ${2:-666} -le 255 ] &&
  [ ${3:-666} -le 255 ] && [ ${4:-666} -le 255 ] 
}

Image 注意除了点分四元组以外的格式也可以是有效的 IP 地址,如127.1216.239.100853639551845

如果命令行上提供的参数是有效的点分四段 IP 地址,则该函数成功返回(即返回代码0)。您可以通过查找包含该函数的文件来在命令行测试该函数:

$ . isvalidip-func

该函数现在在 shell 提示符下可用。让我们用几个 IP 地址来测试一下:

$ for ip in 127.0.0.1 168.260.0.234 1.2.3.4 123.1OO.34.21 204.225.122.150
> do
>   if isvalidip "$ip"
>   then
>     printf "%15s: valid\n" "$ip"
>   else
>     printf "%15s: invalid\n" "$ip"
>   fi
> done
      127.0.0.1: valid
  168.260.0.234: invalid
        1.2.3.4: valid
  123.1OO.34.21: invalid
204.225.122.150: valid

复合命令

一个复合命令是用( ... ){ ... }括起来的命令列表,(( ... ))[[ ... ]]括起来的表达式,或者是块级 shell 关键字之一(即caseforselectwhileuntil)。

第三章中的valint程序是转换成函数的一个很好的候选。它可能被调用不止一次,因此节省的时间可能非常可观。该程序是一个单一的复合命令,所以不需要大括号(见清单 6-2 )。

清单 6-2valint,检查有效整数

valint() #@ USAGE: valint INTEGER
  case ${1#-} in      ## Leading hyphen removed to accept negative numbers
    *[!0-9]*) false;; ## the string contains a non-digit character
    *) true ;;        ## the whole number, and nothing but the number
  esac

如果函数体用括号括起来,那么它是在子 shell 中执行的,在执行过程中所做的更改在退出后不再有效:

$ funky() ( name=nobody; echo "name = $name" )
$ name=Rumpelstiltskin
$ funky
name = nobody
$ echo "name = $name"
name = Rumpelstiltskin

获得结果

前面两个函数都是为它们的退出状态调用的;调用程序只需要知道函数是成功还是失败。通过设置一个或多个变量或打印结果,函数还可以从一系列返回代码中返回信息。

设置不同的退出代码

你可以将第三章中的rangecheck脚本转换成一个函数,并做一些改进;和以前一样,如果成功,它返回0,但是区分一个过高的数字和一个过低的数字。如果数字太低,它返回1,如果数字太高,它返回2。它还接受要检查的范围作为命令行上的参数,如果没有给出范围,默认为1020(清单 6-3 )。

清单 6-3rangecheck,检查整数是否在指定范围内

rangecheck() #@ USAGE: rangecheck int [low [high]]
  if"$1" -lt ${2:-10} ]
  then
    return 1
  elif"$1" -gt ${3:-20} ]
  then
    return 2
  else
    return 0
  fi

返回代码是单个无符号字节;因此,它们的范围是 0 到 255。如果需要大于 255 或小于 0 的数字,请使用其他返回值方法之一。

打印结果

一个函数的目的可能是打印信息,要么打印到终端,要么打印到一个文件(清单 6-4 )。

清单 6-4uinfo,打印关于环境的信息

uinfo() #@ USAGE: uinfo [file]
{
  printf "%12s: %s\n" \
    USER    "${USER:-No value assigned}" \
    PWD     "${PWD:-No value assigned}" \
    COLUMNS "${COLUMNS:-No value assigned}" \
    LINES   "${LINES:-No value assigned}" \
    SHELL   "${SHELL:-No value assigned}" \
    HOME    "${HOME:-No value assigned}" \
    TERM    "${TERM:-No value assigned}"
} > ${1:-/dev/fd/1}

重定向在运行时进行评估。在本例中,它扩展到函数的第一个参数,如果没有给定参数,则扩展到/dev/fd/1(标准输出):

$ uinfo
        USER: chris
         PWD: /home/chris/work/BashProgramming
     COLUMNS: 100
       LINES: 43
       SHELL: /bin/bash
        HOME: /home/chris
        TERM: rxvt
$ cd; uinfo $HOME/tmp/info
$ cat $HOME/tmp/info
        USER: chris
         PWD: /home/chris
     COLUMNS: 100
       LINES: 43
       SHELL: /bin/bash
        HOME: /home/chris
              TERM: rxvt

当输出打印到标准输出时,可以使用命令替换来捕获它:

info=$( uinfo )

但是命令替换创建了一个新的进程,因此很慢;保存它以供外部命令使用。当脚本需要函数的输出时,把它放入变量中。

将结果放入一个或多个变量中

我正在写一个需要从最低到最高排序三个整数的脚本。我不想调用外部命令进行最多三次比较,所以我编写了如清单 6-5 所示的函数。它将结果存储在三个变量中:_MIN3_MID3_MAX3

清单 6-5_max3,排序三个整数

_max3() #@ Sort 3 integers and store in $_MAX3, $_MID3 and $_MIN3
{       #@ USAGE:
    [ $# -ne 3  ] && return 5
    [ $1 -gt $2 ] && { set -- $2 $1 $3; }
    [ $2 -gt $3 ] && { set -- $1 $3 $2; }
    [ $1 -gt $2 ] && { set -- $2 $1 $3; }
    _MAX3=$3
    _MID3=$2
    _MIN3=$1
}

在本书的第一版中,我使用了在函数名开头加下划线的惯例,当它们设置变量而不是打印结果时。变量是转换成大写的函数名。在这个例子中,我还需要另外两个变量。

我可以用一个数组来代替三个变量:

_MAX3=( "$3" "$2" "$1" )

现在,我通常通过一个变量的名字来存储结果。bash-4.x 中引入的nameref属性使其易于使用:

max3() #@ Sort 3 integers and store in an array
{      #@ USAGE: max3 N1 N2 N3 [VARNAME]
  declare -n _max3=${4:-_MAX3}
  (( $# < 3 )) && return 4
  (( $1 > $2 )) && set -- "$2" "$1" "$3"
  (( $2 > $3 )) && set -- "$1" "$3" "$2"
  (( $1 > $2 )) && set -- "$2" "$1" "$3"
  _max3=( "$3" "$2" "$1" )
}

如果命令行上没有提供变量名,则使用_MAX3

函数库

在 我的scripts目录中,我有大约 100 个除了函数什么都没有的文件。少数只包含单一函数,但大多数是具有共同主题的函数集合。这些文件中的一个定义了许多可以在当前脚本中使用的相关函数。

我有一个操作日期的函数库和另一个解析字符串的函数库。我有一个用于创建象棋图的 PostScript 文件,一个用于玩纵横字谜。有一个用于读取功能键和光标键的库和一个不同的用于鼠标按钮的库。

使用库中的函数

大多数时候,我在脚本中包含了这个库的所有函数:

. date-funcs ## get date-funcs from:
             ## http://cfaj.freeshell.org/shell/ssr/08-The-Dating-Game.shtml

有时候,我只需要库中的一个函数,所以我将它剪切并粘贴到新脚本中。

样本脚本

下面的脚本定义了四个函数:dieusageversionreadline。根据您使用的 shell,readline函数会有所不同。该脚本创建了一个基本的网页,包括标题和主要标题(<H1>)。readline函数使用内置命令read的选项,这将在第九章中详细讨论。

##
## Set defaults
##
prompt=" ==> "
template='<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset=utf-8>
    <title>%s</title>
    <link href="%s" rel="stylesheet">
  </head>
  <body>
    <h1>%s</h1>
    <div id=main>

    </div>
  </body>
</html>
'

##
## Define shell functions
##
die() #@ DESCRIPTION: Print error message and exit with ERRNO code
{     #@ USAGE: die ERRNO MESSAGE ...
  error=$1
  shift
  [ -n "$*" ] && printf "%s\n" "$*" >&2
  exit "$error"
}

usage() #@ Print script's usage information
{       #@ USAGE: usage
  printf "USAGE: %s HTMLFILE\n" "$progname"
}

version() #@ Print scrpt's version information
{          #@ USAGE: version
  printf "%s version %s" "$progname" "${version:-1}"
}

#@ USAGE: readline var prompt default
#@ DESCRIPTION: Prompt user for string and offer default
##
#@ Define correct version for your version of bash or other shell
bashversion=${BASH_VERSION%%.*}
if${bashversion:-0} -ge 4 ]
then
  ## bash4.x has an -i option for editing a supplied value
  readline()
  {
    read -ep "${2:-"$prompt"}" -i "$3" "$1"
  }
elif${BASHVERSION:-0} -ge 2 ]
then
  readline()
  {
    history -s "$3"
    printf "Press up arrow to edit default value: '%s'\n" "${3:-none}"
    read -ep "${2:-"$prompt"}" "$1"
  }
else
  readline()
  {
    printf "Press enter for default of '%s'\n" "$3"
    printf "%s " "${2:-"$prompt"}"
    read
    eval "$1=\${REPLY:-"$3"}"
  }
fi

if$# -ne 1 ]
then
  usage
  exit 1
fi

filename=$1

readline title "Page title: "
readline h1 "Main headline: " "$title"
readline css "Style sheet file: " "${filename%.*}.css"

printf "$template" "$title" "$css" "$h1" > "$filename"

摘要

Shell 函数使您能够创建大型、快速、复杂的程序。没有它们,shell 很难被称为真正的编程语言 。从这里到书的结尾,函数将是几乎所有事物的一部分。

命令

  • local:将变量的范围限制在当前函数及其子函数
  • return:退出一个函数(带有可选的返回码)
  • set:使用--,用剩余的参数替换位置参数(在--之后)

练习

  1. 使用参数扩展重写函数isvalidip而不是改变IFS
  2. 添加对max3的检查,以验证VARNAME是变量的有效名称。

七、字符串操作

在 Bourne shell 中,不借助外部命令,很少的字符串操作是可能的。字符串可以通过并置来连接,可以通过改变IFS的值来拆分,也可以用case来搜索,但是其他任何事情都需要外部命令。

甚至可以完全在 shell 中完成的事情也经常被委托给外部命令,这种做法一直延续到今天。在一些当前的 Linux 发行版中,您可以在/etc/profile中找到下面的代码片段。它检查目录是否包含在PATH变量中:

ifecho ${PATH} |grep -q /usr/games
then
  PATH=$PATH:/usr/games
fi

即使在 Bourne shell 中,您也可以在没有外部命令的情况下做到这一点:

case :$PATH: in
  *:/usr/games:*);;
  *) PATH=$PATH:/usr/games ;;
esac

POSIX shell 包含了大量的参数扩展,这些参数扩展可以分割字符串,而bash甚至增加了更多。这些在第五章中有所概述,它们的用法将在本章和其他弦乐技巧一起展开。

串联

串联是将两个或多个项目的连接在一起,形成一个更大的项目。在这种情况下,项目是字符串。它们通过一个接一个地放置而连接在一起。在第一章的中使用了一个常见的例子,向PATH变量添加一个目录。它将一个变量与一个单字符字符串(:)、另一个变量和一个文字字符串连接起来:

PATH=$PATH:$HOME/bin

如果赋值的右边包含一个空格或其他 shell 特有的字符,那么必须用双引号括起来(单引号内的变量不展开):

var=$HOME/bin # this comment is not part of the assignment
var="$HOME/bin # but this is"

bash-3.1中,增加了一个字符串追加操作符(+= ) :

$ var=abc
$ var+=xyz
$ echo "$var"
abcxyz

这个追加操作符+=看起来更好,也更容易理解。与另一种方法相比,它还有一点性能优势。使用+=追加到数组也是有意义的,如第五章所示。

Image 提示对于那些想对这两种方法进行基准测试的人来说,你可以试试这个小工具var=; time for i in {1..1000};do var=${var}foo;done;var=; time for i in {1..1000};do var+=foo;done

将字符重复到给定长度

这个函数使用串联来构建一个由N个字符组成的字符串;它循环,每次添加一个$1的实例,直到字符串($_REPEAT ) 达到期望的长度(包含在$2中)。

_repeat()
{
  #@ USAGE: _repeat string number
  _REPEAT=
  while (( ${#_REPEAT} < $2 ))
  do
    _REPEAT=$_REPEAT$1
  done
}

结果存储在变量_REPEAT中:

$ _repeat % 40
$ printf "%s\n" "$_REPEAT"
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

您可以通过在每个循环中连接多个实例来加速该函数,这样长度会呈几何级数增长。这个版本的问题是产生的字符串通常比要求的要长。为了解决这个问题,参数扩展被用来将字符串修剪到期望的长度(清单 7-1 )。

清单 7-1repeat ,重复一个字符串 N 次

_repeat()
{
  #@ USAGE: _repeat string number
  _REPEAT=$1
  while (( ${#_REPEAT} < $2 )) ## Loop until string exceeds desired length
  do
    _REPEAT=$_REPEAT$_REPEAT$_REPEAT ## 3 seems to be the optimum number
  done
  _REPEAT=${_REPEAT:0:$2} ## Trim to desired length
}

repeat()
{
  _repeat "$@"
  printf "%s\n" "$_REPEAT"
}

_repeat函数由alert函数 ( 清单 7-2 )调用。

清单 7-2alert,打印带有边框和嘟嘟声的警告信息

alert() #@ USAGE: alert message border
{
  _repeat "${2:-#}" $(( ${#1}8 ))
  printf '\a%s\n' "$_REPEAT" ## \a = BEL
  printf '%2.2s  %s  %2.2s\n' "$_REPEAT" "$1" "$_REPEAT"
  printf '%s\n' "$_REPEAT"
}

该函数打印用_repeat生成的边框包围的消息:

$ alert "Do you really want to delete all your files?"
####################################################
##  Do you really want to delete all your files?  ##
####################################################

可以使用命令行参数来更改边框字符:

$ alert "Danger, Will Robinson" $
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
$$  Danger, Will Robinson  $$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$

逐字符处理

没有直接的参数扩展来给出一个字符串的第一个或最后一个字符,但是通过使用通配符(?),一个字符串可以被扩展为除了之外的所有字符:

$ var=strip
$ allbutfirst=${var#?}
$ allbutlast=${var%?}
$ sa "$allbutfirst" "$allbutlast"
:trip:
:stri:

然后可以从原始变量中移除allbutfirstallbutlast 的值,以给出第一个或最后一个字符:

$ first=${var%"$allbutfirst"}
$ last=${var#"$allbutlast"}
$ sa "$first" "$last"
:s:
:p:

字符串的第一个字符也可以用printf获得:

printf -v first "%c" "$var"

要一次操作一个字符串中的每个字符,可以使用一个while循环和一个临时变量来存储var减去第一个字符后的值。然后temp变量被用作${var%PATTERN}扩展中的模式。最后,$ temp 被赋给var,循环继续,直到var中没有剩余字符:

while [ -n "$var" ]
do
  temp=${var#?}        ## everything but the first character
  char=${var%"$temp"}  ## remove everything but the first character
  : do something with "$char"
  var=$temp            ## assign truncated value to var
done

反转

您可以使用相同的方法颠倒字符串中字符的顺序。每个字母都被附加在一个新变量的末尾(清单 7-3 )。

清单 7-3revstr,反转一个字符串的顺序;将结果存储在_REVSTR

_revstr() #@ USAGE: revstr STRING
{
  var=$1
  _REVSTR=
  while [ -n "$var" ]
  do
    temp=${var#?}
    _REVSTR=$temp${var%"$temp"}
    var=$temp
  done
}

案例转换

在 Bourne shell 中,大小写转换是通过外部命令完成的,例如tr,它将第一个参数中的字符转换为第二个参数中的相应字符:

$ echo abcdefgh | tr ceh CEH # c => C, e => E, h => H
abCdEfgH
$ echo abcdefgh | tr ceh HEC # c => H, e => E, h => C
abHdEfgC

用连字符指定的范围扩展到包括所有中间字符:

$ echo touchdown | tr 'a-z' 'A-Z'
TOUCHDOWN

在 POSIX shell 中,可以使用参数扩展和包含一个作为查找表的case语句的函数来有效地转换短字符串。该函数查找其第一个参数的第一个字符,并将对应的大写字符存储在_UPR 中。如果第一个字符不是小写字母,它是不变的(清单 7-4 )。

清单 7-4to_upper,将$1的第一个字符转换成大写

to_upper()
    case $1 in
        a*) _UPR=A ;; b*) _UPR=B ;; c*) _UPR=C ;; d*) _UPR=D ;;
        e*) _UPR=E ;; f*) _UPR=F ;; g*) _UPR=G ;; h*) _UPR=H ;;
        i*) _UPR=I ;; j*) _UPR=J ;; k*) _UPR=K ;; l*) _UPR=L ;;
        m*) _UPR=M ;; n*) _UPR=N ;; o*) _UPR=O ;; p*) _UPR=P ;;
        q*) _UPR=Q ;; r*) _UPR=R ;; s*) _UPR=S ;; t*) _UPR=T ;;
        u*) _UPR=U ;; v*) _UPR=V ;; w*) _UPR=W ;; x*) _UPR=X ;;
        y*) _UPR=Y ;; z*) _UPR=Z ;;  *) _UPR=${1%${1#?}} ;;
    esac

要大写一个单词(即,只大写第一个字母),以该单词作为参数调用to_upper,并将该单词的其余部分追加到$_UPR:

$ word=function
$ to_upper "$word"
$ printf "%c%s\n" "$_UPR" "${word#?}"
Function

要将整个单词转换成大写,可以使用清单 7-5 中的upword函数。

清单 7-5upword,将单词转换成大写

_upword() #@ USAGE: upword STRING
{
  local word=$1
  while [ -n "$word"## loop until nothing is left in $word
  do
    to_upper "$word"
    _UPWORD=$_UPWORD$_UPR
    word=${word#?} ## remove the first character from $word
  done
}

upword()
{
  _upword "$@"
  printf "%s\n" "$_UPWORD"
}

您可以使用相同的技术将大写字母转换为小写字母;作为练习,你可以试着为此编写代码。

使用bash-4.x中引入的参数扩展进行案例转换的基础知识在第五章中介绍。下面几节将介绍它们的一些用途。

不考虑情况比较内容

当获取用户输入时,程序员通常希望接受大写或小写,甚至是两者的混合。当输入是单个字母时,比如要求输入YN,代码很简单。可以选择使用or符号(|):

read ok
case $ok in
  y|Y) echo "Great!" ;;
  n|N) echo Good-bye
       exit 1
       ;;
  *) echo Invalid entry ;;
esac

或者带括号的字符列表:

read ok
case $ok in
  [yY]) echo "Great!" ;;
  [nN]) echo Good-bye
       exit 1
       ;;
  *) echo Invalid entry ;;
esac

当输入较长时,第一种方法要求列出所有可能的组合,例如:

jan | jaN | jAn | jAN | Jan | JaN | JAn | JAN) echo "Great!" ;;

第二种方法可行,但是很难看,很难读懂,字符串越长,就越难懂,越难看:

read monthname
case $monthname in ## convert $monthname to number
  [Jj][Aa][Nn]*) month=1 ;;
  [Ff][Ee][Bb]*) month=2 ;;
  ## ...put the rest of the year here
  [Dd][Ee][Cc]*) month=12 ;;
  [1-9]|1[0-2]) month=$monthname ;; ## accept number if entered
  *) echo "Invalid month: $monthname" >&2 ;;
esac

更好的解决方案是首先将输入转换为大写,然后进行比较:

_upword "$monthname"
case $_UPWORD in ## convert $monthname to number
  JAN*) month=1 ;;
  FEB*) month=2 ;;
  ## ...put the rest of the year here
  DEC*) month=12 ;;
  [1-9]|1[0-2]) month=$monthname ;; ## accept number if entered
  *) echo "Invalid month: $monthname" >&2 ;;
esac

Image 参见本章末尾的清单 7-11 了解另一种将月份名称转换成数字的方法。

bash-4.x中,你可以用case ${monthname^^} in替换_upword函数,尽管我可能会将它保留在一个函数中,以方便bash版本之间的转换:

_upword()
{
  _UPWORD=${1^^}
}

检查有效的变量名

您和我都知道什么是有效的变量名,但是您的用户知道吗?如果您要求用户输入变量名,就像在创建其他脚本的脚本中一样,您应该检查输入的名称是否有效。这样做的函数是一个简单的违反规则的检查:名称必须只包含字母、数字和下划线,并且必须以字母或下划线开头(清单 7-6 )。

清单 7-6validname,检查$1是否有有效的变量或函数名

validname() #@ USAGE: validname varname
 case $1 in
   ## doesn't begin with a letter or an underscore, or
   ## contains something that is not a letter, a number, or an underscore
   [!a-zA-Z_]* | *[!a-zA-z0-9_]* ) return 1;;
 esac

如果第一个参数是有效的变量名,则函数成功;否则,它会失败。

$ for name in name1 2var first.name first_name last-name
> do
>   validname "$name" && echo " valid: $name" || echo "invalid: $name"
> done
  valid: name1
invalid: 2var
invalid: first.name
  valid: first_name
invalid: last-name

将一根绳子插入另一根

要将一个字符串插入到另一个字符串中,必须将该字符串分成两部分——位于所插入字符串左侧的部分和位于右侧的部分。然后插入线被夹在它们之间。

这个函数有三个参数:主字符串、要插入的字符串和要插入的位置。如果省略该位置,则默认在第一个字符后插入。这项工作由第一个函数完成,它将结果存储在_insert_string中。可以调用这个函数来节省使用命令替换的开销。insert_string函数接受相同的参数,并将其传递给_insert_string,然后打印结果(清单 7-7 )。

清单 7-7insert_string ,将一个字符串插入到另一个字符串的指定位置

_insert_string() #@ USAGE: _insert_string STRING INSERTION [POSITION]
{
  local insert_string_dflt=2                 ## default insert location
  local string=$1                            ## container string
  local i_string=$2                          ## string to be inserted
  local i_pos=${3:-${insert_string_dflt:-2}} ## insert location
  local left right                           ## before and after strings
  left=${string:0:$(( $i_pos - 1 ))}         ## string to left of insert
  right=${string:$(( $i_pos - 1 ))}          ## string to right of insert
  _insert_string=$left$i_string$right        ## build new string
}

insert_string()
{
  _insert_string "$@" && printf "%s\n" "$_insert_string"
}

例子

$ insert_string poplar u 4
popular
$ insert_string show ad 3
shadow
$ insert_string tail ops  ## use default position
topsail

覆盖物

将一个字符串覆盖在另一个字符串之上(替换、覆盖),这种技术类似于插入一个字符串,不同之处在于字符串的右侧不是紧接在左侧之后开始,而是沿着覆盖的长度开始(清单 7-8 )。

清单 7-8overlay ,将一根弦放在另一根弦的上面

_overlay() #@ USAGE: _overlay STRING SUBSTRING START
{          #@ RESULT: in $_OVERLAY
  local string=$1
  local sub=$2
  local start=$3
  local left right
  left=${string:0:start-1}        ## See note below
  right=${string:start+${#sub}-1}
  _OVERLAY=$left$sub$right
}

overlay() #@ USAGE: overlay STRING SUBSTRING START
{
  _overlay "$@" && printf "%s\n" "$_OVERLAY"
}

Image 注意子串扩展内的算术不需要完整的 POSIX 算术语法;bash如果在整数位置找到一个表达式,将对其求值。

例子

$ {
> overlay pony b 1
> overlay pony u 2
> overlay pony s 3
> overlay pony d 4
> }
bony
puny
posy
pond

修剪不需要的字符

变量通常带有不需要的填充:通常是空格或前导零。这些可以通过一个循环和一个case语句轻松删除:

var="     John    "
while :   ## infinite loop
do
  case $var in
      ' '*) var=${var#?} ;; ## if $var begins with a space remove it
      *' ') var=${var%?} ;; ## if $var ends with a space remove it
      *) break ;; ## no more leading or trailing spaces, so exit the loop
  esac
done

一种更快的方法是找到不以要修剪的字符开头或结尾的最长字符串,然后从原始字符串中删除除此之外的所有内容。这类似于从字符串中获取第一个或最后一个字符,这里我们使用了allbutfirstallbutlast变量。

如果字符串是“John”,则以不需要修剪的字符结尾的最长字符串是“John”。这个被删除了,末尾的空格用这个存储在rightspaces中:

rightspaces=${var##*[! ]} ## remove everything up to the last non-space

然后从$var中删除$rightspaces:

var=${var%"$rightspaces"} ## $var now contains "     John"

接下来,你用这个找到左边所有的空格:

leftspaces=${var%%[! ]*} ## remove from the first non-space to the end

$var中移除$leftspaces:

var=${var#"$leftspaces"} ## $var now contains "John"

这项技术对trim函数做了一点改进(清单 7-9 )。它的第一个参数是要修剪的字符串。如果有第二个参数,那就是将从字符串中删除的字符。如果没有提供字符,则默认为空格。

清单 7-9trim,修剪不想要的字符

_trim() #@ Trim spaces (or character in $2) from $1
{
  local trim_string
  _TRIM=$1
  trim_string=${_TRIM##*[!${2:- }]}
  _TRIM=${_TRIM%"$trim_string"}
  trim_string=${_TRIM%%[!${2:- }]*}
  _TRIM=${_TRIM#"$trim_string"}
}

trim() #@ Trim spaces (or character in $2) from $1 and print the result
{
  _trim "$@" && printf "%s\n" "$_TRIM"
}

例子

$ trim "   S p a c e d  o u t   "
S p a c e d  o u t
$ trim "0002367.45000" 0
2367.45

索引

index函数将一个月份名称转换成它的序数;它返回一个字符串在另一个字符串中的位置(清单 7-10 )。它使用参数扩展来提取子字符串前面的字符串。子字符串的索引比提取的字符串的长度大 1。

清单 7-10index,返回一个字符串在另一个字符串中的位置

_index() #@ Store position of $2 in $1 in $_INDEX
{
  local idx
  case $1 in
    "")  _INDEX=0; return 1 ;;
    *"$2"*) ## extract up to beginning of the matching portion
            idx=${1%%"$2"*}
            ## the starting position is one more than the length
           _INDEX=$(( ${#idx}1 )) ;;
    *) _INDEX=0; return 1 ;;
  esac
}

index()
{
  _index "$@"
  printf "%d\n" "$_INDEX"
}

清单 7-11 展示了将月份名称转换成数字的函数。它将月份名称的前三个字母转换成大写,并在months字符串中找到它的位置。它将该位置除以 4,然后加 1 得到月份数。

清单 7-11month2num ,将月份名称转换成它的序数

_month2num()
{
  local months=JAN.FEB.MAR.APR.MAY.JUN.JUL.AUG.SEP.OCT.NOV.DEC
  _upword "${1:0:3}" ## take first three letters of $1 and convert to uppercase
  _index "$months" "$_UPWORD" || return 1
  _MONTH2NUM=$(( $_INDEX41 ))
}

month2num()
{
  _month2num "$@" &&
  printf "%s\n" "$_MONTH2NUM"
}

摘要

在本章中,您学习了以下命令和功能。

命令

  • tr:翻译字符

功能

  • repeat:重复一个字符串,直到它有长度N
  • alert:打印带有边框和嘟嘟声的警告信息
  • revstr:反转字符串的顺序;将结果存储在_REVSTR
  • to_upper:将$1的第一个字符转换成大写
  • upword:将单词转换成大写
  • validname:检查$1是否有有效的变量或函数名
  • insert_string:在指定位置将一个字符串插入另一个字符串
  • 将一个字符串放在另一个字符串的上面
  • trim:修剪不想要的字符
  • index:返回一个字符串在另一个字符串中的位置
  • month2num:将月份名称转换成它的序数

练习

  1. 这段代码有什么问题(除了本章开头提到的效率低下之外)?

    ifecho ${PATH} |grep -q /usr/games
      PATH=$PATH:/usr/games
    fi
    
  2. 编写一个名为to_lower的函数,它与清单 7-4 中的to_upper函数相反。

  3. 编写一个函数palindrome,它检查它的命令行参数是否是一个回文(也就是说,一个单词或短语前后拼写相同)。请注意,空格和标点符号在测试中被忽略。如果是回文,则成功退出。包括打印消息以及设置返回代码的选项。

  4. 编写两个函数,ltrimrtrim,它们以与trim相同的方式修剪字符,但是分别从字符串的左边和右边开始。

八、文件操作和命令

因为 shell 是一种解释语言,所以它相对较慢。对文件的许多操作最好用隐式循环遍历文件行的外部命令来完成。在其他时候,shell 本身效率更高。本章介绍 shell 如何处理文件——既包括修改和扩展文件名扩展的 shell 选项,也包括读取和修改文件内容的 shell 选项。解释了几个对文件起作用的外部命令,通常伴随着何时使用它们的例子。

本章中的一些脚本使用了一个特别准备的文件,其中包含了钦定版的圣经。该文件可以从http://cfaj.freeshell.org/kjv/kjv.txt下载。使用wget将其下载到您的主目录:

wget http://cfaj.freeshell.org/kjv/kjv.txt

在这个文件中,《圣经》的每一节都在一行上,前面是书名和章节号,都用冒号分隔:

Genesis:001:001:In the beginning God created the heaven and the earth.
Exodus:020:013:Thou shalt not kill.
Exodus:022:018:Thou shalt not suffer a witch to live.
John:011:035:Jesus wept.

文件的路径将保存在变量kjv中,当需要该文件时将会用到它。

export kjv=$HOME/kjv.txt

读取文件

读取文件内容的最基本方法是while循环,其输入被重定向:

while read  ## no name supplied so the variable REPLY is used
do
  : do something with "$REPLY" here
done < "$kjv"

文件将被存储在变量REPLY中,一次一行。更常见的是,一个或多个变量名将作为参数提供给read:

while read name phone
do
  printf "Name: %-10s\tPhone: %s\n" "$name" "$phone"
done < "$file"

使用IFS中的字符作为单词分隔符来拆分行。如果$file中包含的文件包含这两行:

John 555-1234
Jane 555-7531

前面代码片段的输出如下:

Name: John      Phone: 555-1234
Name: Jane      Phone: 555-7531

通过在read命令之前改变IFS的值,其他字符可以用于分词。同样的脚本,仅在IFS中使用连字符,而不是默认的空格、制表符和换行符,会产生这样的结果:

$ while IFS=- read name phone
> do
>  printf "Name: %-10s\tPhone: %s\n" "$name" "$phone"
> done < "$file"
Name: John 555  Phone: 1234
Name: Jane 555  Phone: 7531

将赋值放在一个命令前面会使它成为该命令的本地值,而不会改变它在脚本中其他地方的值。

为了阅读钦定版的圣经(以下简称为 KJV),字段分隔符IFS应该设置为冒号,这样就可以将行分成书、章、节和文本,每一行都分配给一个单独的变量(清单 8-1 )。

清单 8-1 。从 KJV 中打印书、章、节和首字

while IFS=: read book chapter verse text
do
  firstword=${text%% *}
  printf "%s %s:%s %s\n" "$book" "$chapter" "$verse" "$firstword"
done < "$kjv"

输出(超过 31,000 行被一个省略号替换)如下所示:

Genesis 001:001 In
Genesis 001:002 And
Genesis 001:003 And
...
Revelation 022:019 And
Revelation 022:020 He
Revelation 022:021 The

当 shell 本身太慢时(如本例),或者当需要 shell 中不存在的特性时(例如,使用十进制分数的算术),通常在 shell 脚本中使用awk编程语言。这种语言在下一节会有更详细的解释。

外部命令

您可以使用 shell 完成许多任务,而无需调用任何外部命令。有些使用一个或多个命令为脚本处理提供数据。其他脚本最好只用外部命令编写。

通常,外部命令的功能可以在 shell 中复制,有时则不能。有时使用 shell 是最有效的方法;有时候是最慢的。在这里,我将介绍一些处理文件的外部命令,并展示它们是如何被使用(以及经常被误用)的。这些不是命令的详细解释;通常它们是一个概述,在大多数情况下,是关于它们在 shell 脚本中是如何被使用或者误用的。

最常被误用的命令之一,cat 读取命令行上的所有文件,并将它们的内容打印到标准输出中。如果没有提供文件名,cat读取标准输入。当需要读取多个文件或者需要将一个文件包含在其他命令的输出中时,这是一个合适的命令:

cat *.txt | tr aeiou AEIOU > upvowel.txt

{
  date                ## Print the date and time
  cat report.txt      ## Print the contents of the file
  printf "Signed: "   ## Print "Signed: " without a newline
  whoami              ## Print the user's login name
} | mail -s "Here is the report" paradigm@example.com

如果一个或多个文件可以放在命令行上,则没有必要:

cat thisfile.txt | head -n 25 > thatfile.txt  ## WRONG
head -n 25 thisfile.txt > thatfile.txt        ## CORRECT

当需要向一个命令提供多个文件(或者没有文件)时,这是很有用的,该命令不能以文件名作为参数,或者只能以单个文件作为参数,例如在重定向中。当一个或多个文件名可能在命令行上,也可能不在命令行上时,这很有用。如果没有给定文件,则使用标准输入:

cat "$@"while read x; do whatever; done

使用进程替换也可以做到同样的事情,好处是在while循环中修改的变量对脚本的其余部分是可见的。缺点是它降低了脚本的可移植性。

while read x; do : whatever; done < <( cat "$@" )

另一个常见的误用cat是将输出作为列表与for一起使用:

for line in $( cat "$kjv" ); do n=$(( ${n:-0}1 )); done

该脚本没有将行放入line变量;它能读出每个单词。n的值将是 795989,这是文件中的字数。文件中有 31,102 行。(如果你真的想要这些信息,你可以使用wc命令。)

头部

默认情况下,head 在命令行上打印每个文件的前十行,如果没有给定文件名,则从标准输入开始打印。-n选项改变了默认设置:

$ head -n 1 "$kjv"
Genesis:001:001:In the beginning God created the heaven and the earth.

像任何命令一样,head的输出可以存储在一个变量中:

filetop=$( head -n 1 "$kjv")

在那种情况下,head是不必要的;这个 shell one liner 在没有任何外部命令的情况下做同样的事情:

read filetop < "$kjv"

使用head读取一行尤其低效,因为变量必须被分成几个组成部分:

book=${filetop%%:*}
text=${filetop##*:}

这可以通过read更快地完成:

$ IFS=: read book chapter verse text < "$kjv"
$ sa "$book" "$chapter" "$verse" "${text%% *}"
:Genesis:
:001:
:001:
:In:

使用 shell 而不是head甚至可以更快地将多行读入变量:

{
  read line1
  read line2
  read line3
  read line4
} < "$kjv"

或者,您可以将这些行放入一个数组:

forin {1..4}
do
  read lines[${#lines[@]}]
done < "$kjv"

bash-4.x中,新的内置命令mapfile也可以用来填充数组:

mapfile -tn 4 lines < "$kjv"

第十三章中的对mapfile命令有更详细的解释。

触控

touch 的默认动作是将文件的时间戳更新为当前时间,如果不存在则创建一个空文件。-d选项的一个参数将时间戳更改为那个时间,而不是现在。没有必要使用touch来创建文件。shell 可以通过重定向来实现:

> filename

即使创建多个文件,shell 也更快:

for file in {a..z}$RANDOM
do
  > "$file"
done

限位开关(Limit Switch)

除非与一个或多个选项一起使用,ls命令 与 shell 文件名扩展相比,几乎没有什么功能优势。两者都按字母顺序列出文件。如果你希望文件在屏幕上以整齐的列显示,ls很有用。如果您想对这些文件名做任何事情,在 shell 中可以做得更好,通常也更安全。

然而,有了期权,情况就不同了。-l选项打印关于文件的更多信息,包括其权限、所有者、大小和修改日期。-t选项根据最后修改时间对文件进行排序,最近的排在最前面。使用-r选项可以颠倒顺序(无论是按名称还是按时间)。

被多次误用,以至于破坏了一个脚本。包含空格的文件名令人厌恶,但如今它们如此普遍,以至于脚本必须考虑它们的可能性(或者说,是必然性?)纳入考虑。在下面的结构中(这种情况很常见),不仅ls是不必要的,而且如果任何文件名包含空格,它的使用都会破坏脚本:

for file in $(ls); do

命令替换的结果受单词分割的影响,因此如果文件名中包含空格,则file将被分配给文件名中的每个单词:

$ touch {zzz,xxx,yyy}\ a  ## create 3 files with a space in their names
$ for file in $(ls *\ *); do echo "$file"; done
xxx
a
yyy
a
zzz
a

另一方面,使用文件名扩展可以得到想要的(即正确的)结果:

$ for file in *\ *; do echo "$file"; done
xxx a
yyy a
zzz a

切口

cut命令提取由字符或字段指定的部分行。如果没有指定文件,则从命令行上列出的文件或标准输入中剪切读取。通过使用代表字节、字符和字段的三个选项-b-c-f中的一个来选择要打印的内容。只有在使用多字节字符的语言环境中,字节和字符才会有所不同。字段由单个制表符分隔(连续的制表符分隔空白字段),但这可以用-d选项改变。

-c选项后面是一个或多个字符位置。多个列(或使用-f选项时的字段)可以用逗号分隔的列表或范围来表示:

$ cut -c 22 "$kjv"head -n3
e
h
o
$ cut -c 22,24,26 "$kjv"head -n3
ebg
h a
o a
$ cut -c 22-26 "$kjv"head -n3
e beg
he ea
od sa

cut的一个常见误用是提取字符串的一部分。这种操作可以通过壳参数扩展来完成。即使需要两三步,也会比调用外部命令快很多。

$ boys="Brian,Carl,Dennis,Mike,Al"
$ printf "%s\n" "$boys"cut -d, -f3  ## WRONG
Dennis
$ IFS=,          ## Better, no external command used
$ boyarray=( $boys )
$ printf "%s\n" "${boyarray[2]}"
Dennis
$ temp=${boys#*,*,} ## Better still, and more portable
$ printf "%s\n" "${temp%%,*}"
Dennis

wc

要计算文件中的行数、字数或字节数,请使用wc 。默认情况下,它按照文件名的顺序打印所有三条信息。如果在命令行中给出了多个文件名,它会在 e 上为每个文件名打印一行信息,然后是总数:

$ wc "$kjv" /etc/passwd
  31102  795989 4639798 /home/chris/kjv.txt
     50     124    2409 /etc/passwd
  31152  796113 4642207 total

如果命令行上没有文件,cut从标准输入中读取:

$ wc < "$kjv"
  31102  795989 4639798

通过使用-c-w-l选项,可以将输出限制为一条或两条信息。如果使用任何选项,wc仅打印要求的信息:

$ wc -l "$kjv"
31102 /home/chris/kjv.txt

较新版本的wc有另一个选项-m,它打印字符数,如果文件包含多字节字符,它将小于字节数。但是,默认输出保持不变。

与许多命令一样,wc经常被误用来获取关于字符串而不是文件的信息。要获得保存在变量中的字符串的长度,使用参数扩展:${#var}。要获得字数,使用set和特殊参数$#:

set -f
set -- $var
echo $#

要获得行数,请使用以下命令:

IFS=$'\n'
set -f
set -- $var
echo $#

正则表达式

正则表达式(通常称为 regexesregexps )是比文件名 globbing 更强大的模式匹配形式,可以更精确地表达更广泛的模式。它们从非常简单的(字母或数字是匹配自身的正则表达式)到令人难以置信的复杂。长表达式是由短表达式串联而成的,分解后不难理解。

regexes 和 file-globbing 模式有相似之处:方括号中的字符列表匹配列表中的任何字符。星号匹配前面字符的零个或多个字符,而不是文件扩展中的任何字符。一个点匹配任何字符,所以.*匹配任何长度的任何字符串,就像一个星号匹配一个字符模式一样。

三个重要的命令使用正则表达式:grepsedawk。第一个用于搜索文件,第二个用于编辑文件,第三个用于几乎任何事情,因为它本身就是一个完整的编程语言。

可做文件内的字符串查找

grep 在命令行中搜索文件,如果没有给定文件,则在标准输入中搜索,并打印与字符串或正则表达式匹配的行。

$ grep ':0[57]0:001:' "$kjv"cut -c -78
Genesis:050:001:And Joseph fell upon his father's face, and wept upon him, and
Psalms:050:001:The mighty God, even the LORD, hath spoken, and called the eart
Psalms:070:001:MAKE HASTE, O GOD, TO DELIVER ME; MAKE HASTE TO HELP ME, O LORD
Isaiah:050:001:Thus saith the LORD, Where is the bill of your mother's divorce
Jeremiah:050:001:The word that the LORD spake against Babylon and against the

Shell 本身可以完成这项工作:

while read line
do
  case $line in
    *0[57]0:001:*) printf "%s\n" "${line:0:78}" ;;
  esac
done < "$kjv"

但是要多花很多倍的时间。

通常使用grep和其他外部命令从文件中选择少量行,并将结果传送到 shell 脚本进行进一步处理:

$ grep 'Psalms:023' "$kjv" |
> {
> total=0
> while IFS=: read book chapter verse text
> do
>   set -- $text  ## put the verse into the positional parameters
>   total=$(( $total$# )) ## add the number of parameters
> done
> echo $total
}
118

grep应该用而不是来检查一个字符串是否包含在另一个字符串中。为此,有casebash的表情评估器[[ ... ]]

sed

对于用另一个字符串替换一个字符串或模式来说,没有什么能比得上sstreameditorsed了。它也适用于从文件中提取特定的一行或一系列行。要获取《利未记》的前三行并将书名转换为大写,可以使用以下代码:

$ sed -n '/Lev.*:001:001/,/Lev.*:001:003/ s/Leviticus/LEVITICUS/p' "$kjv" |
> cut -c -78
LEVITICUS:001:001:And the LORD called unto Moses, and spake unto him out of th
LEVITICUS:001:002:Speak unto the children of Israel, and say unto them, If any
LEVITICUS:001:003:If his offering be a burnt sacrifice of the herd, let him of

-n选项告诉sed不要打印任何东西,除非被明确告知要这样做;默认情况下,打印所有行,无论是否修改。这两个正则表达式用斜杠括起来,用逗号分隔,定义了从匹配第一个的行到匹配第二个的行的范围;s是一个搜索和替换命令,可能是最常用的命令。

修改文件时,标准的 Unix 惯例是将输出保存到新文件中,如果命令成功,则将其移动到旧文件的位置:

sed 's/this/that/g' "$file" > tempfile && mv tempfile "$file"

一些最近版本的sed有一个-i选项,可以在原位改变文件*。如果使用该选项,应该给它加上一个后缀,以便在脚本无法挽回地损坏原始文件时制作备份副本:*

sed -i.bak 's/this/that/g' "$file"

使用sed可以编写更复杂的脚本,但是它们很快变得很难阅读。这个例子远不是我见过的最糟糕的例子,但是要想弄清楚它在做什么,只看一眼是远远不够的。(它搜索耶稣哭泣并打印包含它的线以及前后的线;在http://www.grymoire.com/Unix/Sed.html可以找到评论版。)

sed -n '
/Jesus wept/ !{
    h
}
/Jesus wept/ {
    N
    x
    G
    p
    a\
---
    s/.*\n.*\n\(.*\)$/\1/
    h
}' "$kjv"

很快您就会看到,awk中的相同程序相对容易理解。

在后面的章节中会有更多关于sed的例子,所以我们将继续讨论通常的告诫,即外部命令应该用于文件,而不是字符串。 Nuff sed!

使用

awk 是一种模式扫描和处理语言。一个awk脚本由一个或多个条件-动作对组成。该条件应用于在命令行上传递的一个或多个文件中的每一行,或者如果没有给定文件,则应用于标准输入。当条件成功解决时,将执行相应的操作。

条件可以是正则表达式、变量测试、算术表达式或任何产生非零或非空结果的内容。它可以通过给出由逗号分隔两个条件来表示范围;一旦有一行符合第一个条件,动作就会执行,直到有一行符合第二个条件。例如,该条件匹配输入行 10 到 20(包括 10 和 20)(NR是包含当前行号的变量):

NR == 10, NR == 20

有两种特殊情况,BEGINEND。在读取任何行之前,执行与BEGIN相关的动作。在所有行都被读取后,执行END动作,或者另一个动作执行exit语句。

该动作可以是任何计算任务。它可以修改输入行,可以保存在变量中,可以对它进行计算,可以打印部分或全部行,还可以做任何你能想到的事情。

条件或操作可能缺失。如果没有条件,该操作将应用于所有行。如果没有操作,则打印匹配行。

根据变量FS的内容,每一行被分成几个字段。默认情况下,它是任何空格。字段编号为:$1$2等。$0包含整行。变量NF包含行中字段的数量。

kjvfirsts脚本的awk版本中,使用-F命令行选项将字段分隔符改为冒号(清单 8-2 )。没有条件,所以对每一行都执行该操作。它将第四个字段(诗句本身)拆分成单词,然后打印前三个字段和诗句的第一个单词。

清单 8-2kjvfirsts-awk、印刷书、章、节、首字出自 KJV

awk -F: '  ## -F: sets the field delimiter to a colon
{
 ## split the fourth field into an array of words
 split($4,words," ")
 ## printf the first three fields and the first word of the fourth
 printf "%s %s:%s %s\n", $1, $2, $3, words[1]
}' "$kjv"

为了找到 KJV 中最短的诗句,下一个脚本检查第四个字段的长度。如果小于目前看到的最短字段的值,用length()函数测得的其长度(减去书名的长度)存储在min中,行存储在verse中。最后,打印存储在verse中的行。

$ awk -F: 'BEGIN { min = 999 } ## set min larger than any verse length
length($0) - length($1) < min {
   min = length($0) – length($1)
   verse = $0
 }
END { print verse }' "$kjv"
John:011:035:Jesus wept.

正如所承诺的,下面是一个awk脚本,它搜索一个字符串(在本例中,耶稣哭泣)并打印它以及上一行和下一行:

awk '/Jesus wept/ {
   print previousline
   print $0
   n = 1
   next
  }
n == 1 {
   print $0
   print "---"
   n = 2
  }
  {
   previousline = $0
  }' "$kjv"

要合计一列数字:

$ printf "%s\n" {12..34} | awk '{ total += $1 }
> END { print total }'
529

这是对awk的一个非常初步的观察。本书后面还会有更多的awk剧本,但是为了全面理解,还有各种关于awk:的书

  • AWK 编程语言的发明者(阿尔弗雷德 V. A 何,彼得 J. W 艾因伯格和布莱恩 W. K 厄尔尼汉)
  • 戴尔·多尔蒂和阿诺德·罗宾斯
  • 阿诺德·罗宾斯的《有效的 awk 编程》

或者从主页开始。

文件名扩展选项

为了向您展示各种文件名扩展选项的效果,将使用在第四章的中定义的sa命令以及pr4,一个在屏幕上以四列打印其参数的函数。脚本sapr4一起被实现为一个函数,并被添加到.bashrc文件中:

sa()
{
    pre=: post=:
    printf "$pre%s$post\n" "$@"
}

pr4函数 在四个相等的列中打印其参数,截断任何超出其分配空间的字符串:

pr4()
{
    ## calculate column width
    local width=$(( (${COLUMNS:-80}2) / 4 ))

    ## Note that braces are necessary on the second $width to separate it from 's'
    local s=%-$width.${width}s
    printf "$s $s $s $s\n" "$@"
}

有六个 shell 选项会影响文件名的扩展方式。分别使用选项-s-u通过shopt命令启用和禁用它们:

shopt -s extglob      ## enable the extglob option
shopt -u nocaseglob   ## disable the nocaseglob option

为了演示各种 globbing 选项,我们将创建一个目录,cd到其中,并将一些空文件放入其中:

$ mkdir "$HOME/globfest" && cd "$HOME/globfest" || echo Failed >&2
$ touch {a..f}{0..9}{t..z}$RANDOM .{a..f}{0..9}$RANDOM

这已经创建了 420 个以字母开头的文件和 60 个以点开头的文件。例如,有 7 个文件以a1开头:

$ sa a1*
:a1t18345:
:a1u18557:
:a1v12490:
:a1w22008:
:a1x6088:
:a1y28651:
:a1z18318:

空气球

通常,当通配符模式不匹配任何文件时,该模式保持不变:

$ sa *xy
:*xy:

如果设置了nullglob选项 并且没有匹配,则返回空字符串:

$ shopt -s nullglob
$ sa *xy
::
$ shopt -u nullglob   ## restore the default behavior

失败球

如果设置了failglob选项 并且没有文件匹配通配符模式,则会打印一条错误消息:

$ shopt -s failglob
$ sa *xy
bash: no match: *xy
$ shopt -u failglob   ## restore the default behavior

dotglob

文件名扩展模式开头的通配符与以点开头的文件名不匹配。这些是“隐藏”文件,不符合标准的文件扩展名:

$ sa * | wc -l  ## not dot files
420

要匹配“点”文件,必须明确给出前导点:

$ sa .* | wc -l ## dot files; includes . and ..
62

本节开头的touch命令创建了 60 个点文件。.*扩展显示 62,因为它包括在所有子目录中创建的硬链接条目...

dotglob选项使点文件像任何其他文件一样被匹配:

$ shopt -s dotglob
$ printf "%s\n" * | wc -l
480

dotglob启用的情况下,*的扩展不包括硬链接...

外螺

当使用shopt -s extglob打开时,增加了五个新的文件名扩展操作符。在每种情况下,pattern-list都是管道分隔的球形模式列表。用括号括起来,括号前面有?*+@!,例如+(a[0-2]|34|2u?(john|paul|george|ringo)

要演示扩展的 globbing,请删除$HOME/globfest中的现有文件,并创建一个新文件集:

$ cd $HOME/globfest
$ rm *
$ touch {john,paul,george,ringo}{john,paul,george,ringo}{1,2}$RANDOM\
> {john,paul,george,ringo}{1,2}$RANDOM{,,} {1,2}$RANDOM{,,,}

?(模式列表 )

这个pattern-list匹配零个或一个给定模式的出现。例如,模式?(john|paul)2匹配john2paul22:

$ pr4 ?(john|paul)2*
222844              228151              231909              232112
john214726          john216085          john26              paul218047
paul220720          paul231051

*(模式列表)

这类似于前面的形式,但是它匹配给定模式的零次或多次出现;*(john|paul)2将匹配前一示例中匹配的所有文件,以及连续多次匹配任一模式的文件:

pr4 *(john|paul)2*
222844              228151              231909              232112
john214726          john216085          john26              johnjohn23185
johnpaul25000       paul218047          paul220720          paul231051
pauljohn221365      paulpaul220101

@(模式列表)

模式@(john|paul)2 匹配任一模式的单个实例后跟 2:

$ pr4 @(john|paul)2*
john214726          john216085          john26              paul218047
paul220720          paul231051

+(模式列表 )

模式+(john|paul)2匹配列表中以一个或多个模式实例开头,后跟 2:

$ pr4 +(john|paul)2*
john214726          john216085          john26              johnjohn23185
johnpaul25000       paul218047          paul220720          paul231051
pauljohn221365      paulpaul220101

!(模式列表 )

最后一个扩展的 globbing 模式匹配除给定模式之外的任何模式。它与其他模式的不同之处在于,每个模式都必须匹配整个文件名。模式!(r|p|j)*不会排除以rpj(或任何其他)开头的文件,但以下模式会排除(也将排除以数字开头的文件):

$ pr4 !([jpr0-9]*)
george115425        george132443        george1706          george212389
george223300        george27803         georgegeorge16122   georgegeorge28573
georgejohn118699    georgejohn29502     georgepaul12721     georgepaul222618
georgeringo115095   georgeringo227768

Image 这里给出的最后一种模式的解释是简化的,但应该足以涵盖它在绝大多数情况下的使用。更完整的解释见第九章中*从 Bash 到 Z Shell * (Apress,2005)。

nocaseglob

设置nocaseglob 选项时,小写字母匹配大写字母,反之亦然:

$ cd $HOME/globfest
$ rm -rf *
$ touch {{a..d},{A..D}}$RANDOM
$ pr4 *
A31783              B31846              C17836              D14046
a31882              b31603              c29437              d26729

默认行为是字母只匹配相同大小写的字母:

$ pr4 [ab]*
a31882              b31603

nocaseglob选项使一个字母匹配两种情况:

$ shopt -s nocaseglob
$ pr4 [ab]*
A31783              B31846              a31882              b31603

全球之星

bash-4.0中引入的globstar 选项允许使用**递归下降到目录和子目录中寻找匹配的文件。例如,创建一个目录层次结构:

$ cd $HOME/globfest
$ rm -rf *
$ mkdir -p {ab,ac}$RANDOM/${RANDOM}{q1,q2}/{z,x}$(( $RANDOM10 ))

双星号通配符扩展到所有目录:

$ shopt -s globstar
$ pr4 **
ab11278             ab11278/22190q1     ab11278/22190q1/z7  ab1394
ab1394/10985q2      ab1394/10985q2/x5   ab4351              ab4351/23041q1
ab4351/23041q1/x1   ab4424              ab4424/8752q2       ab4424/8752q2/z9
ac11393             ac11393/20940q1     ac11393/20940q1/z4  ac17926
ac17926/19435q2     ac17926/19435q2/x0  ac23443             ac23443/5703q2
ac23443/5703q2/z4   ac5662              ac5662/17958q1      ac5662/17958q1/x4

摘要

许多外部命令处理文件。在这一章中,已经涵盖了最重要的和最常被误用的。没有详细讨论它们,重点放在当 shell 可以更有效地完成相同的工作时如何避免调用它们。基本上归结为这样:使用外部命令处理文件,而不是字符串。

Shell 选项

  • nullglob:如果没有文件匹配模式,则返回空字符串
  • failglob:如果没有匹配的文件,打印错误信息
  • dotglob:在模式匹配中包含点文件
  • extglob:启用扩展文件名扩展模式
  • nocaseglob:匹配文件,忽略大小写差异
  • globstar:在文件层次结构中搜索匹配文件

外部命令

  • awk:是一种模式扫描和处理语言
  • cat:连接文件并在标准输出上打印
  • cut:从一个或多个文件的每一行中删除部分
  • grep:打印与图案匹配的线条
  • head:输出一个或多个文件的第一部分
  • ls:列出目录内容
  • sed:是一个流编辑器,用于过滤和转换文本
  • touch:更改文件时间戳
  • wc:统计一个或多个文件中的行数、字数和字符数

练习

  1. 修改kjvfirsts脚本:接受一个指定要打印多少章节的命令行参数。
  2. 为什么kjvfirsts中的章节号格式是%s而不是%d
  3. 写一个awk脚本,找出 KJV 中最长的诗句。

九、保留字和内置命令

bash内置命令差不多 60 个,保留字 20 多个。有些是必不可少的,有些是脚本中很少用到的。有些主要在命令行中使用,有些很少在任何地方出现。有些已经讨论过了,有些将在以后的章节中广泛使用。

保留字(也叫关键词)是!casecoprocdodoneelifelseesacfiforfunctionifinselectthenuntilwhile{}time[[]]。除了coprocselecttime之外,其他都已经在本书的前面介绍过了。

除了标准命令之外,新的内置命令可以在运行时动态加载到 shell 中。bash源代码包中有 20 多个这样的命令准备编译。

因为关键字和内置命令是 shell 本身的一部分,所以它们的执行速度比外部命令快得多。他们不需要启动一个新的进程,他们可以访问并改变 shell 的环境。

本章着眼于一些更有用的保留字和内置命令,对一些进行详细研究,对一些进行总结;有几个被否决了。在本书的其他地方描述了更多。至于其他的,有builtins手册页和内置的help

帮助,显示关于内置命令的信息

命令打印关于内置命令和保留字用法的简要信息。使用-s选项,它会打印一份使用概要。

bash-4.x有两个新选项:-d-m。第一个命令打印一行简短的命令描述;后者将输出格式化为手册页的样式:

$ help -m help
NAME
    help - Display information about builtin commands.

SYNOPSIS
    help [-dms] [pattern ...]

DESCRIPTION
    Display information about builtin commands.

    Displays brief summaries of builtin commands. If PATTERN is
    specified, gives detailed help on all commands matching PATTERN,
    otherwise the list of help topics is printed.

    Options:
      -d        output short description for each topic
      -m        display usage in pseudo-manpage format
      -s        output only a short usage synopsis for each topic matching
        PATTERN

    Arguments:
      PATTERN   Pattern specifying a help topic

    Exit Status:
    Returns success unless PATTERN is not found or an invalid option is given.

SEE ALSO
    bash(1)

IMPLEMENTATION
    GNU bash, version 4.3.30(1)-release (i686-pc-linux-gnu)
    Copyright (C) 2013 Free Software Foundation, Inc.
    License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

该模式是一个 globbing 模式,其中*匹配任意数量的任意字符,而[...]匹配封闭列表中的任意单个字符。如果没有任何通配符,则假定尾随的*:

$ help -d '*le' tr ## show commands ending in le and beginning with tr
Shell commands matching keyword '*le, tr'

enable - Enable and disable shell builtins.
mapfile - Read lines from the standard input into an array variable.
while - Execute commands as long as a test succeeds.
trap - Trap signals and other events.
true - Return a successful result.

时间,执行一个命令所花费的打印时间

保留字time,打印命令执行所需的时间。该命令可以是简单或复合命令,也可以是管道。默认输出显示在三行中,显示命令占用的实时时间、用户 CPU 时间和系统 CPU 时间:

$ time echo {1..30000} >/dev/null 2>&1

real    0m0.175s
user    0m0.152s
sys     0m0.017s

您可以通过改变TIMEFORMAT变量来修改该输出:

$ TIMEFORMAT='%R seconds  %P%% CPU usage'
$ time echo {1..30000} >/dev/null
0.153 seconds  97.96% CPU usage

附录包含对TIMEFORMAT变量的完整描述。

关于time命令的一个常见问题是,“为什么我不能重定向time的输出?”答案展示了保留字和内置命令之间的区别。当 shell 执行一个命令时,这个过程是严格定义的。shell 关键字不必遵循这个过程。在time的情况下,整个命令行(除了关键字本身,但包括重定向)都被传递给 shell 来执行。命令完成后,将打印定时信息。

要重定向time的输出,请用大括号将其括起来:

$ { time echo {1..30000} >/dev/null 2>&1 ; } 2> numlisttime
$ cat numlisttime
0.193 seconds  90.95% CPU usage

read ,从输入流中读取一行

如果read没有参数,bash从其标准输入流中读取一行,并将其存储在变量REPLY中。如果输入的行尾包含一个反斜杠,则该反斜杠和后面的换行符将被删除,并读取下一行,将两行连接起来:

$ printf "%s\n" '   First line   \' '   Second line   ' | {
> read
> sa "$REPLY"
> }
:   First line      Second line   :

Image 注意这段代码和下面的代码片段中的括号({ })为readsa命令创建了一个公共的子 shell。如果没有它们,read将单独在一个 subshell 中,sa将看不到REPLY(或 subshell 中设置的任何其他变量)的新值。

只有一个选项-r是 POSIX 标准的一部分。许多bash选项(-a-d-e-n-p-s-n-t-u以及bash-4.x-i的新增选项)是这个 shell 对于交互式脚本如此有效的部分原因。

-r ,逐字读反斜杠

使用-r选项,反斜杠按字面意思处理:

$ printf "%s\n" '   First line\' "   Second line   " | {
> read -r
> read line2
> sa "$REPLY" "$line2"
> }
:   First line\:
:Second line:

该代码片段中的第二个read提供了一个变量来存储输入,而不是使用REPLY。因此,它对输入应用单词拆分,并删除前导和尾随空格。如果IFS被设置为一个空字符串,那么空格将不会被用于分词:

$ printf "%s\n" '   First line\' "   Second line   " | {
> read -r
> IFS= read line2
> sa "$REPLY" "$line2"
> }
:   First line\:
:   Second line   :

如果命令行中给出了多个变量,则第一个字段存储在第一个变量中,后续字段存储在后面的变量中。如果字段比变量多,最后一个存储该行的剩余部分:

$ printf "%s\n" "first second third fourth fifth sixth" | {
> read a b c d
> sa "$a" "$b" "$c" "$d"
> }
:first:
:second:
:third:
:fourth fifth sixth:

-e ,用 readline 库获取输入

当在命令行或使用带有-e选项的read从键盘获得输入时,使用readline库。它允许整行编辑。大多数 shells 中的默认编辑风格只允许通过用退格键删除光标左侧的字符来进行编辑。

当然,使用-e,退格键仍然有效,但是光标可以使用箭头键或者 Ctrl-B 和 Ctrl-N 分别向后和向前移动一个字符到整行。Ctrl-A 移动到行首,Ctrl-E 移动到行尾。

此外,其他readline命令可以绑定到您喜欢的任何组合键。我将 Ctrl-左箭头键绑定到backward-word,将 Ctrl-右箭头键绑定到forward-word。这样的绑定可以放在$HOME/.inputrc里。我的有两个终端的条目,rxvtxterm:

"\eOd": backward-word     ## rxvt
"\eOc": forward-word      ## rxvt
"\e[1;5D": backward-word  ## xterm
"\e[1;5C": forward-word   ## xterm

要检查在您的终端仿真中使用哪个代码,请按^V (Ctrl-v),然后按您想要的组合键。例如,在xterm中,当我按下 Ctrl-左箭头键时,我会看到^[[1;5D

-a ,将字读入一个数组

-a选项将读取的字分配给数组,从索引零开始:

$ printf "%s\n" "first second third fourth fifth sixth" | {
> read -a array
> sa "${array[0]}"
> sa "${array[5]}"
> }
:first:
:sixth:

-d DELIM ,一直读到 DELIM 而不是换行

-d选项接受一个参数,该参数将read的分隔符从换行符更改为该参数的第一个字符:

$ printf "%s\n" "first second third fourth fifth sixth" | {
> read -d ' nrh' a
> read -d 'nrh' b
> read -d 'rh' c
> read -d 'h' d
> sa "$a" "$b" "$c" "$d"
> }
:first:          ## -d ' '
:seco:           ## -d n
:d thi:          ## -d r
:d fourt:        ## -d h

-n NUM ,最多读取 NUM 个字符

当需要单个字符(例如,yn)时最常用,read在读取NUM字符后返回,而不是等待换行符。它经常与-s连用。

-s ,不回应来自端子的输入

对于输入密码和单个字母的响应非常有用,-s选项抑制了输入的击键显示。

-p 提示:,输出提示 不带尾随换行符

以下代码片段是这三个选项的典型用法:

read -sn1 -p "Continue (y/n)? " var
case ${var^} in  ## bash 4.x, convert $var to uppercase
  Y) ;;
  N) printf "\n%s\n" "Good bye."
     exit
     ;;
esac

运行时,当输入nN时,看起来是这样的:

Continue (y/n)?
Good bye.

-t 超时,仅等待超时秒完成输入

-t选项是在bash-2.04中引入的,接受大于0的整数作为参数。如果TIMEOUT 秒后才输入一个完整的行,read失败退出;任何已经输入的字符都留在输入流中,供下一个读取标准输入的命令使用。

bash-4.x中,-t选项接受一个值0,如果有输入等待读取,则成功返回。它还接受十进制格式的小数参数:

read -t .1 var  ## timeout after one-tenth of a second
read -t 2 var   ## timeout after 2 seconds

将变量TMOUT设置为大于零的整数与-t选项具有相同的效果。在bash-4.x中,也可以使用十进制分数:

$ TMOUT=2.5
$ TIMEFORMAT='%R seconds  %P%% CPU usage'
$ time read
2.500 seconds  0.00% CPU usage

-u FD :从文件描述符 FD 中读取,而不是标准输入

-u选项告诉bash从文件描述符中读取。给定该文件:

First line
Second line
Third line
Fourth line

这个脚本读取它,在重定向和-u选项之间交替,并打印所有四行:

exec 3<$HOME/txt
read var <&3
echo "$var"
read -u3 var
echo "$var"
read var <&3
echo "$var"
read -u3 var
echo "$var"

-i 文本,使用文本作为 Readline 的初始文本

对于bash-4.x-i选项是新的,与-e选项一起使用,将文本放在命令行上进行编辑。

$ read –ei 'Edit this' -p '==>'

会是什么样子

==> Edit this •

清单 9-1 中的bash-4.x脚本循环显示一个旋转的繁忙指示器,直到用户按下一个键。它使用四个read选项:-s-n-p-t

清单 9-1spinner,在等待用户按键时,显示忙碌指示器

spinner="\|/-"              ## spinner
chars=1                     ## number of characters to display
delay=.15                   ## time in seconds between characters
prompt="press any key..."     ## user prompt
clearline="\eK"            ## clear to end of line (ANSI terminal)
CR="\r"                     ## carriage return

## loop until user presses a key
until read -sn1 -t$delay -p "$prompt" var
do
  printf "  %.${chars}s$CR" "$spinner"
  temp=${spinner#?}               ## remove first character from $spinner
  spinner=$temp${spinner%"$temp"} ## and add it to the end
done
printf "$CR$clearline"

![Image 提示如果delay改成整数,那么脚本在所有版本的bash中都可以工作,但是微调器会非常慢。

eval ,展开参数并执行结果命令

在第五章中,内置的eval用于获取一个变量名在另一个变量中的变量的值。它完成了与bash的变量扩展、${!var}相同的任务。实际发生的是eval在引号内扩展了变量;反斜杠去掉了引号和美元符号的特殊含义,因此它们仍然是字面字符。然后执行产生的字符串:

$ x=yes
$ a=x
$ eval "sa \"\$$a\"" ## executes: sa "$x"
yes

eval的其他用途包括给一个变量赋值,该变量的名称包含在另一个变量中,以及从一个命令中获得多个值。

穷人的阵列

bash有关联数组之前(也就是 4.0 版本之前),可以用eval模拟。这两个函数设置和检索这样的值,并将它们用于测试运行(清单 9-2 )。

清单 9-2varfuncs ,仿真关联数组

validname() ## Borrowed from Chapter 7
 case $1 in
   [!a-zA-Z_]* | *[!a-zA-Z0-9_]* ) return 1;;
 esac

setvar() #@ DESCRIPTION: assign value to supplied name
{        #@ USAGE: setvar varname value
  validname "$1" || return 1
  eval "$1=\$2"
}

getvar() #@ DESCRIPTION: print value assigned to varname
{        #@ USAGE: getvar varname
  validname "$1" || return 1
  eval "printf '%s\n' \"\${$1}\""
}

echo "Assigning some values"
forin {1..3}
do
  setvar "var_$n" "$n$RANDOM"
done
echo "Variables assigned; printing values:"
forin {1..3}
do
 getvar "var_$n"
done

以下是一次运行的结果示例:

Assigning some values
Variables assigned; printing values:
1 - 28538
2 - 22523
3 - 19362

注意setvar中的赋值。和这个比较一下:

setvar() { eval "$1=\"$2\""; }

如果用这个函数代替varfuncs中的函数并运行脚本,结果看起来非常相似。有什么区别?让我们使用不同的值来尝试一下,在命令行中使用这些函数的精简版本:

$ {
> setvar() { eval "$1=\$2"; }
> getvar() { eval "printf '%s\n' \"\${$1}\""; }
> n=1
> setvar "qwerty_$n" 'xxx " echo Hello"'
> getvar "qwerty_$n"
> }
xxx " echo hello"
$ {
> setvar2() { eval "$1=\"$2\""; }
> setvar2 "qwerty_$n" 'xxx " echo Hello"'
> }
Hello

喂?那是从哪里来的?使用set -x,您可以清楚地看到正在发生的事情:

$ set -x ## shell will now print commands and arguments as they are executed
$ setvar "qwerty_$n" 'xxx " echo Hello"'
+ setvar qwerty_1 'xxx " echo Hello"'
+ eval 'qwerty_1=$2'

最后一行是重要的一行。在那里,变量qwerty_1被设置为$2\. $2中的任何内容都不会以任何方式展开或解释;它的值被简单地赋值给qwerty_1:

$ setvar2 "qwerty_$n" 'xxx " echo Hello"'
+ setvar2 qwerty_1 'xxx " echo Hello"'
+ eval 'qwerty_1="xxx " echo Hello""'
++ qwerty_1='xxx '
++ echo HelloHello

在这个版本中,$2在赋值之前被展开,因此要进行分词;eval查看后跟命令的赋值。进行分配,然后执行命令。在这种情况下,该命令是无害的,但是如果该值是由用户输入的,则可能是危险的。

为了安全地使用eval,请确保使用eval "$var=\$value"将未展开的变量进行赋值。如有必要,在使用eval之前,将多个元素组合成一个变量:

string1=something
string2='rm -rf *' ## we do NOT want this to be executed
eval "$var=\"Example=$string1\" $string2" ## WRONG!! Files gone!
combo="Example=$string1 $string2"
eval "$var=\$combo" ## RIGHT!

如果var被设置为xx,其名称在var中的变量值现在与combo的内容相同:

$ printf "%s\n" "$xx"
Example=something rm -rf *

从一个命令设置多个变量

我见过许多脚本,其中使用以下命令(或类似的命令)将几个变量设置为日期和时间的组成部分:

year=$(date +%Y)
month=$(date +%m)
day=$(date +%d)
hour=$(date +%H)
minute=$(date +%M)
second=$(date +%S)

这是低效的,因为它调用了date命令六次。它也可能给出错误的结果。如果脚本在午夜前几分之一秒被调用,并且日期在设置monthday之间改变,会发生什么?该脚本在 2009-05-31T23:59:59 被调用(这是日期和时间的 ISO 标准格式),但是分配的值可能达到 2009-05-01T00:00:00。想要的日期是31 May 2009 23:59:5901 June 2009 00:00:00;剧本拿到的是1 May 2009 00:00:00。那可是整整一个月的假啊!

一个更好的方法是从date中获取一个字符串,并把它分成几个部分:

date=$(date +%Y-%m-%dT%H:%M:%S)
time=${date#*T}
date=${date%T*}
year=${date%%-*}
daymonth=${date#*-}
month=${daymonth%-*}
day=${daymonth#*-}
hour=${time%%:*}
minsec=${time#*-}
minute=${minsec%-*}
second=${minsec#*-}

更好的是,使用eval:

$ eval "$(date "+year=%Y month=%m day=%d hour=%H minute=%M second=%S")"

日期命令的输出由eval执行:

year=2015 month=04 day=25 hour=22 minute=49second=04

后两种方法只使用了一次对date的调用,所以所有变量都使用相同的时间戳填充。它们花费的时间大致相同,只是迄今为止多次通话时间的一小部分。关键是eval法的长度大约是劈弦法的三分之一。

键入,显示有关命令的信息

许多人使用which来确定执行一个命令时将使用的实际命令。这有两个问题。

首先是which至少有两个版本,其中一个是在 Bourne 类型的 shell 中不太好用的csh脚本(谢天谢地,这个版本变得非常罕见)。第二个问题是which是一个外部命令,它不能确切知道 shell 将对任何给定的命令做什么。它所做的只是在PATH变量的目录中搜索一个同名的可执行文件:

$ which echo printf
/bin/echo
/usr/bin/printf

知道echoprintf 都是内置命令,但是which不知道。不用which,用 Shell 内置type:

$ type echo printf sa
echo is a shell builtin
printf is a shell builtin
sa is a function
sa ()
{
    pre=: post=:;
    printf "$pre%s$post\n" "$@"
}

当对于一个给定的名字有多个可能执行的命令时,它们都可以通过使用-a选项来显示:

$ type -a echo printf
echo is a shell builtin
echo is /bin/echo
printf is a shell builtin
printf is /usr/bin/printf

-p选项将搜索限制在文件,并且不给出任何关于内置、函数或别名的信息。如果 shell 在内部执行该命令,则不会打印任何内容,除非同时给出了-a选项:

$ type -p echo printf sa time  ## no output as no files would be executed
$ type -ap echo printf sa time
/bin/echo
/usr/bin/printf
/usr/jayant/bin/sa
/usr/bin/time

或者你可以使用-P:

$ type -P echo printf sa time
/bin/echo
/usr/bin/printf
/usr/jayant/bin/sa
/usr/bin/time

-t选项为每个命令给出一个单词,可以是aliaskeywordfunctionbuiltinfile,也可以是一个空字符串:

$ type -t echo printf sa time ls
builtin
builtin
function
keyword
file

如果没有找到任何参数,type命令就会失败。

内置,执行一个内置命令

builtin的参数是将被调用的 shell 内置命令,而不是同名的函数。它防止函数调用自己,并令人讨厌地调用自己:

cd() #@ DESCRIPTION: change directory and display 10 most recent files
{    #@ USAGE: cd DIR
  builtin cd "$@" || return## don't call function recursively
  ls -t | head
}

命令,执行命令或显示命令信息

-v-V,显示一条命令的信息。如果没有选项,请从外部文件而不是函数中调用该命令。

pwd ,打印当前工作目录

打印当前目录的绝对路径名。使用-P选项,它打印没有符号链接的物理位置:

$ ls -ld $HOME/Book   ## Directory is a symbolic link
lrwxrwxrwx  1 jayant jayant 10 Apr 25  2015 /home/jayant/Book -> work/Cook
$ cd $HOME/Book
$ pwd                 ## Include symbolic links
/home/jayant/Book
$ pwd -P              ## Print physical location with no links
/home/jayant/work/Book

unalias ,删除一个或多个别名

在我的~/.bashrc文件中,我有unalias -a来删除所有别名。一些 GNU/Linux 发行版犯了一个危险的错误,定义了替代标准命令的别名。

最糟糕的例子之一就是将rm(删除文件或目录)重新定义为rm -i。如果一个习惯在删除文件前被提示的人,把rm *(例如)放在一个脚本中,所有的文件将会没有任何提示地消失。别名不会导出,并且默认情况下不会在 shell 脚本中运行,即使定义了别名也是如此。

不推荐使用的内置

我不建议使用以下不推荐使用的内置命令:

  • alias:定义别名。正如bash手册页所说,“对于几乎所有用途,别名都被 shell 函数所取代。”
  • let:对算术表达式求值。请改用 POSIX 语法$(( expression ))
  • 不灵活的菜单命令。使用 shell 可以轻松编写更好的菜单。
  • typeset :声明变量的属性,在函数中,将变量的范围限制在该函数及其子函数。使用local将变量的范围限制为一个函数,使用declare设置任何其他属性(如果需要)。

可动态加载的内置

如果需要,可以在运行时加载新的内置命令。bash源包有一个目录,里面装满了准备编译的例子。为此,请从ftp://ftp.cwru.edu/pub/bash/下载源代码。将 tarball、cd解压到顶层目录,运行configure脚本:

version=4.3 ## or use your bash version
wget ftp://ftp.cwru.edu/pub/bash/bash-$version.tar.gz
gunzip bash-$version.tar.gz
tar xf bash-$version.tar
cd bash-$version
./configure

Image 注意建议使用 4.3 作为版本,因为它是当前版本,并且修复了早期版本中发现的漏洞。

可以把可动态加载的内置程序想象成用 C 语言编写的自定义命令库,可以作为编译后的二进制文件使用。这些也可以以编译后的形式与他人共享。加载时,它们会提供 Bash 中原来没有的新命令。这些像本地 Bash 命令一样工作,而不是外部脚本或程序。

configure脚本在整个源代码树中创建 makefiles,包括在examples/loadables中的一个。在那个目录中是许多标准命令的内置版本的源文件,正如README文件所说,“它们的执行时间由进程启动时间决定。”你可以cd进入那个目录并运行make:

cd examples/loadables
make

现在,您已经准备好将许多命令加载到您的 shell 中。其中包括以下内容:

logname  tee       head      mkdir     rmdir     uname
ln       cat       id        whoami

还有一些有用的新命令:

print     ## Compatible with the ksh print command
finfo     ## Print file information
strftime  ## Format date and time

可以使用下面的命令将这些内置程序加载到正在运行的 shell 中:

enable -f filename built-in-name

这些文件包括文档,可以使用help命令,就像使用其他内置命令一样:

$ enable -f ./strftime strftime
$ help strftime
strftime: strftime format [seconds]
    Converts date and time format to a string and displays it on the
    standard output.  If the optional second argument is supplied, it
    is used as the number of seconds since the epoch to use in the
    conversion, otherwise the current time is used.

有关编写可动态加载的内置命令的信息,请参见本文http://shell.cfajohnson.com/articles/dynamically-loadable/

摘要

在本章中,您学习了以下命令。

命令和保留字

  • builtin:执行内置命令
  • command:执行外部命令或打印命令信息
  • eval:作为 shell 命令执行参数
  • help:显示内置命令的信息
  • pwd:打印当前工作目录
  • read:从标准输入中读取一行,并将其分割成多个字段
  • time:报告管道执行所消耗的时间
  • type:显示命令类型信息

不推荐使用的命令

  • alias:定义或显示别名
  • let:评估算术表达式
  • select:从列表中选择单词并执行命令
  • typeset:设置变量值和属性

锻炼

编写一个脚本,将命令(您选择的命令)运行所需的时间存储在三个变量中,realusersystem,对应于time输出的三个默认时间。