Shell脚本中的关联数组

154

我们需要一个能够模拟关联数组或类似于映射数据结构的脚本,用于Shell脚本编程。请问有人知道如何实现吗?


2
参见:如何在Bash中定义哈希表? - Gabriel Staples
17个回答

201

如果可移植性不是您的主要问题,另一个选择是使用内置到 shell 中的关联数组。这应该适用于 bash 4.0(现在大多数主要的发行版都有提供,但在 OS X 上除非您自己安装它,否则不可用),ksh 和 zsh:

declare -A newmap
newmap[name]="Irfan Zulfiqar"
newmap[designation]=SSE
newmap[company]="My Own Company"

echo ${newmap[company]}
echo ${newmap[name]}

根据所使用的shell,你可能需要执行typeset -A newmap而不是declare -A newmap,或者在某些情况下可能完全不需要。


感谢您发布的答案,我认为这将是对使用Bash 4.0或更高版本的人来说最好的方法。 - Irfan Zulfiqar
我会加一点小修补来确保设置了BASH_VERSION,并且版本号 >= 4。对,BASH 4真的非常酷! - Tim Post
我正在使用类似这样的东西。如何“捕获”数组索引/下标不存在的错误?例如,如果我将下标作为命令行选项,并且用户输入“designatio”时出现了拼写错误,我会收到“坏的数组下标”错误,但是我不知道如何在数组查找时验证输入是否正确,如果可能的话? - Jer
3
@Jer 这段话比较晦涩,但是如果你想要确定Shell中的一个变量是否被设置,可以使用test -z ${variable+x}(这里的x并不重要,可以是任何字符串)。对于Bash中的关联数组,你也可以采用类似的方式:使用test -z ${map[key]+x}来判断是否已经设置了某个键值。 - Brian Campbell
我在 iTerm2 3.4 中不得不使用 declare -a newmap - harshainfo
好消息是这样的表达方式有效:var2=${newmap[$mykey]} - Aditya Kashi

120

另外一种非bash 4的方法。

#!/bin/bash

# A pretend Python dictionary with bash 3 
ARRAY=( "cow:moo"
        "dinosaur:roar"
        "bird:chirp"
        "bash:rock" )

