【问题标题】:How to define hash tables in Bash?如何在 Bash 中定义哈希表?
【发布时间】:2024-04-22 02:20:02
【问题描述】:

什么是 Python dictionaries 但在 Bash 中的等价物(应该在 OS X 和 Linux 上工作)。

【问题讨论】:

  • 让 bash 运行一个 python/perl 脚本...太灵活了!

标签: bash dictionary hashtable associative-array


【解决方案1】:

重击 4

Bash 4 本身就支持此功能。确保你的脚本的 hashbang 是#!/usr/bin/env bash#!/bin/bash,这样你就不会最终使用sh。确保您直接执行脚本,或者使用bash script 执行script。 (实际上没有使用 Bash 执行 Bash 脚本确实会发生,并且会真的令人困惑!)

您可以通过以下方式声明关联数组:

declare -A animals

您可以使用普通的数组赋值运算符来填充它。比如你想要一张animal[sound(key)] = animal(value)的地图:

animals=( ["moo"]="cow" ["woof"]="dog")

或者在一行中声明和实例化:

declare -A animals=( ["moo"]="cow" ["woof"]="dog")

然后像普通数组一样使用它们。使用

  • animals['key']='value'设置值

  • "${animals[@]}" 扩展值

  • "${!animals[@]}"(注意!)展开键

别忘了引用它们:

echo "${animals[moo]}"
for sound in "${!animals[@]}"; do echo "$sound - ${animals[$sound]}"; done

重击 3

在 bash 4 之前,您没有关联数组。 不要使用eval 来模仿它们。像瘟疫一样避免eval,因为它 shell 脚本的瘟疫。最重要的原因是eval 将您的数据视为可执行代码(还有很多其他原因)。

首先:考虑升级到 bash 4。这将使整个过程对您来说更容易。

如果有无法升级的原因,declare 是一个更安全的选择。它不会像eval 那样评估数据,因此不会轻易地允许任意代码注入。

让我们通过引入概念来准备答案:

首先,间接。

$ animals_moo=cow; sound=moo; i="animals_$sound"; echo "${!i}"
cow

其次,declare

$ sound=moo; animal=cow; declare "animals_$sound=$animal"; echo "$animals_moo"
cow

把它们放在一起:

# Set a value:
declare "array_$index=$value"

# Get a value:
arrayGet() { 
    local array=$1 index=$2
    local i="${array}_$index"
    printf '%s' "${!i}"
}

让我们使用它:

$ sound=moo
$ animal=cow
$ declare "animals_$sound=$animal"
$ arrayGet animals "$sound"
cow

注意:declare 不能放在函数中。在 bash 函数中对 declare 的任何使用都会将它创建的变量 local 转换为该函数的范围,这意味着我们无法使用它访问或修改全局数组。 (在 bash 4 中,您可以使用 declare -g 来声明全局变量 - 但在 bash 4 中,您可以首先使用关联数组,避免这种解决方法。)

总结:

  • 升级到 bash 4 并将 declare -A 用于关联数组。
  • 如果无法升级,请使用 declare 选项。
  • 考虑改用 awk 并完全避免此问题。

【讨论】:

  • 无法升级:我在 Bash 中编写脚本的唯一原因是“在任何地方运行”的可移植性。因此,依靠 Bash 的非通用特性排除了这种方法。真可惜,否则对我来说这将是一个很好的解决方案!
  • 遗憾的是 OSX 仍然默认使用 Bash 3,因为这代表了很多人的“默认”。我认为 ShellShock 恐慌可能是他们需要的推动力,但显然不是。
  • @ken 这是一个许可问题。 OSX 上的 Bash 卡在最新的非 GPLv3 许可版本。
  • @jww Apple 不会将 GNU bash 升级到 3 以上,因为它对 GPLv3 怀有恶意。但这不应该是一种威慑。 brew install bashbrew.sh
  • ...或sudo port install bash,适用于那些(明智地,恕我直言)不愿意在没有明确的每个进程权限提升的情况下为所有用户在 PATH 中创建可写目录的人。
【解决方案2】:

一位同事刚刚提到了这个帖子。我已经在 bash 中独立实现了哈希表,它不依赖于版本 4。来自我 2010 年 3 月的一篇博客文章(在此处的一些答案之前......),标题为 Hash tables in bash

