「速通Shell」Shell 数组、关联数组与 mapfile

前两篇我们聊了循环、字符串处理和正则匹配,解决的都是一次处理一个数据的问题。但真实场景里,数据往往不是一个两个地来------日志是一行行的,配置是一段段的,参数是成群的。要高效处理这类成组的数据,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 看看。

相关推荐
fox_charon1 天前
Windows 下 CLI 参数的引号陷阱:为什么 --resume 'uuid' 会失败
windows·shell·cmd·cli·引号
吴声子夜歌10 天前
Shell编程实例——编写安全的shell脚本(二)
linux·运维·shell
吴声子夜歌11 天前
Shell编程实例——内务及管理任务(一)
linux·运维·shell
吴声子夜歌11 天前
Shell编程实例——高级脚本编程(一)
linux·运维·网络·shell
吴声子夜歌11 天前
Shell编程实例——bash的配置与自定义(二)
linux·运维·shell
吴声子夜歌12 天前
Shell编程实例——与解析相关的任务(二)
linux·运维·shell
吴声子夜歌12 天前
Shell编程实例——脚本编程的附加特性
linux·运维·shell
云计算练习生12 天前
什么是内核?操作系统内核到底管哪些事
linux·windows·操作系统·内核·shell
吴声子夜歌15 天前
Shell编程实例——bash入门
linux·运维·shell
Swizard16 天前
Linux 日志神器 journalctl 完全指南:彻底替代传统日志查看方式
shell