Bash-编程高级教程-全-

Bash 编程高级教程(全)

原文:Pro Bash Programming

协议:CC BY-NC-SA 4.0

一、你好世界:你的第一个 Shell 程序

一个 shell 脚本是一个包含一个或多个您可以在命令行上输入的命令的文件。本章描述了如何创建这样的文件并使其可执行。它还涵盖了围绕 shell 脚本的一些其他问题,包括如何命名文件、将文件放在哪里以及如何运行它们。

我将从每种计算机语言中传统演示的第一个程序开始:一个打印“Hello,World!”在你的终端。这是一个简单的程序,但它足以演示许多重要的概念。代码本身是本章最简单的部分。命名文件并决定将它放在哪里并不是复杂的任务,但却很重要。

在本章的大部分时间里,您将在一个终端中工作。它可以是一个虚拟终端,一个终端窗口,甚至是一个哑终端。在您的终端中,shell 将立即执行您键入的任何命令(当然是在您按 Enter 键之后)。

你应该在你的主目录中,你可以在变量$ HOME 中找到:

echo "$HOME"

你可以用pwd命令或PWD变量找到当前目录:

pwd
echo "$PWD"

如果您不在您的主目录中,您可以通过键入cd并在 shell 提示符下按 Enter 键到达那里。

Image 注意如果你在 Mac 上尝试本书中的代码,请注意,当前版本的 Mac OS X,Yosemite,官方支持 Bash 版本 3.2.53(1)。Bash 的当前版本是 4.3,它修复了 Shellshock 漏洞。Bash 4.3 适用于大多数 Linux 发行版。一些代码/功能可能在 Mac OS X 系统上不可用,因为它是特定于 Bash 4.x 的

《守则》

代码无非是这样的:

echo Hello, World!

这个命令行有三个词:命令本身和两个参数。命令echo打印其参数,用一个空格分隔,并以换行符结束。

文件

在将代码转换成脚本之前,您需要做出两个决定:将文件命名为什么,以及将它放在哪里。该名称应该是唯一的(也就是说,它不应该与任何其他命令冲突),并且应该放在 shell 可以找到它的地方。

脚本的命名

初学者经常犯的错误是把一个测试脚本叫做test。要了解为什么这样不好,请在命令提示符下输入以下内容:

type test

命令告诉你对于任何给定的命令,shell 将执行什么(如果它是一个外部文件,还可以在哪里找到它)。在bashtype -a test会显示所有与名称test,匹配的命令:

$ type test
test is a shell builtin
$ type -a test
test is a shell builtin
test is /usr/bin/test

如你所见,名为test的命令已经存在;它用于测试文件类型和比较值。如果你调用你的脚本test,当你在 shell 提示符下键入test时,它将不会运行;将运行由type标识的第一个命令。(关于typetest,我将在后面的章节中详细讨论。)

通常,Unix 命令名尽可能短。它们通常是描述性词语的前两个辅音(例如,mv代表 m o v e 或ls代表 l i s t)或描述性短语的第一个字母(例如,ps代表pprocessstatus 或sed代表sstreameditor)

在这个练习中,调用脚本hw。很多 shell 程序员都会加一个后缀,比如 sh,以表示该程序是一个 shell 脚本。脚本不需要它,我只对正在开发的程序使用一个。我的后缀是-sh,当程序结束时,我删除它。shell 脚本成为另一个命令,不需要与任何其他类型的命令区分开来。

为脚本选择一个目录

当 shell 被赋予要执行的命令的名称时,它会在PATH变量中列出的目录中查找该名称。此变量包含以冒号分隔的目录列表,这些目录包含可执行命令。这是$PATH的典型值:

!"
/bin:/usr/bin:/usr/local/bin:/usr/games

如果你的程序不在PATH目录中,你必须给出一个路径名,无论是绝对的还是相对的,以便bash找到它。一个绝对路径名给出了文件系统根目录的位置,比如/home/chris/bin/hw;一个相对路径名是相对于当前工作目录(当前应该是你的主目录)给出的,如bin/hw

命令通常存储在名为bin的目录中,用户的个人程序存储在$HOME目录下的bin子目录中。要创建该目录,请使用以下命令:

mkdir bin

现在它已经存在,必须添加到PATH变量中:

PATH=$PATH:$HOME/bin

要将这一更改应用到您打开的每个 shell,请将它添加到一个文件中,当 shell 被调用时,它将。这将是.bash_profile.bashrc.profile,取决于bash是如何被调用的。这些文件仅用于交互式 shells,不用于脚本。

创建文件并运行脚本

通常您会使用文本编辑器来创建您的程序,但是对于这样一个简单的脚本,没有必要调用编辑器。您可以使用重定向从命令行创建该文件:

echo echo Hello, World! > bin/hw

大于号(>)告诉 shell 将命令的输出发送到指定的文件,而不是发送到终端。你将在第二章中了解更多关于重定向的内容。

现在,可以通过将该程序作为 shell 命令的参数调用来运行该程序:

bash bin/hw

那行得通,但并不完全令人满意。您希望能够键入hw,而不必在前面加上bash,并执行命令。为此,授予文件执行权限:

chmod +x bin/hw

现在,只需使用其名称就可以运行该命令:

!"
$ hw
Hello, World!

选择和使用文本编辑器

对许多人来说,电脑软件中最重要的一部分是文字处理器。虽然我用一个来写这本书(LibreOffice 作家 ),但我不经常用它。我最后一次使用文字处理器是在五年前,当时我写了这本书的第一版。另一方面,文本编辑器是不可或缺的工具。我用它来写电子邮件、新闻组文章、shell 脚本、PostScript 程序、网页等等。

文本编辑器对纯文本文件进行操作。它只存储您键入的字符;它没有添加任何隐藏的格式代码。如果我在文本编辑器中键入A并按回车键,然后保存它,文件将包含两个字符:一个和一个换行符。包含相同文本的文字处理文件要大几千倍。(带 ,文件包含 2526 字节;LibreOffice.org 文件包含 7579 个字节。)

您可以在任何文本编辑器中编写脚本,从基本的e3nano到全功能的emacsnedit。更好的文本编辑器允许你一次打开多个文件。例如,它们通过语法突出显示、自动缩进、自动完成、拼写检查、宏、搜索和替换以及撤消来简化代码编辑。最终,你选择哪个编辑器是个人喜好的问题。我使用 GNU emacs(见图 1-1 )。

9781484201220_Fig01-01.jpg

图 1-1 。GNU emacs文本编辑器中的 Shell 代码

Image 注意在 Windows 文本文件中,!“行以两个字符结束:一个回车符 (CR)和一个换行符 (LF)。在 Unix 系统上,比如 Linux,行以一个换行符结束。如果在 Windows 文本编辑器中编写程序,则必须用 Unix 行尾保存文件,或者在保存后删除回车。

建设一个更好的“你好,世界!”

在本章的前面,您使用重定向创建了一个脚本。至少可以说,那个剧本是极简主义的。所有的程序,甚至是一行程序,都需要文档。信息至少应该包括作者、日期和命令描述。在文本编辑器中打开文件bin/ hw ,使用注释添加清单 1-1 中的信息。

清单 1-1hw

#!/bin/bash
#: Title       : hw
#: Date        : 2008-11-26
#: Author      : "Chris F.A. Johnson" <shell@cfajohnson.com>
#: Version     : 1.0
#: Description : print Hello, World!
#: Options     : None

printf "%s\n" "Hello, World!" !"

注释在一个单词的开头以一个八叉符或者散列 开始,一直持续到行尾。Shell 会忽略它们。我经常在散列后添加一个字符来表示注释的类型。然后,我可以在文件中搜索我想要的类型,忽略其他注释。

第一行是一种特殊类型的注释,称为 shebanghash- bang 。它告诉系统使用哪个解释器来执行文件。字符#! 必须出现在第一行的最开头;换句话说,它们必须是文件的前两个字节才能被识别。

摘要

以下是您在本章中学到的命令、概念和变量。

命令

  • pwd:打印当前工作目录的名称
  • cd:改变 shell 的工作目录
  • echo:打印它的参数,用空格分开,用换行符结束
  • type:显示命令的信息
  • mkdir:新建一个目录
  • chmod:修改文件的权限
  • source: a.k.a. . (dot):在当前 shell 环境中执行脚本
  • printf:打印格式字符串指定的参数

