前言
在日常 Shell 脚本编写中,我们常常会遇到这样的场景:需要根据服务名获取对应的端口、根据环境变量加载不同的配置参数、或者根据用户名快速查找用户信息。用 Python 的话,一个字典就能轻松搞定。但在 Shell 脚本的世界里,这该如何实现?
实际上,Bash 从 4.0 版本开始,就引入了对关联数组(Associative Arrays,又称 Hash Tables 或字典)的原生支持。如果考虑跨 Shell 兼容性(如 POSIX sh 或旧版 Bash),则还有基于字符串处理和间接引用的替代方案。
本文将从最基础的关联数组入手,逐步深入到参数传递技巧、旧环境兼容方案、配置文件读取,最后总结社区公认的最佳实践。无论你使用的是 Bash 4+、Zsh,还是需要兼容老旧环境的 POSIX sh,都能在这里找到合适的方案。
一、基础知识:Bash 关联数组
1.1 版本检查
关联数组是 Bash 4.0 及以上版本引入的特性。在编写脚本之前,务必确认当前环境是否支持:
bash
echo $BASH_VERSION # 输出如 5.1.16(1)-release
1.2 声明与初始化
逐个赋值(推荐初学者使用):
bash
declare -A my_dict
my_dict["name"]="Alice"
my_dict["age"]="28"
my_dict["city"]="Seattle"
声明时直接初始化:
bash
declare -A my_dict=(
["name"]="Alice"
["age"]="28"
["city"]="Seattle"
)
还有一种更紧凑的写法,省略键名两侧的双引号也是可行的,但从可读性角度考虑,双引号能明确区分字符串与命令,帮助阅读者快速识别这是关联数组而非索引数组。
1.3 基础操作
| 操作 | 语法 |
|---|---|
| 取值 | ${my_dict["key"]} |
| 获取所有键 | ${!my_dict[@]} |
| 获取所有值 | ${my_dict[@]} |
| 检查键是否存在 | [[ -v my_dict["key"] ]] |
| 删除元素 | unset my_dict["key"] |
| 删除整个数组 | unset my_dict |
遍历关联数组的键值对:
bash
for key in "${!my_dict[@]}"; do
echo "$key: ${my_dict[$key]}"
done
⚠️ 重要提醒 :关联数组在 Bash 中是无序的,遍历顺序与赋值顺序无关。如果需要保持顺序,请使用两个独立的索引数组,或者借助外部工具如
jq维护 JSON 数据。
1.4 使用变量作为键名
这是日常脚本中最实用的技巧之一。使用变量作为键名时,必须加双引号以避免分词和特殊字符问题:
bash
declare -A dict
key_name="user_name"
dict["$key_name"]="John" # ✅ 正确
# dict[$key_name]="John" # ❌ 如果键名包含空格会出错
二、进阶技巧:处理更复杂的场景
2.1 在函数中传递关联数组:local -n
Bash 4.3+ 引入了 nameref 特性,允许通过 local -n 在函数内引用外部关联数组,这是目前最优雅的传递方式:
bash
declare -A weapons=(
["Straight Sword"]=75
["Imperial Sword"]=90
)
print_weapons() {
local -n arr=$1 # 创建名引用
for key in "${!arr[@]}"; do
printf "%s\t%d\n" "$key" "${arr[$key]}"
done
}
print_weapons weapons
2.2 间接引用(间接变量)
对于 Bash 4.0--4.2 版本(不支持 local -n),可以通过 ${!var} 语法实现变量名引用:
bash
dict_name="user_1"
declare "name_$dict_name=Alice"
echo "${!dict_name}" # 输出 name_user_1
这种间接引用机制在构建"动态变量名"时非常有用,例如在处理配置文件时,可以动态地读取和设置配置项。
2.3 数组去重
利用关联数组的键唯一性,可以轻松对列表进行去重:
bash
declare -A unique_keys
items=("apple" "banana" "apple" "orange" "banana")
for item in "${items[@]}"; do
unique_keys["$item"]=1
done
unique_list=("${!unique_keys[@]}") # 得到去重后的列表
2.4 删除空条目
删除关联数组中的空值条目也是一项常见需求,可以借助 unset 命令轻松实现:
bash
declare -A arr
arr["key1"]="value1"
arr["key2"]=""
arr["key3"]="value3"
for key in "${!arr[@]}"; do
if [ -z "${arr[$key]}" ]; then
unset arr["$key"]
fi
done
三、应对旧环境:兼容 Bash 3 与 POSIX sh
3.1 为什么不推荐使用 eval
Stack Overflow 上有个广为流传的观点:"Do not use eval to emulate associative arrays. You must avoid eval like the plague." eval 会将数据当作代码执行,若数据来自不可信源,存在严重的安全隐患。
3.2 间接变量组合(declare 方法)
在 Bash 3 环境中,可以通过拼接变量名来模拟键值存储:
bash
# 模拟 setter
setValue() {
local array_name=$1 key=$2 value=$3
declare "${array_name}_${key}=$value"
}
# 模拟 getter
getValue() {
local array_name=$1 key=$2
local var_name="${array_name}_${key}"
echo "${!var_name}"
}
setValue "config" "port" "8080"
echo "$(getValue config port)" # 输出 8080
3.3 字符串映射法
对于必须运行在 POSIX sh 中的脚本(例如某些嵌入式环境或 Docker 容器),可以通过一个精心设计的分隔符字符串来存储所有键值对。借助 grep 和 sed 进行高效查找:
bash
# 存储格式: --key1=value1:SP:--key2=value2:SP:...
store() {
dict="$1 --$2=$3"
}
get() {
echo "$1" | sed -e "s/.*--$2=\([^ ]*\).*/\1/" -e 's/:SP:/ /g'
}
3.4 各 Shell 的关联数组支持
| Shell | 版本 | 关联数组支持 | 声明方式 |
|---|---|---|---|
| Bash | ≥ 4.0 | ✅ 原生支持 | declare -A |
| Zsh | ≥ 5.0 | ✅ 原生支持 | typeset -A |
| Ksh | ≥ 93 | ✅ 原生支持 | typeset -A |
| Dash / POSIX sh | 任意 | ❌ 无 | --- |
| Fish | --- | ⚠️ 需通过字符串和列表模拟 | --- |
如果你的脚本需要在多种 Shell 环境下运行,最稳健的做法是使用 POSIX 兼容的子 shell 调用外部命令(如 grep、awk、jq)来处理数据,或直接使用 Python/Perl 等脚本语言执行逻辑。
四、配置文件管理:将字典思想延伸至文件
4.1 Shell 原生配置(Source 方案)
如果你的配置文件本身就是 config.sh,可以通过 source 直接引入:
bash
# config/config.sh
DB_HOST="localhost"
DB_PORT="3306"
DB_USER="admin"
SERVICE_PORTS=([web]=8080 [api]=3000)
# main.sh
source "./config/config.sh"
echo "DB_HOST=$DB_HOST"
⚠️ 安全警告 :
source会把配置文件当作脚本执行。如果配置文件来自不可信源,会引入代码注入风险。生产环境中应确保配置文件只被信任的脚本维护,或改用解析方式读取。
4.2 JSON + jq 解析(推荐)
对于复杂数据结构,JSON + jq 是最理想的方案,它在跨语言兼容性、数据类型支持和脚本可维护性方面都有显著优势。
bash
# config.json
{
"database": {
"host": "localhost",
"port": 3306
},
"services": {
"web": 8080,
"api": 3000
}
}
解析示例:
bash
#!/bin/bash
host=$(jq -r '.database.host' config.json)
web_port=$(jq -r '.services.web' config.json)
4.3 INI 格式的轻量解析
INI 文件适合非敏感的结构化配置,语义清晰。可以编写一个简单的解析器将其转换为关联数组:
bash
# config.ini
[database]
host=localhost
port=3306
[services]
web=8080
bash
declare -A config
while IFS='=' read -r key value; do
[[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
key=$(echo "$key" | xargs)
value=$(echo "$value" | xargs)
config["$key"]="$value"
done < config.ini
五、最佳实践总结
5.1 Shebang 和环境
确保脚本以正确的 Bash 解释器运行:
bash
#!/usr/bin/env bash
# 或 #!/bin/bash
避免使用 #!/bin/sh 执行 Bash 特有语法,否则关联数组会失效。
5.2 变量引用原则
- 始终用双引号包裹键名 :
${array["$key"]},可以避免键包含空格或特殊字符时的解析问题。 - 始终用双引号包裹数组展开 :
"${array[@]}",确保每个元素保持独立。 - 使用
[[ ]]而不是[ ]进行条件判断。
5.3 选择策略速查
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 单脚本内少量键值对 | declare -A |
语法简洁,性能好 |
| 函数间传递 | local -n (Bash 4.3+) |
零拷贝,语义清晰 |
| 需保持插入顺序 | 两个索引数组 / 外部 JSON | 关联数组本身无序 |
| 跨 Shell 兼容(含 POSIX sh) | 外部工具 (jq/awk) 或 字符串映射 |
安全性高,但性能略降 |
| 静态配置(少改动) | Source Shell 配置 | 简单直接,但注意安全边界 |
| 动态配置/复杂结构 | JSON + jq |
表达能力最强,社区生态成熟 |
5.4 工程化建议
- 将配置读取逻辑封装为独立模块,实现
load_config()、get_key()等函数,便于复用和维护。 - 敏感配置(如密钥、密码)绝不要硬编码在脚本中;应通过环境变量或密钥管理服务动态注入。
- 在脚本开头添加
set -eu,让未定义变量和命令错误立刻退出,避免因配置缺失导致不可预期的行为。
结语
在 Shell 脚本中模拟字典功能,并不像在其他高级语言中那样"天然",但也远没有想象中复杂。Bash 4+ 的原生关联数组配合 nameref 引用传递,足以覆盖绝大多数日常场景。面对旧环境兼容性或跨 Shell 需求时,通过间接变量拼接或外部工具处理,也能优雅地解决问题。
掌握这些技巧后,你的 Shell 脚本将告别冗长的 if-elif 链和独立的零散变量,变得更加模块化、可维护。而当某天你发现 Shell 处理起来实在过于复杂时,不妨思考一下:这个任务是否更适合转用 Python 或 Ruby 来完成?毕竟,在合适的场景使用合适的工具,才是真正的专家之道。