previously 使用 cksum 进行哈希处理,但后来将 Java's string hashCode 翻译为原生 bash/zsh。

# Here's the hashing function
ht() {
  local h=0 i
  for (( i=0; i < ${#1}; i++ )); do
    let "h=( (h<<5) - h ) + $(printf %d \'${1:$i:1})"
    let "h |= h"
  done
  printf "$h"
}

# Example:

myhash[`ht foo bar`]="a value"
myhash[`ht baz baf`]="b value"

echo ${myhash[`ht baz baf`]} # "b value"
echo ${myhash[@]} # "a value b value" though perhaps reversed
echo ${#myhash[@]} # "2" - there are two values (note, zsh doesn't count right)

它不是双向的,内置的方式要好得多,但无论如何都不应该真正使用。 Bash 用于快速一次性,此类事情很少涉及可能需要哈希的复杂性,除非您的 ~/.bashrc 和朋友可能。

【讨论】:

  • 答案中的链接很吓人!如果单击它,您将陷入重定向循环。请更新。
  • @MohammadRakibAmin – 是的,我的网站已关闭,我怀疑我是否会恢复我的博客。我已将上述链接更新为存档版本。感谢您的关注!
  • 看起来这不会处理哈希冲突。
  • @neuralmer – 是的。这被设计为散列结构的实际 hash 实现。如果你想处理哈希冲突,我推荐一个真正的哈希实现而不是像这样的 hack。调整它来管理碰撞会消除它所有的优雅。
【解决方案3】:

只使用文件系统

文件系统是一个树形结构,可以用作哈希映射。 您的哈希表将是一个临时目录,您的键将是文件名,您的值将是文件内容。优点是它可以处理巨大的哈希图,并且不需要特定的外壳。

哈希表创建

hashtable=$(mktemp -d)

添加元素

echo $value > $hashtable/$key

读取一个元素

value=$(< $hashtable/$key)

性能

当然,它很慢,但不是那么慢。 我在我的机器上使用 SSD 和 btrfs 对其进行了测试,它每秒读取/写入大约 3000 个元素

【讨论】:

  • 哪个版本的 bash 支持mkdir -d? (不是 4.3,在 Ubuntu 14 上。我会求助于 mkdir /run/shm/foo,或者如果它填满了 RAM,mkdir /tmp/foo。)
  • 也许是mktemp -d 的意思?
  • 好奇$value=$(&lt; $hashtable/$key)value=$(&lt; $hashtable/$key) 有什么区别?谢谢!
  • “在我的机器上测试过”这听起来像是在 SSD 上烧一个洞的好方法。并非所有 Linux 发行版都默认使用 tmpfs。
  • 我正在处理大约 50000 个哈希值。 Perl 和 PHP 只需不到 1/2 秒即可完成。节点在 1 秒之内。 FS选项听起来很慢。但是,我们能否以某种方式确保文件仅存在于 RAM 中?
【解决方案4】:

我在 bash 3 中使用动态变量创建 HashMap。我在回答中解释了它是如何工作的:Associative arrays in Shell scripts

你也可以看看 shell_map,这是一个 bash 3 中的 HashMap 实现。

【讨论】:

    【解决方案5】:

    考虑使用内置 bash read 的解决方案,如以下 ufw 防火墙脚本中的代码 sn-p 所示。这种方法的优点是可以根据需要使用尽可能多的分隔字段集(而不仅仅是 2 个)。我们使用了 | 分隔符,因为端口范围说明符可能需要冒号,即 6001:6010

    #!/usr/bin/env bash
    
    readonly connections=(       
                                '192.168.1.4/24|tcp|22'
                                '192.168.1.4/24|tcp|53'
                                '192.168.1.4/24|tcp|80'
                                '192.168.1.4/24|tcp|139'
                                '192.168.1.4/24|tcp|443'
                                '192.168.1.4/24|tcp|445'
                                '192.168.1.4/24|tcp|631'
                                '192.168.1.4/24|tcp|5901'
                                '192.168.1.4/24|tcp|6566'
    )
    
    function set_connections(){
        local range proto port
        for fields in ${connections[@]}
        do
                IFS=$'|' read -r range proto port <<< "$fields"
                ufw allow from "$range" proto "$proto" to any port "$port"
        done
    }
    
    set_connections
    

    【讨论】:

    • @CharlieMartin :读取是一个非常强大的功能,但许多 bash 程序员没有充分利用。它允许 lisp-like 列表处理的紧凑形式。例如,在上面的示例中,我们可以通过以下方式仅剥离第一个元素并保留其余元素(即类似于 lisp 中的 firstrest 的概念):@987654322 @
    【解决方案6】:

    我也使用了 bash4 方式,但我发现了一个恼人的 bug。

    我需要动态更新关联数组的内容,所以我使用了这种方式:

    for instanceId in $instanceList
    do
       aws cloudwatch describe-alarms --output json --alarm-name-prefix $instanceId| jq '.["MetricAlarms"][].StateValue'| xargs | grep -E 'ALARM|INSUFFICIENT_DATA'
       [ $? -eq 0 ] && statusCheck+=([$instanceId]="checkKO") || statusCheck+=([$instanceId]="allCheckOk"
    done
    

    我发现使用 bash 4.3.11 附加到 dict 中的现有键会导致附加值(如果已经存在)。因此,例如经过一些重复后,值的内容是“checkKOcheckKOallCheckOK”,这并不好。

    bash 4.3.39 没有问题,其中附加现有密钥意味着如果已经存在,则替换实际值。

    我解决了这个问题,只是在 cicle 之前清理/声明 statusCheck 关联数组:

    unset statusCheck; declare -A statusCheck
    

    【讨论】:

      【解决方案7】:

      这就是我在这里寻找的:

      declare -A hashmap
      hashmap["key"]="value"
      hashmap["key2"]="value2"
      echo "${hashmap["key"]}"
      for key in ${!hashmap[@]}; do echo $key; done
      for value in ${hashmap[@]}; do echo $value; done
      echo hashmap has ${#hashmap[@]} elements
      

      这不适用于 bash 4.1.5:

      animals=( ["moo"]="cow" )
      

      【讨论】:

      • 注意,该值不能包含空格,否则一次添加多个元素
      • 赞成 hashmap["key"]="value" 语法,我也发现在其他出色的接受答案中缺少该语法。
      • @rubo77 key 也没有,它添加了多个键。有什么办法可以解决这个问题?
      【解决方案8】:

      我同意@lhunath 和其他人的观点,即关联数组是 Bash 4 的最佳选择。如果您坚持使用 Bash 3(OSX,无法更新的旧发行版),您也可以使用 expr,它应该无处不在,一个字符串和正则表达式。我喜欢它,尤其是当字典不太大的时候。

      1. 选择 2 个您不会在键和值中使用的分隔符(例如 ',' 和 ':' )
      2. 将地图写成字符串(注意分隔符“,”也在开头和结尾)

        animals=",moo:cow,woof:dog,"
        
      3. 使用正则表达式提取值

        get_animal {
            echo "$(expr "$animals" : ".*,$1:\([^,]*\),.*")"
        }
        
      4. 拆分字符串以列出项目

        get_animal_items {
            arr=$(echo "${animals:1:${#animals}-2}" | tr "," "\n")
            for i in $arr
            do
                value="${i##*:}"
                key="${i%%:*}"
                echo "${value} likes to $key"
            done
        }
        

      现在你可以使用它了:

      $ animal = get_animal "moo"
      cow
      $ get_animal_items
      cow likes to moo
      dog likes to woof
      

      【讨论】:

        【解决方案9】:

        您可以进一步修改 hput()/hget() 接口,以便将哈希命名如下:

        hput() {
            eval "$1""$2"='$3'
        }
        
        hget() {
            eval echo '${'"$1$2"'#hash}'
        }
        

        然后

        hput capitals France Paris
        hput capitals Netherlands Amsterdam
        hput capitals Spain Madrid
        echo `hget capitals France` and `hget capitals Netherlands` and `hget capitals Spain`
        

        这使您可以定义其他不冲突的地图(例如,'rcapitals' 按首都进行国家/地区查找)。但是,无论哪种方式,我认为您会发现这一切都非常糟糕,就性能而言。

        如果你真的想要快速的哈希查找,那么有一个可怕的、可怕的 hack 确实效果很好。就是这样:将您的键/值写入一个临时文件,每行一个,然后使用 'grep "^$key"' 将它们取出,使用带有 cut 或 awk 或 sed 的管道或其他任何方法来检索值。

        就像我说的,这听起来很糟糕,而且听起来它应该很慢并且会执行各种不必要的 IO,但实际上它非常快(磁盘缓存很棒,不是吗?),即使对于非常大的哈希表。您必须自己强制执行密钥唯一性等。即使您只有几百个条目,输出文件/grep 组合也会快得多 - 根据我的经验,速度要快几倍。它还消耗更少的内存。

        这是一种方法:

        hinit() {
            rm -f /tmp/hashmap.$1
        }
        
        hput() {
            echo "$2 $3" >> /tmp/hashmap.$1
        }
        
        hget() {
            grep "^$2 " /tmp/hashmap.$1 | awk '{ print $2 };'
        }
        
        hinit capitals
        hput capitals France Paris
        hput capitals Netherlands Amsterdam
        hput capitals Spain Madrid
        
        echo `hget capitals France` and `hget capitals Netherlands` and `hget capitals Spain`
        

        【讨论】:

        • 太棒了!你甚至可以迭代它: for i in $(compgen -A variable capitols);做 hget "$i" "" 完成
        【解决方案10】:

        Bash 3 解决方案:

        在阅读一些答案时,我整理了一个快速的小功能,我想回馈可能对其他人有所帮助。

        # Define a hash like this
        MYHASH=("firstName:Milan"
                "lastName:Adamovsky")
        
        # Function to get value by key
        getHashKey()
         {
          declare -a hash=("${!1}")
          local key
          local lookup=$2
        
          for key in "${hash[@]}" ; do
           KEY=${key%%:*}
           VALUE=${key#*:}
           if [[ $KEY == $lookup ]]
           then
            echo $VALUE
           fi
          done
         }
        
        # Function to get a list of all keys
        getHashKeys()
         {
          declare -a hash=("${!1}")
          local KEY
          local VALUE
          local key
          local lookup=$2
        
          for key in "${hash[@]}" ; do
           KEY=${key%%:*}
           VALUE=${key#*:}
           keys+="${KEY} "
          done
        
          echo $keys
         }
        
        # Here we want to get the value of 'lastName'
        echo $(getHashKey MYHASH[@] "lastName")
        
        
        # Here we want to get all keys
        echo $(getHashKeys MYHASH[@])
        

        【讨论】:

        • 我认为这是一个非常简洁的 sn-p。它可以使用一点清理(虽然不多)。在我的版本中,我将 'key' 重命名为 'pair' 并将 KEY 和 VALUE 设为小写(因为我在导出变量时使用大写)。我还将 getHashKey 重命名为 getHashValue 并将键和值都设为本地(但有时您希望它们不是本地的)。在 getHashKeys 中,我没有分配任何值。我使用分号进行分隔,因为我的值是 URL。
        【解决方案11】:

        有参数替换,虽然它也可能是非 PC ......就像间接。

        #!/bin/bash
        
        # Array pretending to be a Pythonic dictionary
        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
        
        printf "%s is an extinct animal which likes to %s\n" "${ARRAY[1]%%:*}" "${ARRAY[1]##*:}"
        

        BASH 4 方式当然更好,但如果您需要 hack ......只有 hack 才能做到。 您可以使用类似的技术搜索数组/哈希。

        【讨论】:

        • 我会将其更改为VALUE=${animal#*:} 以保护ARRAY[$x]="caesar:come:see:conquer" 的情况
        • 在 ${ARRAY[@]} 周围加上双引号也很有用,以防键或值中有空格,如for animal in "${ARRAY[@]}"; do
        • 但是效率不是很差吗?如果您想与另一个键列表进行比较,我正在考虑 O(n*m),而不是 O(n) 与适当的哈希图(恒定时间查找,单个键为 O(1))。
        • 这个想法不是关于效率,而是关于那些具有 perl、python 甚至 bash 4 背景的人的理解/阅读能力。允许您以类似的方式编写。
        • @CoDEmanX:这是一个 hack,一个聪明而优雅但仍然是基本的 workaround 来帮助那些在 2007 年仍然卡在 Bash 3 中的可怜人。X。在这样一个简单的代码中,你不能指望“正确的哈希映射”或效率考虑。
        【解决方案12】:

        我真的很喜欢 Al P 的回答,但希望以低成本执行唯一性,所以我更进一步 - 使用目录。有一些明显的限制(目录文件限制、无效文件名)但它应该适用于大多数情况。

        hinit() {
            rm -rf /tmp/hashmap.$1
            mkdir -p /tmp/hashmap.$1
        }
        
        hput() {
            printf "$3" > /tmp/hashmap.$1/$2
        }
        
        hget() {
            cat /tmp/hashmap.$1/$2
        }
        
        hkeys() {
            ls -1 /tmp/hashmap.$1
        }
        
        hdestroy() {
            rm -rf /tmp/hashmap.$1
        }
        
        hinit ids
        
        for (( i = 0; i < 10000; i++ )); do
            hput ids "key$i" "value$i"
        done
        
        for (( i = 0; i < 10000; i++ )); do
            printf '%s\n' $(hget ids "key$i") > /dev/null
        done
        
        hdestroy ids
        

        它在我的测试中也表现得更好。

        $ time bash hash.sh 
        real    0m46.500s
        user    0m16.767s
        sys     0m51.473s
        
        $ time bash dirhash.sh 
        real    0m35.875s
        user    0m8.002s
        sys     0m24.666s
        

        只是以为我会参与。干杯!

        编辑:添加 hdestroy()

        【讨论】:

          【解决方案13】:

          有两件事,您可以在任何内核 2.6 中使用内存而不是 /tmp,方法是使用 /dev/shm (Redhat),其他发行版可能会有所不同。 hget 也可以使用 read 重新实现,如下所示:

          function hget {
          
            while read key idx
            do
              if [ $key = $2 ]
              then
                echo $idx
                return
              fi
            done < /dev/shm/hashmap.$1
          }
          

          此外,通过假设所有键都是唯一的,返回会使读取循环短路并避免必须读取所有条目。如果您的实现可以有重复的键,那么只需省略返回即可。这节省了读取和分叉 grep 和 awk 的费用。对这两种实现使用 /dev/shm 产生以下使用时间 hget 在 3 条目哈希搜索最后一个条目:

          Grep/Awk:

          hget() {
              grep "^$2 " /dev/shm/hashmap.$1 | awk '{ print $2 };'
          }
          
          $ time echo $(hget FD oracle)
          3
          
          real    0m0.011s
          user    0m0.002s
          sys     0m0.013s
          

          读取/回显:

          $ time echo $(hget FD oracle)
          3
          
          real    0m0.004s
          user    0m0.000s
          sys     0m0.004s
          

          在多次调用中,我从未见过低于 50% 的改进。 由于使用了/dev/shm,这都可以归因于分叉。

          【讨论】:

            【解决方案14】:

            在 bash 4 之前,没有在 bash 中使用关联数组的好方法。最好的办法是使用实​​际上支持此类功能的解释语言,例如 awk。另一方面,bash 4 确实支持它们。

            至于 less bash 3 中的好方法,这里有一个参考,可能会有所帮助:http://mywiki.wooledge.org/BashFAQ/006

            【讨论】:

              【解决方案15】:
              hput () {
                eval hash"$1"='$2'
              }
              
              hget () {
                eval echo '${hash'"$1"'#hash}'
              }
              hput France Paris
              hput Netherlands Amsterdam
              hput Spain Madrid
              echo `hget France` and `hget Netherlands` and `hget Spain`
              

              $ sh hash.sh
              Paris and Amsterdam and Madrid
              

              【讨论】:

              • 叹息,这似乎是不必要的侮辱,而且无论如何也不准确。人们不会将输入验证、转义或编码(看,我实际上知道)放在哈希表的内部,而是放在包装器中,并在输入后尽快。
              • @DigitalRoss 你能解释一下#hash 在eval echo '${hash'"$1"'#hash}' 中的用途吗?对我来说,我的评论似乎不止于此。 #hash 在这里有什么特殊含义吗?
              • @Sanjay ${var#start} 从存储在变量 var 中的值的开头删除文本 start