概念

  • 脚本:这是一个包含 shell 要执行的命令的文件。
  • Word :单词是 shell 认为是一个单元的字符序列。
  • 输出重定向:您可以使用> FILENAME 将命令的输出发送到一个文件而不是终端。
  • 变量:这些是存储值的名字。
  • 注释:它们由一个未加引号的单词组成,以#开头。该行的所有剩余字符构成一个注释,将被忽略。
  • Shebang 或 hash-bang :这是一个散列和一个感叹号(#!),后跟应该执行文件的解释器的路径。
  • 解释器:这是一个读取文件并执行其中包含的语句的程序。它可能是一个 shell 或另一种语言解释器,如awkpython

变量

  • PWD包含 shell 当前工作目录的路径名。
  • HOME存储用户主目录的路径名。
  • PATH是一个用冒号分隔的目录列表,其中存储了命令文件。shell 在这些目录中搜索它需要执行的命令。

练习

  1. 编写一个脚本,在$HOME 中创建一个名为bpl的目录。用两个子目录binscripts填充这个目录。
  2. 编写一个脚本来创建“Hello,World!”$HOME/bpl/bin/中的脚本hw;使其可执行;然后执行它。

二、输入、输出和吞吐

我们在第一章的中使用的两个命令是 shell 脚本程序的核心:echoprintf。两者都是bash内置的命令。两者都以标准输出流打印信息,但是printf要强大得多,而echo也有它的问题。

在这一章中,我将介绍echo及其问题、printf的功能、read命令以及标准的输入和输出流。然而,我将从参数和变量的概述开始。

参数和变量

引用bash手册(在命令提示符下键入man bash以阅读它),“参数是存储值的实体。”有三种类型的参数:位置参数、特殊参数和变量。位置参数是命令行上出现的参数,它们由一个数字引用。特殊参数由 shell 设置,用于存储关于其当前状态的信息,例如参数的数量和最后一个命令的退出代码。它们的名字是非字母数字字符(例如,*#_)。变量由一个名称标识。名称又能代表什么呢我将在“变量”部分解释这一点。

通过在参数名称、数字或字符前加上美元符号来访问参数值,如$3$#$HOME。这个名字可以用大括号括起来,如${10}${PWD}${USER}

位置参数

命令行上的参数可以作为编号参数供 shell 程序使用。第一个参数是$1,第二个是$2,以此类推。

您可以通过使用位置参数使第一章中的hw脚本更加灵活。清单 2-1 称之为hello

清单 2-1hello

#: Description: print Hello and the first command-line argument
printf "Hello, %s!\n" "$1"

现在,您可以调用带有参数的脚本来更改其输出:

$ hello John
Hello, John!
$ hello Susan
Hello, Susan!

Bourne shell 最多只能处理九个位置参数。如果一个脚本使用了$10,它将被解释为$1后跟一个零。为了能够运行旧的脚本,bash保持这种行为。要访问大于9的位置参数,数字必须用大括号括起来:${15}

脚本被传递给参数,这些参数可以通过它们的位置、$0、$1、$2 等等来访问。函数shift N将位置参数移动N个位置,如果运行shift(N的默认值为 1),那么$0将被丢弃,$1将变成$0 , $2将变成$1,以此类推:它们都将被移动 1 个位置。shift 有一些非常聪明和简单的用法来遍历未知长度的参数列表。

Image 移位功能是破坏性的:即被丢弃的参数不见了,不能再取回。

特殊 *@#0$?_!- 参数

前两个特殊参数$*$@扩展为所有位置参数的组合值。$#扩展到位置参数的个数。$0包含当前运行脚本的路径,如果没有脚本正在执行,则包含 shell 本身的路径。

$$包含当前进程的进程标识号(PID),$?被设置为最后执行的命令的退出代码,$_被设置为该命令的最后一个参数。$!包含后台执行的最后一个命令的 PID,$-设置为当前有效的选项标志。

我将在编写脚本的过程中更详细地讨论这些参数。

变量

变量是用名称表示的参数;名称是一个只包含字母、数字或下划线,并以字母或下划线开头的单词。

可以按以下形式将值赋给变量:

name=VALUE

Image 注意 Bash 对间距非常讲究:注意=前面没有空格,后面也没有。如果有空格,该命令将不起作用。

许多变量是由 shell 自己设置的,包括您已经看到的三个:HOMEPWDPATH。除了两个小的例外,auto_resumehistchars,shell 设置的所有变量都是大写字母。

参数和选项

在命令后输入的单词是它的参数。这些单词由空格分隔(一个或多个空格或制表符)。如果空格被转义或引用,它不再分隔单词,而是成为单词的一部分。

以下命令行都有四个参数:

echo 1 '2   3'   4 5
echo  -n  Now\ is  the  time
printf "%s %s\n" one two three

在第一行中,23之间的空格被引用了,因为它们被单引号包围了。在第二个例子中,now后面的空格用反斜杠进行转义,这是 shell 的转义字符。

在最后一行,空格用双引号引起来。

在第二个命令中,第一个参数是一个选项。传统上,Unix 命令的选项是前面带连字符的单个字母,有时后面跟一个参数。Linux 发行版中的 GNU 命令通常也接受长选项。这些单词前面有一个双连字符。例如,大多数 GNU 实用程序都有一个名为--version的选项来打印版本:

$ bash --version
GNU bash, version 4.3.11(1)-release (x86_64-unknown-linux-gnu)

Copyright (C) 2013 Free Software Foundation, Inc.

License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

回声,以及为什么你应该避免它

当我开始编写 shell 脚本时,我很快就了解了 Unix 的两个主要分支:美国电话电报公司的 System V 和 BSD 。他们的不同之处之一是echo的行为。所有现代 shells 中的一个内部命令,echo将其参数打印到标准输出流中,参数之间有一个空格,后跟一个换行符:

$ echo The quick brown fox
The quick brown fox

根据 shell 的不同,默认换行符可以通过以下两种方式取消:

$ echo -n No newline
No newline$ echo "No newline\c"
No newline$

echo的 BSD 变体接受了选项 -n,抑制了换行符。T 的版本使用了一个转义序列\c来做同样的事情。还是反过来了?我很难记住哪个是哪个,因为尽管我使用的是 AT & T 系统(硬件操作系统),它的echo命令接受 AT & T 和 BSD 语法。

当然,这是历史。在这本书里,我们要讨论的是bash,那么这有什么关系呢?bash-e选项来激活转义序列,比如\c,但是默认情况下使用-n来防止换行被打印。(除了\c之外,echo -e识别的转义序列与下一节描述的相同)。

Image 提示如果您想让转义序列被识别,请在 echo 命令中添加–e。

问题是,bash有一个xpg_echo选项(XPG 代表 X/Open Portability Guide,Unix 系统的一个规范),使得echo的行为像另一个版本。这可以在 shell 中打开或关闭(在命令行或脚本中使用shopt -s xpg_echo),也可以在编译 shell 时打开。换句话说,即使在bash中,你也不能绝对确定你会得到什么样的行为。

如果您将echo的使用限制在不存在冲突的情况下,也就是说,您确定参数不是以-n开始并且不包含转义序列,那么您将会相当安全。对于其他一切(或者如果你不确定),使用printf

printf :格式化和打印数据

shell 命令printf源自 C 编程语言的同名函数,目的相似,但在一些细节上有所不同。像 C 函数一样,它使用一个格式字符串来指示如何表示其余的参数:

printf FORMAT ARG ...

FORMAT字符串可以包含普通字符、转义序列和格式说明符。普通字符按标准输出原样打印。转义序列被转换成它们所代表的字符。格式说明符被替换为命令行中的参数。

转义序列

转义序列是以反斜杠开头的单个字母:

  • \a::警报(铃声)
  • \b:退格
  • \e:转义字符
  • \f:表格进给
  • \n:换行符
  • \r:回车
  • \t:水平标签
  • \v:垂直制表符
  • \\:反斜杠
  • \nnn:由一至三个八进制数字指定的字符
  • \xHH:由一个或两个十六进制数字指定的字符

必须用引号或另一个反斜杠来保护反斜杠不受 shell 的影响:

$  printf "Q\t\141\n\x42\n"
Q       a
B

格式规范

格式说明符是以百分号开头的字母。可选的修饰语可以放在两个字符之间。说明符被相应的参数替换。当参数多于说明符时,格式字符串将被重用,直到所有的参数都用完为止。最常用的说明符有%s``%d``%f%x

%s说明符打印参数中的文字字符:

$ printf "%s\n" Print arguments on "separate lines"
Print
arguments
on
separate lines

除了参数中的转义序列被转换之外,%b%s相似:

$ printf "%b\n" "Hello\nworld" "12\tword"
Hello
world
12      word

整数打印有%d 。该整数可以指定为十进制、八进制(使用前导 0)或十六进制(在十六进制数前面加上0x)数。如果该数字不是有效的整数,printf会打印一条错误消息:

$ printf "%d\n" 23 45 56.78 0xff 011
23
45
bash: printf: 56.78: invalid number
0
255
9

对于小数或浮点数,使用%f 。默认情况下,它们将以六位小数打印:

$ printf "%f\n" 12.34 23 56.789 1.2345678
12.340000
23.000000
56.789000
1.234568

浮点数可以使用%e 以指数(也称为科学符号)表示:

$ printf "%e\n" 12.34 23 56.789 123.45678
1.234000e+01
2.300000e+01
5.678900e+01
1.234568e+02

整数可以用十六进制打印,小写字母用%x表示,大写字母用%X表示。例如,当指定网页的颜色时,它们是用十六进制表示法指定的。我从 X Window 系统包含的rgb.txt文件中知道,皇家蓝的红绿蓝值分别是 65、105 和 225。要将它们转换为网页的样式规则,请使用:

$ printf "color: #%02x%02x%02x;\n" 65 105 225
color: #4169e1;

宽度规格

您可以通过在百分号后加上宽度规格来修改格式。参数将在该宽度的字段中右对齐打印,如果数字为负数,则左对齐打印。这里我们有宽度为 8 个字符的第一个字段;这些字将被打印在右边。然后是一个 15 个字符宽的字段,它将左对齐打印:

$ printf "%8s %-15s:\n" first second third fourth fifth sixth
   first second         :
   third fourth         :
   fifth sixth          :

如果宽度规格以 0 开头,则数字以前导零填充,以填充宽度:

$ printf "%04d\n" 12 23 56 123 255
0012
0023
0056
0123
0255

带小数的宽度说明符指定浮点数的精度或字符串的最大宽度:

$  printf "%12.4s %9.2f\n" John 2 Jackson 4.579 Walter 2.9
        John      2.00
        Jack      4.58
        Walt      2.90

中显示的脚本。清单 2-2 使用printf输出一个简单的销售报告。

清单 2-2 。报告

#!/bin/bash
#: Description : print formatted sales report

## Build a long string of equals signs
divider=====================================
divider=$divider$divider

## Format strings for printf
header="\n %-10s %11s %8s %10s\n"
format=" %-10s %11.2f %8d %10.2f\n"

## Width of divider
totalwidth=44

## Print categories
printf "$header" ITEM  "PER UNIT" NUM TOTAL

## Print divider to match width of report
printf "%$totalwidth.${totalwidth}s\n" "$divider"

## Print lines of report
printf "$format" \
    Chair 79.95 4 319.8 \
   Table  209.99 1 209.99 \
   Armchair 315.49 2 630.98

生成的报告如下所示:

 ITEM          PER UNIT      NUM      TOTAL
============================================
 Chair            79.95        4     319.80
 Table           209.99        1     209.99
 Armchair        315.49        2     630.98

注意在第二个totalwidth变量名:${totalwidth}周围使用了大括号。在第一个实例中,名称后面跟一个句点,句点不能是变量名的一部分。在第二个中,它后面跟有字母s,可能是,所以totalwidth名称必须用大括号与它分开。

打印到变量

在 3.1 版本中,bash增加了一个-v选项,将输出存储在一个变量中,而不是打印到标准输出:

$ printf -v num4 "%04d" 4
$ printf "%s\n" "$num4"
0004

行延续

report脚本的结尾,使用行继续符,最后四行作为一行读取。行尾的反斜杠告诉 shell 忽略换行符,有效地将下一行连接到当前行。

标准输入/输出流和重定向

在 Unix 中(Linux 是其中的一个变种),一切都是字节流。这些流可以作为文件访问,但是有三个流很少通过文件名访问。这些是附加到每个命令的输入/输出(I/O)流:标准输入、标准输出和标准错误。默认情况下,这些流连接到您的终端。

当一个命令读取一个字符或一行时,它从标准输入流中读取,标准输入流就是键盘。当它打印信息时,它被发送到标准输出,你的显示器。第三个流,标准误差,也连接到您的监视器;顾名思义,它用于错误消息。这些流用数字来指代,称为文件 描述符 (FDs )。它们分别是 0、1 和 2。流名也常常缩写为 stdinstdoutstderr

I/O 流可以重定向到(或来自)一个文件或一个管道。

重定向 : >、>、??、<

在第一章中,您使用>重定向操作符将标准输出重定向到一个文件。

使用>重定向时,如果文件不存在,则创建该文件。如果它确实存在,在向它发送任何内容之前,该文件会被截断为零长度。您可以通过将空字符串(即空字符串)重定向到文件来创建空文件:

printf "" > FILENAME

或者简单地用这个:

> FILENAME

重定向是在执行该行上的任何命令之前执行的。如果您重定向到您正在读取的同一个文件,该文件将被截断,并且该命令将没有可读取的内容。

>>操作符不会截断目标文件;它附加到它上面。通过执行以下操作,您可以在第一章的hw命令中添加一行:

echo exit 0 >> bin/hw

重定向标准输出不会重定向标准错误。错误信息仍会显示在您的显示器上。要将错误消息发送到一个文件,换句话说,要重定向 FD2,重定向操作符前面要有 FD。

标准输出和标准错误都可以重定向到同一行。下一个命令向FILE发送标准输出,向ERRORFILE发送标准误差:

$ printf '%s\n%v\n' OK? Oops! > FILE 2> ERRORFILE
$ cat ERRORFILE
bash4: printf: `v': invalid format character

在这种情况下,错误消息将被保存到一个特殊的文件/dev/null。有时称为比特桶 ,任何写入其中的东西都被丢弃。

printf '%s\n%v\n' OK? Oops! 2>/dev/null

可以使用>&N将输出重定向到另一个 I/O 流,而不是将输出发送到文件,其中N是文件描述符的编号。该命令将标准输出和标准误差发送到FILE:

printf '%s\n%v\n' OK? Oops! > FILE 2>&1

在这里,顺序很重要。标准输出被发送到FILE,然后标准错误被重定向到标准输出要去的地方。如果顺序反过来,效果就不一样了。重定向将标准错误发送到标准输出当前所在的位置,然后更改标准输出所在的位置。标准误差仍然指向标准输出最初指向的地方:

printf '%s\n%v\n' OK? Oops! 2>&1 > FILE

bash还有一个非标准语法,用于将标准输出和标准错误重定向到同一个位置:

&> FILE

要将标准输出和标准误差附加到FILE,使用以下命令:

&>> FILE

从标准输入读取的命令可以将其输入从文件重定向:

tr, H wY < bin/hw

您可以使用exec命令为脚本的其余部分重定向 I/O 流,或者直到它再次被更改。

exec 1>tempfile
exec 0<datafile
exec 2>errorrfile

所有标准输出现在都将转到文件tempfile ,输入将从datafile 中读取,错误信息将转到errorfile ,而无需为每个命令指定。

阅读输入

read命令是一个从标准输入读取的内置命令。默认情况下,它会一直读取,直到收到一个换行符。输入存储在一个或多个作为参数给出的变量中:

read var

如果给定了多个变量,则第一个单词(直到第一个空格或制表符的输入)被分配给第一个变量,第二个单词被分配给第二个变量,依此类推,剩余的单词被分配给最后一个变量:

$ read a b c d
January February March April May June July August
$ echo $a
January
$ echo $b
February
$ echo $c
March
$ echo $d
April May June July August

readbash版本有几个选项。POSIX 标准只认可-r选项。它告诉 shell 逐字解释转义序列。

默认情况下,read从输入中去掉反斜杠,后面的字符按字面理解。这种默认行为的主要效果是允许行的延续。使用-r选项,一个反斜杠后跟一个换行符被读为一个字面反斜杠和输入的结束。

我将在第十五章中讨论其他选项。

像任何其他读取标准输入的命令一样,read可以通过重定向从文件中获取输入。例如,要从FILENAME中读取第一行,请使用以下命令:

read var < FILENAME

管道

管道将一个命令的标准输出直接连接到另一个命令的标准输入。管道符号(|)用于命令之间:

$ printf "%s\n" "$RANDOM" "$RANDOM" "$RANDOM" "$RANDOM" | tee FILENAME
618
11267
5890
8930

tee命令读取标准输入,并将其传递给一个或多个文件以及标准输出。$RANDOM 是一个bash变量,每次被引用时返回 0 到 32,767 之间的不同整数。

$ cat FILENAME
618
11267
5890
8930

命令替换

使用命令替换可以将命令的输出存储在变量中。有两种形式可以做到这一点。第一个源自《谍影重重》,使用了反斜杠:

date=`date`

较新的(也是推荐的)语法如下:

date=$( date )

命令替换通常应该保留给外部命令。当与内置命令一起使用时,它非常慢。这就是为什么printf中增加了-v选项。

摘要

以下是您在本章中学到的命令和概念。

命令

  • cat:将一个或多个文件的内容打印到标准输出
  • tee:将标准输入复制到标准输出以及一个或多个文件中
  • read:从标准输入中读取一行的内置 shell 命令
  • date:打印当前日期和时间

概念

  • 标准 I/O 流:这些是字节流,命令从这些字节流中读取,输出发送到这些字节流。
  • 自变量:这些是跟随命令的字;参数可能包括选项以及其他信息,如文件名。
  • 参数:这些是存储值的实体;这三种类型是位置参数、特殊参数和变量。
  • 管道:管道是由|分隔的一个或多个命令的序列;管道符号之前的命令的标准输出被提供给其后的命令的标准输入。
  • 行继续符:这是一行末尾的反斜杠,用于移除新行并将该行与下一行合并。
  • 命令替换:这意味着将命令的输出存储在变量中或命令行上。

练习

  1. 这个命令有什么问题?

    tr A Z < $HOME/temp > $HOME/temp
    
  2. 使用$RANDOM编写一个脚本,将以下输出写入文件和变量。以下数字仅用于显示格式;你的脚本应该产生不同的数字:

     1988.2365
    13798.14178
    10081.134
     3816.15098
    

三、循环和分支

任何编程语言的核心都是迭代和条件执行。迭代是一段代码的重复,直到条件发生变化。条件执行是基于一个条件在两个或多个动作(其中一个可能是什么都不做)之间做出选择。

在 shell 中,有三种类型的循环(whileuntilfor)和三种类型的条件执行(ifcase,以及条件运算符&&和||,分别表示ANDOR)。除了forcase之外,命令的退出状态控制行为。

退出状态

您可以直接使用 shell 关键字whileuntilif或者使用控制操作符&&||来测试命令是否成功。退出代码存储在特殊参数$?中。

如果命令执行成功(或真),则$?的值为零。如果命令由于某种原因失败,$?将包含一个介于 1 和 255 之间的正整数,包括 1 和 255。失败的命令通常返回 1。零和非零退出代码也分别称为

命令可能会因为语法错误而失败:

$ printf "%v\n"
bash: printf: `v': invalid format character
$ echo $?
1

或者,失败可能是命令无法完成其任务的结果:

$ mkdir /qwerty
bash: mkdir: cannot create directory `/qwerty': Permission denied
$ echo $?
1

测试表达式

表达式由test命令或两个非标准 shell 保留字之一[[((判断为真或假。test命令比较字符串、整数和各种文件属性;((测试算术表达式,而[[ ... ]]做的和test一样,增加了比较正则表达式的特性。

测试,又名[ … ]

命令评估多种表达式,从文件属性到整数到字符串。它是一个内置命令,因此它的参数会像其他命令一样进行扩展。(详见第五章。)另一个版本(``)在末尾需要一个右括号。

![Image 注意正如前面在第二章中提到的,bash 对间距很讲究,要求括号周围有空格。这也很重要,因为没有空格的命令[ test[test与预期的不同。

文件测试

几个操作符测试一个文件的状态。可以用-e(或者非标准的-a)来测试文件的存在性。文件类型可用-f检查常规文件,用-d检查目录,用-h-L检查符号链接。其他运算符测试特殊类型的文件以及设置了哪些权限位。

以下是一些例子:

test -f /etc/fstab    ## true if a regular file
test -h /etc/rc.local ## true if a symbolic link
[ -x "$HOME/bin/hw" ]   ## true if you can execute the file
[[ -s $HOME/bin/hw ]]  ## true if the file exists and is not empty

整数测试

整数之间的比较使用-eq-ne-gt-lt-ge-le运算符。

-eq测试整数的相等性:

$ test 1 -eq 1
$ echo $?
0
$ [ 2 -eq 1 ]
$ echo $?
1

不等式测试用-ne:

$ [ 2 -ne 1 ]
$ echo $?
0

其余的运算符测试大于、小于、大于或等于以及小于或等于。

字符串测试

字符串是零个或多个字符的串联,可以包含除NUL (ASCII 0)之外的任何字符。可以测试它们是否相等,是否为非空字符串或空字符串,以及在bash中是否按字母顺序排序。=操作符测试相等性,换句话说,它们是否相同;!=不平等测试。bash也接受==的等式,但是没有理由使用这个非标准操作符。

以下是一些例子:

test "$a" = "$b"
[ "$q" != "$b" ]

如果-z-n运算符的参数为空或非空,则它们会成功返回:

$ [ -z "" ]
$ echo $?
0
$ test -n ""
$ echo $?
1

大于和小于符号在bash中用于比较字符串的词汇位置,必须进行转义以防止它们被解释为重定向操作符:

$ str1=abc
$ str2=def
$ test "$str1" \< "$str2"
$ echo $?
0
$ test "$str1" \> "$str2"
$ echo $?
1

前面的测试可以用-a(逻辑AND)和-o(逻辑OR)操作符组合成一个对test的调用:

test -f /path/to/file -a $test -eq 1
test -x bin/file -o $test -gt 1

test通常与if或条件运算符&&||结合使用。

[[ … ]]:计算表达式

test一样,[[ ... ]]评估一个表达式。与test不同,它不是一个内置命令。它是 shell 语法的一部分,不像内置命令那样接受解析。参数被扩展,但是在[[]]之间的单词不进行分词和文件名扩展。

它支持所有与test相同的操作符,并有一些增强和补充。然而,它是非标准的,所以当test可以执行相同的功能时,最好不要使用它。

测试增强

=!=右侧的参数未被引用时,它被视为一个模式,并复制case命令的功能。

在 shell 中没有复制的[[ ... ]]的特性是使用=~操作符匹配扩展正则表达式的能力:

$ string=whatever
$ [[ $string =~ h[aeiou] ]]
$ echo $?
0
$ [[ $string =~ h[sdfghjkl] ]]
$ echo $?
1

正则表达式在第八章中有解释。

((…)):计算算术表达式

一个非标准特性,如果算术表达式的值为零,则(( arithmetic expression ))返回false,否则返回true。可移植的等价物使用test和 POSIX 语法进行 shell 运算:

test $(( a - 2 )) -ne 0
[ $a != 0 ]

但是因为(( expression ))是 shell 语法而不是内置命令,表达式的解析方式与命令的参数不同。这意味着,例如,大于符号(>)的或小于符号(<)的不会被解释为重定向运算符:

if (( total > max )); then : ...; fi

测试裸变量是否为零,如果变量不为零,则成功退出:

((verbose)) && command ## execute command if verbose != 0

非数字值相当于 0:

$ y=yes
$ ((y)) && echo $y || echo n
$ nLists

一个列表是一个或多个命令的序列,由分号、“与”符号、控制操作符或换行符分隔。列表可以用作whileuntil循环中的条件、 if 语句或任何循环的主体。列表的退出代码是列表中最后一个命令的退出代码。

条件执行

条件构造使脚本能够决定是执行一个代码块,还是选择执行两个或多个代码块中的哪一个。

如果

基本的 if 命令评估一个或多个命令的列表,如果<condition list>的执行成功,则执行列表:

if <condition list>
then
   <list>
fi

通常,<condition list>是一个单独的命令,通常是test或者它的同义词,,或者,在bash中,[[。在清单 3-1 的[中,test-z操作数检查是否输入了一个名字。

清单 3-1 。读取并检查输入

read name
if [[ -z $name ]]
then
   echo "No name entered" >&2
   exit 1  ## Set a failed return code
fi

使用else 关键字,如果<condition list>失败,可以执行一组不同的命令,如清单 3-2 所示。请注意,在数值表达式中,变量不需要前导$。

清单 3-2 。提示输入一个数字,并检查它是否不大于 10

printf "Enter a number not greater than 10: "
read number
if (( number > 10 ))
then
    printf "%d is too big\n" "$number" >&2
    exit 1
else
    printf "You entered %d\n" "$number"
fi

可以给出不止一个条件,使用elif关键字,这样如果第一个测试失败,就尝试第二个,如清单 3-3 所示。

清单 3-3 。提示输入一个数字,并检查它是否在给定的范围内

printf "Enter a number between 10 and 20 inclusive: "
read number
if (( number < 10 ))
then
    printf "%d is too low\n" "$number" >&2
    exit 1
elif (( number > 20 ))
then
    printf "%d is too high\n" "$number" >&2
    exit 1
else
    printf "You entered %d\n" "$number"
fi

Image 注意在实际使用中,在比较数值之前,会检查前面例子中输入的数字是否有无效字符。“案例”一节给出了实现这一点的代码。

通常使用&&||<condition list>中给出一个以上的测试。

条件运算符、&&和||

包含 ANDOR条件操作符的列表从左到右计算。如果前一条命令成功,则执行AND操作符 ( &&)之后的命令。如果前面的命令失败,则执行OR操作符 ( ||)之后的部分。

例如,要检查一个目录并cd进入它,如果它存在,使用这个:

test -d "$directory" && cd "$directory"

如果cd失败,要更改目录并出错退出,请使用以下命令:

cd "$HOME/bin" || exit 1

下一个命令试图创建一个目录并cd到它。如果mkdircd失败,它将出错退出:

mkdir "$HOME/bin" && cd "$HOME/bin" || exit 1

条件运算符通常与if一起使用。在本例中,如果两个测试都成功,则执行echo命令:


if [ -d "$dir" ] && cd "$dir"
then
    echo "$PWD"
fi

情况

一个case语句将一个单词(通常是一个变量)与一个或多个模式进行比较,并执行与该模式相关的命令。这些模式是使用通配符(*?)以及字符列表和范围([...])的路径名扩展模式。语法如下:

case WORD in
  PATTERN) COMMANDS ;;
  PATTERN) COMMANDS ;; ## optional
esac

case的一个常见用途是确定一个字符串是否包含在另一个字符串中。它比使用grep要快得多,后者创建了一个新的进程。这个简短的脚本通常会被实现为一个 shell 函数(参见第六章),这样它将在不创建新进程的情况下被执行,如清单 3-4 所示。

清单 3-4 。一个字符串包含另一个字符串吗?

case $1 in
    *"$2"*) true ;;
    *) false ;;
esac

命令truefalse只能分别成功或失败。

另一个常见任务是检查字符串是否是有效数字。同样,清单 3-5 通常会被实现为一个函数。

清单 3-5 。这是有效的正整数吗?

case $1 in
    *[!0-9]*) false;;
    *) true ;;
esac

许多脚本在命令行上需要一个或多个参数。为了检查是否有正确的数字,常用case:

case $# in
    3) ;; ## We need 3 args, so do nothing
    *) printf "%s\n" "Please provide three names" >&2
       exit 1
       ;;
esac

当一个命令或一系列命令需要重复时,它被放入一个循环中。shell 提供了三种类型的循环:whileuntilfor。前两个执行,直到条件为真或假;第三个循环遍历一个值列表。

正在…

while循环 的条件是一个或多个命令的列表,条件为真时要执行的命令放在关键字dodone之间:

while <list>
do
  <list>
done

通过在每次执行循环时增加一个变量,命令可以运行特定的次数:

n=1
while [ $n -le 10 ]
do
  echo "$n"
  n=$(( $n + 1 ))
done

true命令可用于创建一个无限循环:

while true ## ':' can be used in place of true
do
  read x
done

一个while循环可用于从文件中逐行读取:

while IFS= read -r line
do
  : do something with "$line"
done < FILENAME?

直到

until很少使用,循环只要条件失败。与while相反:

n=1
until [ $n -gt 10 ]
do
  echo "$n"
  n=$(( $n + 1 ))
done

for循环 的顶端,一个变量被赋予一个来自单词列表的值。在每次迭代中,分配列表中的下一个单词:

for var in Canada USA Mexico
do
  printf "%s\n" "$var"
done

bash也有一种非标准形式,类似于 C 编程语言中的形式。第一个表达式在 for 循环开始时计算,第二个是测试条件,第三个在每次迭代结束时计算:

for (( n=1; n<=10; ++n ))
do
  echo "$n"
done

破裂

使用break命令可以在任何点退出循环:

while :
do
  read x
  [ -z "$x" ] && break
done

使用数值参数,break可以退出多个嵌套循环:

for n in a b c d e
do
  while true
  do
    if [ $RANDOM -gt 20000 ]
    then
      printf .
      break 2 ## break out of both while and for loops
    elif [ $RANDOM -lt 10000 ]
    then
      printf '"'
      break ## break out of the while loop
    fi
  done
done
echo

继续

在循环内部, continue命令通过传递任何剩余命令,立即开始循环的新迭代:

for n in {1..9} ## See Brace expansion in Chapter 4
do
  x=$RANDOM
  [ $x -le 20000 ] && continue
  echo "n=$n x=$x"
done

摘要

循环和分支是计算机程序的主要组成部分。在本章中,您学习了用于这些任务的命令和运算符。

命令

  • test:对表达式求值并返回成功或失败
  • if:如果命令列表成功,则执行一组命令,如果不成功,则可选地执行另一组命令
  • case:将单词与一个或多个模式进行匹配,并执行与第一个匹配模式相关的命令
  • while:当一列命令执行成功时,重复执行一组命令
  • until:重复执行一组命令,直到一列命令执行成功
  • for:对列表中的每个单词重复执行一组命令
  • break:退出循环
  • continue:立即开始下一次循环迭代

概念

  • 退出状态:命令的成功或失败,在特殊参数$?中存储为 0 或正整数
  • 列表:由;&&&||或换行符分隔的一个或多个命令的序列

练习

  1. 编写一个脚本,要求用户输入一个介于 20 和 30 之间的数字。如果用户输入了无效的数字或非数字,请再次询问。重复操作,直到输入满意的数字。
  2. 编写一个脚本,提示用户输入文件名。重复操作,直到用户输入一个存在的文件。

四、命令行解析和扩展

作为一种编程语言,shell 的优势之一是它对命令行参数的解析,以及它对命令行中的单词执行的各种扩展。当使用参数调用命令时,shell 在调用命令之前会做几件事。

为了帮助可视化所发生的事情,清单 4-1 中的简短脚本ba将显示 shell 在处理完所有参数后传递给它的内容。它的每个参数都打印在单独的一行上,前面是$pre的值,后面是$post的值。

清单 4-1ba;显示命令行参数

pre=:
post=:
printf "$pre%s$post\n" "$@"

注意:用清单 4-1 中的文本创建一个名为 sa 的脚本。这是在本章的代码示例中使用的。

特殊参数$@扩展为所有命令行参数的列表,但是结果会根据它是否被引用而不同。当被引用时,它扩展为位置参数"$1""$2""$3""$4"等等,包含空格的参数将被保留。如果$@未加引号,则只要有空白,就会发生拆分。

当一行被执行时,无论是在命令提示符下还是在脚本中,只要有未加引号的空格,shell 就会将该行拆分成单词。然后bash检查生成的单词,根据需要对它们进行多达八种扩展。扩展的结果作为参数传递给命令。本章考察了整个过程,从基于未加引号的空格的单词的初始解析,到按执行顺序的每个扩展:

  1. 支撑膨胀
  2. 波状符号展开
  3. 参数和变量扩展
  4. 算术扩展
  5. 命令替换
  6. 单词拆分
  7. 路径名扩展
  8. 过程替代

本章以一个 shell 程序结束,该程序演示了如何在命令行上使用内置命令getopts解析选项(以连字符开头的参数)。

引用

shell 对命令行的初始解析使用不带引号的空格,即空格、制表符和换行符来分隔单词。单引号或双引号之间的空格或转义字符(\)前面的空格被视为周围单词的一部分(如果有的话)。分隔引号从参数中去除。

下面的代码有五个参数。第一个是单词this,前面有一个空格(反斜杠去掉了它的特殊含义)。第二个参数是'is a';整个参数用双引号括起来,再次删除了空格的特殊含义。短语demonstration of用单引号括起来。接下来是一个单一的,逃逸的空间。最后,字符串quotes and escapes通过转义空格连接在一起。

$ sa \ this "is a" 'demonstration of' \  quotes\ and\ escapes
: this:
:is a:
:demonstration of:
: :
:quotes and escapes:

引号可以嵌入单词中。在双引号里面,单引号并不特殊,但是双引号必须转义。在单引号内,双引号并不特殊。

$ sa "a double-quoted single quote, '" "a double-quoted double quote, \""
:a double-quoted single quote, ':
:a double-quoted double quote, ":
$ sa 'a single-quoted double quotation mark, "'
:a single-quoted double quotation mark, ":

单引号内的所有字符都按字面理解。单引号单词即使经过转义也不能包含单引号;引号将被视为关闭前一个,另一个单引号打开一个新的引用部分。中间没有任何空格的连续引用单词被视为单个参数:

$ sa "First argument "'still the first argument'
:First argument still the first argument:

bash中,如果被转义,单引号可以包含在$'string'形式的单词中。此外,第二章对printf的描述中列出的转义序列被替换为它们所代表的字符:


$ echo $'\'line1\'\n\'line2\''
'line1'
'line2'

引用的参数可以包含文字换行符:

$ sa "Argument containing 
`> a newline"`
`:Argument containing`
`a newline:`

![Image](https://gitee.com/OpenDocCN/vkdoc-linux-zh/raw/master/docs/pro-bash-prog/img/image00265.jpeg) **注意**![Image](https://gitee.com/OpenDocCN/vkdoc-linux-zh/raw/master/docs/pro-bash-prog/img/image00267.jpeg)是回车键,不是要在终端上键入的东西。因为 shell 确定命令不完整,所以它会显示一个>`提示,让您完成命令。

支撑膨胀

执行的第一个扩展(大括号扩展)是非标准的(也就是说,它不包含在 POSIX 规范中)。它对包含逗号分隔列表或序列的无引号大括号进行操作。每个元素都成为一个单独的参数。

$ sa {one,two,three}
:one:
:two:
:three:
$ sa {1..3} ## added in bash3.0
:1:
:2:
:3:
$ sa {a..c}
:a:
:b:
:c:

大括号表达式之前或之后的字符串将包含在每个扩展参数中:

$ sa pre{d,l}ate
:predate:
:prelate:

大括号可以嵌套:

$ sa {{1..3},{a..c}}
:1:
:2:
:3:
:a:
:b:
:c:

同一个单词中的多个大括号被递归展开。扩展第一个大括号表达式,然后为下一个大括号表达式处理每个结果单词。使用单词{1..3}{a..c},扩展了第一个术语,给出以下内容:

1{a..c} 2{a..c} 3{a..c}

然后对这些单词中的每一个进行扩展,得到最终结果:

$ sa {1..3}{a..c}
:1a:
:1b:
:1c:
:2a:
:2b:
:2c:
:3a:
:3b:
:3c:

在第 4 版的bash中,更多的功能被添加到了大括号扩展中。数字序列可以用零填充,并且可以指定序列中的增量:

$ sa {01..13..3}
:01:
:04:
:07:
:10:
:13:

增量也可用于字母序列:

$ sa {a..h..3}
:a:
:d:
:g:

波状符号展开

不带引号的波浪号扩展到用户的主目录:

$ sa ~
:/home/chris:

后跟登录名,它会扩展到该用户的主目录:

$ sa ~root ~chris
:/root:
:/home/chris:

在命令行或变量赋值中引用时,波浪号不会展开:

$ sa "~" "~root"
:~:
:~root:
$ dir=~chris
$ dir2="~chris"
$ sa "$dir" "$dir2"
:/home/chris:
:~chris:

如果波浪号后面的名称不是有效的登录名,则不执行扩展:

$ sa ~qwerty
:~qwerty:

参数和变量扩展

参数扩展用变量的内容替换变量;它由一个美元符号($)引入。其后是要展开的符号或名称:

$ var=whatever
$ sa "$var"
:whatever:

参数可以用大括号括起来:

$ var=qwerty
$ sa "${var}"
:qwerty:

在大多数情况下,大括号是可选的。当引用大于九的位置参数时,或者当变量名后紧跟可能是名称一部分的字符时,它们是必需的:

$ first=Jane
$ last=Johnson
$ sa "$first_$last" "${first}_$last"
:Johnson:
:Jane_Johnson:

因为first_是一个有效的变量名,所以 shell 试图扩展它而不是first;添加大括号可以消除歧义。

大括号也用在扩展中,不仅仅是返回参数值。这些经常是神秘的扩展(例如,${var##*/}${var//x/y})给 shell 增加了大量的功能,在下一章中将详细讨论。

没有用双引号括起来的参数扩展要经过分词路径名扩展

算术扩展

当 shell 遇到$(( expression ))时,对expression求值,并将结果放在命令行上;expression是一个算术表达式。除了加、减、乘、除这四种基本的算术运算外,它使用最多的运算符是%(模,除法后的余数)。

$ sa "$(( 1 + 12 ))" "$(( 12 * 13 ))" "$(( 16 / 4 ))" "$(( 6 - 9 ))"
:13:
:156:
:4:
:-3:

算术运算符(参见表 4-1 和 4-2 )的优先级与您在学校学到的相同(基本上,乘法和除法在加法和减法之前执行),它们可以用括号分组以改变求值顺序:

$ sa "$(( 3 + 4 * 5 ))" "$(( (3 + 4) * 5 ))"
:23:
:35:

表 4-1 。算术运算符

|

操作员

|

描述

|
| --- | --- |
| -  + | 一元减号和加号 |
| !  ~ | 逻辑与按位求反 |
| *  /  % | 乘法、除法、余数 |
| + - | 加法、减法 |
| <<  >> | 向左和向右按位移位 |
| <=  >=  < > | 比较 |
| == != | 平等和不平等 |
| & | 按位AND |
| ^ | 按位异或OR |
| &#124; | 按位OR |
| && | 逻辑AND |
| &#124;&#124; | 逻辑OR |
| =  *=  /=  %=  +=  -=  <<=  >>=  &=  ^=  &#124;= | 分配 |

表 4-2 。bash扩展

|

操作员

|

描述

|
| --- | --- |
| ** | 指数运算 |
| id++  id-- | 可变后增量和后减量 |
| ++id  –-id | 可变预增量和预减量 |
| expr ? expr1 : expr2 | 条件运算符 |
| expr1 , expr2 | 逗号 |

模运算符%返回除法运算后的余数:

$ sa "$(( 13 % 5 ))"
:3:

将秒(这是 Unix 系统存储时间的方式)转换成日、小时、分钟和秒涉及除法和模运算符,如清单 4-2 所示。

清单 4-2secs2dhms,将秒(在参数$1中)转换为日、小时、分钟和秒

secs_in_day=86400
secs_in_hour=3600
mins_in_hour=60
secs_in_min=60

days=$(( $1 / $secs_in_day ))
secs=$(( $1 % $secs_in_day ))
printf "%d:%02d:%02d:%02d\n" "$days" "$(($secs / $secs_in_hour))" \
        "$((($secs / $mins_in_hour) %$mins_in_hour))" "$(($secs % $secs_in_min))"

如果没有用双引号括起来,算术展开的结果以分词为准。

命令替换

命令替换用命令的输出替换命令。该命令必须放在反斜杠(command)之间,或者放在以美元符号($( command ))开头的括号之间。例如,为了计算名称中包含今天日期的文件的行数,该命令使用date命令的输出:

$ wc -l $( date +%Y-%m-%d ).log
61 2009-03-31.log

命令替换的旧格式使用反斜杠。该命令与上一个命令相同:

$ wc -l `date +%Y-%m-%d`.log
2 2009-04-01.log

这并不完全相同,因为我在午夜前不久运行了第一个命令,在午夜后不久运行了第二个命令。因此,wc处理了两个不同的文件。

如果命令替换没有加引号,则对结果进行分词路径名扩展

单词拆分

如果参数和算术展开以及命令替换的结果没有被加上引号,则它们会被拆分:


$ var="this is a multi-word value"
$ sa $var "$var"
:this:
:is:
:a:
:multi-word:
:value:
:this is a multi-word value:

分词基于Iinternalffields分隔符变量IFS的值。IFS的默认值包含空格、制表符和换行符(IFS=$' \t\n')。当IFS有默认值或未设置时,任何默认的IFS字符序列被读取为单个分隔符。

$ var='   spaced
   out   '
$ sa $var
:spaced:
:out:

如果IFS包含另一个字符(或多个字符)以及空白,那么任何空白字符加上该字符的序列将界定一个字段,但是每个非空白字符的实例界定一个字段:

S IFS=' :'
$ var="qwerty  : uiop :  :: er " ## :  :: delimits 2 empty fields
$ sa $var
:qwerty:
:uiop:
::
::
:er:

如果IFS只包含非空白字符,那么IFS中每个字符的每一次出现都限定了一个字段,空白被保留:

$ IFS=:
$ var="qwerty  : uiop :  :: er "
$ sa $var
:qwerty  :
: uiop :
:  :
::
: er :

路径名扩展

命令行中包含字符*?[的未加引号的单词被视为文件分块模式,并被替换为与该模式匹配的文件的字母列表。如果没有文件匹配该模式,则该单词保持不变。

星号匹配任何字符串。h*匹配当前目录中所有以h开头的文件,*k匹配所有以k结尾的文件。shell 用按字母顺序排列的匹配文件列表替换通配符模式。如果没有匹配的文件,通配符模式保持不变。

$ cd "$HOME/bin"
$ sa h*
:hello:
:hw:
$ sa *k
:incheck:
:numcheck:
:rangecheck:

问号匹配任何单个字符;以下模式匹配第二个字母是a的所有文件:

$ sa ?a*
:rangecheck:
:ba:
:valint:
:valnum:

方括号匹配任何一个括起来的字符,可以是一个列表,一个范围,或者一类字符:[aceg]匹配ace或者g中的任何一个;[h-o]匹配从ho的任意字符;而[[:lower:]]匹配所有小写字母。

您可以使用set -f命令禁用文件名扩展。bash有许多影响文件扩展名的选项。我会在第八章的中详细介绍它们。

过程替代

进程替换为命令或命令列表创建一个临时文件名。您可以在任何需要文件名的地方使用它。表单<(command)使得command的输出可以作为文件名使用;>(command)是一个可以写入的文件名。

$ sa <(ls -l) >(pr -Tn)
:/dev/fd/63:
:/dev/fd/62:

Image 注意pr命令通过插入页眉来转换文本文件进行打印。可以用-T选项关闭标题,用-n选项给行编号。

当命令行上的文件名被读取时,它产生命令的输出。进程替换可以用来代替管道,允许循环中定义的变量对脚本的其余部分可见。在这个代码片段中,totalsize对循环外的脚本不可用:

$ ls -l |
> while read perms links owner group size month day time file
> do
>   printf "%10d %s\n" "$size" "$file"
>   totalsize=$(( ${totalsize:=0} + ${size:-0} ))
> done
$  echo ${totalsize-unset} ## print "unset" if variable is not set
unset

通过使用进程替换,变量totalsize在循环之外变得可用:

$ while read perms links owner group size month day time file
> do
>   printf "%10d %s\n" "$size" "$file"
>   totalsize=$(( ${totalsize:=0} + ${size:-0} ))
> done < <(ls -l *)
$ echo ${totalsize-unset}
12879

解析选项

shell 脚本的选项,前面有连字符的单个字符,可以用内置命令getopts解析。某些选项可能有参数,并且选项必须在非选项参数之前。

多个选项可以用一个连字符连接,但是任何带参数的选项都必须是字符串中的最后一个选项。它的参数如下,中间有或没有空格。

在下面的命令行中,有两个选项,-a-f。后者接受一个文件名参数。John是第一个非选项参数,-x不是选项,因为它在非选项参数之后。

myscript -a -f filename John -x Jane

getopts的语法如下:

getopts OPTSTRING var

OPTSTRING包含所有选项的字符;那些带参数的函数后跟一个冒号。对于清单 4-3 中的脚本,字符串是f:v。每个选项都放在变量$var中,选项的参数(如果有的话)放在$OPTARG中。

通常用作while循环的条件,getopts成功返回,直到它已经解析了命令行上的所有选项,或者直到它遇到单词--。命令行上所有剩余的单词都是传递给脚本主要部分的参数。

一个经常使用的选项是-v打开详细模式,它显示的不仅仅是关于脚本运行的默认信息。其他选项—例如-f—需要文件名参数。

这个示例脚本处理-v-f选项,并且在详细模式下显示一些信息。

清单 4-3parseopts,解析命令行选项

progname=${0##*/} ## Get the name of the script without its path

## Default values
verbose=0
filename=

## List of options the program will accept;
## those options that take arguments are followed by a colon
optstring=f:v

## The loop calls getopts until there are no more options on the command line
## Each option is stored in $opt, any option arguments are stored in OPTARG
while getopts $optstring opt
do
  case $opt in
    f) filename=$OPTARG ;; ## $OPTARG contains the argument to the option
    v) verbose=$(( $verbose + 1 )) ;;
    *) exit 1 ;;
  esac
done

## Remove options from the command line
## $OPTIND points to the next, unparsed argument
shift "$(( $OPTIND - 1 ))"

## Check whether a filename was entered
if [ -n "$filename" ]
then
   if [ $verbose -gt 0 ]
   then
      printf "Filename is %s\n" "$filename"
   fi
else
   if [ $verbose -gt 0 ]
   then
     printf "No filename entered\n" >&2
   fi
   exit 1
fi

## Check whether file exists
if [ -f "$filename" ]
then
  if [ $verbose -gt 0 ]
  then
    printf "Filename %s found\n" "$filename"
  fi
else
  if [ $verbose -gt 0 ]
  then
    printf "File, %s, does not exist\n" "$filename" >&2
  fi
  exit 2
fi

## If the verbose option is selected,
## print the number of arguments remaining on the command line
if [ $verbose -gt 0 ]
then
  printf "Number of arguments is %d\n" "$#"
fi

不带任何参数运行脚本除了生成一个失败的返回代码之外没有任何作用:

$ parseopts
$ echo $?
1

使用 verbose 选项,它还会打印一条错误消息:

$ parseopts -v
No filename entered
$ echo $?
1

对于非法选项(即不在$optstring中的选项),shell 会打印一条错误消息:

$ parseopts -x
/home/chris/bin/parseopts: illegal option – x

如果输入了一个文件名,但该文件不存在,它会产生以下结果:

$ parseopts -vf qwerty; echo $?
Filename is qwerty
File, qwerty, does not exist
2

为了允许非选项参数以连字符开始,选项可以以--明确结束:

$ parseopts -vf ~/.bashrc -– -x
Filename is /home/chris/.bashrc
Filename /home/chris/.bashrc found
Number of arguments is 1

摘要

shell 在将命令行传递给命令之前对其进行预处理,这为程序员节省了大量工作。

命令

  • head:从文件中提取前N行;N默认为 10
  • cut:从文件中提取列

练习

  1. 这个命令行上有多少个参数?

    sa $# $(date "+%Y %m %d") John\ Doe
    
  2. 以下代码片段存在什么潜在问题?

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

五、参数和变量

自从 30 多年前 Unix shell 诞生以来,变量就一直是它的一部分,但是这些年来,它们的功能不断发展。标准的 Unix shell 现在有了参数扩展,可以对其内容执行复杂的操作。bash增加了更多的扩展能力以及索引和关联数组。

本章涵盖了您可以对变量和参数做什么,包括它们的范围。换句话说,在定义了一个变量之后,在哪里可以访问它的值呢?本章简要介绍了程序员可以使用的 shell 使用的 80 多个变量。它讨论了如何命名变量,以及如何用参数扩展来区分它们。

位置参数是传递给脚本的参数。它们可以用shift命令操作,按数字单独使用或循环使用。

数组为一个名称分配多个值。bash既有数字索引数组,也有从bash-4.0开始的关联数组,它们由字符串而不是数字来赋值和引用。

变量的命名

变量名只能包含字母、数字和下划线,并且必须以字母或下划线开头。除了这些限制,你可以自由地建立你认为合适的名字。然而,使用一致的方案来命名变量是一个好主意,选择有意义的名称对使您的代码自文档化大有帮助。

也许最常引用的(尽管很少实现)惯例是环境变量应该用大写字母,而局部变量应该用小写字母。鉴于bash本身在内部使用了超过 80 个大写变量,这是一种危险的做法,冲突并不少见。我见过诸如PATHHOMELINESSECONDSUID这样的变量被误用,带来潜在的灾难性后果。没有一个bash的变量以下划线开头,所以在我的第一本书 Shell 脚本编写方法:一个问题解决方法 (Apress,2005)中,我使用大写名称加下划线来表示 Shell 函数设置的值。

单字母的名字应该很少使用。它们适合作为循环中的索引,其唯一的功能是作为计数器。传统上用于这个目的的字母是i,但我更喜欢n。(在教室里教编程的时候,黑板上的字母I太容易和数字 1 混淆,所以我开始用n代表“数字”,25 年后我还在用它)。

我使用单字母变量名的另一个地方是从文件中读取一次性材料的时候。例如,如果我只需要文件中的一个或两个字段,我可以这样使用:

while IFS=: read login a b c name e
do
  printf "%-12s %s\n" "$login" "$name"
done < /etc/passwd

我推荐使用两种命名方案中的任何一种。第一个是 Heiner Steven 在http://www.shelldorado.com/``. He capitalizes the first letter of all variables and also the first letters of further words in the name: ConfigFileLastDirFastMath在他的 Shelldorado 网站上使用的。在某些情况下,他的用法更接近我的。

我用的都是小写字母:configfilelastdirfastmath。当组合在一起的单词含糊不清或难以阅读时,我用下划线将它们分开:line_widthbg_underlineday_of_week`。

无论您选择什么系统,重要的是名称给出了变量包含的内容的真实指示。但是不要忘乎所以,用这样的东西:

long_variable_name_which_may_tell_you_something_about_its_purpose=1

一个变量的范围:你能从这里看到它吗?

默认情况下,变量的定义只有定义它的 shell(以及该 shell 的子 shell)知道。调用当前脚本的脚本不会知道这个变量,被当前脚本调用的脚本也不会知道这个变量,除非它被导出到环境

环境是一个形式为name=value的字符串数组。每当执行外部命令(创建子进程)时,无论是编译后的二进制命令还是解释后的脚本,该数组都会在后台传递给它。在 shell 脚本中,这些字符串可以作为变量使用。

可以使用 shell 内置命令export将脚本中分配的变量导出到环境中:

var=whatever
export var

bash中,这可以缩写成这样:

export var=whatever

没有必要导出一个变量,除非你想让当前脚本调用的脚本(或其他程序)可以使用它...).导出变量不会使它在除子进程之外的任何地方可见。

清单 5-1 告诉你变量$x是否在环境中,如果有的话,它包含什么。

清单 5-1showvar,打印变量x的值

if [[ ${x+X} = X ]] ## If $x is set
then
  if [[ -n $x ]] ## if $x is not empty
  then
    printf "  \$x = %s\n" "$x"
  else
    printf "  \$x is set but empty\n"
  fi
else
  printf " %s is not set\n" "\$x"
fi

一旦变量被导出,它将一直保留在环境中,直到被取消设置:

$ unset x
$ showvar
  $x is not set
$ x=3
$ showvar
  $x is not set
$ export x
$ showvar
  $x = 3
$ x= ## in bash, reassignment doesn't remove a variable from the environment
$ showvar
  $x is set but empty

Image 注意 showvar不是一个 bash 命令,而是一个如清单 5-1 所示的脚本,它使用x的值。

子 shell 中设置的变量对于调用它的脚本是不可见的。子 Subshells 包括命令替换,如在$(command)command中;管道的所有元素,以及括号中的代码,如( command )

关于 shell 编程,可能最常被问到的问题是,“我的变量去哪里了?我知道我设置了它们,为什么它们是空的?”通常,这是由于将一个命令的输出通过管道传输到一个分配变量的循环中造成的:

printf "%s\n" ${RANDOM}{,,,,,} |
  while read num
  do
    (( num > ${biggest:=0} )) && biggest=$num
  done
printf "The largest number is: %d\n" "$biggest"

biggest被发现为空时,在所有的 shell 论坛中都可以听到关于在while循环中设置的变量在它们之外不可用的抱怨。但问题不在于循环;这是因为循环是管道的一部分,因此在 subshell 中执行。

在 bash-4.2 中,一个新选项lastpipe使管道中的最后一个进程能够在当前 shell 中执行。通过以下方式调用它:

shopt -s lastpipe

Shell 变量

shell 要么设置要么使用 80 多个变量。其中许多是由bash内部使用的,对 shell 程序员来说用处不大。有些用于调试,有些常用于 shell 程序。大约一半是由 shell 自己设置的,其余的是由操作系统、用户、终端或脚本设置的。

在 shell 设置的那些中,您已经看到了RANDOM,它返回 0 到 32,767 之间的随机整数,以及PWD,它包含当前工作目录的路径。您看到了在解析命令行选项时使用的OPTINDOPTARG(第四章)。有时,BASH_VERSION(或BASH_VERSINFO)用于确定正在运行的 shell 是否能够运行脚本。本书中的一些脚本至少需要bash-3.0,并且可能使用其中一个变量来确定当前的 shell 是否足够新以运行该脚本:

case $BASH_VERSION in
  [12].*) echo "You need at least bash3.0 to run this script" >&2; exit 2;;
esac

提示字符串变量PS1PS2,在命令行交互 shells 中使用;PS3select内置命令一起使用,在执行跟踪模式下PS4打印在每一行之前(详见第十章)。

Shell 变量

以下变量由 shell 设置:

Taba

shell 使用以下变量,可能会为其中一些变量设置默认值(例如,IFS):

Tabb

参见附录 A 了解所有 Shell 变量的描述。

参数扩展

现代 Unix shell 的大部分功能来自于它的参数扩展。在 Bourne shell 中,这些主要涉及测试参数是已设置还是为空,以及用默认值或替代值进行替换。合并到 POSIX 标准中的 KornShell additions 增加了字符串操作。KornShell 93 增加了更多扩展,这些扩展还没有被纳入标准,但是bash已经采用了。bash-4.0增加了两个新的资料片。

伯恩·谢尔

Bourne shell 和它的后继程序有一些扩展,可以用缺省值替换一个空的或未设置的变量,如果一个变量是空的或未设置的,就给它分配一个缺省值,如果一个变量是空的或未设置的,就停止执行并打印一条错误消息。

\({var:-default}和\){var-default}:使用默认值

最常用的扩展${var:-default}检查变量是否未设置或为空,如果是,则扩展为默认字符串:

$ var=
$ sa "${var:-default}"  ## The sa script was introduced in Chapter 4
:default:

如果省略冒号,则扩展只检查变量是否未设置:

$ var=
$ sa "${var-default}" ## var is set, so expands to nothing
::
$ unset var
$ sa "${var-default}" ## var is unset, so expands to "default"
:default:

如果选项未提供默认值或环境中未继承默认值,则此代码片段会将默认值分配给$filename:

defaultfile=$HOME/.bashrc
## parse options here
filename=${filename:-"$defaultfile"}

\({var:+alternate}、\){var+alternate}:使用替代值

如果参数不为空,或者如果设置了不带冒号的参数,则前一个扩展的补码将替换替代值。仅当$var被设置且不为空时,第一次扩展才会使用alternate:

$ var=
$ sa "${var:+alternate}" ## $var is set but empty
::
$ var=value
$ sa "${var:+alternate}" ## $var is not empty
:alernate:

如果没有冒号,如果设置了变量,则使用alternate,即使变量为空:

$ var=
$ sa "${var+alternate}" ## var is set
:altername:
$ unset var
$ sa "${var+alternate}" ## $var is not set
::
$ var=value
$ sa "${var:+alternate}" ## $var is set and not empty
:alternate:

向变量添加字符串时经常使用这种扩展。如果变量为空,您不想添加分隔符:

$ var=
$ for n in a b c d e f g
> do
>   var="$var $n"
> done
$ sa "$var"
: a b c d e f g:

为了防止前导空格,可以使用参数扩展:

$ var=
$ for n in a b c d e f g
> do
>   var="${var:+"$var "}$n"
> done
$ sa "$var"
:a b c d e f g:

这是对n的每个值执行以下操作的一种简化方法:

if [ -n "$var" ]
then
  var="$var $n"
else
  var=$n
fi

或者:

[ -n "$var" ] && var="$var $n" || var=$n

\({var:=default},\){var=default}:分配默认值

${var:=default}扩展的行为方式与${var:-default}相同,只是它也将默认值赋给变量:

$ unset n
$ while :
> do
>  echo :$n:
>  [ ${n:=0} -gt 3 ] && break ## set $n to 0 if unset or empty
>  n=$(( $n + 1 ))
> done
::
:1:
:2:
:3:
:4:

\({var:?消息},\){var?message}:如果为空或未设置,则显示错误消息

如果var为空或未设置,message将被打印到标准错误,脚本将以状态 1 退出。如果message为空,将打印parameter null or not set。清单 5-2 需要两个非空的命令行参数,并在它们缺失或为空时使用这个扩展来显示错误消息。

清单 5-2checkarg,如果参数未设置或为空,退出

## Check for unset arguments
: ${1?An argument is required} \
  ${2?Two arguments are required}

## Check for empty arguments
: ${1:?A non-empty argument is required} \
  ${2:?Two non-empty arguments are required}

echo "Thank you."

第一次扩展失败时会打印出message,脚本将在此时退出:

$ checkarg
/home/chris/bin/checkarg: line 10: 1: An argument is required
$ checkarg x
/home/chris/bin/checkarg: line 10: 2: Two arguments are required
$ checkarg '' ''
/home/chris/bin/checkarg: line 13: 1: A non-empty argument is required
$ checkarg x ''
/home/chris/bin/checkarg: line 13: 2: Two non-empty arguments are required
$ checkarg x x
Thank you.

POSIX Shell

除了来自 Bourne shell 的扩展,POSIX shell 还包括许多来自 KornShell 的扩展。这些包括返回长度和从变量内容的开头或结尾删除模式。

${#var}:变量内容的长度

此扩展返回变量扩展值的长度:

read passwd
if [ ${#passwd} -lt 8 ]
then
  printf "Password is too short: %d characters\n" "$#" >&2
  exit 1
fi

${var%PATTERN}:从末尾删除最短的匹配

变量被扩展,匹配PATTERN的最短字符串从扩展值的末尾删除。这里和其他参数扩展中的PATTERN 是文件名扩展(又名文件打包)模式。

给定字符串Toronto和模式o*,最短的匹配模式就是最终的o:

$ var=Toronto
$ var=${var%o*}
$ printf "%s\n" "$var"
Toront

因为被截断的字符串已经被分配给了var,所以现在与模式匹配的最短字符串是ont:

$ printf "%s\n" "${var%o*}"
Tor

这个扩展可以用来替换外部命令dirname,它去掉了路径的文件名部分,留下了到目录的路径(清单 5-3 )。如果字符串中没有斜杠,如果是当前目录中现有文件的名称,则打印当前目录;否则,打印一个点。

清单 5-3dname,打印文件路径的目录部分

case $1 in
  */*) printf "%s\n" "${1%/*}" ;;
  *) [ -e "$1" ] && printf "%s\n" "$PWD" || echo '.' ;;
esac

Image 注意我称这个脚本为dname而不是dirname,因为它不符合dirname命令的 POSIX 规范。在下一章中,有一个名为dirname的 shell 函数实现了 POSIX 命令。

$ dname /etc/passwd
/etc
$ dname bin
/home/chris

${var%%PATTERN}:从末尾删除最长的匹配项

变量被展开,从展开值的末尾开始匹配PATTERN的最长字符串被删除:

$ var=Toronto
$ sa "${var%%o*}"
:t:

${var#PATTERN}:从开头删除最短的匹配

变量被扩展,匹配PATTERN的最短字符串从扩展值的开始处删除:

$ var=Toronto
$ sa "${var#*o}"
:ronto:

${var##PATTERN}:从开头删除最长的匹配

变量被扩展,匹配PATTERN的最长字符串从扩展值的开头删除。这通常用于从$0参数中提取脚本的名称,该参数包含脚本的完整路径:

scriptname=${0##*/} ## /home/chris/bin/script => script

尝试

bash2中引入了 KornShell 93 的两个扩展:搜索和替换以及子串提取。

${var//PATTERN/STRING}:用字符串替换模式的所有实例

因为问号匹配任何单个字符,所以本示例隐藏了一个密码:

$ passwd=zxQ1.=+-a
$ printf "%s\n" "${passwd//?/*}"
*********

使用单斜线时,只替换第一个匹配的字符。

$ printf "%s\n" "${passwd/[[:punct:]]/*}"
zxQ1*=+-a

\({var:OFFSET:LENGTH}:返回\)var 的子字符串

返回从OFFSET开始的$var的子串。如果指定了LENGTH,则替换该数量的字符;否则,返回字符串的其余部分。第一个字符位于偏移量0:

$ var=Toronto
$ sa "${var:3:2}"
:on:
$ sa "${var:3}"
:onto:

负的OFFSET从字符串的末尾开始计数。如果使用文字减号(与变量中包含的减号相反),则必须在它前面加一个空格,以防止它被解释为default扩展:

$ sa "${var: -3}"
:nto:

${!var}:间接引用

如果一个变量包含另一个变量的名称,例如x=yesa=xbash可以使用间接引用:

$ x=yes
$ a=x
$ sa "${!a}"
:yes:

使用eval builtin 命令可以达到同样的效果,它扩展了它的参数,并将结果字符串作为命令执行:

$ eval "sa \$$a"
:yes:

关于eval的更详细解释,参见第九章。

Bash-4.0

在 4.0 版本中,bash引入了两个新的参数扩展,一个用于转换大写,一个用于转换小写。都有单字符和全局版本。

${var^PATTERN}:转换成大写

var的第一个字符如果匹配PATTERN就转换成大写;用一个双插入符号(^^,它转换所有匹配PATTERN的字符。如果省略PATTERN,则匹配所有字符:

$ var=toronto
$ sa "${var^}"
:Toronto:
$ sa "${var^[n-z]}"
:Toronto:
$ sa "${var^^[a-m]}" ## matches all characters from a to m inclusive
:toronto:
$ sa "${var^^[n-q]}"
:tOrONtO:
$ sa "${var^^}"
:TORONTO:

${var,PATTERN}:转换成小写

除了将大写字母转换为小写字母之外,此扩展的工作方式与上一个扩展相同:

$ var=TORONTO
$ sa "${var,,}"
:toronto:
$ sa "${var,,[N-Q]}"
:ToRonTo:There is also an undocumented expansion that inverts the case:
$ var=Toronto
$ sa "${var~}"
:toronto:
$ sa "${var~~}"
:tORONTO:

位置参数

位置参数可以通过数字($1 ... $9 ${10} ...)单独引用,也可以通过"$@""$*"一次性引用。正如已经提到的,大于9的参数必须用大括号括起来:${10}${11}

不带参数的shift命令删除第一个位置参数,并将剩余的参数向前移动,以便$2变成$1 , $3变成$2,依此类推。有了一个论点,它可以删除更多。要删除前三个参数,请提供一个包含要删除的参数数量的参数:

$ shift 3

要删除所有参数,使用特殊参数$#,它包含位置参数的数量:

$ shift "$#"

要删除最后两个位置参数以外的所有参数,请使用以下命令:

$ shift "$(( $# - 2 ))"

要依次使用每个参数,有两种常用方法。第一种方法是通过展开"$@"来遍历参数值:

for param in "$@"  ## or just:  for param
do
  : do something with $param
done

这是第二个:

while (( $# ))
do
  : do something with $1
  shift
done

数组

迄今为止使用的所有变量都是标量变量;也就是说,它们只包含一个值。相比之下,数组变量可以包含很多值。POSIX shell 不支持数组,但是bash(从版本 2 开始)支持。它的数组是一维的,由整数索引,从bash-4.0开始,也由字符串索引。

整数索引数组

数组变量的单个成员用一个形式为[N]的下标来赋值和访问。第一个元素的索引为0。在bash中,数组是稀疏的;它们不需要被分配连续的索引。一个数组可以有一个索引为0的元素,另一个索引为42的元素,中间没有元素。

显示数组

数组元素由名称和大括号中的下标引用。这个例子将使用 shell 变量BASH_VERSINFO。它是一个数组,包含正在运行的 shell 的版本信息。第一个元素是主要版本号,第二个是次要版本号:

$ printf "%s\n" "${BASH_VERSINFO[0]}"
4
$ printf "%s\n" "${BASH_VERSINFO[1]}"
3

一个数组的所有元素可以用一条语句打印出来。下标@*类似于它们与位置参数的使用:*如果被引用,则扩展为单个参数;如果未加引号,将对结果进行分词和文件名扩展。使用@作为下标并引用展开,每个元素展开为一个单独的自变量,不再对它们进行进一步的展开。

$ printf "%s\n" "${BASH_VERSINFO[*]}"
4 3 30 1 release i686-pc-linux-gnuoldld
$  printf "%s\n" "${BASH_VERSINFO[@]}"
4
3
30
1
release
i686-pc-linux-gnu

各种参数扩展对数组起作用;例如,要从数组中获取第二个和第三个元素,请使用以下命令:

$ printf "%s\n" "${BASH_VERSINFO[@]:1:2}" ## minor version number and patch level
3
30

当下标为*@时,长度扩展返回数组中元素的数量,如果给定了数字索引,则返回单个元素的长度:

$ printf "%s\n" "${#BASH_VERSINFO[*]}"
6
$ printf "%s\n" "${#BASH_VERSINFO[2]}" "${#BASH_VERSINFO[5]}"
2
17

分配数组元素

可以使用索引来分配元素;以下命令创建一个稀疏数组:

name[0]=Aaron
name[42]=Adams

当元素被连续赋值(或者打包)时,索引数组更有用,因为它使得对它们的操作更简单。可以直接对下一个未分配的元素进行分配:

$ unset a
$ a[${#a[@]}]="1 $RANDOM" ## ${#a[@]} is 0
$ a[${#a[@]}]="2 $RANDOM" ## ${#a[@]} is 1
$ a[${#a[@]}]="3 $RANDOM" ## ${#a[@]} is 2
$ a[${#a[@]}]="4 $RANDOM" ## ${#a[@]} is 3
$ printf "%s\n" "${a[@]}"
1 6007
2 3784
3 32330
4 25914

一条命令就可以填充整个数组:

$ province=( Quebec Ontario Manitoba )
$ printf "%s\n" "${province[@]}"
Quebec
Ontario
Manitoba

+=操作符可用于将值追加到索引数组的末尾。这使得下一个未赋值元素的赋值形式更加简洁:

$ province+=( Saskatchewan )
$ province+=( Alberta "British Columbia" "Nova Scotia" )
$ printf "%-25s %-25s %s\n" "${province[@]}"
Quebec                    Ontario                   Manitoba
Saskatchewan              Alberta                   British Columbia
Nova Scotia

关联数组

4.0 版的bash中引入的关联数组使用字符串作为下标,并且必须在使用前声明:

$ declare -A array
$ for subscript in a b c d e
> do
>   array[$subscript]="$subscript $RANDOM"
> done
$ printf ":%s:\n" "${array["c"]}" ## print one element
:c 1574:
$ printf ":%s:\n" "${array[@]}" ## print the entire array
:a 13856:
:b 6235:
:c 1574:
:d 14020:
:e 9165:

摘要

到目前为止,本章最大的主题是参数扩展,而到目前为止,参数扩展的最大部分专门讨论由 KornShell 引入并集成到标准 Unix shell 中的那些扩展。这些工具为 POSIX shell 提供了强大的功能。本章给出的例子相对简单;参数扩展的全部潜力将在本书后面开发严肃的程序时展示。

其次重要的是数组。尽管不是 POSIX 标准的一部分,但它们通过使以逻辑单元收集数据成为可能,为 shell 添加了大量功能。

理解变量的作用域可以省去很多麻烦,而命名良好的变量使程序更容易理解和维护。

操纵位置参数是 shell 编程的一个次要但重要的方面,本章给出的例子将在本书的后面部分重新讨论和扩展。

命令

  • declare:声明变量并设置其属性
  • eval:展开参数并执行结果命令
  • export:将变量放入环境中,以便它们可用于子进程
  • shift:删除位置参数并重新编号
  • shopt:设置 Shell 选项
  • unset:完全删除一个变量

概念

  • 环境:从调用程序继承并传递给子进程的变量集合
  • 数组变量:包含多个值的变量,使用下标访问
  • 标量变量:包含单个值的变量
  • 关联数组:下标为字符串而非整数的数组变量

练习

  1. 默认情况下,可以在哪里访问脚本中赋值的变量?选择所有适用的选项:
    • 在当前脚本中
    • 在当前脚本中定义的函数中
    • 在调用当前脚本的脚本中
    • 在当前脚本调用的脚本中
    • 在当前脚本的子 Shell 中
  2. 我建议不要使用单个字母的变量名,但是给出了几个合理的地方。你能想到它们的其他合法用途吗?
  3. 给定var=192.168.0.123,编写一个使用参数扩展提取第二个数168的脚本。`

六、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变量中:

if ! echo ${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=$(( $_INDEX / 4 + 1 ))
}

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

摘要

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

命令

  • tr:翻译字符

功能

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

练习

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

    if ! echo ${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"

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

for n in {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}$(( $RANDOM % 10 ))

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

$ 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"
for n in {1..3}
do
  setvar "var_$n" "$n - $RANDOM"
done
echo "Variables assigned; printing values:"
for n in {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 1 ## 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输出的三个默认时间。

十、编写并调试无错误的脚本

从未编写过错误程序 的程序员是某人想象中的虚构人物。bug 是程序员存在的祸根。它们从简单的打字错误到糟糕的编码再到错误的逻辑。有些很容易修复;其他人可能需要几个小时的狩猎。

一个极端是语法错误,它阻止脚本完成或运行。这些可能涉及到丢失的字符:空格、括号或大括号、引号。它可能是输入错误的命令或变量名。可能是遗漏的关键词,比如elif后面的then

另一个极端是逻辑上的错误。它可能在你应该从 0 开始的时候从 1 开始计数,或者它可能在应该是-ge(大于或等于)的时候使用-gt(大于)。可能是公式有误(华氏到摄氏(F – 32) * 1.8不是吗?)或者在一条数据记录中使用了错误的字段(我以为 shell 是/etc/passwd中的字段 5!).

在这两个极端之间,常见的错误包括试图对错误类型的数据进行操作(要么是程序本身提供了错误的数据,要么是外部源提供了错误的数据),以及在进行下一步之前未能检查命令是否成功。

本章着眼于让程序做它应该做的事情的各种技术,包括用于检查和跟踪脚本进度的各种 shell 选项,有策略地放置调试指令,以及最重要的,从一开始就防止错误。

防胜于治

避免引入 bug 比消除 bug 要好得多。没有办法保证没有 bug 的脚本,但是一些预防措施可以大大降低 bug 的频率。让你的代码易于阅读会有所帮助。记录它也是如此,这样你就知道它是做什么的,它期望什么,它产生什么结果,等等。

构建您的程序

术语结构化编程 适用于各种编程范例,但它们都涉及模块化编程——将问题分解成可管理的部分。在使用 shell 开发大型应用时,这意味着要么使用函数、单独的脚本,要么两者结合使用。

即使是一个短程序也能从某种结构中受益;它应该包含离散的部分:

  • 评论
  • 变量的初始化
  • 函数定义
  • 运行时配置(解析选项、读取配置文件等)
  • 健全性检查(所有值都合理吗?)
  • 过程信息(计算、切片和切割线、I/O 等)

使用这个大纲,一个简短但完整的脚本的所有组件将在下面的部分中呈现。提供的脚本中有错误;将使用各种调试技术找到并纠正这些问题。

评论

注释应包括关于脚本的元数据,包括描述、如何调用命令或函数的概要、作者、创建日期、最后修订日期、版本号、选项以及成功运行命令所需的任何其他信息,如下例所示:

#:       Title: wfe - List words ending with PATTERN
#:    Synopsis: wfe [-c|-h|-v] REGEX
#:        Date: 2009-04-13
#:     Version: 1.0
#:      Author: Chris F.A. Johnson
#:     Options: -c - Include compound words
#:              -h - Print usage information
#:              -v - Print version number

#:用于引入这些注释,以便grep '^#:' wfe提取所有元数据。

变量的初始化

首先,定义一些包含元数据的变量。与前面的注释有些重复,但是后面可能需要这些变量:

## Script metadata
scriptname=${0##*/}
description="List words ending with REGEX"
usage="$scriptname [-c|-h|-v] REGEX"
date_of_creation=2009-04-13
version=1.0
author="Chris F.A. Johnson"

然后定义默认值、文件位置和该脚本所需的其他信息:

## File locations
dict=$HOME
wordfile=$dict/singlewords
conpoundfile=$dict/Compounds

## Default is not to show compound words
compounds=

## Regular expression supplied on the command line
pattern=$1

功能定义

有三个函数是原作者脚本的一部分(除了快速和肮脏的一次性)。分别是dieusageversion;它们可能包含在脚本本身或脚本提供的函数库中。他们还没有被包括在这本书的剧本里;这将是不必要的重复。这些例子有:

## Function definitions
die() #@ DESCRIPTION: print error message and exit with supplied return code
{     #@ USAGE: die STATUS [MESSAGE]
  error=$1
  shift
  [ -n "$*" ] printf "%s\n" "$*" >&2
  exit "$error"
}

usage() #@ DESCRIPTION: print usage information
{       #@ USAGE: usage
        #@ REQUIRES: variable defined: $scriptname
  printf "%s - %s\n" "$scriptname" "$description"
  printf "USAGE: %s\n" "$usage"
}

version() #@ DESCRIPTION: print version information
{         #@ USAGE: version
          #@ REQUIRES: variables defined: $scriptname, $author and $version
  printf "%s version %s\n" "$scriptname" "$version"
  printf "by %s, %d\n" "$author"  "${date_of_creation%%-*"
}

任何其他函数将紧随这些通用函数之后。

运行时配置和选项

第十二章将深入介绍运行时配置以及可以使用的不同方法。大多数时候,您需要做的就是解析命令行选项:

## parse command-line options, -c, -h, and -v
while getopts chv var
do
  case $var in
    c) compounds=$compoundfile ;;
    h) usage; exit ;;
    v) version; exit ;;
  esac
done
shift $(( $OPTIND - 1 ))

过程信息

正如短脚本中经常出现的情况,脚本的实际工作相对较短;设置参数和检查数据的有效性占据了程序的大部分:

## Search $wordfile and $compounds if it is defined
{
  cat "$wordfile"
  if [ -n "$compounds" ]
  then
    cut -f1 "$compounds"
  fi
} | grep -i ".$regex$" |
 sort -fu ## Case-insensitive sort; remove duplicates

这里,cat是必要的,因为第二个文件的位置存储在compounds变量中,不能作为参数给grep,因为它不仅仅是一个单词列表。该文件有三个制表符分隔的字段:带有空格和其他非字母字符的短语被删除,下面的字母大写,原始短语,以及它们在神秘的纵横字谜中出现的长度:

corkScrew       cork-screw      (4-5)
groundCrew      ground crew     (6,4)
haveAScrewLoose have a screw loose      (4,1,5,5)

如果它是一个简单的单词列表,就像singlewords一样,管道可以被一个简单的命令代替:

grep -i ".$regex$" "$wordfile" ${compounds:+"$compounds"}

grep命令在命令行给出的文件中搜索匹配正则表达式的行。-i选项告诉grep将大写字母和小写字母视为等同。

记录您的代码

这本书的第一作者克里斯·约翰森提到,

直到最近,我自己的文档习惯还有很多不足之处。在我的脚本目录中,有超过 900 个程序是在过去 15 年左右编写的。有 90 多个函数库。大约有 20 个脚本被 cron 调用,还有十几个被这些脚本调用。我经常使用的脚本大概有 100 个左右,“经常”可以是从一天几次到一年一两次。

其余的是正在开发的脚本,被放弃的脚本,没有成功的脚本,以及我不再知道它们有什么用的脚本。我不知道它们有什么用,因为我没有包括任何文档,甚至没有一行描述。我不知道它们是否有用,也不知道我是否真的不需要那个剧本,或者关于它们的任何事情。

对他们中的许多人来说,我可以从他们的名字看出他们是做什么的。在其他情况下,代码很简单,目的也很明显。但是还有很多剧本的目的我不知道。当我再次需要这个任务时,我可能会重复其中的一些。当我这么做的时候,他们至少会有最少的文件。

许多开发人员都是如此,尤其是代码片段。有一些软件可以帮助你组织你的代码片段,但是没有什么比文档和添加注释、待办事项等更好的了。

一致地格式化您的代码

漂亮的打印代码有各种各样的模型,有些人非常大声地为他们的特殊风格辩护。我有自己的偏好(你会从本书的脚本中注意到这一点),但一致性比每层缩进两个、四个或六个空格更重要。有压痕比压痕的数量更重要。我会说,两个空格(这是我使用的)是最少的,八个是最少的,如果不是太多的话。

同样,你有没有thenif在一条线上也没关系。这两个都可以:

if [ "$var" = "yes" ]; then
  echo "Proceeding"
fi

if [ "$var" = "yes" ]
then
  echo "Proceeding"
fi

其他循环和函数定义也是如此。我更喜欢这种格式:

funcname()
{
  : body here
}

其他人喜欢这种格式:

funcname() {
  : body here
}

只要格式一致,结构清晰,使用哪种格式都没关系。

知识创新系统原则

简单性有助于理解程序的意图,但重要的不仅仅是让代码尽可能短。当有人在下面发布以下问题时,我的第一个想法是,“这将是一个复杂的正则表达式。”第二,我不会使用正则表达式:

  • 我需要一个正则表达式来用美国符号表示金融数量。它们有一个前导美元符号和一个可选的星号字符串、一个十进制数字字符串和一个由小数点(.)和两位十进制数字。小数点左边的字符串可以是一个零。否则,它不能以零开始。如果小数点左边有三位以上的数字,三个一组的数字必须用逗号隔开。例如:$ * * 2345.67。

我会将任务分解成几个独立的步骤,并分别对每个步骤进行编码。例如,第一项检查是:

amount='$**2,345.67'
case $amount in
  \$[*0-9]*) ;; ## OK (dollar sign followed by asterisks or digits), do nothing
  *) exit 1 ;;
esac

当测试完成时,将会有比正则表达式多得多的代码,但是如果需求改变,将会更容易理解和改变。

分组命令

与其重定向几行中的每一行,不如用大括号将它们分组,并使用单个重定向。最近在一个论坛上看到这个:

echo "user odad odd" > ftp.txt
echo "prompt" >> ftp.txt
echo "cd $i" >> ftp.txt
echo "ls -ltr" >> ftp.txt
echo "bye" >> ftp.txt

我建议您这样做:

{
  echo "user odad odd"
  echo "prompt"
  echo "cd $i"
  echo "ls -ltr"
  echo "bye"
} > ftp.txt

边走边测试

与其把所有的调试工作留到最后,不如把它作为开发程序过程中不可或缺的一部分。每个部分都应该在编写时进行测试。作为一个例子,让我们看看我作为国际象棋程序的一部分编写的一个函数。不,它不是一个下棋程序(尽管当它完成时可能是);在 Shell 中,这将是极其缓慢的。这是一套准备教学材料的功能。

它需要能够将一种形式的国际象棋符号转换为另一种形式,并列出棋盘上任何棋子的所有可能的移动。它需要能够判断一项变动是否合法,并在变动后创建一个新的董事会职位。在最基本的层面上,它必须能够将标准代数符号(SAN )中的正方形转换为它的数字等级和文件。这就是这个函数的作用。

命名方块的 SAN 格式是代表文件的小写字母和代表等级的数字。文件是从棋盘的白方到黑方的一排排方块。行列是从左到右的一排排正方形。白棋左角的方块是a1;那个穿黑色的是h8。为了计算可能的移动,这些需要转换为普通士兵:a1转换为rank=1file=1h8变成了rank=8file=8

这是一个简单的函数,但是它演示了如何测试一个函数。该函数接收一个正方形的名称作为参数,并将等级和文件存储在这些变量中。如果方块无效,它将 rank 和 file 都设置为0,并返回一个错误:

split_square() #@ DESCRIPTION: convert SAN square to numeric rank and file
{              #@ USAGE: split_square SAN-SQUARE
  local square=$1
  rank=${square#?}
  case $square in
    a[1-8]) file=1;; ## Conversion of file to number
    b[1-8]) file=2;; ## and checking that the rank is
    c[1-8]) file=3;; ## a valid number are done in a
    d[1-8]) file=4;; ## single look-up
    e[1-8]) file=5;;
    f[1-8]) file=6;; ## If the rank is not valid,
    g[1-8]) file=7;; ## it falls through to the default
    h[1-8]) file=8;;
    *) file=0
       rank=0
       return 1      ## Not a valid square
       ;;
  esac
  return 0
}