for animal in "${ARRAY[@]}" ; do
    KEY=${animal%%:*}
    VALUE=${animal#*:}
    printf "%s likes to %s.\n" "$KEY" "$VALUE"
done

echo -e "${ARRAY[1]%%:*} is an extinct animal which likes to ${ARRAY[1]#*:}\n"

你还可以加入一个if语句进行搜索。例如:if [[ $var =~ /blah/ ]]. 或者其他什么的。


3
这种方法在你确实没有Bash 4的情况下是不错的。但我认为获取VALUE的那一行用这种方式会更安全:VALUE=${animal#*:}。只有一个#字符,匹配将停止在第一个":"上。这样允许值中包含":"。 - Ced-le-pingouin
@Ced-le-pingouin ~ 非常好的观点!我没有注意到这一点。我已经编辑了我的帖子,以反映你提出的改进建议。 - Bubnoff
1
这是一种使用BASH参数替换的相当hackish的关联数组模拟。 "key" param-sub替换冒号之前的所有内容,而value模式则替换冒号之后的所有内容。类似于正则表达式通配符匹配。因此不是真正的关联数组。除非您需要一种易于理解的方式在BASH 3或更低版本中执行哈希/关联数组功能,否则不建议使用。但它确实有效!更多信息请参见:http://tldp.org/LDP/abs/html/parameter-substitution.html#PSOREX2 - Bubnoff
1
这并没有实现关联数组,因为它没有提供通过键查找项的方法。它只提供了一种从数字索引中查找每个键(和值)的方法。(可以通过迭代数组来按键查找项,但这不是关联数组所期望的。) - Eric Postpischil
@EricPostpischil 确实。这只是一个技巧。它允许人们在设置中使用熟悉的语法,但仍然需要像你说的那样迭代数组。我在之前的评论中已经尽力明确它绝对不是关联数组,如果有其他选择,我甚至不建议使用它。在我看来,它唯一有利的一点是对于那些熟悉其他语言(如Python)的人来说,编写和使用都很容易。如果您真的想在BASH 3中实现关联数组,那么您可能需要重新审视自己的步骤。 - Bubnoff

38

我认为你需要退后一步,思考一下映射或关联数组的本质。它只是一种存储给定键的值,并快速有效地获取该值的方式。您可能还希望能够迭代键以检索每个键值对,或者删除键及其关联值。

现在,想想你在 shell 脚本中经常使用的数据结构,甚至在没有编写脚本的情况下在 shell 中使用,具有这些属性的数据结构是什么?卡住了吧?那就是文件系统。

实际上,在 shell 编程中拥有关联数组所需的只是一个临时目录。mktemp -d 就是您的关联数组构造函数:

prefix=$(basename -- "$0")
map=$(mktemp -dt ${prefix})
echo >${map}/key somevalue
value=$(cat ${map}/key)

如果您不想使用echocat,您可以编写一些小的包装器;这些包装器是模仿Irfan的,但它们只输出值,而不像$value那样设置任意变量:

#!/bin/sh

prefix=$(basename -- "$0")
mapdir=$(mktemp -dt ${prefix})
trap 'rm -r ${mapdir}' EXIT

put() {
  [ "$#" != 3 ] && exit 1
  mapname=$1; key=$2; value=$3
  [ -d "${mapdir}/${mapname}" ] || mkdir "${mapdir}/${mapname}"
  echo $value >"${mapdir}/${mapname}/${key}"
}

get() {
  [ "$#" != 2 ] && exit 1
  mapname=$1; key=$2
  cat "${mapdir}/${mapname}/${key}"
}

put "newMap" "name" "Irfan Zulfiqar"
put "newMap" "designation" "SSE"
put "newMap" "company" "My Own Company"

value=$(get "newMap" "company")
echo $value

value=$(get "newMap" "name")
echo $value

编辑:这种方法实际上比问题提出者建议的使用sed进行线性搜索要快得多,而且更加健壮(它允许键和值包含“-”、“=”、“空格”、“qnd”:SP:”)。它使用文件系统并不会使它变慢;除非您调用sync,否则这些文件实际上永远不会保证写入磁盘;对于寿命较短的临时文件,很可能其中许多文件永远不会被写入磁盘。

我使用以下驱动程序对Irfan的代码、Jerry对Irfan代码的修改以及我的代码进行了一些基准测试:

#!/bin/sh

mapimpl=$1
numkeys=$2
numvals=$3

. ./${mapimpl}.sh    #/ <- fix broken stack overflow syntax highlighting

for (( i = 0 ; $i < $numkeys ; i += 1 ))
do
    for (( j = 0 ; $j < $numvals ; j += 1 ))
    do
        put "newMap" "key$i" "value$j"
        get "newMap" "key$i"
    done
done

运行结果:

    $ time ./driver.sh irfan 10 5
真实时间:0m0.975s 用户态CPU时间:0m0.280s 系统态CPU时间:0m0.691s
$ time ./driver.sh brian 10 5
真实时间:0m0.226s 用户态CPU时间:0m0.057s 系统态CPU时间:0m0.123s
$ time ./driver.sh jerry 10 5
真实时间:0m0.706s 用户态CPU时间:0m0.228s 系统态CPU时间:0m0.530s
$ time ./driver.sh irfan 100 5
真实时间:0m10.633s 用户态CPU时间:0m4.366s 系统态CPU时间:0m7.127s
$ time ./driver.sh brian 100 5
真实时间:0m1.682s 用户态CPU时间:0m0.546s 系统态CPU时间:0m1.082s
$ time ./driver.sh jerry 100 5
真实时间:0m9.315s 用户态CPU时间:0m4.565s 系统态CPU时间:0m5.446s
$ time ./driver.sh irfan 10 500
真实时间:1m46.197s 用户态CPU时间:0m44.869s 系统态CPU时间:1m12.282s
$ time ./driver.sh brian 10 500
真实时间:0m16.003s 用户态CPU时间:0m5.135s 系统态CPU时间:0m10.396s
$ time ./driver.sh jerry 10 500
真实时间:1m24.414s 用户态CPU时间:0m39.696s 系统态CPU时间:0m54.834s
$ time ./driver.sh irfan 1000 5
真实时间:4m25.145s 用户态CPU时间:3m17.286s 系统态CPU时间:1m21.490s
$ time ./driver.sh brian 1000 5
真实时间:0m19.442s 用户态CPU时间:0m5.287s 系统态CPU时间:0m10.751s
$ time ./driver.sh jerry 1000 5
真实时间:5m29.136s 用户态CPU时间:4m48.926s 系统态CPU时间:0m59.336s

3
我认为你不应该使用文件系统来处理地图,这基本上是在使用IO来处理一些可以在内存中相对快速完成的事情。 - Irfan Zulfiqar
10
除非调用同步(sync)函数,否则这些文件不一定会被写入磁盘;操作系统可能只是将它们留在内存中。你的代码正在调用sed并执行几次线性搜索,这些都非常缓慢。我进行了一些快速基准测试,我的版本比你的快5-35倍。 - Brian Campbell
7
“快速”和“Shell”本来就不太相配:尤其是对于我们在“避免微小IO”层面所讨论的速度问题而言。您可以搜索并使用/dev/shm来保证没有IO。 - jmtd
4
这个解决方案让我感到惊讶,太棒了。即使是到了2016年仍然适用。它真的应该成为被采纳的答案。 - Gordon
我正在寻找一个快速的方法来解决这个问题,这真是邪恶的天才!非常感谢“2009年的Brian” :) - Michael P
显示剩余3条评论

20

Irfan的答案的基础上,这里提供了一个更短更快的get()版本,因为它不需要对映射内容进行迭代:

get() {
    mapName=$1; key=$2

    map=${!mapName}
    value="$(echo $map |sed -e "s/.*--${key}=\([^ ]*\).*/\1/" -e 's/:SP:/ /g' )"
}

18
在子shell中分叉和使用sed并不是最佳选择。Bash4原生支持此功能,而bash3有更好的替代方案。 - lhunath

8

以下是另一种非bash-4(即,适用于bash 3和Mac的)方法:

val_of_key() {
    case $1 in
        'A1') echo 'aaa';;
        'B2') echo 'bbb';;
        'C3') echo 'ccc';;
        *) echo 'zzz';;
    esac
}

