Bash mapfile 命令详解
mapfile是 Bash 4.0 及以上版本提供的内置命令,用于从标准输入中读取数据,并把每条记录保存到索引数组中。它也可以使用别名readarray。
目录
- 1. 快速认识 mapfile
- 2. 语法与工作原理
- 3. 参数选项详解
- 4. 从文件获取数据
- 5. 从命令获取数据
- 6. 自定义分隔符与文件名安全
- 7. 数组操作与遍历
- 8. 回调函数
- 9. 常见陷阱
- 10. 与 while read 的对比
- 11. 综合案例
- 12. 兼容性与替代方案
- 13. 速查表
1. 快速认识 mapfile
最常见的两种用法如下。
从文件读取
mapfile -t lines < file.txt
从命令输出读取
mapfile -t lines < <(some_command)
读取完成后,lines 是一个 Bash 索引数组:
printf '%s\n' "${lines[@]}"
实际使用时,通常都应添加 -t,让 mapfile 删除每条记录末尾的分隔符,默认即换行符。
2. 语法与工作原理
完整语法
mapfile [-d 分隔符] [-n 数量] [-O 起始下标] [-s 跳过数量]
[-t] [-u 文件描述符] [-C 回调函数] [-c 回调间隔]
[数组名]
在 Bash 帮助中可以查看当前环境支持的准确语法:
help mapfile
readarray 与 mapfile 等价:
readarray -t lines < file.txt
基本工作流程
- 从标准输入或
-u指定的文件描述符中读取数据。 - 默认使用换行符划分记录。
- 将记录依次保存到索引数组。
- 默认保留每条记录末尾的分隔符;
-t可以删除它。
默认数组名
如果省略数组名,内容会写入默认数组 MAPFILE:
mapfile -t < file.txt
printf '%s\n' "${MAPFILE[@]}"
虽然合法,但明确指定数组名通常可读性更好。
返回状态
一般情况下,命令成功返回 0;参数错误、目标不是索引数组、文件描述符无效等情况会返回非零状态。
if mapfile -t lines < file.txt; then
printf '成功读取 %d 行\n' "${#lines[@]}"
else
printf '读取失败\n' >&2
fi
注意:重定向文件不存在时,Shell 会在执行 mapfile 前报告错误,整个简单命令也会失败。
3. 参数选项详解
| 选项 | 含义 | 常见用途 |
|---|---|---|
-t |
删除每条记录末尾的分隔符 | 按行读取时几乎总会使用 |
-d delim |
使用 delim 的首个字符作为记录分隔符 |
CSV 式简单分隔、NUL 分隔数据 |
-n count |
最多读取 count 条;0 表示不限制 |
只读前若干条 |
-s count |
先丢弃 count 条记录 |
跳过表头或前若干行 |
-O index |
从数组下标 index 开始赋值 |
追加或覆盖数组指定位置 |
-u fd |
从文件描述符 fd 读取 |
同时管理多个输入源 |
-C callback |
每隔一定记录调用回调 | 读取进度、观察或预处理 |
-c quantum |
设置回调调用间隔 | 通常与 -C 一起使用 |
3.1 -t:删除末尾分隔符
不加 -t 时,默认换行符会保留在数组元素中:
printf 'apple\nbanana\n' > /tmp/fruits.txt
mapfile fruits < /tmp/fruits.txt
declare -p fruits
概念上结果类似:
declare -a fruits=([0]=$'apple\n' [1]=$'banana\n')
加上 -t:
mapfile -t fruits < /tmp/fruits.txt
declare -p fruits
结果类似:
declare -a fruits=([0]="apple" [1]="banana")
3.2 -n count:限制读取数量
只读取前 3 行:
mapfile -t -n 3 lines < file.txt
-n 0 表示读取所有记录:
mapfile -t -n 0 lines < file.txt
3.3 -s count:跳过记录
跳过第一行表头:
mapfile -t -s 1 rows < data.txt
跳过前 10 行,再读取 20 行:
mapfile -t -s 10 -n 20 rows < data.txt
可以把它理解为读取第 11~30 行。
3.4 -O index:指定写入起始下标
values=(old0 old1 old2)
mapfile -t -O 1 values < file.txt
新记录会从 values[1] 开始赋值,可能覆盖已有元素。
追加到已有数组末尾:
values=(existing1 existing2)
mapfile -t -O "${#values[@]}" values < file.txt
重要区别:
- 未指定
-O时,目标数组会在赋值前被清空。 - 指定
-O时,数组原有内容保留,但相同下标可能被覆盖。 - 对存在“下标空洞”的稀疏数组,
${#values[@]}是元素数量,不一定等于最大下标加一。
稀疏数组需要严格追加时,可先求最大下标:
values=([2]='a' [10]='b')
max=-1
for index in "${!values[@]}"; do
(( index > max )) && max=$index
done
mapfile -t -O "$((max + 1))" values < file.txt
3.5 -d delim:自定义分隔符
以逗号作为记录分隔符:
mapfile -t -d ',' fields <<< 'red,green,blue,'
printf '<%s>\n' "${fields[@]}"
输出:
<red>
<green>
<blue>
注意事项:
- Bash 实际使用参数的第一个字符作为分隔符。
-
-t删除每条记录末尾的这个分隔符。 -
-d ''表示使用 NUL 字符,即\0作为分隔符。 -
-d ','不是完整的 CSV 解析器,无法正确理解带引号、转义或字段内逗号的 CSV。
3.6 -u fd:从文件描述符读取
exec 3< file.txt
mapfile -t -u 3 lines
exec 3<&-
这里:
-
exec 3< file.txt打开文件并绑定到文件描述符 3。 -
-u 3让mapfile从描述符 3 读取。 -
exec 3<&-关闭描述符 3。
3.7 -C callback 和 -c quantum
callback() {
local index=$1
local record=$2
printf '即将写入下标 %d,内容:%q\n' "$index" "$record"
}
mapfile -t -C callback -c 1 lines < file.txt
回调接收两个附加参数:
- 即将写入的数组下标。
- 当前记录的原始内容。
回调在数组元素正式赋值之前执行。若使用 -C 而省略 -c,Bash 默认的回调间隔通常为 5000 条,因此小文件可能看不到回调。
4. 从文件获取数据
4.1 读取整个文本文件
假设 users.txt:
alice
bob
charlie
读取:
mapfile -t users < users.txt
printf '共读取 %d 个用户\n' "${#users[@]}"
printf '%s\n' "${users[@]}"
4.2 文件名来自变量
input_file='users.txt'
if [[ -r $input_file ]]; then
mapfile -t users < "$input_file"
else
printf '文件不存在或不可读:%s\n' "$input_file" >&2
exit 1
fi
重定向中的变量也应使用双引号,避免空格或通配符导致路径被错误解释。
4.3 保留空行
文件内容:
first
third
执行:
mapfile -t lines < file.txt
declare -p lines
结果包含空字符串元素:
declare -a lines=([0]="first" [1]="" [2]="third")
mapfile 不会自动过滤空行。如果需要过滤:
mapfile -t lines < <(sed '/^[[:space:]]*$/d' file.txt)
4.4 最后一行没有换行符
printf 'first\nsecond' > file.txt
mapfile -t lines < file.txt
second 仍会成为数组元素,不会因为缺少末尾换行符而丢失。
4.5 跳过表头并读取数据
假设 users.tsv:
id name role
1 Alice admin
2 Bob user
3 Carol editor
mapfile -t -s 1 rows < users.tsv
for row in "${rows[@]}"; do
printf '数据行:%s\n' "$row"
done
如果数据具有复杂字段结构,应继续使用合适的 TSV/CSV 解析工具,而不是简单依赖字符串拆分。
4.6 分批读取同一个文件
借助文件描述符,可以连续读取:
exec 3< large.txt
mapfile -t -n 100 -u 3 batch1
mapfile -t -n 100 -u 3 batch2
exec 3<&-
第二次读取会接着第一次停止的位置继续。不过,如果目标是持续处理超大文件,逐行 while read 往往更省内存、更自然。
5. 从命令获取数据
5.1 推荐方式:进程替换
mapfile -t branches < <(git branch --format='%(refname:short)')
这里有两个容易混淆但用途不同的 <:
- 外层
<:把一个输入源重定向给mapfile。 -
<(command):进程替换,把命令输出表现成一个可读取的临时输入源。
5.2 获取命令生成的多行文本
generate_items() {
printf '%s\n' alpha beta gamma
}
mapfile -t items < <(generate_items)
printf '[%s]\n' "${items[@]}"
输出:
[alpha]
[beta]
[gamma]
5.3 获取匹配结果
mapfile -t errors < <(grep -n 'ERROR' application.log)
如果没有匹配项,grep 会返回非零状态,但 mapfile 自身通常仍可能成功读取到空输入。进程替换中的生产者退出状态不会自然成为 mapfile 的退出状态。因此需要严格判断 grep 成败时,应单独执行并保存结果,或使用临时文件等更明确的结构。
5.4 获取排序后的唯一值
mapfile -t unique_names < <(cut -d: -f1 users.txt | sort -u)
只要命令输出遵循“一行一条记录”,就很适合交给 mapfile -t。
5.5 不要使用命令替换来构造数组
不推荐:
items=($(some_command))
原因包括:
- 会根据
$IFS对空格、制表符和换行符进行分词。 - 可能发生路径名展开。
- 无法可靠保留空行。
- 一行包含空格时会被拆成多个元素。
推荐:
mapfile -t items < <(some_command)
5.6 捕获命令错误状态的可靠写法
如果既要得到数组,也必须准确判断生产命令的状态,可以先把输出放入临时文件:
tmp_file=$(mktemp)
trap 'rm -f "$tmp_file"' EXIT
if some_command > "$tmp_file"; then
mapfile -t items < "$tmp_file"
else
printf '命令执行失败\n' >&2
exit 1
fi
这比假设 mapfile 的状态等于进程替换中命令的状态更可靠。
6. 自定义分隔符与文件名安全
6.1 为什么换行分隔文件名并不完全安全
Unix 文件名可以包含空格、制表符,甚至换行符。因此下面的代码不能覆盖所有合法文件名:
mapfile -t files < <(find . -type f)
如果文件名本身包含换行符,它会被误拆成两个数组元素。
6.2 NUL 分隔的安全方式
mapfile -d '' files < <(find . -type f -print0)
for file in "${files[@]}"; do
printf '文件:%q\n' "$file"
done
find -print0 输出以 NUL 字符结尾的文件名;mapfile -d '' 使用 NUL 字符读取。这是处理任意合法文件名的可靠组合。
若希望明确移除分隔符,也可写成:
mapfile -d '' -t files < <(find . -type f -print0)
对于 NUL 分隔输入,常见 Bash 版本中记录写入数组时不会留下可存储的 NUL;Bash 字符串本身也不能保存 NUL 字节。核心仍是使用 NUL 作为记录边界。
6.3 不要解析 ls 输出
不推荐:
mapfile -t files < <(ls)
ls 的输出是为人类阅读设计的,可能受选项、别名、转义和文件名字符影响。应使用:
mapfile -d '' files < <(find . -maxdepth 1 -type f -print0)
或 Bash 通配符,在不需要递归时直接构造数组:
files=(./*)
7. 数组操作与遍历
7.1 查看元素数量
printf '%d\n' "${#lines[@]}"
7.2 读取单个元素
printf '%s\n' "${lines[0]}"
printf '%s\n' "${lines[1]}"
数组下标从 0 开始。
7.3 正确遍历所有元素
for line in "${lines[@]}"; do
printf '%s\n' "$line"
done
必须注意双引号。"${lines[@]}" 会让每个数组元素保持为一个独立参数。
7.4 同时遍历下标和值
for index in "${!lines[@]}"; do
printf 'lines[%d]=%q\n' "$index" "${lines[index]}"
done
7.5 ${array[@]} 与 ${array[*]}
在双引号中:
-
"${array[@]}":每个元素分别展开,适合遍历和传参。 -
"${array[*]}":所有元素连接成一个字符串,连接符是$IFS的第一个字符。
因此遍历时通常应使用:
for item in "${array[@]}"; do
...
done
7.6 判断数组是否为空
if ((${#lines[@]} == 0)); then
printf '没有读取到内容\n'
fi
8. 回调函数
8.1 每条记录调用一次
show_record() {
local index=$1
local record=$2
printf '读取中:index=%d record=%q\n' "$index" "$record"
}
mapfile -t -C show_record -c 1 rows < file.txt
8.2 每 1000 条报告进度
report_progress() {
local index=$1
printf '即将处理第 %d 条记录\n' "$((index + 1))" >&2
}
mapfile -t -C report_progress -c 1000 rows < large.txt
8.3 回调的限制
- 回调发生在数组赋值前,不应误以为当前元素已经存在。
- 回调更适合观察、计数或轻量处理。
- 如果主要目标是逐条转换、验证或提前停止,
while read往往更直观。 - 回调参数可能包含空格或换行,应始终正确引用
"$2"。
9. 常见陷阱
9.1 管道导致数组修改丢失
以下写法经常出错:
printf '%s\n' a b c | mapfile -t items
printf '数量:%d\n' "${#items[@]}"
在常见 Bash 配置下,管道中的 mapfile 在子 Shell 中执行。子 Shell 设置的数组不会回到当前 Shell。
推荐使用进程替换:
mapfile -t items < <(printf '%s\n' a b c)
printf '数量:%d\n' "${#items[@]}"
Bash 的 shopt -s lastpipe 在特定非交互环境中可能改变最后一个管道组件的行为,但脚本为了可读性和可移植性,通常仍应优先使用进程替换。
9.2 忘记 -t
症状是打印元素时出现额外空行,或字符串比较失败:
mapfile lines < file.txt
[[ ${lines[0]} == 'hello' ]] # 可能失败,因为元素是 $'hello\n'
解决:
mapfile -t lines < file.txt
9.3 未引用数组展开
不推荐:
for line in ${lines[@]}; do
printf '%s\n' "$line"
done
这会再次进行分词和路径名展开。应写成:
for line in "${lines[@]}"; do
printf '%s\n' "$line"
done
9.4 目标变量不是索引数组
关联数组不适合作为 mapfile 目标:
declare -A data
mapfile -t data < file.txt
应使用普通索引数组:
declare -a data
mapfile -t data < file.txt
9.5 把简单分隔文本当作完整 CSV
mapfile -t -d ',' fields < data.csv
只能机械地按逗号切记录,无法处理:
1,"Beijing, China",active
这种数据应使用专门的 CSV 解析器。
9.6 大文件占用大量内存
mapfile 会把读到的所有记录保存到数组。文件非常大时,应改为逐行处理:
while IFS= read -r line; do
process "$line"
done < large.txt
9.7 进程替换隐藏生产命令状态
mapfile -t rows < <(some_command)
这里 $? 主要反映 mapfile 的状态,而不是可靠反映 some_command 的状态。关键流程应显式捕获生产命令状态,参见前面的临时文件案例。
9.8 变量作用域
函数中建议把数组声明为局部变量,避免污染全局环境:
load_users() {
local -a users
mapfile -t users < "$1" || return
printf '%s\n' "${users[@]}"
}
如果函数需要把数组返回给调用者,现代 Bash 可以使用名称引用:
load_lines() {
local filename=$1
local output_name=$2
local -n output_ref=$output_name
mapfile -t output_ref < "$filename"
}
declare -a result
load_lines file.txt result
名称引用 local -n 需要 Bash 4.3+。
10. 与 while read 的对比
| 对比点 | mapfile |
while IFS= read -r |
|---|---|---|
| 主要用途 | 一次读入数组 | 逐条处理 |
| 写法 | 简短 | 更灵活 |
| 内存 | 保存全部记录 | 可保持低内存 |
| 随机访问 | 容易 | 默认不保存 |
| 复杂处理 | 回调能力有限 | 每条记录都可自由处理 |
| Bash 版本 | Bash 4.0+ | 更老版本也支持 |
| 管道作用域 | 同样需留意 | 同样需留意 |
选择建议
使用 mapfile:
- 输入规模可控。
- 后续需要按下标访问。
- 需要知道总记录数。
- 想把命令的逐行输出可靠地变成数组。
使用 while read:
- 文件非常大。
- 每行读到后立即处理即可。
- 需要按条件提前停止。
- 需要复杂验证、转换或错误处理。
标准的逐行读取模板:
while IFS= read -r line || [[ -n $line ]]; do
printf '%s\n' "$line"
done < file.txt
其中:
-
IFS=防止删除行首、行尾空白。 -
-r防止反斜杠被解释。 -
|| [[ -n $line ]]确保没有换行符的最后一行仍能被处理。
11. 综合案例
案例 1:读取配置项,忽略空行和注释
输入文件 services.conf:
# enabled services
api
worker
cache
脚本:
mapfile -t services < <(
sed -e '/^[[:space:]]*#/d' \
-e '/^[[:space:]]*$/d' \
services.conf
)
for service in "${services[@]}"; do
printf '启用服务:%s\n' "$service"
done
案例 2:读取指定行范围
读取日志第 101~150 行:
mapfile -t -s 100 -n 50 records < application.log
这里 -s 100 跳过前 100 行,-n 50 再读取 50 行。
案例 3:合并两个文件到同一数组
declare -a all_lines
mapfile -t all_lines < first.txt
mapfile -t -O "${#all_lines[@]}" all_lines < second.txt
printf '%s\n' "${all_lines[@]}"
案例 4:从命令获取进程信息
mapfile -t process_lines < <(ps -eo pid=,comm=)
printf '共获取 %d 条进程记录\n' "${#process_lines[@]}"
for line in "${process_lines[@]}"; do
printf '%s\n' "$line"
done
这里每一整行作为一个数组元素,因此命令名或格式中的空格不会因数组构造过程而被再次拆分。
案例 5:安全批量处理文件
mapfile -d '' files < <(
find ./input -type f -name '*.txt' -print0
)
for file in "${files[@]}"; do
printf '处理:%q\n' "$file"
# process_file "$file"
done
即使文件名包含空格、引号、通配符或换行符,数组边界也不会被破坏。
案例 6:把函数输出载入数组
get_environments() {
printf '%s\n' development testing production
}
mapfile -t environments < <(get_environments)
for environment in "${environments[@]}"; do
printf '环境:%s\n' "$environment"
done
案例 7:验证文件读取结果
input_file=${1:?用法: script.sh FILE}
if [[ ! -r $input_file ]]; then
printf '无法读取文件:%s\n' "$input_file" >&2
exit 1
fi
declare -a lines
if ! mapfile -t lines < "$input_file"; then
printf '读取失败:%s\n' "$input_file" >&2
exit 1
fi
if ((${#lines[@]} == 0)); then
printf '文件为空:%s\n' "$input_file" >&2
exit 0
fi
printf '读取成功,共 %d 行\n' "${#lines[@]}"
案例 8:按冒号读取简单字段流
input='alpha:beta:gamma:'
mapfile -t -d ':' fields <<< "$input"
for index in "${!fields[@]}"; do
printf 'fields[%d]=%q\n' "$index" "${fields[index]}"
done
注意:<<<(here-string)会额外向输入末尾添加一个换行符。如果需要精确控制输入字节,使用:
mapfile -t -d ':' fields < <(printf '%s' "$input")
12. 兼容性与替代方案
Bash 版本检查
printf 'Bash version: %s\n' "$BASH_VERSION"
也可以检查命令是否存在:
if ! type mapfile &>/dev/null; then
printf '当前 Bash 不支持 mapfile\n' >&2
exit 1
fi
macOS 注意事项
传统 macOS 系统自带的 /bin/bash 通常是 Bash 3.2,不支持 mapfile。即使用户另外安装了新版 Bash,使用 sh script.sh 或旧版 /bin/bash script.sh 运行仍然不会获得 mapfile。
脚本若依赖新版 Bash,应使用与实际安装路径匹配的解释器,并在部署环境中验证:
#!/usr/bin/env bash
/usr/bin/env bash 会寻找当前 PATH 中的 Bash,但最终版本仍取决于运行环境。
Bash 3 的替代写法
lines=()
while IFS= read -r line || [[ -n $line ]]; do
lines+=("$line")
done < file.txt
从命令读取时,为避免管道子 Shell,可以使用进程替换:
lines=()
while IFS= read -r line; do
lines+=("$line")
done < <(some_command)
Zsh 不是 Bash
mapfile 是 Bash 内置命令。在 Zsh 中直接执行通常会提示命令不存在。可明确用 Bash 运行脚本,或使用 Zsh 自己的数组读取语法。例如按行拆分命令输出的 Zsh 写法与 Bash 不同,不应把两种 Shell 的语法混用。
13. 速查表
从文件读取全部行
mapfile -t lines < file.txt
从命令读取全部行
mapfile -t lines < <(some_command)
读取前 10 行
mapfile -t -n 10 lines < file.txt
跳过第一行
mapfile -t -s 1 lines < file.txt
跳过 10 行,再读取 20 行
mapfile -t -s 10 -n 20 lines < file.txt
追加到现有连续数组
mapfile -t -O "${#lines[@]}" lines < file.txt
按逗号分隔
mapfile -t -d ',' fields < <(printf '%s' 'a,b,c,')
安全读取文件名
mapfile -d '' files < <(find . -type f -print0)
从文件描述符读取
exec 3< file.txt
mapfile -t -u 3 lines
exec 3<&-
每 1000 条执行回调
mapfile -t -C callback -c 1000 lines < file.txt
遍历数组
for line in "${lines[@]}"; do
printf '%s\n' "$line"
done
总结
最值得记住的核心规则有五条:
- 按行读取文件时,优先使用
mapfile -t array < file。 - 获取命令输出时,优先使用
mapfile -t array < <(command)。 - 不要使用
command | mapfile ...后再期待数组保留在当前 Shell。 - 处理任意文件名时,使用
find -print0配合mapfile -d ''。 - 输入很大或需要逐行复杂处理时,改用
while IFS= read -r。