为了测试这个函数,传递给它所有可能的合法方块以及一些不合法的方块。它打印方块的名称、文件和等级编号:

test_split_square()
{
  local f r
  for f in {a..i}
  do
    for r in {1..9}
    do
      split_square "$f$r"
      printf "$f$r %d-%d  " "$file" "$rank"
    done
    echo
  done
}

运行测试时,输出如下:

a1 1-1  a2 1-2  a3 1-3  a4 1-4  a5 1-5  a6 1-6  a7 1-7  a8 1-8  a9 0-0
b1 2-1  b2 2-2  b3 2-3  b4 2-4  b5 2-5  b6 2-6  b7 2-7  b8 2-8  b9 0-0
c1 3-1  c2 3-2  c3 3-3  c4 3-4  c5 3-5  c6 3-6  c7 3-7  c8 3-8  c9 0-0
d1 4-1  d2 4-2  d3 4-3  d4 4-4  d5 4-5  d6 4-6  d7 4-7  d8 4-8  d9 0-0
e1 5-1  e2 5-2  e3 5-3  e4 5-4  e5 5-5  e6 5-6  e7 5-7  e8 5-8  e9 0-0
f1 6-1  f2 6-2  f3 6-3  f4 6-4  f5 6-5  f6 6-6  f7 6-7  f8 6-8  f9 0-0
g1 7-1  g2 7-2  g3 7-3  g4 7-4  g5 7-5  g6 7-6  g7 7-7  g8 7-8  g9 0-0
h1 8-1  h2 8-2  h3 8-3  h4 8-4  h5 8-5  h6 8-6  h7 8-7  h8 8-8  h9 0-0
i1 0-0  i2 0-0  i3 0-0  i4 0-0  i5 0-0  i6 0-0  i7 0-0  i8 0-0  i9 0-0

