三  函数的命名参数设计

三 函数的命名参数设计

【动机】

众所周知, 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, 用于保存命名参数填充与获取函数. 为了叙述方便, 约定:

  1. 被设计为使用命名参数的函数称作目标函数;
  2. 为目标函数创建参数名与值对应关系的函数为参数解析函数,;
  3. 最后将参数值整理给目标函数的函数称为参数输出函数.

先创建全局作用域的关联数组(稍后将会看到, 如何将此关联数组配置为局部, 以充分减少目标函数之间的参数影响)

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 循环做头部要简洁许多?

【瑕疵】

本文讨论的仅仅是一种命名参数的简单配置和获取形式, 当然它也存在一些有待改进的地方:

  1. 无法使用多字母形式(单词形式)的命名(这是由 getopts 命令决定的), 虽然 52 个字母(分大小写)对于大部分应用可能已经足够, 但是对于首字母相同的参数名, 不得不采用不同的字母, 可能会造成一些辞不达意甚至混淆, 这只能依赖于文档的详细说明了.再或者, 考虑使用 getopt ……
  2. 一般不要同时使用位置参数. 如果一定要与位置参数并存, 则位置参数必须放在最后.
  3. 如果要为指定参数赋类似值: func -t ‘-t’ 目前该赋值会被忽略, 补救办法是在其左或右边添加空格, 可成功赋值(如果你不在乎该变量值多了一个先导或后缀空格的话), 即 func -t ‘ -t’ 或 func -t ‘-t ‘

【解决方案】

不依赖于 getopt 或 getopts, 采用手工筛选的方式, 可达目的. 参见再论命名参数.