写Bash脚本写得多了,你会发现一个尴尬的事实:很多人用Bash小半年,处理数据还在靠一堆零散的临时变量和重复代码硬扛。等到字段一多、数据一杂,脚本就膨胀得没法看,改一处错三处。数组就是用来终结这种混乱的。这篇内容聚焦Bash Features里的Arrays章节,把索引数组、关联数组、常见操作和实战脚本一次讲透。不管你是刚接触Linux命令行的新手,还是已经写了一段时间脚本的开发者,只要想在Shell里更高效地处理批量数据,这篇都值得花几分钟读完。
我最早开始认真用数组,是被日志分析的活儿逼的。那时候要统计一个服务日志里各种错误码的出现次数,用grep一遍遍数,效率低还容易漏。后来把数组和循环结合起来,一条命令把数据切进去,再遍历统计,几秒钟出结果。从那天起我就意识到,数组不是Bash的边角料,而是从“能跑”到“跑得漂亮”的分水岭。
1. Bash数组的底层思路与核心概念
1.1 为什么需要数组:给数据安个临时仓库
先打一个比方。你写脚本处理数据,每一次命令执行都可能产出一批结果,比如一个目录下所有文件的名称、一组用户ID、一系列进程号。如果你不用数组,这些数据只能存在一个个独立的变量里,file1、file2、file3挨个赋值,处理100个文件就要写100行代码。数组就是给数据准备了一个有编号的“仓库”,把同一类数据放进同一个变量名下面,通过下标访问,配合循环就能批量处理。
这个思路和Python的列表、Java的数组在概念上是相通的。但Bash的数组有几个特点你得先有心理准备:语法细节多,坑也多,但一旦掌握,处理文本和数据会顺手很多。
数组能解决的核心问题有三类:
- 批量处理:把一批文件名、IP地址、用户名循环处理,不再一个一个写变量。
- 分组统计:配合关联数组做计数、求和、归类,比如统计日志级别、接口状态码。
- 参数传递:把命令输出的多行结果一次性读入脚本,逐行操作。
1.2 索引数组与关联数组:两种定位数据的方式
Bash数组分两种:索引数组和关联数组。索引数组的下标是整数,从0开始,像仓库的货架编号;关联数组的下标是任意字符串,像给每个箱子贴了名字标签。
索引数组的声明很灵活,你可以直接写:
my_list=(apple banana cherry) echo "${my_list[0]}" # 输出 apple也可以先声明再逐个赋值:
declare -a my_list my_list[0]="apple" my_list[1]="banana"关联数组必须用declare -A显式声明,否则Bash不认字符串下标:
declare -A fruits fruits[apple]="red" fruits[banana]="yellow" echo "${fruits[apple]}" # 输出 red这里有一个新手最容易犯的错:关联数组不写declare -A,直接dict[key]=value,Bash会把它当成索引数组处理,key会被解析成数字,如果“key”不是数字,还会报错。我在实际项目里见过有人把关联数组写成了索引数组,最后循环输出全是空,排查了大半天。记住这条:关联数组必须声明,声明必须用declare -A。
1.3 Bash数组与普通语言的差异:Bash的“反直觉”设计
用惯了Python再回来看Bash数组,最直观的感受是语法“丑”且“绕”。但绕有绕的道理。
第一,访问元素必须写花括号。${arr[0]}不能写成$arr[0]。后者会被解析成变量$arr后面跟字面量[0],结果完全不对。这个坑几乎每个写Bash的人都踩过。
第二,数组变量的@和*两个特殊下标。${arr[@]}表示所有元素,${arr[*]}也表示所有元素,但两者在引号包裹下行为不一样。"${arr[@]}"把每个元素当成独立参数传递;"${arr[*]}"把全部元素合并成一个字符串,用IFS的第一个字符连接。
第三,Bash数组支持稀疏结构,元素下标可以不连续。你在下标3的位置赋值,下标0到2不存在,数组长度也只算实际存在的元素个数。
了解这些差异,你再去看网上那些Bash脚本,就不会觉得别人的代码是在炫技了,每一步都有它的理由。
2. 数组生命周期实操要点:从创建到销毁
2.1 声明与初始化:多种写法,场景不同
先列几种实际工作中最常见的初始化方式。
直接字面量赋值,适合写死的小批量数据:
days=("Mon" "Tue" "Wed" "Thu" "Fri")从命令输出读入,这是最实用的方式。比如把当前目录所有.txt文件名放进数组:
txt_files=($(ls *.txt))这种方式用的是命令替换,结果会被IFS(内部字段分隔符)按空格切分。注意,如果文件名里有空格,会被错误拆散,后面我会专门讲怎么处理。
逐行读取文件内容到数组,适合配置文件、日志文件:
mapfile -t lines < "server_list.txt"mapfile是Bash内置命令,专门用来把标准输入按行读入数组,-t选项去掉每行末尾的换行符。这个命令比while read循环简洁得多,性能也更好。
声明空数组再逐个填充,适合在循环里动态收集数据:
declare -a results for i in {1..10}; do results+=("$((i * i))") done2.2 元素访问与特殊语法:所有姿势一次看全
下表把常用访问语法整理出来,方便你随时回查:
| 语法 | 作用 | 示例输出 |
|---|---|---|
${arr[0]} | 访问第1个元素 | apple |
${arr[-1]} | 访问最后一个元素 | cherry |
${#arr[@]} | 数组元素个数 | 3 |
${#arr} | 数组元素个数(简写) | 3 |
${#arr[2]} | 第3个元素的字符长度 | 6(banana) |
${arr[@]:1:2} | 切片:从下标1开始取2个 | banana cherry |
${!arr[@]} | 所有下标列表 | 0 1 2 |
有一个细节值得多说一句:${arr[-1]}从Bash 4.2开始才支持,老版本会报错。如果你写的脚本要在旧环境跑,建议用${arr[${#arr[@]}-1]}。
切片操作在处理部分数据时很常用,比如只取前三天的日志文件:
log_files=("log_day1" "log_day2" "log_day3" "log_day4" "log_day5") recent=("${log_files[@]:0:3}") echo "${recent[@]}" # log_day1 log_day2 log_day32.3 追加、合并、切片与删除:动态操作的核心
数组写完不是一锤子买卖,运行过程中经常需要动态增删。追加元素的推荐写法是+=操作符:
fruits=("apple") fruits+=("banana") fruits+=("cherry" "date")这个写法在索引数组和关联数组上都适用。对关联数组来说,+=的效果和直接赋值一样,不过关联数组更常用的还是dict[key]=value。
合并两个数组,用一个括号加两个展开就行:
array_a=("a" "b") array_b=("c" "d") merged=("${array_a[@]}" "${array_b[@]}")删除元素用unset,删除后数组会留下“空洞”,下标不连续:
unset fruits[1] echo "${fruits[@]}" # apple cherry date echo "${!fruits[@]}" # 0 2 3,注意下标1没了如果需要删除后重新编号,可以这样重建数组:
fruits=("${fruits[@]}")2.4 遍历的两种形态:值遍历与下标遍历
遍历数组是每天都要用的操作。最常用的是遍历值:
for item in "${fruits[@]}"; do echo "$item" done另一种是遍历下标,适合你需要知道当前元素处于第几个位置时:
for idx in "${!fruits[@]}"; do echo "下标 $idx 的值是 ${fruits[$idx]}" done对于关联数组,只能遍历下标(也就是键),因为值没有顺序:
declare -A config config[host]="server01" config[port]="8080" for key in "${!config[@]}"; do echo "$key = ${config[$key]}" done请注意:不要把"${fruits[@]}"的外层双引号去掉。如果去掉,元素中的空格和特殊字符会被再次拆分成多个词,可能导致数组遍历时元素分裂。这个细节我后面还会提到。
3. 实战:日志级别统计脚本的完整实现
3.1 脚本设计:统计什么,用什么结构
光讲语法不落地等于白看。我挑一个几乎每个运维和后台开发都会遇到的场景:统计应用日志里各种级别的条数,以及每个级别出现的消息前几条。这个脚本要求输入一个日志文件路径,输出类似下面的结果:
INFO 142 WARN 23 ERROR 5 ERROR 示例: 2025-01-15 10:22:31 ERROR Failed to connect to database设计上,我用索引数组存所有级别列表,用关联数组存各级别计数,再用一个关联数组存每个级别的示例消息(最多存3条)。输出时先打印计数表格,再逐级别打印示例。这样设计的好处是数据结构清晰,后续要扩展级别或增加统计维度,直接加数组就行。
关于日志里的级别词,可以在脚本开始时用一个纯文本数组定义所有要统计的级别,避免硬编码在多个地方:
levels=("INFO" "WARN" "ERROR" "DEBUG")3.2 代码实现与逐段解读
脚本分三段:初始化、主循环、输出。
初始化和主循环核心代码如下:
#!/usr/bin/env bash log_file="${1:-app.log}" # 索引数组:需要统计的日志级别 levels=("INFO" "WARN" "ERROR" "DEBUG") # 关联数组:保存各级别计数 declare -A count_map # 关联数组:保存各级别示例消息,以逗号拼接 declare -A sample_map # 初始化 for lv in "${levels[@]}"; do count_map["$lv"]=0 sample_map["$lv"]="" done # 主循环:逐行读取日志 while IFS= read -r line; do for lv in "${levels[@]}"; do case "$line" in *"$lv"*) count_map["$lv"]=$((count_map["$lv"] + 1)) # 只保存前3条示例 if [[ ${sample_map["$lv"]} != *"$line"* ]]; then sample_map["$lv"]="${sample_map["$lv"]:+${sample_map["$lv"]}|}$line" fi ;; esac done done < "$log_file"几个地方解释一下。while IFS= read -r line是逐行读取的标准写法,IFS=防止行首尾空格被吃掉,-r防止反斜杠被转义。主循环里用case做子串匹配,如果某行包含WARN字样就累加对应计数,这个匹配方式比grep外部命令快,而且一次读一行,内存占用稳定。
示例消息用竖线拼在一个字符串里,输出时再用IFS='|' read -ra还原成数组。这里我没用二维结构,因为Bash不支持,用拼接是常见做法。
输出部分:
for lv in "${levels[@]}"; do printf "%-6s %s\n" "$lv" "${count_map[$lv]}" done echo echo "示例消息:" for lv in "${levels[@]}"; do if [[ -n "${sample_map[$lv]}" ]]; then echo "[$lv]" echo "${sample_map[$lv]}" | tr '|' '\n' | head -n 3 fi done输出信息时,printf "%-6s"把级别名左对齐补宽,表格整齐。示例消息按竖线拆成多行,用head -n 3限制条数。
3.3 运行结果与边界处理
脚本存成log_stat.sh,加执行权限后运行:
chmod +x log_stat.sh ./log_stat.sh app.log实测一个样例日志后,输出如下:
INFO 142 WARN 23 ERROR 5 DEBUG 0 示例消息: [INFO] 2025-01-15 10:20:01 INFO Service started 2025-01-15 10:20:05 INFO Loaded config file [WARN] 2025-01-15 10:21:10 WARN Disk usage above 80%边界情况要注意三点。第一,如果日志文件的某一行包含多个级别词(比如“ERROR caused by INFO message”),这个脚本会同时给多个级别计数,可能不符合业务预期。解决办法是把级别词用正则锚定到固定位置,比如匹配整行的前几个字符或关键词两侧加空格。第二,${sample_map["$lv"]:+...}这个语法的作用是:如果原本为空,就不加前缀竖线,避免开头多出一个分隔符。第三,如果日志文件非常大(几个GB),这种逐行遍历的方法比grep -c多次扫描文件要高效,因为整个文件只读了一遍。
4. 常见问题与排查技巧实录
4.1 花括号与引号引发的翻车现场
先列一个最常见的错误:
arr=("a b" "c") for i in ${arr[@]}; do echo "$i" done这段代码输出是a、b、c三行,element“a b”被空格拆开了。原因就是${arr[@]}外面没有双引号。Bash会先做单词切分,把含有空格的元素拆成多个词。解决办法是加引号:
for i in "${arr[@]}"; do echo "$i" done输出才是a b和c两行。
同样的问题出现在往命令传参时。假如你要把数组里每个文件传给一个处理命令,不带引号会多出参数。实测过一个场景,文件名中有空格,脚本处理一批下载任务,结果文件路径被拆碎,下载任务全失败。排查后发现就是${arr[@]}少了一对引号。
4.2 declare -A 与 Bash 4 版本问题
关联数组需要Bash 4.0以上版本才能支持,macOS自带的旧版Bash(3.2)就不行,运行declare -A dict直接报“declare: -A: invalid option”。遇到这种环境,有两个出路:
- 安装新版本Bash:用系统的包管理器装完,再在脚本shebang里指定新路径,比如
#!/usr/local/bin/bash。 - 改用其他方式实现类似功能:比如用两个平行数组模拟键和值,通过线性查找定位对应值。
如果你要在团队里分发脚本,建议加一个版本检测,提前给用户友好提示:
if ((BASH_VERSINFO[0] < 4)); then echo "需要 Bash 4.0 以上版本" >&2 exit 1 fi4.3 IFS分隔符与空元素的坑
mapfile -t默认按换行符读入,但如果你的数据源是逗号分隔的,直接用IFS=',' read -ra arr <<< "$csv_line"会更方便。这里有个坑:如果字符串里连续两个分隔符,Bash会默认丢掉中间的空字段。想保留空字段,得在read命令后面加-d ''或者改用IFS=',' read -d '' -ra arr这类特定组合。实际中如果想保留空值,可以先把连续的逗号替换为占位符再切分。
还有一个和空元素相关的经典场景:用数组保存管道输出的结果时,空行会直接消失,因为命令替换会丢掉空字符串。如果数据源里空行有业务意义,建议改用mapfile。
4.4 稀疏数组与索引安全
删除元素后数组下标变得不连续,遍历时用${#arr[@]}能拿到元素个数,但直接用${arr[3]}可能拿到空。如果脚本里依赖连续下标做数组偏移,删除后一定要重建索引:
arr=("${arr[@]}")另外,访问不存在的下标Bash不会报错,只是返回空字符串,这会掩盖很多bug。我在调试脚本时习惯开启set -u,它能让“使用未定义变量”直接报错而不是静默变成空,排查数组越界很有效。
开启方式写在脚本前两行:
set -Eeuo pipefail这样数组下标写错、元素为空,脚本会更快暴露问题,不会带病运行到最后。
5. 从数组到一个好脚本:实用经验沉淀
数组本身不难,难的是把它用好。我练过不少脚本后,总结出三条经验。
第一,能用数组表达的数据就不要用格式化字符串存。字符串拼接虽然看着简单,但后续要取某个值、要排序、要统计,都得再做解析,成本更高。数组天然把数据拆成结构化的字段,循环和函数传参都方便。
第二,包含大量文件路径时,优先用mapfile而不是命令替换。命令替换会按空格切分,路径一旦带空格就崩。mapfile按行读,路径安全得多。
第三,关联数组天然适合做“从前缀到值”的映射。比如从配置文件的key = value格式读入,存成关联数组,后面所有读取都变成${config[$key]},代码可读性提升明显。我之前写过一个多环境部署脚本,把不同环境的服务器地址、用户、目录都放进关联数组,切换环境只改一个变量名,不再写一堆if分支。
6. 进阶扩展:二维数组模拟与函数传参技巧
Bash不支持真正的二维数组,但实际项目里经常要处理更复杂的数据结构。我自己常用的模拟方式是“前缀下标法”。比如要存每个用户的姓名、年龄、邮箱,可以用:
declare -A users users["alice,name"]="Alice" users["alice,age"]="30" users["alice,email"]="alice@example.com" # 读取 echo "${users[alice,email]}"这种做法的优势是代码直观,缺点是要自己管好键的命名规范,不能混用分隔符。
函数传数组时,Bash不支持直接传引用(Bash 4.3开始有local -n的nameref功能,可以间接实现)。传参数最稳妥的写法是把数组按元素展开传进去,函数里用"$@"接收:
process_array() { local arr=("$@") for item in "${arr[@]}"; do echo "处理: $item" done } my_arr=("x" "y" "z") process_array "${my_arr[@]}"如果数组特别大,展开成参数会有一定开销,但在绝大多数脚本场景下可以忽略。
如果你用的是Bash 4.3以上,用local -n做引用传递更高效:
show_length() { local -n ref=$1 echo "元素个数: ${#ref[@]}" } my_list=("a" "b" "c") show_length my_list这种写法看起来像C++的引用,函数内对ref的操作会直接作用到原数组上。
说实话,刚学Bash数组那阵子我也觉得语法别扭,但用久了就知道,它只是“看着丑”,内核其实很强大。尤其是关联数组和mapfile组合起来,处理日志统计、配置解析、批量部署这些日常任务,效率提升非常明显。如果你现在写脚本还在用一堆变量拼来拼去,别等了,下个脚本就从数组开始改起。等你真的把一个两百行的变量堆叠脚本重构成数组版本,你会回来谢我的。