mapfile命令详解

Bash mapfile 命令详解

mapfile 是 Bash 4.0 及以上版本提供的内置命令,用于从标准输入中读取数据,并把每条记录保存到索引数组中。它也可以使用别名 readarray

目录

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

readarraymapfile 等价:

readarray -t lines < file.txt

基本工作流程

  1. 从标准输入或 -u 指定的文件描述符中读取数据。
  2. 默认使用换行符划分记录。
  3. 将记录依次保存到索引数组。
  4. 默认保留每条记录末尾的分隔符;-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 3mapfile 从描述符 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

回调接收两个附加参数:

  1. 即将写入的数组下标。
  2. 当前记录的原始内容。

回调在数组元素正式赋值之前执行。若使用 -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

总结

最值得记住的核心规则有五条:

  1. 按行读取文件时,优先使用 mapfile -t array < file
  2. 获取命令输出时,优先使用 mapfile -t array < <(command)
  3. 不要使用 command | mapfile ... 后再期待数组保留在当前 Shell。
  4. 处理任意文件名时,使用 find -print0 配合 mapfile -d ''
  5. 输入很大或需要逐行复杂处理时,改用 while IFS= read -r
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容