所有带有普通 0-0 的方格都是无效的。

调试脚本

在前面一节一节介绍的wfe脚本中,有一些错误。让我们运行这个脚本,看看会发生什么。剧本在$HOME/bin 里,?? 在你的PATH里,因此它可以单以它的名字来称呼。然而,在此之前,最好先用-n选项检查脚本。这将在不实际执行代码的情况下测试任何语法错误:

$ bash -n wfe
/home/jayant/bin/wfe-sh: wfe: line 70: unexpected EOF while looking for matching '"'
/home/jayant/bin/wfe-sh: wfe: line 72: syntax error: unexpected end of file

错误消息指出缺少引号(")。它已经到达文件的末尾,但没有找到它。这意味着它可能在文件的任何地方丢失。在快速(或不那么快速)浏览文件后,不清楚它应该在哪里。

当这种情况发生时,我开始从文件底部删除一些部分,直到错误消失。我去掉最后一节;它还在那里。我删除了解析选项,错误并没有消失。我去掉最后一个函数定义,version() ,错误就没了。错误一定在函数中;它在哪里?

version() #@ DESCRIPTION: print script's version information
{         #@ USAGE: version
          #@ REQUIRES: variables defined: $scriptname, $author and $version
  printf "%s version %s\n" "$scriptname" "$version"
  printf "by %s, %d\n" "$author"  "${date_of_creation%%-*"
}

没有不匹配的引号,所以一定是缺少了其他的结束字符导致了这个问题。快速浏览后,我发现最后一个变量展开缺少了一个右括号。固定了,就变成了"${date_of_creation%%-*}"。用-n再检查一次,它就获得了一份健康证明。现在是运行它的时候了:

$ wfe
bash: /home/jayant/bin/wfe: Permission denied

哎呀!我们忘记了让脚本可执行。这通常不会发生在主脚本中;对于被另一个脚本调用的脚本,这种情况更为常见。请更改权限,然后重试:

$ chmod +x /home/jayant/bin/wfe
$ wfe
cat: /home/jayant/singlewords: No such file or directory

singlewordsCompounds两个文件下载了吗?如果有,你把它们放在哪里了?在脚本中,它们被声明在$dict,定义为$HOME。如果你把它们放在别的地方,比如放在一个名为words的子目录中,修改脚本中的那一行。让我们制作一个目录,words,并把它们放在那里:

mkdir $HOME/words &&
cd $HOME/words &&
wget http://cfaj.freeshell.org/wordfinder/singlewords &&
wget http://cfaj.freeshell.org/wordfinder/Compounds

在脚本中,更改dict的赋值以反映这些文件的实际位置:

dict=$HOME/words

让我们再试一次:

$ wfe
a
aa
Aachen
aalii
aardvark
*.... 183,758 words skipped ....*
zymotic
zymotically
zymurgy
Zyrian
zythum

我们忘了告诉程序我们在找什么。脚本应该检查是否提供了参数,但是我们忘记了包含健全性检查部分。在搜索完成之前(在第shift $(( $OPTIND - 1 ))行之后)添加:

## Check that user entered a search term
if [ -z "$pattern" ]
then
  {
    echo "Search term missing"
    usage
  } >&2
  exit 1
fi

现在,再试一次:

$ wfe
Search term missing
wfe - List words ending with REGEX
USAGE: wfe [-c|-h|-v] REGEX

这样更好。现在让我们真正地寻找一些单词:

$ wfe drow
a
aa
Aachen
aalii
aardvark
*.... 183,758 words skipped ....*
zymotic
zymotically
zymurgy
Zyrian
zythum

还是有问题。

最有用的调试工具之一是set - x ,它在执行时打印每个命令及其扩展参数。每一行前面都有PS4变量的值。PS4的默认值为“+”;我们将把它改为包含正在执行的行号。将这两行放在脚本的最后一部分之前:

export PS4='+ $LINENO: ' ## single quotes prevent $LINENO being expanded immediately
set -x

再试一次:

$ wfe drow
++ 77: cat /home/jayant/singlewords
++ 82: grep -i '.$'
++ 83: sort -fu
++ 78: '[' -n '' ']' ## Ctrl-C pressed to stop entire word list being printed

在第 82 行,您看到命令行中输入的模式丢失了。那是怎么发生的?应该是grep -i '.drow$'。脚本中的第 82 行应该如下所示:

} | grep -i ".$regex$" |

regex的值怎么了?注释掉set -x ,在脚本顶部添加set -u选项 。该选项将未设置的变量在展开时视为错误。再次运行脚本,检查是否设置了regex:

$ wfe drow
/home/jayant/bin/wfe: line 84: regex: unbound variable

为什么regex未设置?看看前面的脚本,看看哪个变量用于保存命令行参数。哦!是pattern,不是regex。你必须保持一致,而regex是对其内容更好的描述,我们就用那个吧。将pattern的所有实例改为regex。你也应该在顶部的评论中这样做。现在试试看:

$ wfe drow
windrow

成功!现在用-c选项将复合词和短语添加到组合中:

$ wfe -c drow
/home/jayant/bin/wfe: line 58: compoundfile: unbound variable

又来了!当然,我们在文件位置部分分配了Compounds文件。看一看;是的,它在 23 线附近。等一下,有个错别字:conpoundfile=$dict/Compounds 。将con改为com。祈祷好运:

$ wfe -c drow
$

什么事?什么都没有?连windrow都没有?是时候set -x了,看看是怎么回事。取消注释该行,并再次播放它:

$ wfe -c drow
++ 79: cat /home/jayant/singlewords
++ 84: grep -i '.-c$'
++ 85: sort -fu
++ 80: '[' -n /home/jayant/Compounds ']'
++ 82: cut -f1 /home/jayant/Compounds

至少这很容易理解。我们在处理选项之前分配了regex,它截取了第一个参数,即-c选项。将任务移动到getopts部分之后,特别是shift命令之后。(你可能会想注释掉set -x。):

shift $(( $OPTIND - 1 ))
## Regular expression supplied on the command line
regex=$1

还有什么问题吗?

$ wfe -c drow
skidRow
windrow

看起来不错。对于一个小脚本来说,这可能看起来工作量很大,但是讲的时间似乎比做的时间长,尤其是一旦你习惯了这样做——或者更好的是,从一开始就把它做好。

摘要

错误是不可避免的,但是只要小心,大多数错误是可以避免的。当它们出现时,有 shell 选项可以帮助跟踪问题。

练习

  1. if [ $var=x ]怎么了?应该是什么?为什么它会给出这样的结果呢?
  2. 编写一个函数valid_square(),如果它的唯一参数是一个有效的 SAN 棋盘方格,则返回成功,否则返回失败。写一个函数来测试它是否工作。

十一、命令行编程

这本书是关于用 shell 编程,而不是在命令行使用它。您在这里找不到关于编辑命令行、创建命令提示符(PS1变量)或从您的交互历史中检索命令的信息。这一章是关于在命令行中比在其他脚本中更有用的脚本。

本章介绍的许多脚本都是 shell 函数。其中一些必须如此,因为它们改变了环境。其他的是函数,因为它们经常被使用,而且用起来更快。其他的既有函数也有独立的脚本。

操作目录堆栈

cd命令会记住之前的工作目录,cd -会返回。还有另一个命令,将改变目录,并记住无限数量的目录:pushd。目录存储在一个数组中,DIRSTACK。为了返回到前一个目录,popd将顶部的条目从DIRSTACK中取出,并使其成为当前目录。我使用了两个函数使处理DIRSTACK更容易,为了完整起见,我在这里添加了第三个函数。

Image 注意本章中创建的一些函数的名称类似于 Bash 中可用的命令。这样做的原因是使用您现有的 shell 脚本,而不需要对它们进行任何更改,并且仍然可以利用一些附加的功能。

激光唱片

cd函数替换同名内置命令。该函数使用内置命令pushd改变目录并将新目录存储在DIRSTACK上。如果没有给出目录,pushd使用$HOME。如果更改目录失败,cd会打印一条错误消息,并且函数返回一个失败的退出代码(清单 11-1 )。

清单 11-1cd,改变目录,在目录栈上保存位置

cd() #@ Change directory, storing new directory on DIRSTACK
{
  local dir error          ## variables for directory and return code

  while :                  ## ignore all options
  do
    case $1 in
      --) break ;;
      -*) shift ;;
      *) break ;;
    esac
  done

  dir=$1

  if [ -n "$dir" ]         ## if a $dir is not empty
  then
    pushd "$dir"           ## change directory
  else
    pushd "$HOME"          ## go HOME if nothing on the command line
  fi 2>/dev/null           ## error message should come from cd, not pushd

  error=$?     ## store pushd's exit code

  if [ $error -ne 0 ]      ## failed, print error message
  then
    builtin cd "$dir"      ## let the builtin cd provide the error message
  fi
  return "$error"          ## leave with pushd's exit code
} > /dev/null

标准输出被重定向到位桶,因为pushd打印DIRSTACK的内容,唯一的其他输出被发送到标准错误(>&2)。

Image 注意标准命令(如cd)的替代应该接受原始命令接受的任何内容。在cd的情况下,选项-L-P被接受,即使它们被忽略。也就是说,我确实有时会忽略选项,甚至没有为它们做好准备,尤其是如果它们是我从未使用过的选项。

螺纹中径

这里的pd函数是为了完整起见(清单 11-2 )。是懒人对popd的称呼方式;我不用它。

清单 11-2pd,用popd返回上一个目录

pd ()
{
    popd
} >/dev/null ### for the same reason as cd

连续地层(倾角仪)

我不用pd的原因不是因为我不懒。远非如此,但我更喜欢保持DIRSTACK不变,这样我就可以在目录之间来回移动。出于这个原因,我使用一个菜单来显示DIRSTACK中的所有目录。

cdm函数将输入字段分隔符(IFS)设置为一个换行符(NLLF),以确保dirs内置命令的输出在分词后将文件名保持在一起(清单 11-3 )。包含换行符的文件名仍然会引起问题;带有空格的名字令人讨厌,但是带有换行符的名字令人厌恶。

该函数遍历DIRSTACK ( for dir in $(dirs -l -p))中的名字,将每个名字添加到一个数组item,除非它已经存在。然后这个数组被用作menu函数的参数(下面讨论),它必须在cdm被使用之前获得。

DIRS 内置命令

dirs内置命令列出了DIRSTACK数组中的目录。默认情况下,它在一行中列出它们,用波浪符号表示HOME的值。-l选项将~扩展到$HOME,并且-p打印目录,每行一个。

清单 11-3cdm,从已经访问过的目录菜单中选择新目录

cdm() #@ select new directory from a menu of those already visited
{
  local dir IFS=$'\n' item
  for dir in $(dirs -l -p)             ## loop through diretories in DIRSTACK[@]
  do
    [ "$dir" = "$PWD" ] && continue    ## skip current directory
    case ${item[*]} in
      *"$dir:"*) ;;                    ## $dir already in array; do nothing
      *) item+=( "$dir:cd '$dir'" ) ;; ## add $dir to array
    esac
  done
  menu "${item[@]}" Quit:              ## pass array to menu function
}

运行时,菜单如下所示:

$ cdm

    1\. /public/music/magnatune.com
    2\. /public/video
    3\. /home/jayant
    4\. /home/jayant/tmp/qwe rty uio p
    5\. /home/jayant/tmp
    6\. Quit

 (1 to 6) ==>

菜单

menu函数的调用语法来自9menu,它是 Plan 9 操作系统的一部分。每个参数包含两个用冒号分隔的字段:要显示的项目和要执行的命令。如果参数中没有冒号,它将同时用作显示和命令:

$ menu who date "df:df ."

    1\. who
    2\. date
    3\. df

 (1 to 3) ==> 3
Filesystem           1K-blocks      Used Available Use% Mounted on
/dev/hda5             48070472  43616892   2011704  96% /home
$ menu who date "df: df ."

    1\. who
    2\. date
    3\. df

 (1 to 3) ==> 1
jayant    tty8         Jun 18 14:00 (:1) 
jayant    tty2         Jun 21 18:10

一个for循环编号并打印菜单;read得到响应;一个case语句检查响应中的退出字符qQ0。最后间接展开检索选中的项,进一步展开提取命令,eval执行:eval "${!num#*:}" ( 清单 11-4 )。

清单 11-4 。菜单、打印菜单和执行相关命令

menu()
{
  local IFS=$' \t\n'                        ## Use default setting of IFS
  local num n=1 opt item cmd
  echo

  ## Loop though the command-line arguments
  for item
  do
    printf "  %3d. %s\n" "$n" "${item%%:*}"
    n=$(( $n + 1 ))
  done
  echo

  ## If there are fewer than 10 items, set option to accept key without ENTER
  if [ $# -lt 10 ]
  then
    opt=-sn1
  else
    opt=
  fi
  read -p " (1 to $#) ==> " $opt num         ## Get response from user

  ## Check that user entry is valid
  case $num in
    [qQ0] | "" ) return ;;                   ## q, Q or 0 or "" exits
    *[!0-9]* | 0*)                           ## invalid entry
       printf "\aInvalid response: %s\n" "$num" >&2
       return 1
       ;;
  esac
  echo

  if [ "$num" -le "$#" ]   ## Check that number is <= to the number of menu items
  then
    eval "${!num#*:}"      ## Execute it using indirect expansion
  else
    printf "\aInvalid response: %s\n" "$num" >&2
    return 1
  fi
}

文件系统功能

这些功能各不相同,从懒惰(给较长的命令起一个短名字)到给标准命令(cpmv))增加功能。它们列出、复制或移动文件或创建目录。

l

POSIX 规范不需要单个字母的命令,只有一个在大多数 Unixes 上可以找到的命令:w,它显示谁登录了以及他们在做什么。我定义了许多单字母函数:

  • a:列出当前播放的音乐曲目
  • c:清除屏幕(有时比^L更快或更容易)
  • d:日期"+%A, %-d %B %Y  %-I:%M:%S %P (%H:%M:%S)"
  • k:相当于man -k,或apropos
  • t:对于 Amiga 和 MS-DOS 命令type,调用less
  • vV:分别降低和提高音量
  • x:注销

还有一个是我最常用的,它通过less传输一个长文件清单,如清单 11-5 所示。

清单 11-5l,以长格式列出文件,通过less管道传输

l()
{
  ls -lA "$@" | less        ## the -A option is specific to GNU and *BSD versions
}

LSR

我最常用的命令是lcdxx.shcdmlsrxx.sh是用于一次性脚本的文件。我不断在顶部添加新的;lsr显示最近的文件(或使用-o选项,显示最旧的文件)。默认设置是显示十个文件,但可以通过-n选项进行更改。

清单 11-6 中的脚本使用-t(或-tr)选项来ls并将结果传送给head

清单 11-6lsr,列出最近修改的文件

num=10                                           ## number of files to print
short=0                                          ## set to 1 for short listing
timestyle='--time-style="+ %d-%b-%Y %H:%M:%S "'  ## GNU-specific time format

opts=Aadn:os

while getopts $opts opt
do
  case $opt in
      a|A|d) ls_opts="$ls_opts -$opt" ;;  ## options passed to ls
      n) num=$OPTARG ;;                   ## number of files to display
      o) ls_opts="$ls_opts -r" ;;         ## show oldest files, not newest
      s) short=$(( $short + 1 )) ;;
  esac
done
shift $(( $OPTIND - 1 ))

case $short in
    0) ls_opts="$ls_opts -l -t" ;;        ## long listing, use -l
    *) ls_opts="$ls_opts -t" ;;           ## short listing, do not use -l
esac

ls $ls_opts $timestyle "$@" | {
    read                                  ## In bash, the same as: IFS= read -r REPLY
    case $line in
        total*) ;;                        ## do not display the 'total' line
        *) printf "%s\n" "$REPLY" ;;
    esac
    cat
} | head -n$num

cp,mv

在我的桌面切换到 GNU/Linux 之前,我使用的是 Amiga。如果没有给出目的地,它的copy命令会将文件复制到当前目录。这个函数给出了与cp ( 清单 11-7 )相同的能力。-b选项是 GNU 特有的,所以如果您使用的是不同版本的cp,请删除它。

清单 11-7cp,复制,如果没有给出目的地,使用当前目录

cp()
{
  local final
  if [ $# -eq 1 ]                  ## Only one arg,
  then
    command cp -b "$1" .           ## so copy it to the current directory
  else
    final=${!#}
    if [ -d "$final" ]             ## if last arg is a directory
    then
      command cp -b "$@"           ## copy all the files into it
    else
      command cp -b "$@" .         ## otherwise, copy to the current directory
    fi
  fi
}

除了在cp出现的地方都有mv之外,mv函数是相同的。

md

使用md函数(清单 11-8 )懒惰是当今的主流。它调用带有-p选项的mkdir来创建中间目录,如果它们不存在的话。使用-c选项,md创建目录(如果它还不存在),然后cd s 进入其中。因为有了-p选项,如果目录存在,就不会产生错误。

清单 11-8md,创建一个新目录和中间目录,并可选地将cd放入其中

md() { #@ create new directory, including intermediate directories if necessary
  case $1 in
     -c) mkdir -p "$2" && cd "$2" ;;
     *) mkdir -p "$@" ;;
  esac
}

杂项功能

我经常使用下面两个函数,但是它们不属于任何类别。

pr1

我将pr1函数作为一个函数和一个独立的脚本(清单 11-9 )。它将每个参数打印在单独的一行上。默认情况下,它将长度限制为终端中的列数,并根据需要截断行。

有两个选项,-w-W。前者消除了截断,所以行总是完整打印,必要时换行。后者指定截断行的宽度。

清单 11-9pr1,函数打印其参数一行一行

pr1() #@ Print arguments one to a line
{
  case $1 in
    -w) pr_w=                   ## width specification modifier
        shift
        ;;
    -W) pr_w=${2}
        shift 2
        ;;
    -W*) pr_w=${1#??}
         shift
         ;;
    *) pr_w=-.${COLUMNS:-80}    ## default to number of columns in window
       ;;
  esac
  printf "%${pr_w}s\n" "$@"
 }

脚本版本(清单 11-10 )使用getopts;我没有在函数中使用它们,因为我希望它是 POSIX 兼容的。

清单 11-10pr1,脚本将其参数一行一行打印出来

while getopts wW: opt
do
  case $opt in
    w) w=
       shift
       ;;
    W) w=$OPTARG ;;
    *) w=-.${COLUMNS:-80} ;;
  esac
done
shift $(( $OPTIND - 1 ))

printf "%${w}s\n" "$@"

计算

Bash缺乏对小数进行算术运算的能力,所以我编写了这个函数(清单 11-11 )来使用awk来完成这项脏工作。请注意,shell 的特殊字符必须进行转义,或者在命令行上加上引号。这尤其适用于乘法符号*

清单 11-11calc,打印算术表达式的结果

calc() #@ Perform arithmetic, including decimal fractions
{
  local result=$(awk 'BEGIN { OFMT="%f"; print '"$*"'; exit}')
  case $result in
    *.*0) result=${result%"${result##*[!0]}"} ;;
  esac
  printf "%s\n" "$result"
}

case语句删除小数点后的尾随零。

管理手册页

我使用了三个与手册页相关的函数。第一个搜索模式或字符串的手册页,第二个查找 POSIX 手册页,第三个相当于man -k

sman

sman函数调用手册页并搜索给定的字符串。它假设less是默认的寻呼机(清单 11-12 )。

清单 11-12 。调用手册页并搜索模式

sman() #@ USAGE: sman command search_pattern
{
  LESS="$LESS${2:+ +/$2}" man "$1"
}

你的

当我想检查给定命令的可移植性,或者更常见的是,检查 POSIX 指定了哪些选项时,我使用sus。它在本地存储了一份 POSIX 手册页的副本,这样就不需要在后续查询中获取它了(清单 11-13 )。

清单 11-13 。在 POSIX 规范中查找手册页

sus()
{
    local html_file=/usr/share/sus/$1.html    ## adjust to taste
    local dir=9699919799
    local sus_dir=http://www.opengroup.org/onlinepubs/$dir/utilities/
    [ -f "$html_file" ] ||
      lynx -source  $sus_dir${1##*/}.html > $html_file ##>/dev/null 2>&1
    lynx -dump -nolist $html_file | ${PAGER:-less}
}

这里的lynx是一个文本模式的网络浏览器。虽然通常用于交互访问 Web,但是-source-dump指令也可以在脚本中使用。

k

k功能保存aproposman -k的所有输入。它实际上做得更多一点。它过滤结果,以便只显示用户命令(来自手册页的第一部分)。系统和内核函数以及文件规范等等,没有显示出来(清单 11-14 )。

清单 11-14k,列出其简短描述包括搜索字符串的命令

k() #@ USAGE: k string
{
    man -k "$@" | grep '(1'
}

游戏

没有游戏的命令行是什么?无聊,就是这样!我用 shell 写了很多游戏。它们包括yahtzee ( 图 11-1 ),一种使用五颗骰子的游戏;maxit ( 图 11-2 ),基于 Commodore 64 的一款算术游戏;当然还有tic-tac-toe ( 图 11-3 )。所有这些游戏都太大了,无法在本书中包含它们的脚本,但是它们的一些部分(例如yahtzee骰子)将在后面的章节中演示。我在这里可以包括的一个游戏是fifteen谜题。

9781484201220_Fig11-01.jpg

图 11-1 。yahtzee游戏,玩家试图获得分数、满堂彩或三、四或五个同类的分数

9781484201220_Fig11-02.jpg

图 11-2 。一种游戏,一名玩家从一行中选择,另一名玩家从一列中选择

9781484201220_Fig11-03.jpg

图 11-3 。tic-tac-toe无处不在的游戏

十五个谜题

fifteen拼图由一个框架中的 15 个编号的滑动瓦片组成;目标是按升序排列它们,如下所示:

        +----+----+----+----+
        |    |    |    |    |
        |  1 |  2 |  3 |  4 |
        |    |    |    |    |
        +----+----+----+----+
        |    |    |    |    |
        |  5 |  6 |  7 |  8 |
        |    |    |    |    |
        +----+----+----+----+
        |    |    |    |    |
        |  9 | 10 | 11 | 12 |
        |    |    |    |    |
        +----+----+----+----+
        |    |    |    |    |
        | 13 | 14 | 15 |    |
        |    |    |    |    |
        +----+----+----+----+

在这个脚本(清单 11-15 )中,磁贴是用光标键移动的。

清单 11-15fifteen,按升序摆放瓷砖

########################################
## Meta data
########################################

scriptname=${0##*/}
description="The Fifteen Puzzle"
author="Chris F.A. Johnson"
created=2009-06-20

########################################
## Variables
########################################

board=( {1..15} "" )         ## The basic board array
target=( "${board[@]}" )     ## A copy for comparison (the target)
empty=15                     ## The empty square
last=0                       ## The last move made
A=0 B=1 C=2 D=3              ## Indices into array of possible moves
topleft='\e[0;0H'            ## Move cursor to top left corner of window
nocursor='\e[?25l'           ## Make cursor invisible
normal=\e[0m\e[?12l\e[?25h   ## Resume normal operation

## Board layout is a printf format string
## At its most basic, it could be a simple:

fmt="$nocursor$topleft

     %2s  %2s  %2s  %2s

     %2s  %2s  %2s  %2s

     %2s  %2s  %2s  %2s

     %2s  %2s  %2s  %2s

"

## I prefer this ASCII board
fmt="\e[?25l\e[0;0H\n
\t+----+----+----+----+
\t|    |    |    |    |
\t| %2s | %2s | %2s | %2s |
\t|    |    |    |    |
\t+----+----+----+----+
\t|    |    |    |    |
\t| %2s | %2s | %2s | %2s |
\t|    |    |    |    |
\t+----+----+----+----+
\t|    |    |    |    |
\t| %2s | %2s | %2s | %2s |
\t|    |    |    |    |
\t+----+----+----+----+
\t|    |    |    |    |
\t| %2s | %2s | %2s | %2s |
\t|    |    |    |    |
\t+----+----+----+----+\n\n"

########################################
###  Functions
########################################

print_board() #@ What the name says
{
  printf "$fmt" "${board[@]}"
}

borders() #@ List squares bordering on the empty square
{
  ## Calculate x/y co-ordinates of the empty square
  local x=$(( ${empty:=0} % 4 ))  y=$(( $empty / 4 ))

  ## The array, bordering, has 4 elements, corresponding to the 4 directions
  ## If a move in any direction would be off the board, that element is empty
  ##
  unset bordering     ## clear array before setting it
  [ $y -lt 3 ] && bordering[$A]=$(( $empty + 4 ))
  [ $y -gt 0 ] && bordering[$B]=$(( $empty - 4 ))
  [ $x -gt 0 ] && bordering[$C]=$(( $empty - 1 ))
  [ $x -lt 3 ] && bordering[$D]=$(( $empty + 1 ))
}

check() #@ Check whether puzzle has been solved
{
  ## Compare current board with target
  if [ "${board[*]}" = "${target[*]}" ]
  then
    ## Puzzle is completed, print message and exit
    print_board
    printf "\a\tCompleted in %d moves\n\n"  "$moves"
    exit
  fi
}

move() #@ Move the square in $1
{
  movelist="$empty $movelist"    ## add current empty square to the move list
  moves=$(( $moves + 1 ))        ## increment move counter
  board[$empty]=${board[$1]}     ## put $1 into the current empty square
  board[$1]=""                   ## remove number from new empty square
  last=$empty                    ## .... and put it in old empty square
  empty=$1                       ## set new value for empty-square pointer
}

random_move() #@ Move one of the squares in the arguments
{
  ## The arguments to random_move are the squares that can be moved
  ## (as generated by the borders function)
  local sq
  while :
  do
    sq=$(( $RANDOM % $# + 1 ))
    sq=${!sq}
    [ $sq -ne ${last:-666} ] &&   ## do not undo last move
       break
  done
  move "$sq"
}

shuffle() #@ Mix up the board using legitimate moves (to ensure solvable puzzle)
{
  local n=0 max=$(( $RANDOM % 100 + 150 ))   ## number of moves to make
  while [ $(( n += 1 )) -lt $max ]
  do
    borders                                  ## generate list of possible moves
    random_move "${bordering[@]}"            ## move to one of them at random
  done
}

########################################
### End of functions
########################################

trap 'printf "$normal"' EXIT                 ## return terminal to normal state on exit

########################################
### Instructions and initialization
########################################

clear
print_board
echo
printf "\t%s\n" "$description" "by $author, ${created%%-*}" ""
printf "
 Use the cursor keys to move the tiles around.

 The game is finished when you return to the
 position shown above.

 Try to complete the puzzle in as few moves
 as possible.

        Press \e[1mENTER\e[0m to continue
"
shuffle                                    ## randomize board
moves=0                                    ## reset move counter
read -s                                    ## wait for user
clear                                      ## clear the screen

########################################
### Main loop
########################################

while :
do
  borders
  print_board
  printf "\t   %d move" "$moves"
  [ $moves -ne 1 ] && printf "s"
  check

  ## read a single character without waiting for <ENTER>
  read -sn1 -p $'        \e[K' key

  ## The cursor keys generate three characters: ESC, [ and A, B, C, or D;
  ## this loop will run three times for each press of a cursor key
  ## but will not do anything until it receives a letter
  ## from the cursor key (or entered directly with A etc.), or a 'q' to exit
  case $key in
    A) [ -n "${bordering[$A]}" ] && move "${bordering[$A]}" ;;
    B) [ -n "${bordering[$B]}" ] && move "${bordering[$B]}" ;;
    C) [ -n "${bordering[$C]}" ] && move "${bordering[$C]}" ;;
    D) [ -n "${bordering[$D]}" ] && move "${bordering[$D]}" ;;
    q) echo; break ;;
  esac
done

摘要

本章提供的脚本只是在命令行使用脚本的一小部分。在需要改变环境的地方(如在cdcdm中),脚本必须是 shell 函数。这些通常保存在$HOME/.bashrc.bashrc提供的文件中。

甚至游戏也可以在不需要 GUI 界面的情况下编程。

练习

  1. 修改menu函数以从文件中接受其参数。
  2. pr1函数重写为prx,它将按照第八章中的pr4的方式运行,但是将接受任意列数的选项。
  3. fifteen游戏中添加一个getopts部分,允许用户在三种不同的棋盘格式中进行选择。写第三种格式。

十二、运行时配置

当我从三四个不同的 POP3 服务器下载电子邮件时,我不会对每个服务器使用不同的脚本。当我打开一个终端ssh连接到一台远程计算机(半打)时,每台计算机都有不同的背景颜色,我对每个连接都使用相同的脚本。为了将文件上传到我的网站(我负责六个网站),我对所有的网站都使用相同的脚本。

运行脚本时,您可以通过多种方式配置脚本的行为。本章介绍七种方法:初始化变量、命令行选项和参数、菜单、问答对话、配置文件、一个脚本的多个名称以及环境变量。这些方法并不相互排斥;事实上,它们经常结合在一起。命令行选项可以告诉脚本使用不同的配置文件,或者为用户提供一个菜单。

定义变量

如果脚本的运行时需求很少改变,那么硬编码变量可能就是你所需要的全部配置。您可以在安装脚本时设置它们。当需要改变时,可以用文本编辑器快速改变参数。

清单 12-1 。初始化的默认变量示例

## File locations
dict=/usr/share/dict
wordfile=$dict/singlewords
compoundfile=$dict/Compounds

## Default is not to show compound words
compounds=no

如果变量需要经常改变,可以增加一个或多个其他方法。

命令行选项和参数

更改运行时行为的最常见方法是使用命令行选项。如清单 12-2 所示,前面定义的所有值都可以在命令行修改。

清单 12-2 。解析命令行选项

while getopts d:w:f:c var
do
  case "$var" in
    c) compounds=1 ;;
    d) dict=$OPTARG ;;
    w) wordfile=$OPTARG ;;
    f) compoundfile=$OPTARG ;;
  esac
