
本章内容索引
变量展开
按优先顺序排列:保持与您发现的一致;引用您的变量;优先使用"var"。
这些是强烈推荐的指南,但并非强制性规定。尽管如此,建议并非强制性规定并不意味着应该轻视或贬低它。
它们按优先顺序列出。
- 对于现有代码,请保持一致。
- 引用变量,请参见下面的引用部分。
- 除非必须或避免严重混淆,否则不要使用花括号分隔单个字符的shell特殊符号/位置参数。
优先使用花括号分隔所有其他变量
- 推荐风格的部分
# 特殊变量的首选样式:
echo "位置参数:$1" "$5" "$3"
echo "特殊变量:!=$! -=$- _=$_. ?=$? #=$# *=$* @=$@ \$=$ …"
# 花括号必需:
echo "许多参数:${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 "文件=${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内部只读特殊变量:#、$!(参见bash手册)。为保持一致性,最好引用“命名”的内部整数变量,例如PPID等。
- 优先引用作为“单词”(而不是命令选项或路径名)的字符串。
- 不要引用字面整数。
- 请注意[[ ... ]]中模式匹配的引用规则。请参阅下面的测试(Test)、[ ... ]和[[ ... ]]部分。
- 除非你有特定的原因要使用@"
# 'Single' quotes indicate that no substitution is desired.
# "Double" quotes indicate that substitution is required/tolerated.
# "quote command substitutions"
# 注意,嵌套在`$()`内的引号不需要转义。
flag="$(some_command and its args "$@" 'quoted separately')"
# "quote variables"
echo "${flag}"
# 使用带引号的扩展数组来表示列表。
declare -a FLAGS
FLAGS=( --foo --bar='baz' )
readonly FLAGS
mybinary "${FLAGS[@]}"
# 内部整数变量不引用也可以。
if (( $# > 3 )); then
echo "ppid=${PPID}"
fi
# "never quote literal integers"
value=32
# "quote command substitutions", even when you expect integers
number="$(generate_number)"
# "prefer quoting words", not compulsory
readonly USE_INTEGER='true'
# "quote shell meta characters"
echo 'Hello stranger, and well met. Earn lots of $#39;
echo "Process $: Done making \$\$\$."
# "command options or path names"
# (这里假设$1包含一个值)
grep -li Hugo /dev/null "$1"
# 较复杂的例子
# "quote variables, unless proven false":ccs 可能为空
git send-email --to "${reviewers}" ${ccs:+"--cc" "${ccs}"}
# 位置参数的注意事项:$1可能未设置
# 单引号保持正则表达式不变。
grep -cP '([Ss]pecial|\|?characters*)#39; ${1:+"$1"}
# 用于传递参数的情况下,
# "$@" 几乎总是正确的选择,而
# $* 几乎总是错误的选择:
#
# * $* 和 $@ 会按空格分割,覆盖含有空格的参数并丢弃空字符串;
# * "$@" 将参数保持原样,因此如果没有参数提供,则不会传递任何参数;
# 这在大多数情况下是传递参数时所需的方法。
# * "$*" 会扩展成一个参数,所有参数以(通常是)空格连接起来,
# 因此如果没有参数提供,将会传递一个空字符串。
# (详细信息请参阅`man bash`)
(set -- 1 "2 two" "3 three tres"; echo $#; set -- "$*"; echo "$#, $@")
(set -- 1 "2 two" "3 three tres"; echo $#; set -- "$@"; echo "$#, $@")
特性和错误
- ShellCheckShellCheck项目可以识别您的Shell脚本中的常见错误和警告。建议在所有大小的脚本中使用。
- 命令替换使用$(command)而不是反引号。
- 嵌套的反引号需要使用\转义内部的引号。$(command)格式在嵌套时不会改变,并且更易于阅读。
示例:
- 推荐
# This is preferred:
var="$(command "$(command1)")"
- 不推荐
# This is not:
var="`command \`command1\``"
Test,[ … ], and[[ … ]]
[[ … ]] 减少了错误,因为在[[和]]之间不会进行路径名扩展或单词拆分。此外,[[ … ]]允许进行正则表达式匹配,而[ … ]则不行。
推荐:
# This ensures the string on the left is made up of characters in
# the alnum character class followed by the string name.
# Note that the RHS should not be quoted here.
if [[ "filename" =~ ^[[:alnum:]]+name ]]; then
echo "Match"
fi
# This matches the exact pattern "f*" (Does not match in this case)
if [[ "filename" == "f*" ]]; then
echo "Match"
fi
不推荐:
# This gives a "too many arguments" error as f* is expanded to the
# contents of the current directory
if [ "filename" == f* ]; then
echo "Match"
fi
Testing Strings
在可能的情况下使用引号而不是填充字符。
Bash足够智能,在测试中处理空字符串。因此,鉴于代码更易于阅读,请使用测试空/非空字符串或空字符串,而不是填充字符。
推荐:
# Do this:
if [[ "${my_var}" == "some_string" ]]; then
do_something
fi
# -z (string length is zero) and -n (string length is not zero) are
# preferred over testing for an empty string
if [[ -z "${my_var}" ]]; then
do_something
fi
# This is OK (ensure quotes on the empty side), but not preferred:
if [[ "${my_var}" == "" ]]; then
do_something
fi
不推荐:
# Not this:
if [[ "${my_var}X" == "some_stringX" ]]; then
do_something
fi
为了避免对你正在测试的内容产生困惑,请明确使用-z或-n
建议:
# Use this
if [[ -n "${my_var}" ]]; then
do_something
fi
不要
# Instead of this
if [[ "${my_var}" ]]; then
do_something
fi
为了清晰起见,使用==表示相等,而不是使用=,尽管两者都有效。前者鼓励使用[[,而后者可能会与赋值混淆。然而,在使用[[...]]时,当涉及<和>进行词典比较时要小心。对于数值比较,请使用((...))或-lt和-gt。
建议:
# Use this
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
不建议:
# Instead of this
if [[ "${my_var}" = "val" ]]; then
do_something
fi
# Probably unintended lexicographical comparison.
if [[ "${my_var}" > 3 ]]; then
# True for 4, false for 22.
do_something
fi
通配符文件名扩展
在使用通配符进行文件名扩展时,请使用明确的路径。
由于文件名可能以“-”开头,使用“./”而不是“*”来扩展通配符是更安全的方法。
推荐
# Here's the contents of the directory:
# -f -r somedir somefile
# Incorrectly deletes almost everything in the directory by force
psa@bilby$ rm -v *
removed directory: `somedir'
removed `somefile'
避免:
# As opposed to:
psa@bilby$ rm -v ./*
removed `./-f'
removed `./-r'
rm: cannot remove `./somedir': Is a directory
removed `./somefile'
Eval
请避免使用 eval。
当用于变量赋值时,eval 可能会修改输入并且可以设置变量,而无法检查这些变量的内容。
# What does this set?
# Did it succeed? In part or whole?
eval $(set_my_variables)
# What happens if one of the returned values has a space in it?
variable="$(eval some_function)"
Arrays
Bash数组应该用于存储元素列表,以避免引号复杂性。这尤其适用于参数列表。不应该使用数组来支持更复杂的数据结构(请参见上述何时使用Shell)。
数组存储有序的字符串集合,并且可以安全地展开为一个命令或循环的单独元素。
应避免将单个字符串用于多个命令参数,因为这最终导致作者使用eval或尝试在字符串内嵌套引号,这不会产生可靠或可读的结果,并导致不必要的复杂性。
推荐:
# An array is assigned using parentheses, and can be appended to
# with +=( … ).
declare -a flags
flags=(--foo --bar='baz')
flags+=(--greeting="Hello ${name}")
mybinary "${flags[@]}"
不推荐:
# Don’t use strings for sequences.
flags='--foo --bar=baz'
flags+=' --greeting="Hello world"' # This won’t work as intended.
mybinary ${flags}
注意:
# 命令扩展返回单个字符串,而不是数组。在数组赋值中避免未引用的扩展,因为如果命令输出包含特殊字符或空格,则无法正常工作。
# 这将列表输出扩展为字符串,然后进行特殊关键字扩展和空格拆分。然后将其转换为一个单词列表。ls命令也可能根据用户的活动环境而改变行为!
declare -a files=($(ls /directory))
# get_arguments将所有内容写入STDOUT,但然后经过上面相同的扩展过程,然后转换为参数列表。
mybinary $(get_arguments)
- 数组的优点使用数组可以创建列表,而不会混淆引号语义。相反,如果不使用数组,则会导致试图将引号嵌套在字符串内部的误解。使用数组可以安全地存储包含空格的任意字符串的序列/列表。
- 数组的缺点使用数组可能会使脚本的复杂性增加。
- 数组的建议应该使用数组来安全地创建和传递列表。特别是在构建一组命令参数时,使用数组可以避免引号混淆问题。使用引号扩展 - "${array[@]}" - 来访问数组。然而,如果需要更高级的数据操作,应该完全避免使用Shell脚本;请参阅上文。
Pipes to While
使用进程替换或优先使用bash4+中的readarray内建函数来代替使用管道连接while循环。管道会创建一个子shell,因此在管道内修改的变量不会传播到父shell。
使用管道连接while循环时隐含的子shell可能引入难以追踪的隐蔽错误。
last_line='NULL'
your_command | while read -r line; do
if [[ -n "${line}" ]]; then
last_line="${line}"
fi
done
# This will always output '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)
# This will output the last non-empty line from 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循环来迭代输出,如for var in $(...),因为输出是按空格分隔的,而不是按行分隔的。
有时你会知道这是安全的,因为输出不会包含任何意外的空白字符,但在这一点上并不明显或者并不提高可读性(比如在$(...)内部的长命令)
使用while read循环或readarray往往更安全和更清晰。
算术
始终使用 (( … )) 或 [ … ] 或 expr。
永远不要使用 $[ … ] 语法、expr 命令或 let 内建命令。
在 [[ … ]] 表达式中,< 和 > 不执行数值比较(它们执行字典比较;请参阅测试字符串)。如果可能,请不要在数字比较中使用 [[ … ]],而是使用 (( … ))。
建议避免将 (( … )) 作为独立语句使用,同时要谨慎处理其表达式求值为零的情况,特别是在启用 set -e 的情况下。例如,set -e;i=0;(( i++ )) 将导致 shell 退出。
建议:
# Simple calculation used as text - note the use of $(( … )) within
# a string.
echo "$(( 2 + 2 )) is 4"
# When performing arithmetic comparisons for testing
if (( a < b )); then
…
fi
# Some calculation assigned to a variable.
(( i = 10 * j + 400 ))
不建议:
# 这个表单不具备可移植性,已经被弃用
i=$[2 * 10]
# Despite appearances, 'let' isn't one of the declarative keywords,
# so unquoted assignments are subject to globbing wordsplitting.
# For the sake of simplicity, avoid 'let' and use (( … ))
let i="2 + 2"
# The expr utility is an external program and not a shell builtin.
i=$( expr 4 + 4 )
# Quoting can be error prone when using expr too.
i=$( expr 4 '*' 4 )
不考虑风格上的考虑,内置算术命令比 expr 快得多。
在使用变量时,{var}(或 {...} 可以使代码更简洁。这与前面关于始终使用花括号的规则略有不同,因此这只是一个建议。
# N.B.: Remember to declare your variables as integers when
# possible, and to prefer local variables over globals.
local -i hundred=$(( 10 * 10 ))
declare -i five=$(( 10 / 2 ))
# Increment the variable "i" by three.
# Note that:
# - We do not write ${i} or $i.
# - We put a space after the (( and before the )).
(( i += 3 ))
# To decrement the variable "i" by five:
(( i -= 5 ))
# Do some complicated computations.
# Note that normal arithmetic operator precedence is observed.
hr=2
min=5
sec=30
echo $(( hr * 3600 + min * 60 + sec )) # prints 7530 as expected
命名约定
- 函数名称小写字母,用下划线分隔单词。用 "::" 分隔库名。函数名后面必须有括号。关键词 "function" 是可选的,但必须在整个项目中一致使用。
如果你在编写单个函数,请使用小写字母并用下划线分隔单词。如果你在编写一个包,用 "::" 分隔包名。大括号必须与函数名在同一行(与 Google 的其他语言一样),函数名和括号之间不要有空格。
# Single function
my_func() {
…
}
# Part of a package
mypackage::my_func() {
…
}
函数关键字在函数名后面加上“()”时是多余的,但可以更快地识别函数。
- 变量名
- 类似函数名称。循环的变量名称应与正在遍历的任何变量命名方式相似。
for zone in "${zones[@]}"; do
something_with "${zone}"
done
- 常量和环境变量名称
- 所有大写字母,用下划线分隔,在文件顶部声明。
- 常量和任何导出到环境的内容应大写。
- # Constant readonly PATH_TO_FILES='/some/path' # Both constant and environment 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,确保局部变量只在函数及其子函数中可见,避免污染全局命名空间并意外设置可能在函数外具有重要意义的变量。
- 当赋值的值由命令替换提供时,声明和赋值必须是分开的语句;因为local内建函数不会传播命令替换的退出代码。
- my_func2() { local name="$1" # Separate lines for declaration and assignment: local my_var my_var="$(my_func)" (( $? == 0 )) || return … } my_func2() { # DO NOT do this: # $? will always be zero, as it contains the exit code of 'local', not my_func local my_var="$(my_func)" (( $? == 0 )) || return … }
- 函数位置
- 将所有功能放在文件中,紧接着常量的下方。不要在函数之间隐藏可执行代码。这样做会使代码难以跟踪,并在调试时导致不愉快的意外。
- 如果你有函数,请将它们全部放在文件的顶部附近。只有包含、设置语句和设置常量可以在声明函数之前完成。
- main
- 脚本长度足够长以包含至少一个其他函数时,需要一个名为main的函数。
- 为了方便找到程序的起始点,将主程序置于名为main的函数中,作为最底部的函数。这样做可以保持与代码库的一致性,同时允许您定义更多的局部变量(如果主代码不是一个函数,则无法这样做)。文件中的最后一行非注释行应该是对main函数的调用:
- main "$@"
显然,对于只有线性流程的简短脚本来说,主函数(main)是多余的,也不是必需的。
Calling Commands
- 检查返回值始终检查返回值并给出有信息的返回值。
对于未使用管道的命令,可以使用 $? 或通过 if 语句直接进行检查,以保持简单。
示例:
if ! mv "${file_list[@]}" "${dest_dir}/"; then
echo "Unable to move ${file_list[*]} to ${dest_dir}" >&2
exit 1
fi
# Or
mv "${file_list[@]}" "${dest_dir}/"
if (( $? != 0 )); then
echo "Unable to move ${file_list[*]} to ${dest_dir}" >&2
exit 1
fi
Bash还有PIPESTATUS变量,可以检查管道中所有部分的返回代码。如果只需要检查整个管道的成功或失败,则可以接受以下内容:
此外,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
Builtin Commands vs. External Commands
在选择调用shell内置命令和调用独立进程之间,请选择内置命令。
我们更倾向于使用类似于bash(1)中的参数扩展函数的内置命令,因为它更加稳健和可移植(特别是与sed等工具相比较)。
示例:
推荐:
# Prefer this:
addition=$(( X + Y ))
substitution="${string/#foo/bar}"
不推荐:
# Instead of this:
addition="$(expr "${X}" + "${Y}")"
substitution="$(echo "${string}" | sed -e 's/^foo/bar/')"
总结
遵循这些Bash shell脚本风格指南,开发人员可以确保脚本可靠、可预测地运行,避免问题或错误影响更大的工作流程或自动化流程。
要想了解更多shell编程的最佳实践和建议,降低生产环境因为非预期行为导致的线上事故,增强shell脚本的健壮性和鲁棒性, 可以点击关注,我会定期分享shell的各种最佳实践 ,如果想了解更多更深入的细节, 可以购买《shell脚本编程最佳实践》专栏,只需要不到一根冰激凌的价格就可以学到我多年的shell生产环境实践编码经验,非常划算 。