「速通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

这段代码把 mapfileIFS=','<<<关联数组 全用上了,是日常数据处理的典型套路。

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 readmapfile 不能在循环里 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+ 环境下测试通过。mapfiledeclare -A${arr[-1]}local -n 都依赖 bash 4.0 以上的特性。CentOS 7 默认的 bash 4.2 也能跑大部分示例,但 nameref 会被跳过;老系统建议先 bash --version 看看。

相关推荐
苏灿烤鱼2 天前
一套进程代替八件套,桌面更稳还是单点更大
linux·github·shell
茶本无香2 天前
通用报表自动化框架:Java调用Shell传参执行PostgreSQL SQL模板
java·sql·postgresql·shell
柒号华仔3 天前
「速通Shell」Shell 循环与遍历
shell·编程语言
十里春风_jzh4 天前
Tabby 修改版:更美观的现代终端
shell·tabby
茶本无香4 天前
Java调用Shell脚本执行SQL数据库操作:从入门到实战
java·sql·shell
柒号华仔6 天前
「速通Shell」织线为面,Shell条件测试和判断
shell
苏灿烤鱼7 天前
把 Agent 做成一家公司,真比通用提示词好用吗?
python·agent·shell
寺中人8 天前
Linux 基础命令入门实战教程:从零掌握常用操作,新手快速上手
linux·运维·服务器·shell·linux 命令·linux 基础教程·linux 入门
k4m7v2pz11 天前
34 天 33 个版本:rvs(rust-verb-shell)如何用版本号对抗 AI 误判
rust·shell·命令行工具·ai agent·系统信息·跨平台开发