done

菜单

对于一个不熟悉软件的用户来说,菜单是允许运行时改变的好方法。在清单 12-3 所示的菜单示例中,选项从 1 到 4 编号,q退出菜单。

清单 12-3 。通过菜单设置参数

while :  ## loop until user presses 'q'
do
  ## print menu
  printf "\n\n%s\n" "$bar"
  printf "  Dictionary parameters\n"
  printf "%s\n\n" "$bar"
  printf "  1\. Directory containing dictionary: %s\n" "$dict"
  printf "  2\. File containing word list: %s\n" "$wordfile"
  printf "  3\. File containing compound words and phrases: %s\n" "$compoundfile"
  printf "  4\. Include compound words and phrases in results? %s\n" "$compounds"
  printf "  q. %s\n" "Exit menu"
  printf "\n%s\n\n" "$bar"

  ## get user response
  read -sn1 -p "Select (1,2,3,4,q): " input
  echo

  ## interpret user response
  case $input in
    1) read -ep "Enter dictionary directory: " dict ;;
    2) read -ep "Enter word-list file: " wordfile ;;
    3) read -ep "Enter compound-word file: " compoundfile ;;
    4) [ "$compounds" = y ] && compounds=n || compounds=y ;;
    q) break ;;
    *) printf "\n\aInvalid selection: %c\n" "$input" >&2
    sleep 2
    ;;
  esac
done

问答对话

问答函数循环遍历所有参数,提示用户为每个参数输入一个值(清单 12-4 )。对于用户来说,这可能会变得很乏味,当没有缺省值时,当需要输入的参数很少时,或者当需要为新的配置文件输入值时,这可能是最好的选择。

清单 12-4 。通过问答设置变量

read -ep "Directory containing dictionary: " dict
read -ep "File containing word list: " wordfile
read -ep "File containing compound words and phrases: " compoundfile
read -sn1 -p "Include compound words and phrases in results (y/n)? " compounds
echo
read -ep "Save parameters (y/n)? " save
case $save in
  y|Y) read -ep "Enter path to configuration file: " configfile
   {
    printf '%-30s ## %s"\n' \
      "dict=$dict" "Directory containing dictionary" \
      "wordfile=$wordfile" "File containing word list" \
      "compoundfile=$compoundfile" "File containing compound words and phrases" \
      "Compounds" "$Compounds" "Include compound words and phrases in results?"
   } > "${configfile:-/dev/tty}"
esac

配置文件

配置文件可以使用任何格式,但是最简单的方法是让它们成为可以获得源代码的 shell 脚本。清单 12-5 所示的示例文件可以找到,但它也可以提供更多信息。

清单 12-5 。配置文件,words.cfg

dict=/usr/share/dict        ## directory containing dictionary files
wordfile=singlewords        ## file containing word list
compoundfile=Compounds      ## file containing compound words and phrases
compounds=no                ## include compound words and phrases in results?

可以使用以下两个命令中的任何一个来获取words.cfg文件:

. words.cfg
source words.cfg

除了寻找文件的来源,还可以用各种方式来解析它(清单 12-6 )。在bash-4.x 中,您可以将文件读入一个数组,并使用参数扩展提取变量和注释,扩展应用于数组的每个元素。

清单 12-6 。解析配置文件

IFS=$'\n'
file=words.cfg
settings=( $( < "$file") )         ## store file in array, 1 line per element
eval "${settings[@]%%#*}"          ## extract and execute the assignments
comments=( "${settings[@]#*## }" ) ## store comments in array

comments数组只包含注释,赋值可以用"${settings[@]%%#*}"settings中提取:

$ printf "%s\n" "${comments[@]}"
directory containing dictionary files
file containing word list
file containing compound words and phrases
include compound words and phrases in results?

你也可以通过显示注释(清单 12-7 )来循环读取文件以设置变量并提供有关变量的信息。

清单 12-7 。解析带注释的配置文件

while read assignment x comment
do
  if [ -n "$assignment" ]
  then
    printf "%20s: %s\n" "${assignment#*=}"  "$comment"
    eval "$assignment"
  fi
done < "$file"

以下是结果:

     /usr/share/dict: directory containing dictionary files
         singlewords: file containing word list
           Compounds: file containing compound words and phrases
                   n: include compound words and phrases in results?

配置文件可以根据你的喜好变得复杂,但是解析它们更适合归入数据处理的范畴,这是第十三章的主题。

有几个名字的脚本

通过以不同的名称存储同一个文件,可以避免命令行选项和菜单。清单 12-8 中的脚本打开一个终端,并使用安全 Shell 连接到不同的远程计算机。终端的颜色、登录的 mac 和远程用户的名称都由脚本的名称决定。

清单 12-8bashful,通过ssh连接到远程计算机

scriptname=${0##*/}

## default colours
bg=#ffffcc     ## default background: pale yellow
fg=#000000     ## default foreground: black

user=bashful   ## default user name
term=xterm     ## default terminal emulator (I prefer rxvt)

case $scriptname in
  sleepy)
     bg=#ffffff
     user=sleepy
     host=sleepy.example.com
     ;;
  sneezy)
     fg=#aa0000
     bg=#ffeeee
     host=sneezy.example.org
     ;;
  grumpy)
     fg=#006600
     bg=#eeffee
     term=rxvt
     host=cfajohnson.example.com
     ;;
  dopey)
     host=127.0.0.1
     ;;
  *) echo "$scriptname: Unknown name" >&2
     exit 1
     ;;
esac

"$term" -fg "$fg" -bg "$bg" -e ssh -l "$user" "$host"

要为同一个文件创建多个名字,创建与ln ( 清单 12-9 )的链接。

清单 12-9 。制作到bashful脚本的多个链接

cd "$HOME/bin" &&
for name in sleepy sneezy grumpy dopey
do
  ln -s bashful "$name"           ## you can leave out the -s option if you like
done

环境变量

还可以使用变量将设置传递给程序。这些既可以导出,也可以在与命令相同的行上定义。在后一种情况下,只为该命令定义变量。

你可以通过检查变量值或者仅仅是它的存在来改变程序的行为。我最常使用这种技术来调整使用verbose的脚本的输出。这将是脚本中的一个典型行:

[ ${verbose:-0} -gt 0 ] && printf "%s\n" "Finished parsing options"

该脚本将按如下方式调用:

verbose=1 myscriptname

您可以在下面的脚本中看到一个示例。

现在都在一起

以下是我用来更新所有网站的程序。它在目录层次结构中找到新的或修改过的文件,将它们存储在 tarball 中,并上传到(通常)远程计算机上的网站。我在我使用的所有站点上都有 shell 访问权限,所以我可以使用一个安全的 shellssh来传输文件,并在站点上用tar对它们进行解包:

ssh -p "$port" -l "$user" "$host" \
      "cd \"$dest\" || exit;tar -xpzf -" < "$tarfile" &&
        touch "$syncfile"

我的所有网站都使用认证密钥(用ssh-keygen 创建),因此不需要密码,脚本可以作为cron作业运行。

这个程序使用了前面提到的所有技术,除了多个名字。这比您通常在单个程序中使用的要多,但是这是一个很好的例子。

用户可以选择是否使用命令行选项、菜单、问答对话框或配置文件来调整设置,或者用户甚至可以使用默认值。命令行选项可用于所有设置:

  • -c configfile:从configfile读取设置
  • -h host:指定远程计算机的 URL 或 IP 地址
  • -p port:指定要使用的 SSH 端口
  • -d dest:指定远程主机上的目标目录
  • -u user:指定用户在远程计算机上的登录名
  • -a archivedir:指定存储归档文件的本地目录
  • -f syncfile:指定时间戳为截止点的文件
  • 还有另外三个控制脚本本身的选项:
  • -t:仅测试,显示最终设置,不存档或上传
  • -m:向用户呈现菜单
  • -q:使用 Q &进行对话

在接下来的几节中,我们将一节一节地详细研究这个脚本。

Image 注意这是一本关于专业 Bash 脚本以及使用脚本的方法的书。写剧本不一定是最好的解决方案。

还有几个不一定基于 Bash 脚本的选项,它们只是为了实现管理结果而创建的。有一个名为集群 SSH (开源)的perl脚本包装器,它允许你同时向多个服务器发送命令,并且是基于 GUI 的。还有一种叫傀儡 ,挺受欢迎的。

脚本信息

注意,参数扩展用于从$0中提取脚本名称,而不是外部命令basename ( 清单 12-10 )。

清单 12-10upload,将文件存档并上传到远程计算机

scriptname=${0##*/}
description="Archive new or modified files and upload to web site"
author="Chris F.A. Johnson"
version=1.0

默认配置

除了设置变量,还会创建一个包含变量名称及其描述的数组(清单 12-11 )。这由标签和提示的menuqa(问题和答案)功能使用。

清单 12-11 。默认值和settings数组

## archive and upload settings
host=127.0.0.1                        ## Remote host (URL or IP address)
port=22                               ## SSH port
dest=work/upload                      ## Destination directory
user=jayant                           ## Login name on remote system
source=$HOME/public_html/oz-apps.com  ## Local directory to upload
archivedir=$HOME/work/webarchives     ## Directory to store archive files
syncfile=.sync                        ## File to touch with time of last upload

## array containing variables and their descriptions
varinfo=( "" ## Empty element to emulate 1-based array
  "host:Remote host (URL or IP address)"
  "port:SSH port"
  "dest:Destination directory"
  "user:Login name on remote system"
  "source:Local directory to upload"
  "archivedir:Directory to store archive files"
  "syncfile:File to touch with time of last upload"
)

## These may be changed by command-line options
menu=0          ## do not print a menu
qa=0            ## do not use question and answer
test=0          ## 0 = upload for real; 1 = don't archive/upload, show settings
configfile=     ## if defined, the file will be sourced
configdir=$HOME/.config  ## default location for configuration files
sleepytime=2    ## delay in seconds after printing messages

## Bar to print across top and bottom of menu (and possibly elsewhere)
bar=================================================================
bar=$bar$bar$bar$bar   ## make long enough for any terminal window
menuwidth=${COLUMNS:-80}

屏幕变量

这些变量使用 ISO-6429 标准,该标准现在在终端和终端仿真器中几乎是通用的。这将在第十四章中详细讨论。当打印到终端时,这些转义序列执行注释中指示的操作。

清单 12-12 。定义屏幕操作变量

topleft='\e0;0H'     ## Move cursor to top left corner of screen
clearEOS='\e[J'       ## Clear from cursor position to end of screen
clearEOL='\e[K'       ## Clear from cursor position to end of line

函数定义

共有五种功能,其中两种功能menuqa允许用户更改设置。在readline能够接受用户输入的情况下,如果 shell 版本是bash-4.x 或更高版本,则使用read-i选项。如果使用测试选项,print_config功能以适合配置文件的格式输出设置,并附有注释。

功能:板牙

当命令失败时,程序通过die函数退出([清单 12-13 )。

清单 12-13 。定义die功能

die() #@ Print error message and exit with error code
{     #@ USAGE: die [errno [message]]

  error=${1:-1}   ## exits with 1 if error number not given
  shift
  [ -n "$*" ] &&
    printf "%s%s: %s\n" "$scriptname" ${version:+" ($version)"} "$*" >&2
  exit "$error"
}

功能:菜单

menu函数使用它的命令行参数来填充菜单(清单 12-14 )。每个参数都包含一个变量名和变量描述,用冒号分隔。

上传设置菜单

================================================================================

    UPLOAD SETTINGS

================================================================================

    1: Remote host (URL or IP address) (127.0.0.1)
    2: ssh port (22)
    3: Destination directory (work/upload)
    4: Login name on remote system (jayant)
    5: Local directory to upload (/home/jayant/public_html/oz-apps.com)
    6: Directory to store archive files (/home/jayant/work/webarchives)
    7: File to touch with time of last upload (.sync)
    q: Quit menu, start uploading
    0: Exit upload

================================================================================

Select 1..7 or 'q/0'

功能进入无限循环,用户通过选择q0退出。在循环中,menu清空屏幕,然后循环遍历每个参数,将其存储在item中。它使用参数扩展提取变量名和描述:

var=${item%%:*}
description=${item#*:}

每个var的值通过间接扩展${!var}获得,并包含在菜单标签中。菜单编号的字段宽度为${#max},即最高项目编号的长度。

清单 12-14 。定义menu功能

menu() #@ Print menu, and change settings according to user input
{
  local max=$#
  local menutitle="UPLOAD SETTINGS"
  local readopt

  if [ $max -lt 10 ]
  then             ## if fewer than ten items,
    readopt=-sn1   ## allow single key entry
  else
    readopt=
  fi

  printf "$topleft$clearEOS"  ## Move to top left and clear screen

  while : ## infinite loop
  do

    #########################################################
    ## display menu
    ##
    printf "$topleft"  ## Move cursor to top left corner of screen

    ## print menu title between horizontal bars the width of the screen
    printf "\n%s\n" "${bar:0:$menuwidth}"
    printf "    %s\n" "$menutitle"
    printf "%s\n\n" "${bar:0:$menuwidth}"

    menunum=1

    ## loop through the positional parameters
    for item
    do
      var=${item%%:*}          ## variable name
      description=${item#*:}   ## variable description

      ## print item number, description and value
      printf "   %${#max}d: %s (%s)$clearEOL\n" \
                 "$menunum" "$description" "${!var}"

      menunum=$(( $menunum + 1 ))
    done

    ## … and menu adds its own items
    printf "   %${##}s\n" "q: Quit menu, start uploading" \
                      "0: Exit $scriptname"

    printf "\n${bar:0:$menuwidth}\n"   ## closing bar

    printf "$clearEOS\n" ## Clear to end of screen
    ##
    #########################################################

    #########################################################
    ## User selection and parameter input
    ##

    read -p " Select 1..$max or 'q' " $readopt x
    echo

    [ "$x" = q ] && break  ## User selected Quit
    [ "$x" = 0 ] && exit   ## User selected Exit

    case $x in
      *[!0-9]* | "")
              ## contains non digit or is empty
              printf "\a %s - Invalid entry\n" "$x" >&2
              sleep "$sleepytime"
              ;;
      *) if [ $x -gt $max ]
         then
           printf "\a %s - Invalid entry\n" "$x" >&2
           sleep "$sleepytime"
           continue
         fi

         var=${!x%%:*}
         description=${!x#*:}

         ## prompt user for new value
         printf "      %s$clearEOL\n" "$description"
         readline value "        >> "  "${!var}"

         ## if user did not enter anything, keep old value
         if [ -n "$value" ]
         then
           eval "$var=\$value"
         else
           printf "\a Not changed\n" >&2
           sleep "$sleepytime"
         fi
         ;;
    esac
    ##
    #########################################################

  done
}

功能: qa

qa函数采用与menu相同的参数,但是它并没有将它们放入菜单中,而是提示用户为每个变量输入一个新值(清单 12-15 )。当它运行完所有的命令行参数时,它以与menu相同的方式分割这些参数,它调用menu函数来验证和编辑这些值。也像menu一样,它使用readline来获得输入,如果没有输入任何东西,它就保持原来的值。

清单 12-15 。定义qa功能

qa() #@ Question and answer dialog for variable entry
{
  local item var description

  printf "\n %s - %s\n" "$scriptname" "$description"
  printf " by %s, copyright %d\n"  "$author" "$copyright"
  echo
  if [ ${BASH_VERSINFO[0]} -ge 4 ]
  then
    printf " %s\n" "You may edit existing value using the arrow keys."
  else
    printf " %s\n" "Press the up arrow to bring existing value" \
                   "to the cursor for editing with the arrow keys"
  fi
  echo

  for item
  do
    ## split $item into variable name and description
    var=${item%%:*}
    description=${item#*:}
    printf "\n %s\n" "$description"
    readline value "   >> " "${!var}"
    [ -n "$value" ] && eval "$var=\$value"
  done

  menu "$@"
}

对话是这样的:

$ upload -qt

 upload - Archive new or modified files and upload to web site
 by Chris F.A. Johnson, copyright 2009

 You may edit existing value using the arrow keys.

 Remote host (URL or IP address)
   >> oz-apps.com

 SSH port
   >> 99

 Destination directory
   >> public_html

 Login name on remote system
   >> jayant

 Local directory to upload
   >> /home/jayant/public_html/oz-apps.com

 Directory to store archive files
   >> /home/jayant/work/webarchives

 File to touch with time of last upload
   >> .sync

功能:打印配置

如本章前面所述,print_config函数将varinfo数组中列出的所有变量以适合配置文件的格式打印到标准输出中。虽然在这个程序中可能没有必要,但是它用双引号将赋值值括起来,并使用bash的搜索和替换参数扩展对值中的双引号进行转义:

$ var=location
$ val='some"where'
$ printf "%s\n" "$var=\"${val//\"/\\\"}\""
location="some\"where"

参见清单 12-16 中的选项解析部分,查看print_config的输出示例。

清单 12-16 。定义print_config功能

print_config() #@ Print values in a format suitable for a configuration file
{
  local item var description

  [ -t 1 ] && echo  ## print blank line if output is to a terminal

  for item in "${varinfo[@]}"
  do
    var=${item%%:*}
    description=${item#*:}
    printf "%-35s ## %s\n" "$var=\"\${!var//\"/\\\"}\"" "$description"
  done

  [ -t 1 ] && echo  ## print blank line if output is to a terminal
}

功能: readline

如果您使用的是bash-4.x或更高版本,readline函数会在光标前放置一个值供您编辑(清单 12-17 )。在早期版本的bash中,它将值放入历史记录中,这样您就可以用向上箭头(或 Ctrl+P)来调出它,然后编辑它。

清单 12-17 。定义readline功能

readline() #@ get line from user with editing of current value
{          #@ USAGE var [prompt] [default]
  local var=${1?} prompt=${2:-  >>> } default=$3

  if [ ${BASH_VERSINFO[0]} -ge 4 ]
  then
    read -ep "$prompt" ${default:+-i "$default"} "$var"
  else
    history -s "$default"
    read -ep "$prompt" "$var"
  fi
}

解析命令行选项

您可以通过adfhpsu选项设置七个配置变量。此外,您可以用c选项指定一个配置文件。可以用t选项触发一个测试运行,它打印配置信息,但不试图创建一个 tarball 或上传任何文件。mq选项分别为用户提供菜单和问答对话框。

如果将主机作为一个选项给出,则使用标准公式构建配置文件名。如果该文件存在,则将其分配给configfile变量,以便从中加载参数。通常这就是为此目的需要添加到命令行的全部内容(清单 12-18 )。

清单 12-18 。解析命令行选项

while getopts c:h:p:d:u:a:s:f:mqt var
do
  case "$var" in
    c) configfile=$OPTARG ;; 
    h) host=$OPTARG
       hostconfig=$configdir/$scriptname.$host.cfg
       [ -f "$hostconfig" ] &&
         configfile=$hostconfig
       ;;
    p) port=$OPTARG ;;
    s) source=$OPTARG ;;
    d) dest=$OPTARG ;;
    u) user=$OPTARG ;;
    a) archivedir=$OPTARG ;;
    f) syncfile=$OPTARG ;;

    t) test=1 ;; ## show configuration, but do not archive or upload

    m) menu=1 ;;
    q) qa=1 ;;
  esac
done
shift $(( $OPTIND - 1 ))

使用选项和重定向,这个程序可以创建新的配置文件。这里,参数是在命令行中给出的,没有给出的参数使用默认值。

$ upload -t -h www.example.com -p 666 -u paradigm -d public_html \
   -s $HOME/public_html/www.example.com > www.example.com.cfg
$ cat www.example.com.cfg
host="www.example.com"              ## Remote host (URL or IP address)
port="666"                          ## SSH port
dest="public_html"                  ## Destination directory
user="paradigm"                     ## Login name on remote system
source="/home/jayant/public_html/www.example.com" ## Local directory to upload
archivedir="/home/jayant/work/webarchives" ## Directory to store archive files
syncfile=".sync"                    ## File to touch with time of last upload

零零碎碎

下面的清单 12-19 显示了脚本的其余部分。

清单 12-19 。剧本的其余部分

## If a configuration file is defined, try to load it
if [ -n "$configfile" ]
then
  if [ -f "$configfile" ]
  then
    ## exit if problem with config file
    . "$configfile" || die 1 Configuration error
  else
    ## Exit if configuration file is not found.
    die 2 "Configuration file, $configfile, not found"
  fi
fi

## Execute menu or qa if defined
if [ $menu -eq 1 ]
then
  menu "${varinfo[@]}"
elif [ $qa -eq 1 ]
then
  qa "${varinfo[@]}"
fi

## Create datestamped filename for tarball
tarfile=$archivedir/$host.$(date +%Y-%m-%dT%H:%M:%S.tgz)

if [ $test -eq 0 ]
then
  cd "$source" || die 4
fi

## verbose must be set (or not) in the environment or on the command line
if [ ${verbose:-0} -gt 0 ]
then
  printf "\nArchiving and uploading new files in directory: %s\n\n" "$PWD"
  opt=v
else
  opt=
fi

## IFS=$'\n' # uncomment this line if you have spaces in filenames (shame on you!)

if [ ${test:-0} -eq 0 ]
then
  remote_command="cd \"$dest\" || exit;tar -xpzf -"

  ## Archive files newer than $syncfile
  tar cz${opt}f "$tarfile" $( find . -type f -newer "$syncfile") &&

    ## Execute tar on remote computer with input from $tarfile
    ssh -p "$port" -l "$user" "$host" "$remote_command" < "$tarfile" &&

       ## if ssh is successful
       touch "$syncfile"

else ## test mode
  print_config
fi

摘要

本章演示了改变脚本运行时行为的七种方法。如果变化很少,脚本中定义的变量可能就足够了。当这还不够时,命令行选项(用getopts解析)通常就足够了。

您可以使用菜单或问答对话来进行运行时配置,也可以根据需要创建配置文件。对同一个脚本使用不同名称的文件可以节省键入时间。在某些情况下,在 shell 环境中设置一个变量就足够了。

练习

  1. upload脚本添加代码,检查所有变量是否都被设置为合法值(例如,port是一个整数)。
  2. 编写一个usagehelp函数,并将其添加到upload脚本中。
  3. upload脚本中添加一个选项,以保存已保存的配置。
  4. 编写一个脚本,创建一个与words.cfg格式相同的配置文件,提示用户在其中输入信息。

十三、数据处理

数据操作包括广泛的动作,远远超过了在一章中所能涵盖的范围。然而,大多数动作只是应用了前面章节中已经介绍过的技术。数组是一种基本的数据结构,虽然语法在第五章的中有所涉及,并且在第十一章的谜题代码中使用了它们,但是我还没有解释它们的用途。参数扩展已经在许多章节中使用,但是它在解析数据结构中的应用还没有被讨论。

本章将介绍使用字符串和数组的不同方式,如何将字符分隔的记录解析成各自的字段,以及如何读取数据文件。有两个操作二维网格的函数库,还有排序和搜索数组的函数。

数组

POSIX shell 中没有包含数组 ,但是bash从 2.0 版本开始就使用索引数组 ,在 4.0 版本中增加了关联数组。索引数组使用整数下标进行赋值和引用;关联数组使用字符串。数组可以包含的元素数量没有预设限制;它们只受可用内存的限制。

索引数组中的孔

如果一个索引数组的一些元素未被设置,那么这个数组就会留下空洞,成为一个稀疏数组。这样就不可能仅仅通过增加一个索引来遍历数组。有各种方法来处理这样的数组。为了演示,让我们创建一个数组,并在其中戳一些洞:

array=( a b c d e f g h i j )
unset array[2] array[4] array[6] array[8]

该数组现在包含六个元素,而不是原来的十个:

$ sa "${array[@]}"
:a:
:b:
:d:
:f:
:h:
:j:

遍历所有剩余元素的一种方法是将它们作为参数扩展到for。在这种方法中,没有办法知道每个元素的下标是什么:

for i in "${array[@]}"
do
  : do something with each element, $i, here
done

对于一个打包的数组 (一个没有洞的数组),索引可以从 0 开始,然后递增以获取下一个元素。对于稀疏(或任意)数组,${!array[@]}展开列出了下标:

$ echo "${!array[@]}"
0 1 3 5 7 9

此扩展可用作for的参数:

for i in "${!array[@]}"
do
  : do something with ${array[$i]} here
done

该解决方案没有提供引用下一个元素的方法。您可以保存前一个元素,但不能获得下一个元素的值。为此,您可以将下标列表放入一个数组中,并使用其元素来引用原始数组。包装阵列要简单得多,去掉孔:

$ array=( "${array[@]}" )
$ echo "${!array[@]}"
0 1 2 3 4 5

注意,这将把关联数组 转换成索引数组。

使用数组进行排序

按字母顺序 (或数字)排序数据通常不是 shell 的任务。sort命令是一个非常灵活高效的工具,可以处理大多数排序需求。然而,在一些情况下,排序最好由 shell 来完成。

最明显的是文件名扩展,其中扩展通配符的结果总是按字母顺序排序。例如,在处理带有日期戳的文件时,这很有用。如果日期戳使用标准 ISO 格式YYYY-MM-DD或压缩版本YYYYMMDD,文件将自动按日期顺序排序。如果您有格式为log.YYYYMMDD的文件,它会按时间顺序循环显示:

for file in log.*    ## loop through files in chronological order
do
   : do whatever
done

没必要用ls;shell 对通配符扩展进行排序。

使用bash-4.x,另一个扩展按字母顺序排序:带有单字符下标的关联数组:

$ declare -A q
$ q[c]=1 q[d]=2 q[a]=4
$ sa "${q[@]}"
:4:
:1:
:2:

这导致了编写一个对单词的字母进行排序的函数(清单 13-1 )。

清单 13-1lettersort,按字母顺序排列单词中的字母

lettersort() #@ Sort letters in $1, store in $2
{
  local letter string
  declare -A letters
  string=${1:?}
  while [ -n "$string" ]
  do
    letter=${string:0:1}
    letters["$letter"]=${letters["$letter"]}$letter
    string=${string#?}
  done
  printf -v "${2:-_LETTERSORT}" "%s" "${letters[@]}"
}

你会问,这有什么意义?看看这些例子:

$ lettersort triangle; printf "%s\n" "$_LETTERSORT"
aegilnrt
$ lettersort integral; printf "%s\n" "$_LETTERSORT"
aegilnrt

当对字母进行排序时,可以看到这两个单词包含相同的字母。因此,它们是彼此的变位词。用改变警告关联的单词来尝试这个过程。

插入排序函数

如果您真的想在 shell 中进行排序,您可以这样做。当元素超过 15 到 20 个时,清单 13-2 中的函数比外部sort命令要慢(具体数字会根据你的计算机、它的负载等等而变化)。它将每个元素插入到数组中的正确位置,然后打印结果数组。

Image 注意sort函数是一个用 C 语言编写的程序,针对速度进行了优化,并进行编译,而用bash编写的脚本在运行时被解释。然而,这完全取决于您正在排序的元素数量和您的 scipt 的构造方式,这决定了sort是否适合使用您自己的脚本排序。

清单 13-2isort,对命令行参数进行排序

isort()
{
  local -a a
  a=( "$1" ) ## put first argument in array for initial comparison
  shift      ## remove first argument
  for e      ## for each of the remaining arguments…
  do
    if [ "$e" \< "${a[0]}" ]                ## does it precede the first element?
    then
      a=( "$e" "${a[@]}" )                  ## if yes, put it first
    elif [ "$e" \> "${a[${#a[@]}-1]}" ]     ## if no, does it go at the end?
    then
      a=( "${a[@]}" "$e" )                  ## if yes, put it at the end
    else                                    ## otherwise,
      n=0
      while [ "${a[$n]}" \< "$e" ]          ## find where it goes
      do
        n=$(( $n + 1 ))
      done
      a=( "${a[@]:0:n}" "$e" "${a[@]:n}" )  ## and put it there
    fi
  done
  printf "%s\n" "${a[@]}"
}

要按字母顺序排列加拿大的十个省会,您可以使用以下代码:

$ isort "St. John's" Halifax Fredericton Charlottetown "Quebec City" \
                       Toronto Winnipeg Regina Edmonton Victoria
Charlottetown
Edmonton
Fredericton
Halifax
Quebec City
Regina
St. John's
Toronto
Victoria
Winnipeg

搜索数组

isort函数一样,这个 函数是为相对较小的数组设计的。如果数组包含超过一定数量的元素(50?60?70?),通过grep管道更快。清单 13-3 中的函数将一个数组名和一个搜索字符串作为参数,并将包含搜索字符串的元素存储在一个新数组_asearch_elements中。

清单 13-3asearch,搜索一个字符串数组的元素

asearch() #@ Search for substring in array; results in array _asearch_elements
{         #@ USAGE: asearch arrayname string
  local arrayname=$1 substring=$2  array

  eval "array=( \"\${$arrayname[@]}\" )"

  case ${array[*]} in
    *"$substring"*) ;;  ## it's there; drop through
    *) return 1 ;;      ## not there; return error
  esac

  unset _asearch_elements
  for subscript in "${!array[@]}"
  do
    case ${array[$subscript]} in
      *"$substring"*)
               _asearch_elements+=( "${array[$subscript]}" )
               ;;
    esac
  done
}