for x in 'A1' 'B2' 'C3' 'D4'; do
    y=$(val_of_key "$x")
    echo "$x => $y"
done

输出:

A1 => aaa
B2 => bbb
C3 => ccc
D4 => zzz

case函数的作用类似于关联数组。不幸的是,它不能使用return,所以必须使用echo来输出结果,但这不是问题,除非你是一个拒绝分叉子shell的纯粹主义者。


7
####################################################################
# Bash v3 does not support associative arrays
# and we cannot use ksh since all generic scripts are on bash
# Usage: map_put map_name key value
#
function map_put
{
    alias "${1}$2"="$3"
}

# map_get map_name key
# @return value
#
function map_get
{
    alias "${1}$2" | awk -F"'" '{ print $2; }'
}

# map_keys map_name 
# @return map keys
#
function map_keys
{
    alias -p | grep $1 | cut -d'=' -f1 | awk -F"$1" '{print $2; }'
}

例子:

mapName=$(basename $0)_map_
map_put $mapName "name" "Irfan Zulfiqar"
map_put $mapName "designation" "SSE"

for key in $(map_keys $mapName)
do
    echo "$key = $(map_get $mapName $key)
done

7

5
对于Bash 3,有一个特殊情况有一个好的简单解决方案:
如果您不想处理很多变量,或者键名只是无效的变量标识符,并且保证您的数组少于256个项目,那么可以滥用函数返回值。这个解决方案不需要任何子shell,因为该值作为变量已经准备好了,也不需要任何迭代,因此性能非常高。而且它非常易读,几乎像Bash 4版本一样。
以下是最基本的版本:
hash_index() {
    case $1 in
        'foo') return 0;;
        'bar') return 1;;
        'baz') return 2;;
    esac
}

hash_vals=("foo_val"
           "bar_val"
           "baz_val");

hash_index "foo"
echo ${hash_vals[$?]}

记住,在case中应使用单引号,否则它将受到通配符的影响。对于静态/冻结哈希表非常有用,但可以从hash_keys=()数组编写索引生成器。

注意,默认情况下它会选择第一个元素,因此您可能需要将零索引元素保留:

hash_index() {
    case $1 in
        'foo') return 1;;
        'bar') return 2;;
        'baz') return 3;;
    esac
}

hash_vals=("",           # sort of like returning null/nil for a non existent key
           "foo_val"
           "bar_val"
           "baz_val");

hash_index "foo" || echo ${hash_vals[$?]}  # It can't get more readable than this

注意:当前长度信息不正确。
或者,如果您想保持从零开始的索引,您可以保留另一个索引值,并防止不存在的键,但这样会降低可读性。
hash_index() {
    case $1 in
        'foo') return 0;;
        'bar') return 1;;
        'baz') return 2;;
        *)   return 255;;
    esac
}

hash_vals=("foo_val"
           "bar_val"
           "baz_val");

hash_index "foo"
[[ $? -ne 255 ]] && echo ${hash_vals[$?]}

或者为了保持长度正确,将偏移索引加一:
hash_index() {
    case $1 in
        'foo') return 1;;
        'bar') return 2;;
        'baz') return 3;;
    esac
}

hash_vals=("foo_val"
           "bar_val"
           "baz_val");

hash_index "foo" || echo ${hash_vals[$(($? - 1))]}

但是如果我想将 hash_index "foo" || echo ${hash_vals[$(($? - 1))]} 赋值给一个变量,那么正确的代码应该是 output="foo" || echo ${hash_vals[$(($? - 1))]}。如果您不介意的话,能否告诉我正确的赋值方式。 - Bowen Peng

