Google Shell 风格指南(强烈推荐)
本文的shell编码规范来源Google Shell Style Guide,是一份专为Bash脚本开发者设计的编程规范指南,旨在提供一套统一、清晰的编程规则和最佳实践。它涵盖了从脚本头部信息、变量和函数的使用,到条件和循环处理的多种主题,同时还提供了关于错误处理、测试和命令行参数使用的建议。
选择哪种Shell
Bash是唯一允许用于可执行文件的shell脚本语言。
可执行文件必须以#!/bin/bash
开始,并有最少数量的标志。使用set
来设置shell选项,以便调用你的脚本,如bash script_name
,不会破坏其功能。
将所有可执行的shell脚本限制为bash,为我们提供了一个在所有机器上都安装的一致的shell语言。
唯一的例外是你被你的编码目标所迫使的情况。这方面的一个例子是Solaris SVR4包,它们需要对任何脚本使用纯Bourne shell。
何时使用Shell
Shell应仅用于小型实用程序或简单的包装器脚本。
尽管shell脚本不是一种开发语言,但它在Google的各种实用程序脚本中被用于编写。这个风格指南更多的是对其使用的认可,而不是建议它被广泛部署。
一些指导原则:
- 如果你主要是调用其他实用程序,并且做的数据操作相对较少,那么shell是完成任务的一个可接受的选择。
- 如果性能很重要,使用shell以外的其他东西。
- 如果你正在编写一个超过100行的脚本,或者使用了非直接的控制流逻辑,你应该_现在_就用更结构化的语言重写它。记住,脚本会增长。尽早重写你的脚本,以避免在后期进行更耗时的重写。
- 在评估你的代码复杂度时(例如,决定是否切换语言),考虑除了作者之外的其他人是否能轻松维护代码。
Shell文件和解释器调用
文件扩展名
可执行文件应该没有扩展名(强烈推荐)或者是.sh
扩展名。库文件必须有.sh
扩展名,且不应该是可执行的。
执行程序时不需要知道程序是用什么语言编写的,shell也不需要扩展名,所以我们更倾向于不为可执行文件使用扩展名。
然而,对于库文件,知道它是用什么语言编写的是很重要的,有时候需要有不同语言的类似库文件。这允许具有相同目的但语言不同的库文件名字相同,只是语言特定的后缀不同。
SUID/SGID
在shell脚本中,SUID和SGID是_禁止_的。
shell存在太多的安全问题,使得几乎无法足够安全地允许SUID/SGID。虽然bash确实使得运行SUID变得困难,但在一些平台上仍然可能,这就是为什么我们明确禁止它的原因。
如果你需要提升访问权限,使用sudo
。
环境
STDOUT与STDERR
所有的错误信息应该发送到STDERR
。
这样可以更容易地区分正常状态和实际问题。
建议使用一个函数来打印错误信息以及其他状态信息。
err() {
echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*" >&2
}
if ! do_something; then
err "Unable to do_something"
exit 1
fi
每个文件都应以其内容的描述开始。
每个文件都必须有一个顶级注释,包括对其内容的简要概述。版权声明和作者信息是可选的。
示例:
#!/bin/bash
#
# 执行Oracle数据库的热备份。
任何不显而易见且简短的函数都必须被注释。库中的任何函数无论长度或复杂度如何都必须注释。
其他人应该能够通过阅读注释(和自助,如果提供的话)学习如何使用你的程序,或者使用你库中的一个函数,而无需阅读代码。
所有的函数注释应该使用以下内容描述预期的API行为:
- 函数的描述。
- 全局变量:使用和修改的全局变量列表。
- 参数:接收的参数。
- 输出:输出到STDOUT或STDERR。
- 返回:除最后运行的命令的默认退出状态外的返回值。
示例:
#######################################
# 清理备份目录中的文件。
# 全局变量:
# BACKUP_DIR
# ORACLE_SID
# 参数:
# 无
#######################################
function cleanup() {
…
}
#######################################
# 获取配置目录。
# 全局变量:
# SOMEDIR
# 参数:
# 无
# 输出:
# 将位置写入stdout
#######################################
function get_dir() {
echo "${SOMEDIR}"
}
#######################################
# 以复杂的方式删除文件。
# 参数:
# 要删除的文件,一个路径。
# 返回:
# 如果事物被删除,返回0,错误返回非零。
#######################################
function del_thing() {
rm "$1"
}
注释你的代码中棘手的、非显而易见的、有趣的或重要的部分。
这遵循Google的一般编码注释实践。不要注释所有东西。如果有一个复杂的算法或者你正在做一些不寻常的事情,写一个简短的注释。
对于临时的、短期解决方案的、或者足够好但不完美的代码,使用TODO注释。
这符合C++指南中的约定。
TODO
应该包括全大写的字符串TODO
,后跟有最佳上下文的人的名字、电子邮件地址或其他标识符。主要目的是有一个一致的TODO
,可以通过搜索找到如何获取更多详细信息的方法。TODO
并不是承诺提到的人会修复问题。因此,当你创建一个TODO
时,几乎总是你的名字被给出。
示例:
# TODO(mrmonkey): 处理不太可能的边缘情况 (bug ####)
格式化
对于你正在修改的文件,你应该遵循已有的风格,但对于任何新的代码,以下规则是必须的。
缩进
缩进2个空格。不要使用制表符。
在代码块之间使用空白行以提高可读性。缩进是两个空格。无论如何,不要使用制表符。对于现有的文件,保持对现有缩进的忠诚。
行长度和长字符串
最大行长度是80个字符。
如果你必须写长度超过80个字符的字符串,如果可能的话,应该使用here文档或嵌入换行符来完成。那些必须超过80个字符且不能合理地分割的字符串是可以的,但强烈建议找到一种方法使其更短。
# 使用 'here document'
cat <<END
我是一个特别长的
字符串。
END
# 嵌入换行符也可以
long_string="我是一个特别
长的字符串。"
管道
如果管道不能全部放在一行上,那么应该将管道分割成一行一个。
如果一个管道可以全部放在一行上,那么它应该在一行上。
如果不是,那么应该在每一行的管道段处进行分割,管道在新行上,并为管道的下一部分缩进2个空格。这适用于使用|
组合的命令链,以及使用||
和&&
的逻辑复合。
# 全部放在一行上
command1 | command2
# 长命令
command1 \
| command2 \
| command3 \
| command4
循环
将; do
和; then
放在while
,for
或if
的同一行上。
Shell中的循环有些不同,但我们遵循与声明函数时使用花括号相同的原则。即:; then
和; do
应该在if/for/while的同一行上。else
应该在自己的行上,关闭语句应该在与开启语句垂直对齐的自己的行上。
示例:
# 如果在函数内部,考虑声明循环变量为
# 局部变量,以避免它泄漏到全局环境:
# local dir
for dir in "${dirs_to_cleanup[@]}"; do
if [[ -d "${dir}/${ORACLE_SID}" ]]; then
log_date "Cleaning up old files in ${dir}/${ORACLE_SID}"
rm "${dir}/${ORACLE_SID}/"*
if (( $? != 0 )); then
error_message
fi
else
mkdir -p "${dir}/${ORACLE_SID}"
if (( $? != 0 )); then
error_message
fi
fi
done
Case语句
- 通过2个空格缩进选项。
- 一行的选项在模式的关闭括号后和
;;
前需要一个空格。 - 长的或多命令的选项应该分割到多行,模式、动作和
;;
应该在不同的行上。
匹配表达式从case
和esac
缩进一级。多行动作再缩进一级。通常,无需引用匹配表达式。模式表达式不应该以开括号为前导。避免使用;&
和;;&
的表示法。
case "${expression}" in
a)
variable="…"
some_command "${variable}" "${other_expr}" …
;;
absolute)
actions="relative"
another_command "${actions}" "${other_expr}" …
;;
*)
error "Unexpected expression '${expression}'"
;;
esac
简单的命令可以放在模式 和 ;;
的同一行上,只要表达式保持可读。这通常适用于单字母选项处理。当动作不适合在一行上时,将模式放在自己的行上,然后是动作,然后是;;
也在自己的行上。当在动作的同一行时,在模式的关闭括号后和;;
前使用空格。
verbose='false'
aflag=''
bflag=''
files=''
while getopts 'abf:v' flag; do
case "${flag}" in
a) aflag='true' ;;
b) bflag='true' ;;
f) files="${OPTARG}" ;;
v) verbose='true' ;;
*) error "Unexpected option ${flag}" ;;
esac
done
变量扩展
按优先级顺序:保持与你发现的一致;引用你的变量;优先使用"${var}"
而不是"$var"
。
这些都是强烈推荐的指南,但不是强制性的规定。尽管如此,这是一项推荐而不是强制性的事实并不意味着它应该被轻视或淡化。
它们按优先级顺序列出。
- 对于现有的代码,保持与你发现的一致。
- 引用变量。
-
不要为单字符的shell特殊符号/位置参数使用大括号定界,除非严格必要或者避免深深的困惑。
对所有其他变量,优先使用大括号定界。
# *推荐*的例子部分。 # 对于'special'变量的优先风格: echo "Positional: $1" "$5" "$3" echo "Specials: !=$!, -=$-, _=$_. ?=$?, #=$# *=$* @=$@ \$=$ …" # 必须使用大括号的情况: echo "many parameters: ${10}" # 避免混淆的使用大括号: # 输出是 "a0b0c0" set -- a b c echo "${1}0${2}0${3}0" # 对于其他变量的优选风格: echo "PATH=${PATH}, PWD=${PWD}, mine=${some_var}" while read -r f; do echo "file=${f}" done < <(find /tmp) # *不推荐*的例子部分 # 未引用的变量,未使用大括号的变量,大括号定界的单字母 # shell特殊符号。 echo a=$avar "b=$bvar" "PID=${$}" "${1}" # 混淆的使用:这是扩展为"${1}0${2}0${3}0", # 而不是"${10}${20}${30} set -- a b c echo "$10$20$30"
注意:在${var}
中使用大括号 不是 引用的一种形式。必须 同时 使用“双引号”。
引用
- 总是引用包含变量、命令替换、空格或shell元字符的字符串,除非需要小心地进行未引用的扩展,或者它是一个shell内部的整数(参见下一点)。
- 对于元素列表的安全引用,特别是命令行标志,使用数组。
- 可选地引用定义为整数的shell内部,只读的特殊变量:
$?
,$#
,$
,$!
(man bash)。出于一致性考虑,优先引用“命名”的内部整数变量,例如PPID等。 - 优先引用那些“单词”的字符串(而不是命令选项或路径名)。
- 绝不引用 字面 整数。
- 注意
[[ … ]]
中模式匹配的引用规则。 - 除非你有特定的原因使用
$*
,比如在消息或日志中简单地将参数追加到一个字符串,否则使用"$@"
。
# '单'引号表示不需要替换。
# "双"引号表示需要/可以进行替换。
# 简单的例子
# "引用命令替换"
# 注意"$()"内部嵌套的引号不需要转义。
flag="$(some_command and its args "$@" 'quoted separately')"
# "引用变量"
echo "${flag}"
# 对于列表,使用带引号扩展的数组。
declare -a FLAGS
FLAGS=( --foo --bar='baz' )
readonly FLAGS
mybinary "${FLAGS[@]}"
# 不引用内部整数变量是可以的。
if (( $# > 3 )); then
echo "ppid=${PPID}"
fi
# "绝不引用字面整数"
value=32
# "引用命令替换",即使你期望整数
number="$(generate_number)"
# "优先引用单词",不是强制的
readonly USE_INTEGER='true'
# "引用shell元字符"
echo 'Hello stranger, and well met. Earn lots of $
echo "Process $: Done making \$\$\$."
# "命令选项或路径名"
# ($1在这里假定包含一个值)
grep -li Hugo /dev/null "$1"
# 不那么简单的例子
# "引用变量,除非证明是假的":ccs可能是空的
git send-email --to "${reviewers}" ${ccs:+"--cc" "${ccs}"}
# 位置参数的预防措施:$1可能未设置
# 单引号保持正则表达式原样。
grep -cP '([Ss]pecial|\|?characters*) ${1:+"$1"}
# 对于参数的传递,
# "$@"几乎每次都是正确的,而
# $*几乎每次都是错误的:
#
# * $*和$@会在空格上分割,破坏包含空格的参数
# 并且丢弃空字符串;
# * "$@"会保持参数原样,所以没有提供参数
# 将导致没有参数被传递;
# 这在大多数情况下是你想用来传递
# 参数的。
# * "$*"扩展为一个参数,所有参数由(通常是)空格连接,
# 所以没有提供参数将导致一个空字符串
# 被传递。
# (参考`man bash`了解更详细的信息;-)
(set -- 1 "2 two" "3 three tres"; echo $#; set -- "$*"; echo "$#, $@")
(set -- 1 "2 two" "3 three tres"; echo $#; set -- "$@"; echo "$#, $@")
特性和错误
ShellCheck
ShellCheck 项目 能够识别你的 shell 脚本中的常见错误和警告。无论脚本大小,我们都推荐使用它。
命令替换
使用 $(command)
替代反引号。
嵌套的反引号需要使用 \
来转义内部的反引号。而 $(command)
格式在嵌套时不会改变,更易于阅读。
例如:
# 这种方式更好:
var="$(command "$(command1)")"
# 不推荐这种方式:
var="`command \`command1\``"
测试,[ … ]
,和 [[ … ]]
[[ … ]]
比 [ … ]
,test
和 /usr/bin/[
更受推荐。
[[ … ]]
减少了错误,因为在 [[
和 ]]
之间不会进行路径名扩展或单词分割。此外,[[ … ]]
允许进行正则表达式匹配,而 [ … ]
不支持。
# 这确保左边的字符串由字母数字字符类构成,后面跟着字符串名。
# 注意这里的 RHS 不应该被引用。
if [[ "filename" =~ ^[[:alnum:]]+name ]]; then
echo "Match"
fi
# 这匹配精确的模式 "f*" (在这种情况下不匹配)
if [[ "filename" == "f*" ]]; then
echo "Match"
fi
# 这会给出一个 "参数过多" 的错误,因为 f* 被扩展为当前目录的内容
if [ "filename" == f* ]; then
echo "Match"
fi
字符串测试
尽可能使用引号而不是填充字符。
Bash 能够处理在测试中的空字符串。所以,考虑到代码的可读性,应该使用测试空/非空字符串或空字符串,而不是使用填充字符。
# 做这样的操作:
if [[ "${my_var}" == "some_string" ]]; then
do_something
fi
# -z (字符串长度为零) 和 -n (字符串长度不为零) 比测试空字符串更受推荐
if [[ -z "${my_var}" ]]; then
do_something
fi
# 这样也可以(确保空字符串的一侧有引号),但不是首选:
if [[ "${my_var}" == "" ]]; then
do_something
fi
# 不要这样做:
if [[ "${my_var}X" == "some_stringX" ]]; then
do_something
fi
为了避免对你正在测试的内容产生混淆,显式使用 -z
或 -n
。
# 使用这种方式
if [[ -n "${my_var}" ]]; then
do_something
fi
# 而不是这种方式
if [[ "${my_var}" ]]; then
do_something
fi
为了清晰,使用 ==
进行等价性测试,而不是 =
,尽管两者都可以。前者鼓励使用 [[
,后者可能会与赋值混淆。然而,在 [[ … ]]
中使用 <
和 >
时要小心,它们执行的是字典序比较。对于数值比较,使用 (( … ))
或 -lt
和 -gt
。
# 使用这种方式
if [[ "${my_var}" == "val" ]]; then
do_something
fi
if (( my_var > 3 )); then
do_something
fi
if [[ "${my_var}" -gt 3 ]]; then
do_something
fi
# 而不是这种方式
if [[ "${my_var}" = "val" ]]; then
do_something
fi
# 这可能是无意中进行了字典序比较。
if [[ "${my_var}" > 3 ]]; then
# 对于 4 来说是真,对于 22 来说是假。
do_something
fi
```
### 文件名的通配符扩展
在进行文件名的通配符扩展时,使用明确的路径。
由于文件名可以以 `-` 开头,所以使用 `./*` 而不是 `*` 来扩展通配符更安全。
```bash
# 这是目录的内容:
# -f -r somedir somefile
# 错误地强制删除目录中的几乎所有内容
psa@bilby$ rm -v *
removed directory: `somedir'
removed `somefile'
# 相对于:
psa@bilby$ rm -v ./*
removed `./-f'
removed `./-r'
rm: cannot remove `./somedir': Is a directory
removed `./somefile'
Eval
应避免使用 `eval`。
当用于变量赋值时,Eval 会混淆输入,并且可以设置变量,而无法检查这些变量是什么。
# 这设置了什么?
# 它成功了吗?部分还是全部?
eval $(set_my_variables)
# 如果返回的值中有一个包含空格会发生什么?
variable="$(eval some_function)"
数组
Bash 数组应用于存储元素列表,以避免引号复杂性。这特别适用于参数列表。不应该使用数组来实现更复杂的数据结构(参见上面的[何时使用 Shell])。
数组存储一个有序的字符串集合,并可以安全地扩展为命令或循环的单个元素。
应避免使用单个字符串用于多个命令参数,因为这必然会导致作者使用 `eval` 或尝试在字符串内部嵌套引号,这不会给出可靠或可读的结果,并导致不必要的复杂性。
# 使用括号赋值数组,并可以使用 +=( … ) 追加。
declare -a flags
flags=(--foo --bar='baz')
flags+=(--greeting="Hello ${name}")
mybinary "${flags[@]}"
# 不要使用字符串进行序列操作。
flags='--foo --bar=baz'
flags+=' --greeting="Hello world"' # 这不会按预期工作。
mybinary ${flags}
# 命令扩展返回单个字符串,而不是数组。避免在数组赋值中使用无引号扩展,
# 因为如果命令输出包含特殊字符或空白,它将不会正确工作。
# 这将列表输出扩展为字符串,然后进行特殊关键字扩展,然后进行空白分割。
# 只有这个时候,它才会变成一个单词列表。 ls 命令也可能根据用户的活动环境改变行为!
declare -a files=($(ls /directory))
# get_arguments 将所有内容写入 STDOUT,但然后在转变为参数列表之前,
# 会经历上面的同样的扩展过程。
mybinary $(get_arguments)
数组的优点
- 使用数组可以让事物的列表不会引起引号语义的混淆。相反,不使用数组会导致试图在字符串内部嵌套引号的错误尝试。
- 数组使得可以安全地存储任意字符串的序列/列表,包括包含空白的字符串。
数组的缺点
使用数组可能会增加脚本的复杂性。
数组的决定
应该使用数组来安全地创建和传递列表。特别是在构建一组命令参数时,使用数组可以避免引号问题。使用引号扩展 – `"${array[@]}"` – 来访问数组。然而,如果需要更高级的数据操作,应该避免使用 shell 脚本;
管道到 While
优先使用进程替换或 `readarray` 内置命令(bash4+)而不是管道到 `while`。管道会创建一个子 shell,所以在管道内修改的任何变量都不会传播到父 shell。
管道到 `while` 的隐式子 shell 可以引入难以追踪的微妙错误。
last_line='NULL'
your_command | while read -r line; do
if [[ -n "${line}" ]]; then
last_line="${line}"
fi
done
# 这将总是输出 'NULL'!
echo "${last_line}"
使用进程替换也会创建一个子 shell。然而,它允许从一个子 shell 重定向到 `while`,而不需要将 `while`(或任何其他命令)放在一个子 shell 中。
last_line='NULL'
while read line; do
if [[ -n "${line}" ]]; then
last_line="${line}"
fi
done < <(your_command)
# 这将输出 your_command 的最后一个非空行
echo "${last_line}"
或者,使用 `readarray` 内置命令将文件读入一个数组,然后遍历数组的内容。注意(出于上述同样的原因)你需要使用进程替换而不是管道与 `readarray`,但是有一个优点,即循环的输入生成位于它之前,而不是之后。
last_line='NULL'
readarray -t lines < <(your_command)
for line in "${lines[@]}"; do
if [[ -n "${line}" ]]; then
last_line="${line}"
fi
done
echo "${last_line}"
注意:在使用 for-loop 迭代输出时要小心,如 `for var in $(...)`,因为输出是按空白分割的,而不是按行分割。有时你会知道这是安全的,因为输出不可能包含任何意外的空白,但是在这不明显或不提高可读性的情况下(如在 `$(...)` 内部的长命令),`while read` 循环或 `readarray` 通常更安全,更清晰。
算术
总是使用 `(( … ))` 或 `$(( … ))` 而不是 `let` 或 `$[ … ]` 或 `expr`。
永远不要使用 `$[ … ]` 语法,`expr` 命令,或 `let` 内置命令。
在 `[[ … ]]` 表达式中,`<` 和 `>` 不执行数值比较(它们执行字典序比较;参见测试字符串)。优先不使用 `[[ … ]]` 进行数值比对,而是使用 `(( … ))`。
建议避免使用 `(( … ))` 作为独立的语句,并且要警惕其表达式评估为零
-
尤其是在启用 `set -e` 的情况下。例如,`set -e; i=0; (( i++ ))` 将导致 shell 退出。
# 简单的计算用作文本 - 注意在字符串内部使用 $(( … ))。 echo "$(( 2 + 2 )) is 4" # 当进行算术比较测试时 if (( a < b )); then … fi # 一些计算结果赋给一个变量。 (( i = 10 * j + 400 )) # 这种形式是非便携式的并且已被弃用 i=$[2 * 10] # 尽管看起来,'let' 不是声明性关键字之一, # 所以未引用的赋值受到 globbing wordsplitting 的影响。 # 为了简单起见,避免 'let' 并使用 (( … )) let i="2 + 2" # expr 实用程序是一个外部程序,不是 shell 内置的。 i=$( expr 4 + 4 ) # 使用 expr 时,引用也可能出错。 i=$( expr 4 '*' 4 )
除了风格考虑外,shell 的内置算术比 `expr` 快很多倍。
当使用变量时,`${var}`(和 `$var`)形式在 `$(( … ))` 内部是不需要的。shell 知道为你查找 `var`,省略 `${…}` 会使代码更清晰。这与之前总是使用大括号的规则稍有矛盾,所以这只是一个建议。
# 注意:记住尽可能声明你的变量为整数,并优先使用局部变量而不是全局变量。
local -i hundred=$(( 10 * 10 ))
declare -i five=$(( 10 / 2 ))
# 将变量 "i" 增加三。
# 注意:
# - 我们不写 ${i} 或 $i。
# - 我们在 (( 后和 ) 前放一个空格。
(( i += 3 ))
# 减少变量 "i" 五:
(( i -= 5 ))
# 进行一些复杂的计算。
# 注意正常的算术运算符优先级被遵循。
hr=2
min=5
sec=30
echo $(( hr * 3600 + min * 60 + sec )) # 如预期打印 7530
命名规范
函数名
使用小写字母,用下划线分隔单词。用`::`分隔库。函数名后必须有括号。`function`关键字是可选的,但必须在整个项目中保持一致。
如果你正在编写单个函数,使用小写并用下划线分隔单词。如果你正在编写一个包,用`::`分隔包名。大括号必须在函数名的同一行(如Google的其他语言)并且函数名和括号之间没有空格。
# 单个函数
my_func() {
…
}
# 包的一部分
mypackage::my_func() {
…
}
当函数名后面有“()”时,`function`关键字是多余的,但可以增强快速识别函数的能力。
变量名
函数名的规则相同。
循环的变量名应该和你正在遍历的任何变量的命名方式相同。
for zone in "${zones[@]}"; do
something_with "${zone}"
done
常量和环境变量名
全部大写,用下划线分隔,声明在文件的顶部。
常量和任何导出到环境的内容都应该大写。
# 常量
readonly PATH_TO_FILES='/some/path'
# 同时是常量和环境变量
declare -xr ORACLE_SID='PROD'
有些东西在第一次设置时就变成了常量(例如,通过getopts)。因此,在getopts或基于条件设置常量是可以的,但之后应立即将其设置为只读。为了清晰起见,建议使用`readonly`或`export`代替等效的`declare`命令。
VERBOSE='false'
while getopts 'v' flag; do
case "${flag}" in
v) VERBOSE='true' ;;
esac
done
readonly VERBOSE
源文件名
小写,如果需要,可以用下划线分隔单词。
这是为了与Google的其他代码风格保持一致: `maketemplate` 或 `make_template` ,但不是 `make-template`。
只读变量
使用`readonly`或`declare -r`确保它们是只读的。
由于全局变量在shell中被广泛使用,因此在使用它们时捕获错误非常重要。当你声明一个变量是只读的时,应明确指出。
zip_version="$(dpkg --status zip | grep Version: | cut -d ' ' -f 2)"
if [[ -z "${zip_version}" ]]; then
error_message
else
readonly zip_version
fi
使用局部变量
使用`local`声明函数特定的变量。声明和赋值应在不同的行。
通过在声明它们时使用`local`,确保局部变量只在函数和其子函数中可见。这避免了污染全局命名空间并无意设置可能在函数外部有意义的变量。
当赋值值由命令替换提供时,声明和赋值必须是单独的语句;因为`local`内置命令不会传播命令替换的退出代码。
my_func2() {
local name="$1"
# 声明和赋值在不同的行:
local my_var
my_var="$(my_func)"
(( $? == 0 )) || return
…
}
my_func2() {
# 不要这样做:
# $? 将永远是零,因为它包含的是 'local' 的退出代码,而不是 my_func 的
local my_var="$(my_func)"
(( $? == 0 )) || return
…
}
函数位置
将所有函数放在文件中的常量下面。不要在函数之间隐藏可执行代码。这样做会使代码难以跟踪,并在调试时产生令人不悦的惊喜。
如果你有函数,将它们全部放在文件的顶部。只有包含,set
语句和设置常量可以在声明函数之前完成。
main
对于足够长以至于包含至少一个其他函数的脚本,需要一个名为main
的函数。
为了方便找到程序的起点,将主程序放在一个名为main
的函数中作为最底部的函数。这提供了与代码库的其他部分的一致性,并允许你定义更多的local
变量(如果主代码不是函数,则无法完成)。文件中的最后一行非注释行应该是对main
的调用:
显然,对于简短的脚本,如果它只是一个线性流程,main
是过分的,因此不需要。
调用命令
检查返回值
始终检查返回值并给出有用的返回值。
对于未管道的命令,使用$?
或直接通过if
语句进行检查以保持简洁。
示例:
if ! mv "${file_list[@]}" "${dest_dir}/"; then
echo "Unable to move ${file_list[*]} to ${dest_dir}" >&2
exit 1
fi
# 或者
mv "${file_list[@]}" "${dest_dir}/"
if (( $? != 0 )); then
echo "Unable to move ${file_list[*]} to ${dest_dir}" >&2
exit 1
fi
Bash还有PIPESTATUS
变量,它允许检查管道所有部分的返回代码。如果只需要检查整个管道的成功或失败,那么以下是可以接受的:
tar -cf - ./* | ( cd "${dir}" && tar -xf - )
if (( PIPESTATUS[0] != 0 || PIPESTATUS[1] != 0 )); then
echo "Unable to tar files to ${dir}" >&2
fi
然而,由于PIPESTATUS
将在你执行任何其他命令后被覆盖,如果你需要根据错误在管道中的发生位置采取不同的行动,你需要在运行命令后立即将PIPESTATUS
赋值给另一个变量(别忘了[
是一个命令,会清除PIPESTATUS
)。
tar -cf - ./* | ( cd "${DIR}" && tar -xf - )
return_codes=( "${PIPESTATUS[@]}" )
if (( return_codes[0] != 0 )); then
do_something
fi
if (( return_codes[1] != 0 )); then
do_something_else
fi
内置命令 vs. 外部命令
在调用shell内置命令和调用单独的进程之间做选择时,选择内置命令。
我们更喜欢使用内置命令,如bash(1)
中的_参数扩展_函数,因为它更健壮和可移植(尤其是与sed
等东西相比)。
示例:
# 更喜欢这样:
addition=$(( X + Y ))
substitution="${string/#foo/bar}"
# 而不是这样:
addition="$(expr "${X}" + "${Y}")"
substitution="$(echo "${string}" | sed -e 's/^foo/bar/')"