要查看函数的运行情况,请将上一节中的省会放入一个数组中,并调用asearch:

$ capitals=( "St. John's" Halifax Fredericton Charlottetown "Quebec City"
                       Toronto Winnipeg Regina Edmonton Victoria )
$ asearch captials Hal && printf "%s\n"  "${_asearch_elements[@]}"
Halifax
$ asearch captials ict && printf "%s\n"  "${_asearch_elements[@]}"
Fredericton
Victoria

将数组读入内存

bash将文件读入数组有多种方式。最明显也是最慢的一个while read循环:

unset array
while read line
do
  array+=( "$line" )
done < "$kjv"         ## kjv is defined in Chapter 8

一种更快的方法仍然是可移植的,它使用外部命令cat:

IFS=$'\n'             ## split on newlines, so each line is a separate element
array=( $(cat "$kjv") )

bash中,cat是不必要的:

array=( < "$kjv" )    ## IFS is still set to a newline

有了bash-4.x,一个新的内置命令mapfile,甚至更快:

mapfile -t array < "$kjv"

mapfile的选项允许您选择开始读取的行(实际上,它是开始读取之前要跳过的行数)、要读取的行数以及开始填充数组的索引。如果没有给定数组名,则使用变量MAPFILE

以下是mapfile的七个选项:

  • -n num:读取不超过num
  • -O index:从元素index开始填充数组
  • -s num:丢弃前num
  • -t:删除每行的结尾换行符
  • -u fd:从输入流fd中读取,而不是标准输入
  • -C callback:每隔N行对 shell 命令callback求值,其中N-c N置位
  • -c N:指定callback每次求值之间的行数;默认是5000

使用旧版本的bash,您可以使用 sed从文件中提取行的范围;有了bash-4.x,你可以使用mapfile。清单 13-4 安装一个函数,如果bash的版本是 4.x 或更高版本,则使用mapfile,否则使用sed

清单 13-4getlines,将文件中的一系列行存储在一个数组中

if [ "${BASH_VERSINFO[0]}" -ge 4 ]
then
  getlines() #@ USAGE: getlines file start num arrayname
  {
    mapfile -t -s$(( $2 - 1 )) -n ${3:?} "$4" < "$1"
  }
else
  getlines() #@ USAGE: getlines file start num arrayname
  {
    local IFS=$'\n' getlinearray arrayname=${4:?}
    getlinearray=( $(sed -n "$2,$(( $2 - 1 + $3 )) p" "$1") )
    eval "$arrayname=( \"\${getlinearray[@]}\" )"
  }
fi

进程替换和外部实用程序可与mapfile一起使用,使用不同的标准提取文件的各个部分:

mapfile -t exodus < <(grep ^Exodus: "$kjv")     ## store the book of Exodus
mapfile -t books < <(cut -d: -f1 "$kjv" | uniq) ## store names of all books in KJV

Image 提示你也可以使用readarray将数据从一个文件读入一个数组,它基本上是mapfile的别名。

二维网格

程序员经常要和二维网格的 打交道。作为纵横字谜的构造者,我需要将字谜文件中的网格转换成我的客户出版物可以导入桌面出版软件的格式。作为一名国际象棋导师,我需要将国际象棋的位置转换成一种我可以在学生的工作表中使用的格式。在tic-tac-toemaxitfifteen(出自第十一章)等游戏中,游戏棋盘是一个格子。

最容易使用的结构是二维数组。因为bash只有一维数组,所以需要一个工作区来模拟二维数组。这可以通过一个数组、一个字符串、一个字符串数组或者一个“穷人”数组来实现(见第九章)。

对于国际象棋图,可以使用关联数组,使用标准代数符号(SAN)来标识方格,a1b1g8h8:

declare -A chessboard
chessboard["a1"]=R
chessboard["a2"]=P
: ... 60 squares skipped
chessboard["g8"]=r
chessboard["h8"]=b

我在一些场合下使用的结构是一个数组,其中每个元素是一个表示等级的字符串:

chessboard=(
  RNBQKBRN
  PPPPPPPP
 "        "
 "        "
 "        "
 "        "
  pppppppp
  rnbqkbnr
)

当使用bash时,我的偏好是一个简单的索引数组:

chessboardarray=(
R N B Q K B R N
P P P P P P P P
"" "" "" "" "" "" "" ""
"" "" "" "" "" "" "" ""
"" "" "" "" "" "" "" ""
"" "" "" "" "" "" "" ""
p p p p p p p p
r n b q k b n r
)

或者,在 POSIX shell 中,它可以是单个字符串:

chessboard="RNBQKBRNPPPPPPPP                                pppppppprnbqkbnr"

接下来,讨论两个函数库,一个用于处理单个字符串中的网格,另一个用于存储在数组中的网格。

使用单字符串网格

我有一个函数库,stringgrid- funcs ,用于处理存储在单个字符串中的二维网格。有一个函数将网格的所有元素初始化为给定的字符,还有一个函数根据xy坐标计算字符串中的索引。一个是使用x/y获取字符串中的字符,另一个是在x / y将字符放入网格。最后,有一些函数可以打印一个网格,从第一行或最后一行开始。这些函数仅适用于方形网格。

函数: initgrid

给定网格的名称(即变量名)、大小和可选的填充字符,initgrid ( 清单 13-5 )用提供的参数创建一个网格。如果没有提供字符,则使用空格。

清单 13-5initgrid,创建一个网格并填充它

initgrid() #@ Fill N x N grid with a character
{          #@ USAGE: initgrid gridname size [character]
  ## If a parameter is missing, it's a programming error, so exit
  local grid gridname=${1:?} char=${3:- } size
  export gridsize=${2:?}                ## set gridsize globally

  size=$(( $gridsize ** 2 ))            ## total number of characters in grid
  printf -v grid "%$size.${size}s" " "  ## print string of spaces to variable
  eval "$gridname=\${grid// /"$char"}"  ## replace spaces with desired character
}

字符串的长度是网格大小的平方。使用printf中的宽度规范创建该长度的字符串,并使用-v选项将其保存到作为参数提供的变量中。然后,模式替换用请求的字符串替换空格。

这个函数和这个库中的其他函数使用${var:?}扩展 ,如果没有参数值,它会显示一个错误并退出脚本。这是适当的,因为如果缺少参数,这是编程错误,而不是用户错误。即使因为用户未能提供而丢失,也仍然是编程错误;脚本应该检查是否输入了一个值。

井字格是由九个空格组成的字符串。对于如此简单的东西,initgrid函数几乎没有必要,但它是一个有用的抽象:

$ . stringgrid-funcs
$ initgrid ttt 3
$ sa "$ttt"       ## The sa script/function has been used in previous chapters
:         :

函数: gridindex

要将xy坐标转换到网格串中相应的位置,从row数中减去 1,乘以gridsize,并添加列。清单 13-6 ,gridindex,是一个简单的公式,可以在需要时内联使用,但是抽象使得使用字符串网格更容易,并且将公式本地化,这样如果变更,它只需要在一个地方修正。

清单 13-6gridindex,计算行列索引

gridindex() #@ Store row/column's index into string in var or $_gridindex
{        #@ USAGE: gridindex row column [gridsize] [var]]
  local row=${1:?} col=${2:?}

  ## If gridsize argument is not given, take it from definition in calling script
  local gridsize=${3:-$gridsize}
  printf -v "${4:-_GRIDINDEX}" "%d" "$(( ($row - 1) * $gridsize + $col - 1))"
}

井字格字符串中第 2 行第 3 列的索引是什么?

$ gridindex 2 3    ## gridsize=3
$ echo "$_GRIDINDEX"
5

功能:放网格

要改变网格字符串中的字符 ,putgrid ( 清单 13-7 )需要四个参数:包含字符串的变量的名称、rowcolumn坐标以及新字符。它使用bash的子串参数扩展将字符串分成字符前的部分和字符后的部分。然后,它将新字符夹在两部分之间,并将复合字符串赋给gridname变量。(与第七章中的_overlay功能进行比较。)

清单 13-7putgrid,在指定行和列的网格中插入字符

putgrid() #@ Insert character int grid at row and column
{         #@ USAGE: putgrid gridname row column char
  local gridname=$1        ## grid variable name
  local left right         ## string to left and right of character to be changed
  local index              ## result from gridindex function
  local char=${4:?}        ## character to place in grid
  local grid=${!gridname}  ## get grid string though indirection

  gridindex ${2:?} ${3:?} "$gridsize" index

  left=${grid:0:index}
  right=${grid:index+1}
  grid=$left$4$right
  eval "$gridname=\$grid"
}

以下是井字游戏第一步的代码:

$ putgrid ttt 1 2 X
$ sa "$ttt"
: X       :

函数:获取网格

putgrid的反义词是 getgrid ( 清单 13-8 )。它返回给定位置的字符。它的参数是网格名称(我也可以使用字符串本身,因为没有给它赋值,但是网格名称用于保持一致性)、坐标和存储字符的变量的名称。如果没有提供变量名,它被存储在_GRIDINDEX中。

清单 13-8getgrid,获取网格中行列位置的字符

getgrid() #@ Get character from grid in row Y, column X
{         #@ USAGE: getgrid gridname row column var
  : ${1:?} ${2:?} ${3:?} ${4:?}
  local grid=${!1}
  gridindex "$2" "$3"
  eval "$4=\${grid:_GRIDINDEX:1}"
}

这个代码片段返回方块e1中的棋子。国际象棋实用程序会将方块转换成坐标,然后调用getgrid函数。这里直接使用它:

$ gridsize=8
$ chessboard="RNBQKBRNPPPPPPPP                                pppppppprnbqkbnr"
$ getgrid chessboard 1 5 e1
$ sa "$e1"
:K:

功能:显示网格

这个函数(清单 13-9 ) 使用子串扩展和gridsize变量从字符串网格中提取行,并将它们打印到标准输出。

清单 13-9showgrid,从一个字符串打印一个网格

