前两篇我们聊了循环、字符串处理和正则匹配,解决的都是一次处理一个数据的问题。但真实场景里,数据往往不是一个两个地来------日志是一行行的,配置是一段段的,参数是成群的。要高效处理这类成组的数据,shell 给的答案就是数组。
很多人学 shell 一上来不重视数组,觉得 shell 是脚本语言,复杂数据用 Python 更好。这话没毛病,但日常写运维脚本、CI/CD 流水线、轻量数据处理时,纯 bash 的数组能力已经够覆盖 80% 的场景。一旦你熟练掌握,shell 脚本的组织能力会上一个台阶,从写一段命令变成写一个小程序。
这一篇我们把 bash 数组讲透。普通数组、关联数组、mapfile 命令这些容器工具,一次性给你梳理清楚。
一、先搞懂 shell 数组的本质
在聊语法之前,先把一个根本性的认知问题说清楚:shell 数组跟其他语言的数组不太一样。
C、Java、Go 里的数组是连续内存、固定类型、按下标访问。Python 的 list 更灵活,但本质也是一个有序集合。而 shell 里的数组更接近一堆字符串的集合------它没有类型概念,元素全是字符串,下标可以不连续,长度可以动态变化,甚至下标可以是字符串(关联数组)。
这种设计的好处是灵活,坏处是性能不如编译型语言,类型安全也差。所以写 shell 数组时,脑子里要绷一根弦:它本质上是字符串集合,所有数组操作最终都在做字符串拼接和分割。
明白了这点,我们看语法。
二、普通数组
2.1 定义数组
shell 定义数组有三种写法:
bash
#!/bin/bash
# 写法一:一口气定义所有元素
fruits=("苹果" "香蕉" "橘子" "葡萄")
# 写法二:单独给每个下标赋值
arr[0]="hello"
arr[1]="world"
arr[5]="shell"
echo "${arr[2]}" # 输出:空(没赋值就是空)
echo "${arr[5]}" # 输出:shell
# 写法三:从命令输出赋值
files=($(ls *.txt))
关键点:
- 数组用小括号
()包裹元素,元素之间用空格或换行分隔。 - 下标是从
0开始的整数。 - 下标可以不连续 ------上面
arr[2]、arr[3]、arr[4]都没赋值,访问它们得到的是空字符串,不会报错。 arr[5]="shell"这种写法是"扩展赋值",shell 数组会自动扩展。
注意一个常被忽略的细节:用 (...) 定义数组时,里面不能用逗号分隔,必须用空格。arr=("a", "b", "c") 是错的 ,会变成单个元素 "a, b, c"。
2.2 访问数组元素
bash
#!/bin/bash
arr=("张三" "李四" "王五" "赵六")
# 访问单个元素
echo "第一个:${arr[0]}"
echo "第三个:${arr[2]}"
# 访问所有元素
echo "所有:${arr[@]}"
echo "所有:${arr[*]}"
# 两者在大多数场景下等价,但有微妙区别------后面讲
${arr[@]} 和 ${arr[*]} 都能取到所有元素,但它们在带引号的情况下行为不同:
bash
#!/bin/bash
arr=("hello world" "foo bar" "baz")
# 不加引号:按 IFS 切分,每个元素可能再次被拆分
for item in ${arr[@]}; do
echo "[$item]"
done
# 加引号:每个元素作为独立项
for item in "${arr[@]}"; do
echo "[$item]"
done
输出对比:
yaml
[hello]
[world]
[foo]
[bar]
[baz]
---
[hello world]
[foo bar]
[baz]
"${arr[@]}" 加双引号时,每个元素保持原样------这是正确处理"包含空格的元素"的标准姿势,也是 shell 数组用法的"第二铁律"(第一铁律是任何时候给变量加双引号)。
${arr[*]} 加双引号时,会把所有元素合并成一个字符串 ,用 IFS 的第一个字符(默认空格)分隔。这在某些拼接场景下有用,但日常用得少。实操建议:默认用 "${arr[@]}" 。
2.3 数组长度和元素长度
bash
#!/bin/bash
arr=("apple" "banana" "cherry" "date")
# 数组长度(元素个数)
echo "元素个数:${#arr[@]}"
# 单个元素的长度
echo "第一个元素长度:${#arr[0]}"
# 用 ${#arr[*]} 也可以,但有同样的合并问题
${#arr[@]} 是取数组长度的标准写法。${#arr[0]} 则是取第一个元素的字符串长度------这跟上一篇讲的 ${#var} 是一回事。
2.4 修改、添加、删除元素
bash
#!/bin/bash
arr=("apple" "banana" "cherry")
# 修改:直接通过下标赋值
arr[1]="blueberry"
echo "${arr[@]}" # apple blueberry cherry
# 追加:使用 += 运算符
arr+=("date" "elderberry")
echo "${arr[@]}" # apple blueberry cherry date elderberry
# 在指定位置插入
arr[3]="kiwi"
echo "${arr[@]}" # apple blueberry cherry kiwi elderberry
# 删除某个元素
unset arr[2]
echo "${arr[@]}" # apple blueberry kiwi elderberry
# 删除整个数组
unset arr
echo "${arr[@]}" # 输出空行
+= 是数组的"追加"操作,它会把新元素接在数组末尾。这是动态构建数组最常用的方式:
bash
#!/bin/bash
# 找出所有大于 100 的数
numbers=(50 200 30 500 1000 75)
big_ones=()
for n in "${numbers[@]}"
do
if [ "$n" -gt 100 ]
then
big_ones+=("$n")
fi
done
echo "大于 100 的数:${big_ones[@]}"
# 输出:大于 100 的数:200 500 1000
这种"先定义空数组,再循环往里塞"的模式,是 shell 脚本里"过滤数据"的标准姿势。
unset 是删除操作,可以删单个元素(unset arr[2]),也可以删整个数组(unset arr)。注意 arr[2]= 这种写法不是删除,而是把元素设为空字符串------这个区别在判断数组长度时会影响结果。
2.5 数组切片
上一篇讲字符串时我们用 ${str:offset:length} 取子串,数组也有类似的语法:
bash
#!/bin/bash
arr=("a" "b" "c" "d" "e" "f" "g")
# 从下标 1 开始取 3 个
echo "${arr[@]:1:3}" # b c d
# 从下标 2 取到末尾
echo "${arr[@]:2}" # c d e f g
# 倒数 3 个
echo "${arr[@]: -3}" # e f g(注意空格)
"${arr[@]:offset:length}" 是数组切片的标准写法,offset 和 length 的含义跟字符串切片一致。注意冒号后面那个空格 ------${arr[@]: -3} 前面有个空格,这是 shell 的语法规定(跟字符串切片的"倒数"用法一样)。
切片在处理分页、固定批次数据时特别有用:
bash
#!/bin/bash
data=($(seq 1 100))
# 每页 10 个,打印第 3 页
page=3
size=10
offset=$(( (page - 1) * size ))
echo "第 ${page} 页:${data[@]:offset:size}"
2.6 数组的拼接和复制
bash
#!/bin/bash
arr1=("a" "b" "c")
arr2=("d" "e" "f")
# 拼接:直接把两个数组展开
combined=("${arr1[@]}" "${arr2[@]}")
echo "${combined[@]}" # a b c d e f
# 复制:展开后重新赋值
copy=("${arr1[@]}")
echo "${copy[@]}" # a b c
combined=("${arr1[@]}" "${arr2[@]}") 是数组拼接的标准写法。这里双引号不能少------少了之后如果元素包含空格,合并出来的数组会"乱"。
三、数组的遍历
数组定义好了,下一步就是遍历。上一篇循环那一篇我们提过 for fruit in "${fruits[@]}",这一节我们把所有姿势列全。
3.1 直接遍历值
bash
#!/bin/bash
fruits=("苹果" "香蕉" "橘子" "葡萄")
for fruit in "${fruits[@]}"
do
echo "我喜欢:$fruit"
done
这是最常见的写法,适合"只关心元素值,不关心下标"的场景。
3.2 遍历下标
bash
#!/bin/bash
fruits=("苹果" "香蕉" "橘子" "葡萄")
for i in "${!fruits[@]}"
do
echo "下标 $i: ${fruits[$i]}"
done
"${!fruits[@]}" 是取所有已定义的下标。注意是"已定义的下标"------如果数组是稀疏的(下标不连续),这里只会列出实际赋值过的下标。
这种写法适合需要同时拿到下标和值的场景:
bash
#!/bin/bash
scores=(85 92 78 95 88)
for i in "${!scores[@]}"
do
if [ "${scores[$i]}" -ge 90 ]
then
echo "学生 $i: ${scores[$i]} (优秀)"
fi
done
3.3 类 C 风格遍历
上一篇讲过的 for ((...)) 配合数组下标:
bash
#!/bin/bash
arr=("a" "b" "c" "d" "e")
for ((i=0; i<${#arr[@]}; i++))
do
echo "arr[$i] = ${arr[$i]}"
done
这种写法的好处是能精确控制下标的步长和方向。比如倒序遍历:
bash
#!/bin/bash
arr=("a" "b" "c" "d" "e")
for ((i=${#arr[@]}-1; i>=0; i--))
do
echo "arr[$i] = ${arr[$i]}"
done
类 C 风格在需要复杂下标运算时很有用,但日常用得不多。
3.4 while 配合下标
bash
#!/bin/bash
arr=("a" "b" "c" "d" "e")
i=0
while [ $i -lt ${#arr[@]} ]
do
echo "arr[$i] = ${arr[$i]}"
((i++))
done
这种写法的好处是可以在循环中途修改下标 ,实现一些 for 做不到的逻辑。比如"满足条件就跳过下一个":
bash
#!/bin/bash
arr=(1 2 3 4 5 6 7 8 9 10)
i=0
while [ $i -lt ${#arr[@]} ]
do
if (( arr[i] % 2 == 0 ))
then
((i++)) # 跳过下一个
fi
echo "值:${arr[$i]}"
((i++))
done
四、关联数组
普通数组用整数下标,关联数组用字符串作 key。这相当于 Python 的 dict、Go 的 map、Java 的 HashMap。
4.1 定义关联数组
关联数组必须用 declare -A 显式声明:
bash
#!/bin/bash
declare -A user
# 三种赋值方式
user[name]="张三"
user[age]=25
user[city]="北京"
# 一次性定义
declare -A scores=(
["张三"]=85
["李四"]=92
["王五"]=78
)
declare -A 千万不能省 。如果你不声明就用 user[name]="张三",shell 会把它当成普通数组,下标被当作算术表达式------name 这种非数字下标会出问题(bash 4.0 之前甚至直接报错)。
4.2 访问关联数组
bash
#!/bin/bash
declare -A user=(
[name]="张三"
[age]=25
[city]="北京"
)
# 取单个值
echo "姓名:${user[name]}"
echo "年龄:${user[age]}"
# 取所有 key
echo "所有 key:${!user[@]}"
# 取所有 value
echo "所有 value:${user[@]}"
# 关联数组长度
echo "条目数:${#user[@]}"
${!user[@]} 是关联数组的核心------它把所有 key 列出来。这在做"遍历所有 key-value 对"时非常有用:
bash
#!/bin/bash
declare -A config=(
[host]="localhost"
[port]="8080"
[user]="admin"
[debug]="true"
)
for key in "${!config[@]}"
do
echo "$key = ${config[$key]}"
done
输出(顺序不固定):
ini
host = localhost
port = 8080
user = admin
debug = true
注意关联数组是无序的------每次遍历的顺序可能不一样。如果需要按特定顺序处理,得先把 key 排序:
bash
for key in $(echo "${!config[@]}" | tr ' ' '\n' | sort)
do
echo "$key = ${config[$key]}"
done
或者用 mapfile 把 key 读进数组再排序(mapfile 我们下一节讲)。
4.3 关联数组的实用场景
关联数组在 shell 脚本里最常见的用途是做计数 和做去重。
场景一:统计日志里每个 IP 的访问次数
bash
#!/bin/bash
declare -A ip_count
while read -r line
do
# 假设每行第一个字段是 IP
ip=$(echo "$line" | awk '{print $1}')
((ip_count[$ip]++))
done < access.log
# 输出访问次数最多的 5 个 IP
for ip in "${!ip_count[@]}"
do
echo "${ip_count[$ip]} $ip"
done | sort -rn | head -5
场景二:判断某个 key 是否存在
bash
#!/bin/bash
declare -A blacklist=(
["192.168.1.100"]=1
["10.0.0.5"]=1
)
ip="192.168.1.100"
if [[ -v blacklist[$ip] ]]
then
echo "$ip 在黑名单中"
else
echo "$ip 不在黑名单中"
fi
[[ -v blacklist[$ip] ]] 是 bash 4.2 引入的 -v 测试,专门判断变量(这里是数组元素)是否被设置。注意它在 [[ ]] 里才有,在 [ ] 里没有等价物。
场景三:构造查询表
bash
#!/bin/bash
declare -A status_code=(
[200]="成功"
[301]="永久重定向"
[404]="未找到"
[500]="服务器错误"
)
code=404
echo "状态码 $code: ${status_code[$code]:-未知}"
${status_code[$code]:-未知} 用了上一篇讲的默认值语法------如果 key 不存在就返回"未知"。
4.4 关联数组的注意事项
关联数组有几个常被忽视的限制:
第一,key 不能是数组 。也就是说 arr[arr2]=1 是非法的。
第二,value 只能是字符串 。如果你要存数字,直接存就行,shell 会自动转换;但做算术运算时需要 $((...)) 或 ((...)) 显式触发。
第三,关联数组在子 shell 中是独立的 。用管道或者 (...) 启动子 shell 时,里面对关联数组的修改不会反映到外面:
bash
#!/bin/bash
declare -A count
count[apple]=1
echo "${count[apple]}" # 输出 1
# 但是如果用 echo "${count[apple]}" | read x 类似的管道子 shell,count 不会变
这个问题在用 while read 处理文件时尤其常见。解决办法是把数据传出去再处理(用 <<< 或者避免管道)。
五、mapfile
mapfile(也写作 readarray,是它的别名)是 bash 4.0 引入的命令,作用是把标准输入的每一行读进数组的每个元素。这是处理按行切割文件最干净的方式。
5.1 基本用法
bash
#!/bin/bash
# 假设 users.txt 里有 100 行用户名单
mapfile -t users < users.txt
echo "共 ${#users[@]} 个用户"
echo "第一个用户:${users[0]}"
echo "最后一个用户:${users[-1]}"
-t 是关键选项,表示"读取时去掉每行末尾的换行符"。几乎所有场景下你都要加 -t ,不然每个元素末尾会带个 \n,打印出来全是空行。
${users[-1]} 是 bash 4.3 引入的负数下标,表示倒数第一个。这是处理最后一行最简洁的写法。
5.2 限制读取的行数
bash
#!/bin/bash
# 只读前 10 行
mapfile -t -n 10 head_lines < log.txt
echo "前 10 行:"
printf '%s\n' "${head_lines[@]}"
-n N 表示最多读 N 行就停止。在做"日志预览"、"采样分析"时很方便。
5.3 跳过开头的行
bash
#!/bin/bash
# 跳过前 3 行(通常是表头或注释),从第 4 行开始读
mapfile -t -s 3 lines < data.csv
-s N 表示跳过前 N 行不读。和 -n 配合可以做"取中间一段":
bash
# 取第 4 到第 13 行
mapfile -t -s 3 -n 10 lines < data.csv
5.4 自定义分隔符
默认 mapfile 是按换行符读每行,但可以用 -d 改成其他分隔符:
bash
#!/bin/bash
# 按空行分割段落
mapfile -t -d '' paragraphs < article.txt
-d '' 是按"空字节"分割,用得不多但偶尔有用。
5.5 指定起始下标
默认 MAPFILE[0] 是第一行,用 -O 可以改成从其他下标开始:
bash
#!/bin/bash
# 跳过下标 0,从 1 开始填
mapfile -t -O 1 lines < file.txt
echo "lines[0] 没设置,lines[1] 是第一行"
这种用法比较少见,了解一下就行。
5.6 实战:分析 CSV 文件
bash
#!/bin/bash
# data.csv 格式:name,age,city
mapfile -t lines < data.csv
# 跳过表头
header="${lines[0]}"
echo "表头:$header"
# 解析每一行
declare -A age_sum
declare -A age_count
for ((i=1; i<${#lines[@]}; i++))
do
IFS=',' read -r name age city <<< "${lines[$i]}"
age_sum[$city]=$(( ${age_sum[$city]:-0} + age ))
age_count[$city]=$(( ${age_count[$city]:-0} + 1 ))
done
# 输出每个城市的平均年龄
for city in "${!age_sum[@]}"
do
avg=$(( age_sum[$city] / age_count[$city] ))
echo "$city: 平均年龄 $avg (${age_count[$city]} 人)"
done
这段代码把 mapfile、IFS=','、<<<、关联数组 全用上了,是日常数据处理的典型套路。
5.7 mapfile vs while read:怎么选
上一篇我们提过 while read 是按行读文件的标准方式,那 mapfile 跟它有什么区别?该怎么选?
bash
# 写法一:mapfile
mapfile -t lines < file.txt
for line in "${lines[@]}"
do
echo "$line"
done
# 写法二:while read
while read -r line
do
echo "$line"
done < file.txt
两者在功能上等价,但有这些差异:
| 维度 | mapfile | while read |
|---|---|---|
| 执行方式 | 一次性读完整个文件 | 逐行处理 |
| 内存占用 | 整个文件常驻内存 | 一次一行,省内存 |
| 需要 bash 4.0+ | 是 | 否(POSIX 兼容) |
| 能否在循环中跳过 | 否 | 容易 |
| 代码简洁度 | 更简洁 | 略冗长 |
实操建议:
- 处理小文件(< 几 MB) :用
mapfile,代码更简洁,而且能"回看"前面的行(数组已加载到内存)。 - 处理大文件(日志、GB 级) :用
while read,边读边处理,不爆内存。 - 需要多次遍历文件 :用
mapfile,读一次就够了。 - 需要在循环中提前退出 :用
while read,mapfile不能在循环里break。
六、数组与函数
把数组传给函数是 shell 编程的进阶关卡,这一节我们专门说一下。
6.1 传递数组元素
shell 函数没有"传数组"的概念,只能"展开数组后传值":
bash
#!/bin/bash
print_array() {
local arr=("$@") # 重新组装成数组
for item in "${arr[@]}"
do
echo "[$item]"
done
}
fruits=("苹果" "香蕉" "橘子")
print_array "${fruits[@]}"
调用时用 "${fruits[@]}" 把数组展开成多个参数,函数里用 local arr=("$@") 重新组装。这是 shell 里"传数组"的标准姿势。
6.2 通过全局变量传递
如果你不想折腾参数传递,可以让函数直接修改全局变量:
bash
#!/bin/bash
result=()
filter_even() {
local input=("$@")
for n in "${input[@]}"
do
if (( n % 2 == 0 ))
then
result+=("$n")
fi
done
}
numbers=(1 2 3 4 5 6 7 8)
filter_even "${numbers[@]}"
echo "偶数:${result[@]}"
# 输出:偶数:2 4 6 8
这种方式在脚本内部使用没问题,但函数对全局状态的依赖会降低代码的可读性。建议小脚本可以用,大项目最好用 6.1 的传参方式。
6.3 返回数组
shell 函数只能通过 echo 返回字符串,要返回数组通常有三种方式:
bash
#!/bin/bash
# 方式一:把结果放进全局变量
declare -g RESULT=()
get_config() {
RESULT=("host=localhost" "port=8080" "user=admin")
}
get_config
echo "${RESULT[@]}"
# 方式二:用 echo 输出,调用方用命令替换 + 数组定义
get_config_str() {
echo "host=localhost"
echo "port=8080"
echo "user=admin"
}
mapfile -t config < <(get_config_str)
echo "${config[@]}"
# 方式三:直接修改传入的"引用变量"
update_config() {
local -n arr_ref=$1 # bash 4.3+ 的 nameref
arr_ref=("new1" "new2" "new3")
}
my_config=()
update_config my_config
echo "${my_config[@]}"
方式三用的是 local -n(nameref),这是 bash 4.3 引入的特性,类似 C++ 里的引用。语法干净但兼容性差,要求 bash 4.3+,日常用方式一或方式二更稳妥。
七、几个实战场景
最后给几个数组+关联数组+mapfile 组合的实战例子,把前面学的东西串起来。
7.1 批量处理文件并收集结果
bash
#!/bin/bash
declare -A results
files=(*.log)
for file in "${files[@]}"
do
# 统计每个日志文件的错误数
error_count=$(grep -c "ERROR" "$file" 2>/dev/null || echo 0)
results[$file]=$error_count
done
# 按错误数从大到小输出
for file in "${!results[@]}"
do
echo "${results[$file]} $file"
done | sort -rn
关联数组 + 排序是 shell 数据统计的常见组合。
7.2 多文件合并去重
bash
#!/bin/bash
declare -A seen
output=()
# 把多个文件合并去重
for file in input1.txt input2.txt input3.txt
do
while read -r line
do
if [[ -z "${seen[$line]}" ]]
then
seen[$line]=1
output+=("$line")
fi
done < "$file"
done
# 输出结果
printf '%s\n' "${output[@]}" > merged.txt
echo "合并完成,共 ${#output[@]} 条"
关联数组天然适合做"去重判断"------seen[key] 存在就说明出现过。
7.3 简易命令行参数解析
bash
#!/bin/bash
declare -A args
while [[ $# -gt 0 ]]
do
case $1 in
--name=*)
args[name]="${1#*=}"
;;
--age=*)
args[age]="${1#*=}"
;;
--debug)
args[debug]=true
;;
*)
echo "未知参数: $1"
exit 1
;;
esac
shift
done
echo "name = ${args[name]:-匿名}"
echo "age = ${args[age]:-未知}"
echo "debug = ${args[debug]:-false}"
ini
$ ./script.sh --name=张三 --age=25 --debug
name = 张三
age = 25
debug = true
这种用关联数组存参数的写法比写一堆 if 清晰多了,也是 shell 脚本里工程化的第一步。
八、总结
这一篇我们把 shell 里的容器都过了一遍。shell 数组虽然不如 Python 那么优雅,但一旦掌握,能解决日常脚本 80% 的数据组织问题。下一篇我们顺着这条线继续往下走,聊一聊 shell 函数的高级用法------参数处理、返回值、递归、模块化------这才是把脚本从小工具变成小工程的关键。
本文示例在 GNU bash 4.3+ 环境下测试通过。
mapfile、declare -A、${arr[-1]}、local -n都依赖 bash 4.0 以上的特性。CentOS 7 默认的 bash 4.2 也能跑大部分示例,但 nameref 会被跳过;老系统建议先bash --version看看。