4
现在回答这个问题。
以下脚本模拟shell脚本中的关联数组。它简单易懂。
映射就是一个不断增长的字符串,其中保存了键值对,例如--name=Irfan --designation=SSE --company=My:SP:Own:SP:Company
空格的值用“:SP:”替换。
put() {
    if [ "$#" != 3 ]; then exit 1; fi
    mapName=$1; key=$2; value=`echo $3 | sed -e "s/ /:SP:/g"`
    eval map="\"\$$mapName\""
    map="`echo "$map" | sed -e "s/--$key=[^ ]*//g"` --$key=$value"
    eval $mapName="\"$map\""
}

get() {
    mapName=$1; key=$2; valueFound="false"

    eval map=\$$mapName

    for keyValuePair in ${map};
    do
        case "$keyValuePair" in
            --$key=*) value=`echo "$keyValuePair" | sed -e 's/^[^=]*=//'`
                      valueFound="true"
        esac
        if [ "$valueFound" == "true" ]; then break; fi
    done
    value=`echo $value | sed -e "s/:SP:/ /g"`
}

put "newMap" "name" "Irfan Zulfiqar"
put "newMap" "designation" "SSE"
put "newMap" "company" "My Own Company"

get "newMap" "company"
echo $value

get "newMap" "name"
echo $value

编辑:刚刚添加了另一种获取所有键的方法。

getKeySet() {
    if [ "$#" != 1 ]; 
    then 
        exit 1; 
    fi

    mapName=$1; 

    eval map="\"\$$mapName\""

    keySet=`
           echo $map | 
           sed -e "s/=[^ ]*//g" -e "s/\([ ]*\)--/\1/g"
          `
}

1
你正在将数据作为bash代码进行eval操作,更糟糕的是:你没有正确地引用它。这两个问题都会导致大量的错误和任意代码注入。 - lhunath

2
您可以使用动态变量名称,让变量名称像哈希映射的键一样工作。
例如,如果您有一个包含两列“姓名”和“信用”的输入文件,如下面的示例所示,并且您想要计算每个用户的收入总和:
Mary 100
John 200
Mary 50
John 300
Paul 100
Paul 400
David 100

下面的命令将使用动态变量作为键,以map_${person}的形式对所有内容进行求和:
while read -r person money; ((map_$person+=$money)); done < <(cat INCOME_REPORT.log)

阅读结果:
set | grep map

输出结果将是:
map_David=100
map_John=500
map_Mary=150
map_Paul=500

详细说明这些技术,我正在GitHub上开发一个与HashMap对象完全相同的函数shell_map
为了创建“HashMap实例”,shell_map函数能够在不同的名称下创建自己的副本。每个新的函数副本将有一个不同的$FUNCNAME变量。然后使用$FUNCNAME为每个Map实例创建命名空间。
映射键是全局变量,格式为$FUNCNAME_DATA_$KEY,其中$KEY是添加到Map中的键。这些变量是动态变量
下面我将放置一个简化版本,以便您可以作为示例使用。
#!/bin/bash

shell_map () {
    local METHOD="$1"

    case $METHOD in
    new)
        local NEW_MAP="$2"

        # loads shell_map function declaration
        test -n "$(declare -f shell_map)" || return

        # declares in the Global Scope a copy of shell_map, under a new name.
        eval "${_/shell_map/$2}"
    ;;
    put)
        local KEY="$2"  
        local VALUE="$3"

        # declares a variable in the global scope
        eval ${FUNCNAME}_DATA_${KEY}='$VALUE'
    ;;
    get)
        local KEY="$2"
        local VALUE="${FUNCNAME}_DATA_${KEY}"
        echo "${!VALUE}"
    ;;
    keys)
        declare | grep -Po "(?<=${FUNCNAME}_DATA_)\w+((?=\=))"
    ;;
    name)
        echo $FUNCNAME
    ;;
    contains_key)
        local KEY="$2"
        compgen -v ${FUNCNAME}_DATA_${KEY} > /dev/null && return 0 || return 1
    ;;
    clear_all)
        while read var; do  
            unset $var
        done < <(compgen -v ${FUNCNAME}_DATA_)
    ;;
    remove)
        local KEY="$2"
        unset ${FUNCNAME}_DATA_${KEY}
    ;;
    size)
        compgen -v ${FUNCNAME}_DATA_${KEY} | wc -l
    ;;
    *)
        echo "unsupported operation '$1'."
        return 1
    ;;
    esac
}

使用方法:

shell_map new credit
credit put Mary 100
credit put John 200
for customer in `credit keys`; do 
    value=`credit get $customer`       
    echo "customer $customer has $value"
done
credit contains_key "Mary" && echo "Mary has credit!"

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接