showgrid() #@ print grid in rows to stdout
{          #@ USAGE: showgrid gridname [gridsize]
  local grid=${!1:?} gridsize=${2:-$gridsize}
  local row    ## the row to be printed, then removed from local copy of grid

  while [ -n "$grid" ]  ## loop until there's nothing left
  do
    row=${grid:0:"$gridsize"}     ## get first $gridsize characters from grid
    printf "\t:%s:\n" "$row"      ## print the row
    grid=${grid#"$row"}           ## remove $row from front of grid
  done
}

这里另一步棋被添加到井字游戏板上并显示出来:

$ gridsize=3    ## reset gridsize after changing it for the chessboard
$ putgrid ttt 2 2 O ## add O's move in the center square
$ showgrid ttt  ## print it
        : X :
        : O :
        :   :

函数: rshowgrid

对于大多数网格,从左上角开始计算 。对于其他的,比如棋盘,从左下角开始。为了显示棋盘,rgridshow函数提取并显示从字符串末尾开始的行,而不是从开头开始。

在清单 13-10 中,子串扩展与负数一起使用。

清单 13-10rshowgrid,以相反的顺序打印一个网格

rshowgrid() #@ print grid to stdout in reverse order
{           #@ USAGE: rshowgrid grid [gridsize]
  local grid gridsize=${2:-$gridsize} row
  grid=${!1:?}
  while [ -n "$grid" ]
  do
    ## Note space before minus sign
    ## to distinguish it from default value substitution
    row=${grid: -$gridsize}   ## get last row from grid
    printf "\t:%s:\n" "$row"  ## print it
    grid=${grid%"$row"}       ## remove it
  done
}

这里,rshowgrid用来显示一盘棋的第一步棋。(感兴趣的话,开篇叫鸟的开篇。不常玩,但我已经成功用了 45 年了。)

$ gridsize=8
$ chessboard="RNBQKBRNPPPPPPPP                                pppppppprnbqkbnr"
$ putgrid chessboard 2 6 ' '
$ putgrid chessboard 4 6 P
$ rshowgrid chessboard
        :rnbqkbnr:
        :pppppppp:
        :        :
        :        :
        :     P  :
        :        :
        :PPPPP PP:
        :RNBQKBRN:

这些输出功能可以通过一个实用程序(如sedawk)的管道输出来扩充,甚至可以替换为特定用途的自定义功能。我发现当管道穿过sed来增加一些间距时,棋盘看起来更好:

$ rshowgrid chessboard | sed 's/./& /g' ## add a space after every character
         : r n b q k b n r :
         : p p p p p p p p :
         :                 :
         :                 :
         :           P     :
         :                 :
         : P P P P P   P P :
         : R N B Q K B R N :

使用数组的二维网格

对于许多网格来说,单个 字符串就足够了(并且可以移植到其他 shells),但是基于数组的网格提供了更多的灵活性。在第十一章的fifteen谜题中,棋盘存放在一个数组中。它使用一个格式字符串用printf打印,这个格式字符串可以很容易地改变,使它具有不同的外观。数组中的井字格可能如下所示:

$ ttt=( "" X "" "" O "" "" X "" )

这是格式字符串:

$ fmt="
     |   |
   %1s | %1s | %1s
 ----+---+----
   %1s | %1s | %1s
 ----+---+----
   %1s | %1s | %1s
     |   |

  "

打印出来的结果是这样的:

$ printf "$fmt" "${ttt[@]}"

     |   |
     | X |
 ----+---+----
     | O |
 ----+---+----
     | X |
     |   |

如果格式字符串更改为:

fmt="

       _/     _/
    %1s  _/  %1s  _/  %1s
       _/     _/
 _/_/_/_/_/_/_/_/_/_/
       _/     _/
    %1s  _/  %1s  _/  %1s
       _/     _/
 _/_/_/_/_/_/_/_/_/_/
       _/     _/
    %1s  _/  %1s  _/  %1s
       _/     _/

"

输出将如下所示:

       _/     _/
       _/  X  _/
       _/     _/
 _/_/_/_/_/_/_/_/_/_/
       _/     _/
       _/  O  _/
       _/     _/
 _/_/_/_/_/_/_/_/_/_/
       _/     _/
       _/  X  _/
       _/     _/

同样的输出可以用单字符串网格来实现,但是它需要循环遍历字符串中的每个字符。数组是一组元素,可以根据需要单独寻址,也可以同时寻址。

arraygrid- funcs 中的功能与stringgrid-funcs中的功能相同。事实上,gridindex的功能与stringgrid-funcs中的功能完全相同,这里不再赘述。与sdtring网格函数一样,有些函数希望网格的大小在变量agridsize 中可用。

函数: initagrid

阵列网格的大多数函数 都比它们的单字符串对应物简单。一个明显的例外是initagrid ( 清单 13-11 ),它更长更慢,因为需要一个循环而不是简单的赋值。整个数组可以被指定为参数,任何未使用的数组元素将被初始化为空字符串。

清单 13-11initagrid,初始化一个网格数组

initagrid() #@ Fill N x N grid with supplied data (or placeholders if none)
{           #@ USAGE: initgrid gridname size [character ...]
  ## If a required parameter is missing, it's a programming error, so exit
  local grid gridname=${1:?} char=${3:- } size
  export agridsize=${2:?}             ## set agridsize globally

  size=$(( $agridsize * $agridsize )) ## total number of elements in grid

  shift 2        ## Remove first two arguments, gridname and agridsize
  grid=( "$@" )  ## What's left goes into the array

  while [ ${#grid[@]} -lt $size ]
  do
    grid+=( "" )
  done

  eval "$gridname=( \"\${grid[@]}\" )"
}

功能: putagrid

改变一个 数组中的值是一个简单的任务。与改变字符串中的字符不同,不需要把它拆开再装回去。所需要的就是从坐标中计算出的索引。这个函数(清单 13-12 )需要定义agridsize

清单 13-12putagrid,替换一个网格元素

putagrid() #@ Replace character in grid at row and column
{          #@ USAGE: putagrid gridname row column char
  local left right pos grid gridname=$1
  local value=${4:?} index
  gridindex ${2:?} ${3:?} "$agridsize" index   ## calculate the index
  eval "$gridname[index]=\$value"              ## assign the value
}

函数: getagrid

给定xy 坐标,getagrid获取该位置的值,并将其存储在提供的变量中(清单 13-13 )。

清单 13-13getagrid,从网格中提取一个条目

getagrid() #@ Get entry from grid in row Y, column X
{          #@ USAGE: getagrid gridname row column var
  : ${1:?} ${2:?} ${3:?} ${4:?}
  local grid

  eval "grid=( \"\${$1[@]}\" )"
  gridindex "$2" "$3"
  eval "$4=\${grid[$_GRIDINDEX]}"
}

功能: showagrid

函数showagrid ( 清单 13-14 )将 的数组网格的每一行打印在单独的一行上。

清单 13-14showagrid,描述

showagrid() #@ print grid to stdout
{           #@ USAGE: showagrid gridname format [agridsize]
  local gridname=${1:?} grid
  local format=${2:?}
  local agridsize=${3:-${agridsize:?}} row

  eval "grid=( \"\${$1[@]}\" )"
  printf "$format" "${grid[@]}"
}

功能: rshowagrid

函数rshowagrid ( 清单 13-15 ) 以相反的顺序在单独的行上打印数组网格的每一行。

清单 13-15 。r showagrid,描述

rshowagrid() #@ print grid to stdout in reverse order
{            #@ USAGE: rshowagrid gridname format [agridsize]
  local format=${2:?} temp grid
  local agridsize=${3:-$agridsize} row
  eval "grid=( \"\${$1[@]}\" )"
  while [ "${#grid[@]}" -gt 0 ]
  do
    ## Note space before minus sign
    ## to distinguish it from default value substitution
    printf "$format" "${grid[@]: -$agridsize}"
    grid=( "${grid[@]:0:${#grid[@]}-$agridsize}" )
  done
}

数据文件格式

数据文件有许多用途,有许多不同的风格,分为两种主要类型:面向行的和面向块的。在面向行的文件中,每一行都是一个完整的记录,通常带有由某个字符分隔的字段。在面向块的文件中,每条记录可以跨多行,一个文件中可能有多个块。在某些格式中,记录不止是一个块(例如,PGN 格式的国际象棋游戏是由空白行分隔的两个块)。

shell 不是处理大型数据文件的最佳语言;当处理单个记录时更好。然而,有一些实用程序,比如sedawk,可以有效地处理大型文件,并提取记录传递给 shell。本节处理单个记录。

基于行的记录

基于行的记录是那些 的记录,其中文件中的每一行都是一个完整的记录。它通常由一个定界字符分成多个字段,但有时这些字段由长度定义:前 20 个字符是名称,接下来的 20 个字符是地址的第一行,依此类推。

当文件很大时,处理通常由外部实用程序完成,如sedawk。有时会使用一个外部实用程序来选择一些记录供 shell 处理。这个代码片段在密码文件中搜索 Shell 为bash的用户,并将结果提供给 Shell 来执行一些(未指定的)检查:

grep 'bash$' /etc/passwd |
while read line
do
  : perform some checking here
done

分隔符分隔的值

大多数单行记录都有由某个字符分隔的字段。在/etc/passwd中,分隔符是冒号。在其他文件中,分隔符可能是制表符、波浪号,或者更常见的是逗号。为了使这些记录有用,必须将它们拆分到各自的字段中。

当在输入流上接收到记录时,分割它们的最简单方法是更改IFS并将每个字段读入它自己的变量:

grep 'bash$' /etc/passwd |
while IFS=: read user passwd uid gid name homedir shell
do
  printf "%16s: %s\n" \
      User       "$user" \
      Password   "$passwd" \
      "User ID"  "$uid" \
      "Group ID" "$gid" \
      Name       "$name" \
"Home directory" "$homedir" \
      Shell      "$shell"

  read < /dev/tty
done

有时无法在读取记录时将其拆分,例如,如果需要完整的记录,也可以将其拆分为组成字段。在这种情况下,可以将整行读入一个变量,然后使用几种技术中的任何一种进行拆分。对于所有这些,这里的例子将使用来自/etc/passwd的根条目:

record=root:x:0:0:root:/root:/bin/bash

可以使用参数扩展一次提取一个字段:

for var in user passwd uid gid name homedir shell
do
  eval "$var=\${record%%:*}"  ## extract the first field
  record=${record#*:}         ## and take it off the record
done

只要没有在任何字段中找到定界字符,就可以通过将IFS设置为定界符来分割记录。进行此操作时,应关闭文件名扩展 (使用set -f)以避免扩展任何通配符。字段可以存储在数组中,变量可以设置为引用它们:

IFS=:
set -f
data=( $record )
user=0
passwd=1
uid=2
gid=3
name=4
homedir=5
shell=6

变量名是可用于从data数组中检索值的字段名称:

$ echo;printf "%16s: %s\n" \
      User       "${data[$user]}" \
      Password   "${data[$passwd]}" \
      "User ID"  "${data[$uid]}" \
      "Group ID" "${data[$gid]}" \
      Name       "${data[$name]}" \
"Home directory" "${data[$homedir]}" \
      Shell      "${data[$shell]}"

            User: root
        Password: x
         User ID: 0
        Group ID: 0
            Name: root
  Home directory: /root
           Shell: /bin/bash

更常见的是将每个字段分配给一个标量变量 。这个函数(清单 13-16 )获取一个passwd记录,用冒号分割它,并将字段分配给变量。

清单 13-16split_passwd,将来自/etc/passwd的记录分割成字段并分配给变量

split_passwd() #@ USAGE: split_passwd RECORD
{
  local opts=$-    ## store current shell options
  local IFS=:
  local record=${1:?} array

  set -f                                  ## Turn off filename expansion
  array=( $record )                       ## Split record into array
  case $opts in *f*);; *) set +f;; esac   ## Turn on expansion if previously set

  user=${array[0]}
  passwd=${array[1]}
  uid=${array[2]}
  gid=${array[3]}
  name=${array[4]}
  homedir=${array[5]}
  shell=${array[6]}
}

同样的事情可以使用这里的文档(清单 13-17 )来完成。

清单 13-17split_passwd,将/etc/passwd中的一条记录拆分成字段并分配给变量

split_passwd()
{
  IFS=: read user passwd uid gid name homedir shell <<.
$1
.
}

更一般地,任何字符分隔的记录都可以用这个函数拆分成每个字段的变量(清单 13-18 )。

清单 13-18split_record,通过读取变量拆分一条记录

split_record() #@ USAGE parse_record record delimiter var ...
{
  local record=${1:?} IFS=${2:?} ## record and delimiter must be provided
  : ${3:?}                       ## at least one variable is required
  shift 2                        ## remove record and delimiter, leaving variables

  ## Read record into a list of variables using a 'here document'
  read "$@" <<.
$record
.
}

使用前面定义的record,下面是输出:

$ split_record "$record" : user passwd uid gid name homedir shell
$ sa "$user" "$passwd" "$uid" "$gid" "$name" "$homedir" "$shell"
:root:
:x:
:0:
:0:
:root:
:/root:
:/bin/bash:

固定长度字段

比带分隔符的 字段更不常见的是固定长度字段。它们不常被使用,但是当它们被使用时,它们会被循环通过name=width字符串来解析它们,这就是许多文本编辑器从固定长度的字段数据文件导入数据的方式:

line="John           123 Fourth Street   Toronto     Canada                "
for nw in name=15 address=20 city=12 country=22
do
  var=${nw%%=*}                 ## variable name precedes the equals sign
  width=${nw#*=}                ## field width follows it
  eval "$var=\${line:0:width}"  ## extract field
  line=${line:width}            ## remove field from the record
done

阻止文件格式

在众多类型的 块数据文件中,可移植游戏符号(PGN) 国际象棋文件是可以使用的。它以人类可读和机器可读的格式存储一个或多个国际象棋游戏。所有的国际象棋程序都可以读写这种格式。

每场比赛开始时都有一个七个标签的花名册,上面标明了比赛的时间和地点,比赛者和结果。接下来是一个空行,然后是游戏的走法。

这里有一个 PGN 象棋游戏文件(来自http://cfaj.freeshell.org/Fidel.pgn):

[Event "ICS rated blitz match"]
[Site "69.36.243.188"]
[Date "2009.06.07"]
[Round "-"]
[White "torchess"]
[Black "FidelCastro"]
[Result "1-0"]

1\. f4 c5 2\. e3 Nc6 3\. Bb5 Qc7 4\. Nf3 d6 5\. b3 a6 6\. Bxc6+ Qxc6 7\. Bb2 Nf6
8\. O-O e6 9\. Qe1 Be7 10\. d3 O-O 11\. Nbd2 b5 12\. Qg3 Kh8 13\. Ne4 Nxe4 14.
Qxg7#
{FidelCastro checkmated} 1-0

你可以使用一个while循环来读取标签,然后使用mapfile来获取游戏的移动。gettag函数从每个标签中提取值,并将其分配给标签名(清单 13-19 )。

清单 13-19readpgn,解析一个 PGN 游戏并打印游戏在一列中

pgnfile="${1:?}"
header=0
game=0

gettag() #@ create a variable with the same name and value as the tag
{
  local tagline=$1
  tag=${tagline%% *}        ## get line before the first space
  tag=${tag#?}              ## remove the open bracket
  IFS='"' read a val b <<.  ## get the 2nd field, using " as delimiter
   $tagline
.

  eval "$tag=\$val"
}

{
  while IFS= read -r line
  do
    case $line in
      \[*) gettag "$line" ;;
      "") [ -n "$Event" ] && break;;  ## skip blank lines at beginning of file
    esac
  done
  mapfile -t game                     ## read remainder of the file
} < "$pgnfile"

## remove blank lines from end of array
while [ -z "${game[${#game[@]}-1]}" ]
do
  unset game[${#game[@]}-1]
done

## print the game with header
echo "Event: $Event"
echo "Date:  $Date"
echo
set -f
printf "%4s  %-10s %-10s\n" "" White Black  ""  ========== ========== \
          "" "$White" "$Black" ${game[@]:0:${#game[@]}-1}
printf "%s\n" "${game[${#game[@]}-1]}"

摘要

这一章仅仅触及了数据操作可能性的表面,但是希望它能提供一些技术来解决您的一些需求,并为其他人提供一些提示。这一章的大部分内容都涉及到使用最基本的编程结构,数组。展示了处理单行字符分隔记录的技术,以及处理文件中数据块的基本技术。

练习

  1. 如果数组超过一定的大小,修改isortasearch函数,分别使用sortgrep

  2. Write a function that transposes rows and columns in a grid (either a single-string grid or an array). For example, transform these:

    123
    456
    789
    

    变成这些:

    147
    256
    369
    
  3. 转换一些网格函数(字符串或数组版本),以处理非正方形的网格,例如 6 × 3。

  4. 将解析固定宽度记录的代码转换成一个函数,该函数接受数据行作为第一个参数,后跟varname=width列表。

十四、编写屏幕脚本

Unix 纯粹主义者会对这一章摇头。传统上,屏幕操作是通过termcapterminfo数据库来完成的,这些数据库提供了操作任何几十种甚至上百种终端所需的信息。数据库的 shell 接口是一个外部命令,tput

在某些系统上,tput 使用的是termcap数据库;在其他系统上(大多数是新系统),它使用terminfo数据库。两个数据库的命令是不同的,所以为一个系统编写的tput命令可能在另一个系统上不起作用。

在一个系统中,将光标放在第 10 行第 20 列的命令如下:

tput cup 9 19

在另一个系统上,这是命令:

tput cm 9 19

这些命令将为TERM变量中指定的任何类型的终端产生正确的输出。(注:tput从 0 开始计数。)

然而,过多的终端类型,出于所有的意图和目的,已经减少到一个单一的,标准的类型。这个标准,ISO 6429(又名 ECMA-48,原名 ANSI X3.64 或 VT100),无处不在,不支持它的终端少之又少。因此,现在为单一终端类型编码是可行的。这种同质性的一个优点是必要的编码可以完全在 Shell 内完成。不需要外部命令。

电传打字机对帆布

将脚本的输出发送到终端屏幕有两种方法。第一种也是更传统的方法使用终端,就好像它是一台打印机或电传打字机一样(这就是屏幕或终端的缩写tty的由来)。在这种模式下,每打印一行,纸张(或屏幕图像)就会向上滚动。旧线条落到地板上(或从屏幕顶部消失)。它很简单,对于许多应用来说绰绰有余。

第二种方法将屏幕视为黑板或画布,并打印到其表面的特定点。它会擦除和套印先前写入的部分。它可以在屏幕上按列或特定位置打印文本。终端变成一个随机访问而不是串行的设备。

本章将屏幕视为画布或黑板。它为屏幕操作定义了许多变量和函数,并展示了一些使用它们的演示程序。

拉伸画布

要将屏幕用作画布,最重要的功能是能够将光标定位在屏幕上的任何给定位置。其顺序是ESC<ROW>;<COL>H。当转换为printf格式字符串时,它可以直接使用或在函数中使用:

cu_row_col=$'\e[%d;%dH'
printf "$cu_row_col" 5 10  ## Row 5, column 10
echo "Here I am!"

本章中的所有函数都是screen-funcs库的一部分,它是screen-vars文件的来源。[清单 14-1 给出了屏幕操作功能。

清单 14-1screen-funcs,屏幕操作函数库

. screen-vars

printat函数(清单 14-2 )将光标放在请求的位置,如果还有其他参数,它将打印出来。如果没有指定行和列,printat将光标移动到屏幕的左上角。

清单 14-2printat,将光标放在指定位置,打印可选字符串

printat() #@ USAGE: printat [row [column [string]]]
{
    printf "${cu_row_col?}" ${1:-1} ${2:-1}
    if [ $# -gt 2 ]
    then
      shift 2
      printf "%s" "$*"
    fi
}

命令序列导入器

像所有的转义序列一样,cu_row_colESC开始。这是命令序列介绍者(CSI) 。在screen-vars文件中定义([清单 14-3 )。

清单 14-3screen-vars,屏幕变量定义

ESC=$'\e'
CSI=$ESC

给画布涂底漆

在屏幕上绘图之前,通常必须清除它,并且不时地,屏幕的各个部分将需要被清除。这些变量包含清除屏幕或线条的基本序列([清单 14-4 )。

清单 14-4screen-vars,用于擦除全部或部分屏幕的变量定义

topleft=${CSI}H      ## move cursor to top left corner of screen
cls=${CSI}J          ## clear the screen
clear=$topleft$cls   ## clear the screen and move to top left corner
clearEOL=${CSI}K     ## clear from cursor to end of line
clearBOL=${CSI}1K    ## clear from cursor to beginning of line
clearEOS=${CSI}0J    ## clear from cursor to end of screen
clearBOS=${CSI}1J    ## clear from cursor to beginning of screen

还有清除屏幕矩形区域的功能,这将在本章后面介绍。

移动光标

除了将移动到绝对位置,光标还可以相对于其当前位置移动。前四个序列与光标键生成的序列相同,它们接受移动多行或多列的参数。接下来的两个按钮打开和关闭光标。以下两个变量分别保存光标位置并将其移回保存的位置。

最后两个移动到下一行或上一行,与前一打印行的开头在同一列。删除了printf说明符、%s,因为它会消耗将要打印的参数(清单 14-5 )。

清单 14-5screen-vars,用于移动光标的变量定义

## cursor movement strings
     cu_up=${CSI}%sA
   cu_down=${CSI}%sB
  cu_right=${CSI}%sC
   cu_left=${CSI}%sD

## turn the cursor off and on
   cu_hide=${CSI}?25l
   cu_show=${CSI}?12l${CSI}?25h

## save the cursor position
   cu_save=${CSI}s                  ## or ${ESC}7
## move cursor to saved position
cu_restore=${CSI}u                  ## or ${ESC}8

## move cursor to next/previous line in block
     cu_NL=$cu_restore${cu_down/\%s/}$cu_save
     cu_PL=$cu_restore${cu_up/\%s/}$cu_save

光标移动的格式字符串使用%s说明符而不是%d,即使任何参数都是数字。这是因为当没有参数填充时,printf用零替换%d。如果发生这种情况,光标根本不会移动。对于%s,当没有参数时,它们移动一列或一行,因为%s被一个空字符串代替。

清单 14-6 中的脚本让这些变量和printat函数开始工作。

清单 14-6screen-demo1,使printat工作的脚本

. screen-funcs                             ## source the screen-funcs library
printf "$clear$cu_hide"                    ## Clear the screen and hide the cursor
printat 10 10 "${cu_save}XX"               ## move, save position, and print XX
sleep 1                                    ## ZZZZZZZZ
printat 20 20 "20/20"                      ## move and print
sleep 1                                    ## ZZZZZZZZ
printf "$cu_restore$cu_down${cu_save}YY"   ## restore pos., move, print, save pos.
sleep 1                                    ## ZZZZZZZZ
printf "$cu_restore$cu_down${cu_save}ZZ" 4 ## restore pos., move, print, save pos.
sleep 1                                    ## ZZZZZZZZ
printat 1 1 "$cu_show"                     ## move to top left and show cursor

作为一种变化,尝试将第一个printat命令的坐标改为其他值,比如 5 和 40。

更改呈现模式和颜色

字符可以以粗体、下划线或反转模式打印,也可以在支持它们的终端上以各种颜色打印。(还有剩下不要的吗?)这些属性都用形式为ESC ATTRm的序列修改,其中ATTR是属性或颜色的编号([清单 14-7 )。可以通过用分号分隔来指定多个属性。

颜色用整数 0 到 7 指定,9 将重置为默认值。前景色以 3 为前缀,背景色以 4 为前缀。属性也由 0 到 7 指定,但没有前缀。虽然定义了八个属性,但只有三个得到广泛支持:1(粗体)、4(下划线)和 7(反转)。这些属性可以分别用值 22、24 和 27 单独关闭。值为 0 会将所有属性和颜色重置为默认值。

清单 14-7screen-vars,颜色和属性的变量定义

## colours
  black=0
    red=1
  green=2
 yellow=3
   blue=4
magenta=5
   cyan=6
  white=7

     fg=3  ## foreground prefix
     bg=4  ## background prefix

## attributes
     bold=1
underline=4
  reverse=7

## set colors
    set_bg="${CSI}4%dm"          ## set background color
    set_fg="${CSI}3%dm"          ## set foreground color
    set_fgbg="${CSI}3%d;4%dm"    ## set foreground and background colors

如下一个演示脚本所示,颜色和属性可以在“tty”模式和“canvas”模式中使用(清单 14-8 )。

清单 14-8screen-demo2,颜色和属性模式

. screen-funcs
echo
for attr in "$underline" 0 "$reverse" "$bold" "$bold;$reverse"
do
  printf "$set_attr" "$attr"
  printf "$set_fg %s " "$red" RED
  printf "$set_fg %s " "$green" GREEN
  printf "$set_fg %s " "$blue" BLUE
  printf "$set_fg %s " "$black" BLACK
  printf "\em\n"
done
echo

在屏幕上放置一个文本块

put_block函数在当前光标位置一个接一个地打印其参数;put_block_at将光标移动到指定位置,移动参数以删除行和列,然后用剩余的参数调用put_block([清单 14-9 )。

cu_NL变量将光标移动到保存的位置,然后向下移动一行并保存该位置。

清单 14-9put_block``put_block_``at,在屏幕上的任何地方打印一块文本

put_block() #@ Print arguments in a block beginning at the current position
{
  printf "$cu_save"      ## save cursor location
  printf "%s$cu_NL" "$@" ## restore cursor location, move line down, save cursor
}

put_block_at() #@ Print arguments in a block at the position in $1 and $2
{
  printat "$1" "$2"
  shift 2
  put_block "$@"
}

清单 14-10 显示了screen-demo3的脚本,它在屏幕上以柱状格式显示数据块。

清单 14-10screen-demo3

. screenfuncs

printf "$cls"
put_block_at 3 12 First Second Third Fourth Fifth
put_block_at 2 50 January February March April May June July

screen-demo3的输出如下:

                                                 January
           First                                 February
           Second                                March
           Third                                 April
           Fourth                                May
           Fifth                                 June
                                                 July

当屏幕为空时,put_blockput_block_at功能工作正常。如果屏幕上已经有很多文本,输出可能会模糊不清。对于这些情况,有print_block_atprint_block函数清除块周围的矩形区域。

为了确定需要清除的宽度,put_block将其参数传递给_max_length函数,该函数循环遍历参数以找到最长的(清单 14-11 )。

清单 14-11_max_length,在_MAX_LENGTH中存储最长参数的长度

_max_length() #@ store length of longest argument in _MAX_LENGTH
{
  local var
  _MAX_LENGTH=${#1}      ## initialize with length of first parameter
  shift                  ## ...and remove first parameter
  for var                ## loop through remaining parameters
  do
    [ "${#var}" -gt "$_MAX_LENGTH" ] && _MAX_LENGTH=${#var}
  done
}

print_block函数使用_max_length的结果作为printf ( 清单 14-12 )的宽度规格。文本前后打印空行,每行前后打印一个空格。print_block_atput_block_at唯一的区别就是一个叫print_block,一个叫put_block

清单 14-12print_block ,清除区域和打印块

print_block() #@ Print arguments in a block with space around them
{
  local _MAX_LENGTH
  _max_length "$@"
  printf "$cu_save"
  printf " %-${_MAX_LENGTH}s $cu_NL" " " "$@" " "
}

print_block_at() #@ Move to position, remove 2 parameters and call print_block
{
  printat $1 $2
  shift 2
  print_block "$@"
}

print_blockprint_block打印的文本更可能是单个字符串,而不是单独的参数。要将字符串分割成足够短的单词或短语以适应给定的空间,使用wrap函数 ( 清单 14-13 )。这个函数将一个字符串拆分成具有命令行指定的最大宽度的行。

清单 14-13wrap,将字符串拆分成元素不超过最大长度的数组

wrap() #@ USAGE: wrap string length
{      #@ requires bash-3.1 or later
  local words=$1 textwidth=$2 line= opts=$-
  local len=0 templen=0
  set -f

  unset -v wrap
  for word in $words
  do
    templen=$(( $len + 1 + ${#word} )) ## Test adding a word
    if [ "$templen" -gt "$textwidth" ] ## Does adding a word exceed length?
    then
      wrap+=( "$line" )                ## Yes, store line in array
      printf -v line "%s" "$word"      ## begin new line
      len=${#word}
    else
      len=$templen                     ## No, add word to line
      printf -v line "%s" "${line:+"$line "}" "$word"
    fi
  done
  wrap+=( "$line" )

  case $opts in
    *f*) ;;
    *) set +f ;;
  esac
}

在清单 14-14 中显示的例子使用了wrapprint_block_at

清单 14-14screen-demo4,演示了wrapprint_block功能

clear
wrap "The quick brown fox jumps over the lazy dog" 15
x=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
printat 1 1
printf "%s\n" $x{,,,,,,,,,,}          ## print 11 lines of 'x's
print_block_at 3 33 "${wrap[@]}"
printat 12 1

输出如下所示:

xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx                 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx The quick       xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx brown fox jumps xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx over the lazy   xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx dog             xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx                 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

滚动文本

通过将数组与子串扩展相结合,文本可以在屏幕的任何区域滚动。因为整个区域可以用一个printf命令打印,所以滚动很快,尽管随着数组大小的增加,滚动会变慢。清单 14-15 中的演示将/usr/bin/中的文件名存储在数组list中;向上滚动列表;等待一秒钟;然后向下滚动。

每个循环,向上和向下,都包含一个注释掉的read -t "$delay"行。当取消注释时,它会减慢滚动速度。它使用bash-4.x小数延迟。如果您使用的是早期版本,请使用sleep来代替。大多数实现(当然是 GNU 和*BSD)都接受分数参数。

清单 14-15scroll-demo,上下滚动文本块

list=( /usr/bin/* )          ## try it with other directories or lists
rows=9                       ## number of rows in scrolling area
delay=.01                    ## delay between scroll advance
width=-33.33                 ## width spec: (no more than) 33 chars, flush left
x=XXXXXXXXXXXXXXXXXXXXXXXXXX ## bar of 'X's
x=$x$x$x$x                   ## longer bar

clear                        ## clear the screen
printf "%50.50s\n" $x{,,,,,,,,,,,,,}          ## print 14 lines of 'X's

n=0                          ## start display with first element

## scroll upwards until reaching the bottom
while [ $(( n += 1 )) -lt $(( ${#list[@]} - $rows )) ]
do
  printf "\e[3;1H"
  printf "\e[7C %${width}s\n" "${list[@]:n:rows}"
#  read -sn1 -t "$delay" && break
done
sleep 1

## scroll downwards until reaching the top
while [ $(( n -= 1 )) -ge 0 ]
do
  printf "\e[3;1H"
  printf "\e[7C %${width}s\n" "${list[@]:n:rows}"
#  read -sn1 -t "$delay" && break
done

printf "\e15;1H"    ## finish with cursor well below scrolling area

滚石杂志说

骰子在许多游戏中都有使用,如果您满足于只打印数字,那么编程起来很简单:

printf "%s\n" "$(( $RANDOM % 6 + 1 ))"

然而,一个令人满意的图形呈现可以用 shell 编程实现,而且非常容易。要打印骰子,将光标定位在屏幕上所需的位置,设置前景色和背景色,并打印数组中的元素([图 14-1 )。

9781484201220_Fig14-01.jpg

图 14-1 。清单 14-16 包含了这些骰子的代码

可以用大约 25 行代码对六个骰子的阵列进行编程。每个骰子由 18 个变量串联而成。其中一些与那些在screen-funcs库中的内容相同,但是它们的名字在这里被缩短以保持行更短。以下是对编号为 5 的骰子的描述:

$b    ## set bold attribute (optional)
$cs   ## save cursor position
$p0   ## print blank row
$cr   ## restore cursor to left side of die
$dn   ## move down one line
$cs   ## save cursor position
$p4   ## print row with two pips
$cr   ## restore cursor to left side of die
$dn   ## move down one line
$cs   ## save cursor position
$p2   ## print row with one pip
$cr   ## restore cursor to left side of die
$dn   ## move down one line
$cs   ## save cursor position
$p4   ## print row with two pips
$cr   ## restore cursor to left side of die
$dn   ## move down one line
$p0   ## print blank row

定义骰子后,清单 14-16 中的脚本清空屏幕,并在屏幕顶部附近打印两个随机骰子。

清单 14-16dice,定义六个骰子的阵列,并在屏幕上放置两个

pip=o                      ## character to use for the pips
p0="       "               ## blank line
p1=" $pip     "            ## one pip at the left
p2="   $pip   "            ## one pipe in the middle of the line
p3="     $pip "            ## one pip at the right
p4=" $pip   $pip "         ## two pips
p5=" $pip $pip $pip "      ## three pips

cs=$'\e7'                  ## save cursor position
cr=$'\e8'                  ## restore cursor position
dn=$'\e[B'                 ## move down 1 line
b=$'\e[1m'                 ## set bold attribute
cu_put='\e[%d;%dH'         ## format string to position cursor
fgbg='\e[3%d;4%dm'         ## format string to set colors

dice=(
  ## dice with values 1 to 6 (array elements 0 to 5)
  "$b$cs$p0$cr$dn$cs$p0$cr$dn$cs$p2$cr$dn$cs$p0$cr$dn$p0"
  "$b$cs$p0$cr$dn$cs$p1$cr$dn$cs$p0$cr$dn$cs$p3$cr$dn$p0"
  "$b$cs$p0$cr$dn$cs$p1$cr$dn$cs$p2$cr$dn$cs$p3$cr$dn$p0"
  "$b$cs$p0$cr$dn$cs$p4$cr$dn$cs$p0$cr$dn$cs$p4$cr$dn$p0"
  "$b$cs$p0$cr$dn$cs$p4$cr$dn$cs$p2$cr$dn$cs$p4$cr$dn$p0"
  "$b$cs$p0$cr$dn$cs$p5$cr$dn$cs$p0$cr$dn$cs$p5$cr$dn$p0"
  )

clear
printf "$cu_put" 2 5               ## position cursor
printf "$fgbg" 7 0                 ## white on black
printf "%s\n" "${dice[RANDOM%6]}"  ## print random die

printf "$cu_put" 2 20              ## position cursor
printf "$fgbg" 0 3                 ## black on yellow
printf "%s\n" "${dice[RANDOM%6]}"  ## print random die

摘要

不涉及传统的 ASCII 艺术,有许多方法可以在终端屏幕上画图。本章介绍了其中的一些,给出了可以用来创建更多的基础知识。

练习

  1. 编写一个函数hbar,它接受两个整数参数,一个宽度和一个颜色,并打印一个具有该颜色和宽度的条形。编写第二个函数hbar_at,它接受四个参数:行、列、宽度和颜色;将光标移动到行和列;并将剩下的争论交给hbar
  2. 编写一个函数clear_area,它接受两个整数参数,即行和列,并清除多个行和列的矩形区域。

十五、入门级编程

与任何其他 POSIX shell 相比,bash更受青睐,这在很大程度上源于其增强交互式编程的扩展。对read内置命令的扩展选项(在第九章中有描述),结合historyreadline库,添加了其他 shell 无法比拟的功能。

尽管它很丰富,但是对于 shell 来说,仍然没有简单的方法来处理诸如生成多个字符的功能键之类的键。为此,本章介绍了key-funcs函数库。本章的第二个主要部分描述了如何在 shell 脚本中使用鼠标,并提供了一个演示程序。

在这两个部分之间,我们将检查用户输入的有效性和历史库。大多数人只在命令行使用bash的历史库。我们将在脚本中使用它,本章将展示如何通过在编辑多字段记录的基本脚本中使用history命令来做到这一点。

单键输入

编写交互式脚本时,您可能希望只按一个键,而不要求用户按 Enter。可移植的方法是使用sttydd:

stty -echo -icanon min 1
_KEY=$(dd count=1 bs=1 2>/dev/null)
stty echo icanon

每次需要按键时使用三个外部命令是多余的。当你需要使用一个可移植的方法时,你通常可以首先在脚本的开始调用stty,然后在最后调用另一个,通常在EXIT陷阱中:

trap 'stty echo icanon' EXIT

另一方面,Bash不需要调用任何外部命令。使用stty在开始时关闭回显并在退出前重新打开可能仍然是一个好主意。这将防止当脚本没有等待输入时字符出现在屏幕上。

函数库,按键功能

本节中的函数包括key-funcs库。它从两个变量定义开始,如清单 15-1 所示。

清单 15-1key-funcs,读取单个按键

ESC=$'\e'
CSI=$'\e'

要用bash获得一次击键,你可以使用清单 15-2 中的[函数。

清单 15-2_key,读取单键按下的功能

_key()
{
   IFS= read -r -s -n1 -d '' "${1:-_KEY}"
}

首先,字段分隔符被设置为空字符串,这样read就不会忽略前导空格(这是一个有效的击键,所以你想要它);-r选项禁用反斜杠转义,-s关闭按键回显,-n1告诉bash只读取单个字符。

-d ''选项告诉read不要将换行符(或任何其他字符)视为输入的结尾;这允许在变量中存储一个新行。该代码指示read在收到第一个密钥(-n1)后停止,因此它不会一直读取。

最后一个参数使用${@:-_KEY}将选项或变量名添加到参数列表中。您可以在清单 15-3 中的函数中看到它的用法。(注意,如果你使用一个没有变量名的选项,输入将被存储在$REPLY。)

Image 注意为了在早期版本的bash或 Mac OS X 上工作,将变量名添加到read命令中,例如IFS= read –r –s –n1 –d'' _KEY "${1:-_KEY}"。如果没有,那么您必须查看$REPLY中的按键读数。

在一个简单的菜单中可以使用_key功能,如清单 15-3 所示。

清单 15-3simplemenu,响应单次按键的菜单

## the _key function should be defined here if it is not already
while :
do
  printf "\n\n\t$bar\n"
  printf "\t %d. %s\n" 1 "Do something" \
                       2 "Do something else" \
                       3 "Quit"
  printf "\t%s\n" "$bar"
  _key
  case $_KEY in
     1) printf "\n%s\n\n" Something ;;
     2) printf "\n%s\n\n" "Something else" ;;
     3) break ;;
     *) printf "\a\n%s\n\n" "Invalid choice; try again"
        continue
        ;;
  esac
  printf ">>> %s " "Press any key to continue"
  _key
done

尽管_key本身是一个有用的函数,但它有其局限性(清单 15-4 )。它可以存储空格、换行符、控制代码或任何其他单个字符,但是它不处理返回多个字符的键:功能键、光标键和其他一些键。

这些特殊键返回 ESC (0 × 1B,保存在变量$ESC中),后跟一个或多个字符。字符的数量根据键(和终端仿真)的不同而不同,因此您不能要求特定数量的键。相反,您必须循环,直到读取到一个终止字符。这就是使用bash内置的read命令而不是外部的dd有所帮助的地方。

清单 15-4_keys,从功能或光标键中读取一系列字符

_keys() #@ Store all waiting keypresses in $_KEYS
{
    _KEYS=
    __KX=

    ## ESC_END is a list of characters that can end a key sequence
    ## Some terminal emulations may have others; adjust to taste
    ESC_END=[a-zA-NP-Z~^\$@$ESC]

    while :
    do
      IFS= read -rsn1 -d '' -t1 __KX
      _KEYS=$_KEYS$__KX
      case $__KX in
          "" | $ESC_END ) break ;;
      esac
    done
}

while :循环使用参数-t1调用_key,参数【】告诉 read 在一秒钟后超时,变量的名称用于存储击键。循环继续,直到$ESC_END中的一个键被按下或者read超时,剩下$__KX为空。

超时本身是检测退出键的一种部分令人满意的方法。在这种情况下,ddread工作得更好,因为它可以设置为以十分之一秒的增量超时。

要测试这些函数,使用_key来获取单个字符;如果那个字符是ESC,调用_keys 来读取序列的剩余部分(如果有的话)。下面的代码片段假设已经定义了_key_keys,并通过hexdump - C 管道显示每个击键的内容:

while :
do
  _key
  case $_KEY in
      $ESC) _keys
            _KEY=$ESC$_KEYS
            ;;
  esac
  printf "%s" "$_KEY" | hexdump -C | {
               read a b
               printf "   %s\n" "$b"
             }
  case "$_KEY" in q) break ;; esac
done

不同于在任何地方都能工作的输出序列,由各种终端仿真器产生的按键序列之间没有同质性。以下是在rxvt终端窗口中,按 F1、F12、上箭头、Home 和 q 退出的运行示例:

   1b 5b 31 31 7e                |.11~|
   1b 5b 32 34 7e                |.[24~|
   1b 5b 41                      |.[A|
   1b 5b 35 7e                   |.[5~|
   71                            |q|

以下是xterm窗口中的相同击键:

   1b 4f 50                      |.OP|
   1b 5b 32 34 7e                |.[24~|
   1b 5b 41                      |.[A|
   1b 5b 48                      |.[H|
   71                            |q|

最后,这是由 Linux 虚拟控制台生成的:

   1b 5b 5b 41                   |.[[A|
   1b 5b 32 34 7e                |.[24~|
   1b 5b 41                      |.[A|
   1b 5b 31 7e                   |.[1~|
   71                            |q|

所有被测试的终端都属于这三组中的一组,至少对于未修改的密钥是这样。

存储在$_KEY中的代码可以直接解释,也可以在单独的函数中解释。最好将解释保存在一个函数中,该函数可以被替换以用于不同的终端类型。例如,如果您使用 Wyse60 终端,source wy60-keys函数将设置替换键。

[清单 15-5 显示了一个函数_esc2key,它适用于 Linux 系统中的各种终端,也适用于 Windows 系统中的putty。它将字符序列转换为描述按键的字符串,例如 UP、DOWN、F1 等:

清单 15-5_esc2key,将字符串翻译成键名

_esc2key()
{
  case $1 in
    ## Cursor keys
    "$CSI"A | ${CSI}OA ) _ESC2KEY=UP ;;
    "$CSI"B | ${CSI}0B ) _ESC2KEY=DOWN ;;
    "$CSI"C | ${CSI}OC ) _ESC2KEY=RIGHT ;;
    "$CSI"D | ${CSI}OD ) _ESC2KEY=LEFT ;;

    ## Function keys (unshifted)
    "$CSI"11~ | "$CSI["A | ${ESC}OP ) _ESC2KEY=F1 ;;
    "$CSI"12~ | "$CSI["B | ${ESC}OQ ) _ESC2KEY=F2 ;;
    "$CSI"13~ | "$CSI["C | ${ESC}OR ) _ESC2KEY=F3 ;;
    "$CSI"14~ | "$CSI["D | ${ESC}OS ) _ESC2KEY=F4 ;;
    "$CSI"15~ | "$CSI["E ) _ESC2KEY=F5 ;;
    "$CSI"17~ | "$CSI["F ) _ESC2KEY=F6 ;;
    "$CSI"18~ ) _ESC2KEY=F7 ;;
    "$CSI"19~ ) _ESC2KEY=F8 ;;
    "$CSI"20~ ) _ESC2KEY=F9 ;;
    "$CSI"21~ ) _ESC2KEY=F10 ;;
    "$CSI"23~ ) _ESC2KEY=F11 ;;
    "$CSI"24~ ) _ESC2KEY=F12 ;;

    ## Insert, Delete, Home, End, Page Up, Page Down
    "$CSI"2~ ) _ESC2KEY=INS ;;
    "$CSI"3~ ) _ESC2KEY=DEL ;;
    "$CSI"[17]~ | "$CSI"H ) _ESC2KEY=HOME ;;
    "$CSI"[28]~ | "$CSI"F ) _ESC2KEY=END ;;
    "$CSI"5~ ) _ESC2KEY=PGUP ;;
    "$CSI"6~ ) _ESC2KEY=PGDN ;;

    ## Everything else; add other keys before this line
    *) _ESC2KEY=UNKNOWN ;;
  esac
  [ -n "$2" ] && eval "$2=\$_ESC2KEY"
}

您可以将_key_esc2key函数包装成另一个函数,称为get_key ( 清单 15-6 ),它返回按下的单个字符,或者在多字符键的情况下,返回键的名称。

清单 15-6 。获取一个密钥,如果需要的话,将其翻译成一个密钥名

get_key()
{
    _key
    case $_KEY in
        "$ESC") _keys
                _esc2key "$ESC$_KEYS" _KEY
                ;;
    esac
}

bash-4.x中,可以使用更简单的函数来读取击键。清单 15-7 中的get_key函数利用了read-t选项接受小数时间的能力。它读取第一个字符,然后等待万分之一秒的时间来读取另一个字符。如果按下了多字符键,在这段时间内将会有一个字符被读取。否则,在按下另一个键之前,它将通过剩余的read语句。

清单 15-7get_key,读取一个键,如果不止一个字符,则将其翻译成一个键名

get_key() #@ USAGE: get_key var
{
  local _v_ _w_ _x_ _y_ _z_ delay=${delay:-.0001}
  IFS= read -d '' -rsn1 _v_
  read -sn1 -t "$delay" _w_
  read -sn1 -t "$delay" _x_
  read -sn1 -t "$delay" _y_
  read -sn1 -t "$delay" _z_
  case $_v_ in
    $'\e') _esc2key "$_v_$_w_$_x_$_y_$_z_"
           printf -v ${1:?} $_ESC2KEY
           ;;
    *) printf -v ${1:?} "%s" "$_v_$_w_$_x_$_y_$_z_" ;;
  esac
}

每当您想在脚本中使用光标或功能键,或者任何单键输入,您可以调用key-funcs来捕获按键。清单 15-8 是使用该库的简单演示。

清单 15-8keycapture,读取并显示击键,直到 Q 被按下

. key-funcs                             ## source the library
while :                                 ## infinite loop
do
  get_key key
  sa "$key"                             ## the sa command is from previous chapters
  case $key in q|Q) break;; esac
done

清单 15-9 中的脚本在屏幕上打印一个文本块。可以用光标键在屏幕上移动,用功能键改变颜色。奇数功能键改变前景色;偶数键改变背景。

清单 15-9key-demo、捕捉功能和光标键改变颜色并在屏幕上移动文本块

trap '' 2
trap 'stty sane; printf "${CSI}?12l${CSI}?25h\e[0m\n\n"' EXIT

stty -echo   ## Turn off echoing of user keystrokes
. key-funcs  ## Source key functions

clear        ## Clear the screen
bar=====================================

## Initial position for text block
row=$(( (${LINES:-24} - 10) / 2 ))
col=$(( (${COLUMNS:-80} - ${#bar}) / 2 ))

## Initial colours
fg="${CSI}33m"
bg="${CSI}44m"

## Turn off cursor
printf "%s" "${CSI}?25l"

## Loop until user presses "q"
while :
do
  printf "\e[1m\e[%d;%dH" "$row" "$col"
  printf "\e7 %-${#bar}.${#bar}s ${CSI}0m   \e8\e[1B"  "${CSI}0m"
  printf "\e7 $fg$bg%-${#bar}.${#bar}s${CSI}0m \e8\e[1B" "$bar" \
              "" "  Move text with cursor keys" \
              "" "  Change colors with function keys" \
              "" "  Press 'q' to quit" \
              "" "$bar"
  printf "\e7%-${#bar}.${#bar}s     "  "${CSI}0m"
  get_key k
  case $k in
      UP) row=$(( $row - 1 )) ;;
      DOWN) row=$(( $row + 1 )) ;;
      LEFT) col=$(( $col - 1 )) ;;
      RIGHT) col=$(( $col + 1 )) ;;
      F1) fg="${CSI}30m" ;;
      F2) bg="${CSI}47m" ;;
      F3) fg="${CSI}31m" ;;
      F4) bg="${CSI}46m" ;;
      F5) fg="${CSI}32m" ;;
      F6) bg="${CSI}45m" ;;
      F7) fg="${CSI}33m" ;;
      F8) bg="${CSI}44m" ;;
      F9) fg="${CSI}35m" ;;
      F10) bg="${CSI}43m" ;;
      F11) fg="${CSI}34m" ;;
      F12) bg="${CSI}42m" ;;
      q|Q) break ;;
  esac
  colmax=$(( ${COLUMNS:-80} - ${#bar} - 4 ))
  rowmax=$(( ${LINES:-24} - 10 ))
  [ $col -lt 1 ] && col=1
  [ $col -gt $colmax ] && col=$colmax
  [ $row -lt 1 ] && row=1
  [ $row -gt $rowmax ] && row=$rowmax
done

脚本中的历史记录

在第六章和第十二章的readline函数中,history -s用于将默认值放入历史列表中。在这些例子中,只存储了一个值,但是可以在历史中存储多个值,甚至可以使用整个文件。在添加到历史记录之前,您应该(在大多数情况下)清除它:

history -c

通过使用多个history -s命令,您可以存储多个值:

history -s Genesis
history -s Exodus

使用-r选项,您可以将整个文件读入历史。这个片段将圣经中前五本书的名字放入一个文件中,并读入历史:

cut -d: -f1 "$kjv" | uniq | head -5 > pentateuch
history -r pentateuch

第六章和第十二章中的readline函数在bash版本小于 4 时使用history,但在版本 4(或更高版本)时使用read-i选项。有时使用history比使用-i更合适,即使后者可用。一个恰当的例子是,新输入可能与默认输入非常不同,但也有可能并非如此。

为了使历史可用,您必须使用带有read-e选项。这也让您可以访问在您的.inputrc文件中定义的其他键绑定。

健全检查

健全性检查是测试输入的正确类型和合理值。如果用户输入作为她的年龄,这显然是错误的:数据是错误的类型。如果她输入 666 ,这是正确的类型,但几乎肯定是不正确的值。使用valint脚本(参见第三章)或函数(参见第六章)很容易检测出不正确的类型。您可以使用第六章中的rangecheck功能来检查合理的值。

有时错误更成问题,甚至是恶意的。假设一个脚本要求变量名,然后使用eval给它赋值:

read -ep "Enter variable name: " var
read -ep "Enter value: " val
eval "$var=\$val"

现在,假设条目是这样的:

Enter variable name: rm -rf *;name
Enter value: whatever

eval将执行的命令如下:

rm -rf *;name=whatever

噗!你所有的文件和子目录都从当前目录中消失了。通过使用第七章中的validname函数检查var的值,可以防止这种情况的发生:

validname "$var" && eval "$var=\$val" || echo Bad variable name >&2

编辑数据库时,检查没有无效字符是一个重要的步骤。例如,在编辑/etc/passwd(或创建它的表格)时,您必须确保任何字段中没有冒号。图 15-1 给这个讨论增加了一些幽默。

9781484201220_Fig15-01.jpg

图 15-1 。漫画由兰道尔·门罗在http://xkcd.com提供

表单条目

清单 15-10 中的脚本演示了如何用菜单和历史来处理用户输入。它使用key-funcs库来获取用户的选择并编辑密码字段。它有一个硬编码的记录,并且不读取/etc/passwd文件。它检查条目中的冒号,如果发现冒号,则打印一条错误消息。

记录从 here 文档读入一个数组。一个单独的printf语句打印菜单,使用带有七个空格 的格式字符串和整个数组作为它的参数。

清单 15-10password,简单的记录编辑脚本

record=root:x:0:0:root:/root:/bin/bash       ## record to edit
fieldnames=( User Password UID
             GID Name Home Shell )

. key-funcs                                  ## load the key functions

IFS=: read -a user <<EOF                     ## read record into array
$record
EOF

z=0
clear
while :                                      ## loop until user presses 0 or q
do
  printf "\e[H\n
   0.     Quit
   1.     User: %s\e[K
   2\. Password: %s\e[K
   3.      UID: %s\e[K
   4.      GID: %s\e[K
   5.     Name: %s\e[K
   6.     Home: %s\e[K
   7.    Shell: %s\e[K

    Select field (1-7): \e[0J" "${user[@]}"   ## print menu and prompt

  get_key field                               ## get user input

  printf "\n\n"                               ## print a blank line
  case $field in
    0|q|Q) break ;;                           ## quit
    [1-7]) ;;                                 ## menu item selected; fall through
    *) continue;;
  esac
  history -c                                  ## clear history
  history -s "${user[field-1]}"               ## insert current value in history
  printf '  Press UP to edit "%s"\n' "${user[field-1]}" ## tell user what's there
  read -ep "        ${fieldnames[field-1]}: " val       ## get user entry
  case $val in
    *:*) echo "      Field may not contain a colon (press ENTER)" >&2  ## ERROR
         get_key; continue
         ;;
    "") continue ;;
    *) user[field-1]=$val ;;
  esac
done

阅读鼠标

在 Linux console_codes 1 手册页上,有一个标注为“鼠标跟踪 的部分有意思!上面写着:“鼠标跟踪工具旨在返回与xterm兼容的鼠标状态报告。”这是否意味着鼠标可以在 shell 脚本中使用?

根据手册页,鼠标跟踪有两种模式 : X10 兼容模式,它在按钮按下时发送一个转义序列;正常跟踪模式,它在按钮按下和释放时都发送一个转义序列。两种模式都会发送修饰键信息。

为了测试这一点,首先在终端窗口输入printf "\e[?9h"。这是设置“X10 鼠标报告(默认关闭):将报告模式设置为 1(或重置为 0)”的转义序列。如果你按下鼠标键,电脑会发出哔哔声,并在屏幕上打印“FB”。在屏幕上的不同点重复点击鼠标会产生更多的蜂鸣声和“&% -( 5\. =2 H7 T= ]C fG rJ }M

鼠标点击发送六个字符:ESC、``、Mbxy 。前三个字符是所有鼠标事件共有的,后三个包含按下的按钮,最后三个是鼠标的xy位置。为了确认这一点,将输入保存在一个变量中,并通过管道传输到hexdump:

$ printf "\e[?9h"
$ read x
^[[M!MO            ## press mouse button and enter
$ printf "$x" | hexdump -C
00000000  1b 5b 4d 21 4d 4f                       |.[M!MO|
00000006

前三个如期出现,但最后三个是什么?根据手册页,按钮字符的低两位表示按下了哪个按钮;高位标识活动修改器。xy坐标是 ASCII 值,加上 32 以使它们脱离控制字符的范围。!是 1,"是 2,以此类推。

这给了我们一个鼠标按钮的1,这意味着按钮 2,因为02分别是按钮 1、2 和 3,而4是释放。xy坐标为 45(O×4d = 77;77–32 = 45)和 47。

令人惊讶的是,自从在 Linux 手册页中浏览了这些关于鼠标跟踪的信息后,发现这些转义码并不适用于所有的 Linux 控制台。他们在 Linux 和 FreeBSD 上的xtermrxvtgnome-terminal中工作。它们也可以在 FreeBSD 和 NetBSD 上使用,通过 Linux rxvt终端窗口的ssh。他们不在 KDE 窗口中工作。

您现在知道鼠标报告是可行的(在大多数xterm窗口中),您可以通过鼠标点击标准输入来获得信息。这就留下了两个问题:如何将信息读入变量(无需按回车键),如何在 shell 脚本中解码按钮和xy信息?

对于bash,使用read命令的-n选项 和一个参数来指定字符数。要阅读鼠标,需要六个字符:

read -n6 x

对于真实的脚本来说,这两种方法都不够(不是所有的输入都是鼠标点击,您可能希望获得单次击键),但是它们足以演示这个概念。

下一步是解码输入。出于演示的目的,您可以假设这六个字符确实代表鼠标点击,前三个字符是ESC[M。这里我们只对最后三个感兴趣,所以我们使用 POSIX 参数扩展将它们提取到三个独立的变量中:

m1=${x#???}    ## Remove the first 3 characters
m2=${x#????}   ## Remove the first 4 characters
m3=${x#?????}  ## Remove the first 5 characters

然后将每个变量的第一个字符转换成它的 ASCII 值。这使用了 POSIX printf扩展:“如果前导字符是单引号或双引号,则该值应该是单引号或双引号后面字符的基础代码集中的数值。” [2

printf -v mb "%d" "'$m1"
printf -v mx "%d" "'$m2"
printf -v my "%d" "'$m3"

最后,解释 ASCII 值。对于鼠标按钮,做一个按位AND 3。对于xy坐标,减去 32:

## Values > 127 are signed, so fix if less than 0
[ $mx -lt 0 ] && mx=$(( 255 + $mx ))
[ $my -lt 0 ] && my=$(( 255 + $my ))

BUTTON=$(( ($mb & 3) + 1 ))
MOUSEX=$(( $mx - 32 ))
MOUSEY=$(( $my - 32 ))

综上所述,清单 15-11 中的脚本会在你按下鼠标按钮时打印出鼠标的坐标。

最上面一排有两个敏感区域。单击左键可以在只报告按键和报告释放之间切换鼠标报告模式。单击右边的按钮退出脚本。

清单 15-11mouse-demo,读取鼠标点击的例子

ESC=$'\e'
but_row=1

mv=9  ## mv=1000 for press and release reporting; mv=9 for press only

_STTY=$(stty -g)      ## Save current terminal setup
stty -echo -icanon    ## Turn off line buffering
printf "${ESC}[?${mv}h        "   ## Turn on mouse reporting
printf "${ESC}[?25l"  ## Turn off cursor

printat() #@ USAGE: printat ROW COLUMN
{
    printf "${ESC}[${1};${2}H"
}

print_buttons()
{
   num_but=$#
   gutter=2
   gutters=$(( $num_but + 1 ))
   but_width=$(( ($COLUMNS - $gutters) / $num_but ))
   n=0
   for but_str
   do
     col=$(( $gutter + $n * ($but_width + $gutter) ))
     printat $but_row $col
     printf "${ESC}[7m%${but_width}s" " "
     printat $but_row $(( $col + ($but_width - ${#but_str}) / 2 ))
     printf "%.${but_width}s${ESC}[0m" "$but_str"
     n=$(( $n + 1 ))
   done
}

clear
while :
do
  [ $mv -eq 9 ] && mv_str="Click to Show Press & Release" ||
                   mv_str="Click to Show Press Only"
  print_buttons "$mv_str" "Exit"

  read -n6 x

  m1=${x#???}    ## Remove the first 3 characters
  m2=${x#????}   ## Remove the first 4 characters
  m3=${x#?????}  ## Remove the first 5 characters

  ## Convert to characters to decimal values
  printf -v mb "%d" "'$m1"
  printf -v mx "%d" "'$m2"
  printf -v my "%d" "'$m3"
  ## Values > 127 are signed
  [ $mx -lt 0 ] && MOUSEX=$(( 223 + $mx )) || MOUSEX=$(( $mx - 32 ))
  [ $my -lt 0 ] && MOUSEY=$(( 223 + $my )) || MOUSEY=$(( $my - 32 ))

  ## Button pressed is in first 2 bytes; use bitwise AND
  BUTTON=$(( ($mb & 3) + 1 ))

  case $MOUSEY in
       $but_row) ## Calculate which on-screen button has been pressed
                 button=$(( ($MOUSEX - $gutter) / $but_width + 1 ))
                 case $button in
                      1) printf "${ESC}[?${mv}l"
                         [ $mv -eq 9 ] && mv=1000 || mv=9
                         printf "${ESC}[?${mv}h"
                         [ $mv -eq 1000 ] && x=$(dd bs=1 count=6 2>/dev/null)
                         ;;
                      2) break ;;
                 esac
                 ;;
       *) printat $MOUSEY $MOUSEX
          printf "X=%d Y=%d [%d]  " $MOUSEX $MOUSEY $BUTTON
          ;;
  esac

done

printf "${ESC}?${mv}l"  ## Turn off mouse reporting
stty "$_STTY"            ## Restore terminal settings
printf "${ESC}[?12l${ESC}[?25h" ## Turn cursor back on
printf "\n${ESC}[0J\n"   ## Clear from cursor to bottom of screen,

摘要

拥有丰富的交互式编程选项。在本章中,你学会了如何利用它来读取任何击键,包括功能键和其他返回多个字符的键。

练习

  1. 使用key-funcs库,编写一个使用功能键进行选择的菜单脚本。
  2. 重写key-funcs库以包含鼠标操作,并将该功能并入mouse-demo脚本。
  3. password脚本对无效条目进行最少的检查。你会添加什么检查?你会怎么编码?

[1

2

十六、附录 A:Shell 变量

该列表摘自bash手册页,并经过编辑成为一个独立的文档。以下变量由bash设定。

尝试

扩展到用于调用这个bash实例的完整的文件名。

巴什 PID

扩展到当前bash进程的进程 ID。这在某些情况下不同于$$,比如不需要bash重新初始化的子 Shell。

BASH _ 别名

一个关联数组变量,其成员对应于由别名内置维护的内部别名列表。添加到此数组的元素出现在别名列表中;取消设置数组元素会导致别名从别名列表中删除。

ARGC 巴什

一个数组变量,其值为当前bash执行调用栈中每一帧的参数个数。当前子程序(shell 函数或用.或 source 执行的脚本)的参数数量在堆栈的顶部。当一个子程序被执行时,传递的参数数被推送到BASH_ARGC。shell 仅在扩展调试模式下设置BASH_ARGC(参见bash手册页中对shopt内置的extdebug选项的描述)。

BASH_ARGV 函数

包含当前bash 执行调用栈中所有参数的数组变量。最后一个子例程调用的最后一个参数在栈顶;初始调用的第一个参数在底部。当子程序被执行时,所提供的参数被推送到BASH_ARGV上。shell 仅在扩展调试模式下设置BASH_ARGV(参见bash手册页中对shopt内置的extdebug选项的描述)。

CMDS 巴什

一个关联数组变量,其成员对应于哈希内置维护的命令内部哈希表。添加到此数组的元素出现在哈希表中;取消设置数组元素会导致命令从哈希表中删除。

BASH _ 命令

当前正在执行或即将执行的命令,除非 Shell 由于陷阱而正在执行命令,在这种情况下,它是陷阱发生时正在执行的命令。

BASH _ 执行 _ 字符串

-c调用选项的命令参数。

BASH_LINENO

一个数组变量,它的成员是对应于FUNCNAME每个成员的源文件中的行号。${BASH_LINENO[$i]}是源文件中调用${FUNCNAME[$i]}的行号(如果在另一个 shell 函数中引用,则为${BASH_LINENO[$i-1]})。对应的源文件名是${BASH_SOURCE[$i]}。使用LINENO获取当前行号。

BASH _ 重赛

一个数组变量,其成员由=~二元运算符分配给[[条件命令。索引为0的元素是字符串中匹配整个正则表达式的部分。索引为n的元素是字符串中匹配第 n 个带括号的子表达式的部分。此变量是只读的。

BASH_SOURCE

一个数组变量,其成员是对应于FUNCNAME数组变量中元素的源文件名。

BASH_SUBSHELL

每产生一个子 Shell 或子 Shell 环境就增加 1。初始值为 0。

BASH _ versi info

一个只读数组变量,其成员保存了bash实例的版本信息。分配给数组成员的值如下:

  • BASH_VERSINFO[0]:主要版本号(发布)
  • BASH_VERSINFO[1]:次要版本号(版本)
  • BASH_VERSINFO[2]:补丁级别
  • BASH_VERSINFO[3]:构建版本
  • BASH_VERSINFO[4]:发布状态(如beta1)
  • BASH_VERSINFO[5]:MACHTYPE的值

BASH_VERSION

扩展为描述这个bash实例版本的字符串。

COMP_CWORD

包含当前光标位置的单词的${COMP_WORDS}索引。该变量仅在由可编程完成工具调用的 shell 函数中可用(参见bash手册页中的“可编程完成”)。

复合键

用于调用当前完成功能的键(或键序列的最后一个键)。

COMP_LINE

当前命令行。此变量仅在可编程完成功能调用的 Shell 函数和外部命令中可用(参见bash手册页中的“可编程完成”)。

红利点数

当前光标位置相对于当前命令开头的索引。如果当前光标位置在当前命令的末尾,该变量的值等于${#COMP_LINE}。该变量仅在可编程完成工具调用的 shell 函数和外部命令中可用(参见bash手册页中的“可编程完成”)。

组件类型

设置一个整数值,对应于导致调用补全函数的补全尝试类型:TAB用于正常补全,?用于在连续制表符后列出补全,!用于列出部分单词补全的备选项,@用于列出单词未被修改的补全,或者%用于菜单补全。该变量仅在可编程完成工具调用的 Shell 函数和外部命令中可用(参见bash手册页中的“可编程完成”)。

COMP_WORDBREAKS

执行单词补全时,readline库将其视为单词分隔符的字符集。如果COMP_ WORDBREAKS 未置位,它将失去其特殊属性,即使它随后被复位。

比较词

由当前命令行中的单个单词组成的数组变量(参见bash手册页中的“数组”)。如前所述,使用 COMP_WORDBREAKS将该行拆分成单词。该变量仅在可编程完成工具调用的 Shell 函数中可用(参见bash手册页中的“可编程完成”)。

DIRSTACK 先生

包含目录堆栈当前内容的数组变量(参见bash手册页中的“数组”)。目录按照dirs内置程序显示的顺序出现在堆栈中。分配给这个数组变量的成员可以用来修改栈中已经存在的目录,但是pushdpopd内置必须用来添加和删除目录。给这个变量赋值不会改变当前目录。如果DIRSTACK未被设置,它将失去其特殊属性,即使它随后被重置。

-你好

扩展到当前用户的有效用户 ID,在 shell 启动时初始化。此变量是只读的。

FUNCNAME

一个数组变量,包含当前执行调用堆栈中所有 shell 函数的名称。索引为 0 的元素是任何当前正在执行的 shell 函数的名称。最底层的元素是main。该变量仅在 shell 函数执行时存在。对FUNCNAME的赋值无效,并返回一个错误状态。如果FUNCNAME未被设置,它将失去其特殊属性,即使它随后被重置。

一个数组变量,包含当前用户所属的组的列表。对GROUPS的赋值无效,并返回一个错误状态。如果GROUPS未置位,它将失去其特殊属性,即使它随后被复位。

HISTCMD

当前命令的历史编号,或历史列表中的索引。如果HISTCMD未置位,它将失去其特殊属性,即使它随后被复位。

主机名

自动设置为当前主机的名称。

主机类型

自动设置为一个字符串,该字符串唯一地描述了执行bash的机器的类型。默认值取决于系统。

直线

每次引用这个参数时,shell 都会替换一个十进制数,表示脚本或函数中当前的顺序行号(从 1 开始)。当不在脚本或函数中时,不能保证被替换的值是有意义的。如果LINENO未置位,它将失去其特殊属性,即使它随后被复位。

公式编辑器

以标准的 GNU cpu-company-system 格式,自动设置为一个字符串,该字符串完整描述了bash正在其上执行的系统类型。默认值取决于系统。

老普德

cd命令设置的先前工作的目录。

OPTARG(选项)

getopts builtin 命令处理的最后一个选项参数的值(请参见bash手册页中的“Shell Builtin 命令”)。

太棒了

getopts内置命令处理的下一个参数的索引(参见bash手册页中的“Shell 内置命令”)。

购买类型

自动设置为描述执行bash的操作系统的字符串。默认值取决于系统。

管道状态

一个数组变量(参见bash手册页中的“数组”),包含最近执行的前台管道中进程的退出状态值列表(可能只包含一个命令)。

PPID 是

shell 的父进程的进程 ID。此变量是只读的。

(美国)公共工程处(Public Works Department)

cd命令设置的当前工作目录。

随意

每次引用该参数时,都会产生一个 0 到 32767 之间的随机整数。随机数序列可以通过给RANDOM赋值来初始化。如果RANDOM未置位,它将失去其特殊属性,即使它随后被复位。

回答

当没有提供参数时,设置由read内置命令读取的输入行。

每次这个参数被引用时,返回自 shell 调用以来的秒数。如果给SECONDS赋值,后续引用返回的值是赋值后的秒数加上赋值后的值。如果SECONDS未置位,它将失去其特殊属性,即使它随后被复位。

甲壳动物

用冒号分隔的启用的 shell 选项列表。列表中的每个单词都是set内置命令的-o选项的有效参数(参见bash手册页中的“Shell 内置命令”)。出现在SHELLOPTS中的选项是由set -o报告的选项。如果bash启动时该变量在环境中,则在读取任何启动文件之前,列表中的每个 shell 选项都将被启用。此变量是只读的。

嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘嘘

每次bash的实例启动时增加 1。

用户界面设计(User Interface Design 的缩写)

扩展到当前用户的用户 ID,在 shell 启动时初始化。此变量是只读的。

Shell 使用以下变量。在某些情况下,bash给一个变量赋一个默认值;这些案例将在以下章节中说明。

BASH_ENV

如果在 bash执行 shell 脚本时设置了该参数,其值将被解释为包含初始化 shell 命令的文件名,如~/.bashrc所示。在被解释为文件名之前,BASH_ENV的值经过参数扩展、命令替换和算术扩展。PATH不用于搜索结果文件名。

CDPATH(路径)

cd命令的搜索路径。这个是一个用冒号分隔的目录列表,shell 在其中查找由cd命令指定的目标目录。一个样本值是.:~:/usr

select内置命令用来确定打印选择列表时的终端宽度。这在收到SIGWINCH时自动设置。

理解

一个数组变量,bash 从中读取由可编程完成工具调用的 shell 函数生成的可能完成(参见bash手册页中的“可编程完成”)。

编辑器

当 shell 以值t开始时,如果bash在环境中找到这个变量,它假定 shell 正在运行在一个emacs shell 缓冲区中,并禁用行编辑。

mcedit

默认编辑器为 fc内置命令。

别理他

执行文件名补全时要忽略的以冒号分隔的后缀列表(请参见bash手册页中的READLINE)。后缀与FIGNORE中的条目之一匹配的文件名从匹配文件名列表中排除。样本值为.o:~

全球忽略

用冒号分隔的模式列表定义了路径名扩展要忽略的文件名集合。如果与路径名扩展模式匹配的文件名也与GLOBIGNORE中的模式之一匹配,它将从匹配列表中删除。

列举控件

用冒号分隔的值列表,控制命令如何保存在历史列表中。如果值列表包含ignorespace,以空格字符开头的行不会保存在历史列表中。值ignoredups导致匹配先前历史条目的行不被保存。值ignorebothignorespaceignoredups的简写。值erasedups导致在保存当前行之前,匹配当前行的所有先前行从历史列表中移除。不在先前列表中的任何值都将被忽略。如果HISTCONTROL未设置或不包含有效值,shell 解析器读取的所有行都保存在历史列表中,取决于HISTIGNORE的值。不测试多行复合命令的第二行和后续行,并将其添加到历史记录中,与HISTCONTROL的值无关。

HISTFILE(历史文件)

保存命令历史的文件名(参见bash手册页中的HISTORY)。默认值为~/.bash_history。如果未设置,当交互式 shell 退出时,不保存命令历史记录。

历史文件大小

历史文件中包含的最大行数。当此变量被赋值时,历史文件将被截断,如有必要,删除最早的条目,使其包含的行数不超过该值。默认值为 500。当交互式 shell 退出时,历史文件在写入后也会被截断到这个大小。

HISTIGNORE

冒号分隔的模式列表用于决定哪些命令行应该保存在历史列表中。每个模式被锚定在该行的开头,并且必须匹配完整的行(没有附加隐含的*)。在应用了HISTCONTROL指定的检查后,对照生产线测试每个图形。除了正常的 shell 模式匹配字符外,&匹配以前的历史行。&可以用反斜杠转义;在尝试匹配之前,会删除反斜杠。不测试多行复合命令的第二行和后续行,不管HISTIGNORE的值是多少,都将其添加到历史记录中。

组份大小

命令历史中要记住的命令数量(参见bash手册页中的HISTORY)。默认值为 500。

历史时间格式

如果这个变量被设置并且不为空,它的值被用作格式字符串,供strftime(3)打印与history内置显示的每个历史条目相关的时间戳。如果设置了此变量,时间戳将被写入历史文件,因此可以跨 shell 会话保存时间戳。这使用历史注释字符来区分时间戳和其他历史行。

当前用户的主目录;cd内置命令的默认参数。执行波浪号展开时,也会使用此变量的值。

档案空间

包含一个文件名,其格式与 shell 需要完成主机名时应该读取的格式相同。在 shell 运行时,可能的主机名完成列表可能会改变;值更改后,下次尝试完成主机名时,bash会将新文件的内容添加到现有列表中。如果HOSTFILE被设置但没有值,bash试图读取/etc/hosts以获得可能的主机名完成列表。当HOSTFILE未置位时,主机名列表被清除。

可安装文件系统

内部字段分隔符,用于扩展后的单词拆分,以及使用read内置命令将行拆分成单词。默认值为''''

无知的

控制一个交互 Shell 在收到一个EOF字符作为唯一输入时的动作。如果设置,该值是在bash退出之前必须作为输入行的第一个字符输入的连续EOF字符的数量。如果变量存在,但没有数值或没有值,则默认值为 10。如果不存在,EOF表示对 shell 的输入结束。

INPUTRC(输入 RC)

readline 启动文件的文件名,覆盖默认的~/.inputrc(参见bash手册页中的READLINE)。

语言

用于为任何没有使用以LC_开头的变量专门选择的类别确定区域设置类别。

LC_ALL

这个变量覆盖了LANG的值和任何其他指定地区类别的LC_变量。

LC _ 校对

该变量确定对路径名扩展的结果进行排序时使用的排序顺序,并确定范围表达式、等价类以及路径名扩展和模式匹配中排序序列的行为。

LC_CTYPE

该变量决定了字符的解释以及路径名扩展和模式匹配中字符类的行为。

LC _ 消息

这个变量决定了用于翻译以$开头的双引号字符串的语言环境。

LC _ 数字

该变量决定了用于数字格式化的区域设置类别。

线

select内置命令用来确定打印选择列表的列长度。这在收到SIGWINCH时自动设置。

邮件

如果该参数设置为一个文件名,并且没有设置MAILPATH变量,bash通知用户指定文件中的邮件到达。

邮件检查

指定bash 检查邮件的频率(秒)。默认值为 60 秒。当需要检查邮件时,shell 会在显示主要提示之前进行检查。如果该变量未设置或设置为不大于或等于零的值,shell 将禁用邮件检查。

邮件路径

要检查邮件的用冒号分隔的文件名列表。邮件到达特定文件时要打印的消息可以通过用?将文件名与消息分开来指定。在消息文本中使用时,$ _ 扩展为当前邮件文件的名称。这里有一个例子:

MAILPATH='/var/mail/bfox?"You have mail":~/shell-mail?"$_ has mail!"'

Bash为此变量提供默认值,但它使用的用户邮件文件的位置取决于系统(例如,/var/mail/$USER)。

奥斯特尔

如果设置为值 1,bash 显示由getopts内置命令生成的错误消息(参见bash手册页中的“Shell 内置命令”)。OPTERR在每次调用 shell 或执行 shell 脚本时初始化为 1。

小路

命令的搜索路径。它是一个用冒号分隔的目录列表,shell 在其中查找命令(参见bash手册页中的“命令执行”)。PATH值中的零长度(null)目录名表示当前目录。空目录名可能显示为两个相邻的冒号,或者显示为开头或结尾的冒号。默认路径取决于系统,由安装bash的管理员设置。常见的值是/usr/gnu/bin:/usr/local/bin:/usr/ucb:/bin:/usr/bin

POSIXLY_CORRECT

如果这个变量在bash启动时处于环境中,那么 shell 在读取启动文件之前进入 POSIX 模式,就好像已经提供了--posix调用选项一样。如果它在 shell 运行时被设置,bash启用 POSIX 模式,就像命令set -o posix已经被执行一样。

提示命令

如果设置,该值在发出每个主要提示之前作为命令执行。

提示 _DIRTRIM

如果设置为大于零的数字,该值将用作展开\w\W提示字符串转义时要保留的尾随目录组件的数量(请参见bash手册页中的“提示”)。删除的字符将替换为省略号。

PS1

该参数的值被扩展(参见bash手册页中的“提示”),并被用作主要提示字符串。默认值为"\s-\v\$ "

PS2

该参数的值与PS1一样被扩展,并用作二级提示字符串。默认为"> "

PS3

该参数的值用作 select 命令的提示(参见前面的“SHELL 语法”)。

PS4

该参数的值与PS1一样被扩展,并且该值在执行跟踪期间每个命令bash显示之前被打印。根据需要,PS4的第一个字符被重复多次,以表示多层次的间接性。默认为"+ "

shell 的完整路径名保存在这个环境变量中。如果在 shell 启动时没有设置,则bash会为其分配当前用户登录 shell 的完整路径名。

时间格式

该参数的值是用作格式字符串的,指定应该如何显示以时间保留字为前缀的管道的定时信息。%字符引入了一个转义序列,该序列被扩展为一个时间值或其他信息。转义序列及其含义如下:大括号表示可选部分。

  • %%:一个文字%
  • %[p][l]R:经过的时间,以秒为单位。
  • %[p][l]U:用户模式下花费的 CPU 秒数。
  • %[p][l]S:系统模式下花费的 CPU 秒数。
  • %P:CPU 百分比,计算方式为(%U + %S) / %R)。可选的p是一个指定精度的数字,小数点后的小数位数。值 0 表示不输出小数点或分数。最多可以指定小数点后三位;大于 3 的p值改为 3。如果未指定p,则使用值 3。可选的 l 指定了一个更长的格式,包括分钟,格式为MMmSS.FFsp的值决定了是否包含该分数。如果没有设置这个变量,bash的行为就好像它有值$'\nreal\t%3lR\nuser\t%3lU\nsys%3lS'一样。如果该值为 null,则不显示任何计时信息。当显示格式字符串时,会添加一个尾随换行符。

TMOUT(数字量输出)

如果设置为大于零的值, TMOUT被视为读取内置的默认超时。当输入来自终端时,如果在TMOUT秒后输入没有到达,选择命令终止。在交互式 shell 中,该值被解释为发出主要提示后等待输入的秒数。Bash如果输入没有到达,等待该秒数后终止。

设置临时文件夹

如果设置了,bash使用它的值作为目录的名称,在这个目录中bash创建临时文件供 shell 使用。

自动恢复

该变量控制 shell 如何与用户和作业控制交互。如果设置了此变量,没有重定向的单个单词简单命令将被视为恢复现有已停止作业的候选命令。不允许有任何歧义;如果有多个以键入的字符串开头的作业,则选择最近访问的作业。在此上下文中,已停止作业的名称是用于启动它的命令行。如果设置为值 exact,则提供的字符串必须与停止的作业的名称完全匹配。如果设置为 substring,则提供的字符串需要与已停止作业的名称的子字符串匹配。子字符串值提供类似于%?作业标识符的功能(参见bash手册页中的“作业控制”)。如果设置为任何其他值,则提供的字符串必须是已停止作业名称的前缀。这提供了类似于%string工作标识符的功能。

历史人物

控制历史扩展和标记化的两三个字符(参见bash手册页中的“历史扩展”)。第一个字符是历史扩展字符,该字符表示历史扩展的开始,通常为!。第二个字符是快速替换字符,用作重新运行之前输入的命令的简写,用命令中的一个字符串替换另一个字符串。默认是^。可选的第三个字符是当作为单词的第一个字符时,指示该行的剩余部分是注释的字符,通常是#。历史注释字符导致跳过对该行中剩余单词的历史替换。这并不一定会导致 shell 解析器将该行的其余部分视为注释。

posted @ 2024-08-02 19:34  绝不原创的飞龙  阅读(3)  评论(0编辑  收藏  举报