【动机】
众所周知, linux shell 函数的定义, 默认采用位置参数, 在函数内部, 以 $1 $2 $3… 获取. 这对于一两个参数的函数, 还不觉得有何不便之处, 当参数个数多了以后, 不论是函数的编写还是调用或者扩展, 可能就不太好玩了.
getopts 设计之初衷, 是用于为脚本命令行注入参数, 以期得到脚本不同的运行结果, 后来也被大量用于自定义函数命名参数. 网络上给出的 getopts 的用法, 大部分是这样的套路(本文不对 getopts 函数的原理做过多解释):
while getopts ":a:b:c" opt; do
case $opt in
a)
echo "Option a is set with value $OPTARG"
;;
b)
echo "Option b is set with value $OPTARG"
;;
c)
echo "Option c is set"
;;
\?)
echo "Invalid option: -$OPTARG"
;;
esac
done
有没有考虑过, 这样的参数解析虽然一点问题没有, 但是设想每个需要定义命名参数的函数都得在前面添加这一大串 while循环, 是不是一场噩梦?!
【意图】
假设函数定义了 -a -b -c 三个命名参数, 我们希望的获取命名参数的形式, 至少应该这样:
initparams
local v_a=$(get_para a)
local v_b=$(get_para b)
local v_c=$(get_para c)
所以, 封装 initparams 和 get_para 成为当务之急
【实现】
创建文件 parameter.sh, 用于保存命名参数填充与获取函数. 为了叙述方便, 约定:
- 被设计为使用命名参数的函数称作目标函数;
- 为目标函数创建参数名与值对应关系的函数为参数解析函数,;
- 最后将参数值整理给目标函数的函数称为参数输出函数.
先创建全局作用域的关联数组(稍后将会看到, 如何将此关联数组配置为局部, 以充分减少目标函数之间的参数影响)
declare -A __param_args__
然后创建参数解析函数 para_analysis
para_analysis(){
# OPTIND 全局有效, 故每次分析均需要复位, 否则二次调用可能出错或失败
let OPTIND=1
local arr_opt=()
for arg in "$@"; do
if [[ "$arg" =~ ^-[A-Za-z]$ ]]; then
arr_opt+=($arg)
fi
done
# 每次调用均需要清空
__param_args__=()
# 将名称参数对, 写入数组
local char opt idx
local max_idx=$[${#arr_opt[@]}-1]
local last_param=${!#}
local cur
for idx in ${!arr_opt[@]} ;do
char=${arr_opt[$idx]:1:1}
# 注意: 当同时包含位置参数, 且位置参数后面还有选项参数,
# getopts 认为该选项缺少赋值而报错(该报错被 : 抑制), 且命令退出码非0, 需要忽略
getopts ":$char:" opt || :
if [[ "$OPTARG" =~ ^-[A-Za-z]$ ]]; then
# 说明前一个选项没有赋值, OPTIND-- 以平衡内部的 OPTIND++
let OPTIND--
# 当最后一个选项没有赋值, getopts 会默认 OPTARG 为该选项的字面值
# 例如 func -a 'hello' -b 'world' -c
# -c 没有赋值, 此时 OPTARG 错误的等于 c.
# 考核当前序号是否是最大, 同时选项与最末参数相等可避过此坑
elif [ $idx -lt $max_idx -o ${arr_opt[$idx]} != "$last_param" ]; then
# 同一选项如出现多次, 则将每次赋予的值连接成一字符串, 中间用换行符隔开
[ "${__param_args__[$char]}" ] \
&& cur=$(echo -ne "${__param_args__[$char]}\n$OPTARG") \
|| cur=$OPTARG
__param_args__+=([$char]=$cur)
fi
done
}
参数输出函数 para
:<<COMMENT
获取指定选项的值
$1: opt_name 选项名称, 限制为一个字母(大小写敏感), 不带先导短连线: -
$2: default_value 如果找不到, 将此参数作为返回值, 默认为 空
返回值: 找到, 则返回; 否则返回 $2
COMMENT
para(){
if [ ${#__param_args__} -eq 0 ]; then
echo "${__param_args__[$1]:-"$2"}"
else
#log_error "you should call para_analysis or eval \$PAS first"
return 13
fi
}
两个函数均有详尽的注释, 在此不做过多解释. 要注意的一点是, 为进一步方便调用, para 函数提供参数, 以指定默认值. 另外, log_error … 先注释, 因为它是后续才会讲到的 log 库的函数之一.
【测试】
新建文件 test.sh
# 根据实际文件位置修改路径
# source ./parameter.sh
# 目标函数, 假设该函数用到的命名参数为:
# -a -c -b -d
# 该函数仅仅演示得到的值与调用方是否相符, 无其他操作
target1_with_named_params(){
para_analysis "$@"
local opts="a c b d"
local v1
for opt in $opts; do
v1=$(para $opt 'null')
echo "[$opt]=<$v1>;"
done
}
target1_with_named_params -a "one two" -b ' three four ' -c "five-six"
echo "another calling..."
target1_with_named_params -a "first second" -c "hello world"
测试结果如下:
[a]=<one two>;
[c]=<five-six>;
[b]=< three four >;
[d]=<null>;
another calling...
[a]=<first second>;
[c]=<hello world>;
[b]=<null>;
[d]=<null>;
可以看到:
1.虽然使用了全局数组, 两次调用同一目标函数, 它们的参数并未互相影响. 要点在于 para_analysis 函数内部的 let OPTIND=1 以及 __param_args__=()
2. 注意, 参数值包含的空格(先导后缀以及中间)被如数保持.
【改进】
如果还想将调用进一步简化, 并取消全局数组, 可以创建 “宏” 命令, 具体来说, 就是在 parameter.sh 中, 将全局关联数组定义语句注释或删除:
# declare -A __param_args_
并添加以下 “宏” 定义:
PAS='declare +g -A __param_args__=(); para_analysis "$@"'
意思是将关联数组定义在目标函数中, 则目标函数每次被调用均创建属于自己的关联数组, 以保存命名参数的名-值对.
然后, para_analysis 函数中的清空关联数组语句, 可以省略:
# 每次调用均需要清空
# __param_args__=()
这其实是利用了局部变量的 “穿透性”.
【再测试】
新建 test.sh
# 根据实际文件位置修改路径
# source ./parameter.sh
# 假设该函数用到的命名参数为:
# -a -c -b -d
# 该函数仅仅演示得到的值与调用方是否相符
target2_with_named_params(){
eval $PAS
local opts="a c b d"
local v1 var1
for opt in $opts; do
v1=$(para $opt 'null')
echo "[$opt]=<$v1>;"
done
}
target2_with_named_params -a "one two" -b ' three four ' -c "five-six"
echo "another calling..."
target2_with_named_params -a "first second" -c "hello world"
echo "multiple same parameter..."
target2_with_named_params -a "one two" -c "here there" -a "123 456" -b
结果与前面相同, 注意添加的最后的一句调用, 演示重复出现的参数赋值, 结果以换行符连接. 当然也可以根据需要, 修改为以空格相隔, 这就看个人的喜好了.
#.....同前
multiple same parameter...
[a]=<one two
123 456>;
[c]=<here there>;
[b]=<null>;
[d]=<null>;
【结论】
总结一下, 目标函数获取调用方给定的参数值, 推荐如下格式:
eval @PAS
local v_a=$(para a 'default value of a')
local v_b=$(para b)
......
是不是比每个函数都弄一个大大的 while 循环做头部要简洁许多?
【瑕疵】
本文讨论的仅仅是一种命名参数的简单配置和获取形式, 当然它也存在一些有待改进的地方:
- 无法使用多字母形式(单词形式)的命名(这是由 getopts 命令决定的), 虽然 52 个字母(分大小写)对于大部分应用可能已经足够, 但是对于首字母相同的参数名, 不得不采用不同的字母, 可能会造成一些辞不达意甚至混淆, 这只能依赖于文档的详细说明了.再或者, 考虑使用 getopt ……
- 一般不要同时使用位置参数. 如果一定要与位置参数并存, 则位置参数必须放在最后.
- 如果要为指定参数赋类似值: func -t ‘-t’ 目前该赋值会被忽略, 补救办法是在其左或右边添加空格, 可成功赋值(如果你不在乎该变量值多了一个先导或后缀空格的话), 即 func -t ‘ -t’ 或 func -t ‘-t ‘
【解决方案】
不依赖于 getopt 或 getopts, 采用手工筛选的方式, 可达目的. 参见再论命名参数.