Прочитать файл построчно - задача, с которой сталкивается почти любой скрипт. Лог, конфиг, список имён, вывод другой команды. Десятилетиями для этого служил один и тот же приём: цикл while read, перебирающий строки одну за другой. Он работает, он привычен, и именно поэтому его продолжают писать по инерции даже там, где он проигрывает. На больших файлах эта инерция выливается в ощутимую задержку и в коварные ошибки с областью видимости переменных.

В bash четвёртой версии появился инструмент, созданный специально для этой задачи, - встроенная команда mapfile, она же readarray под другим именем. Она читает весь файл в массив одним махом, без перебора в цикле, и делает это заметно быстрее. Разберём, чем она отличается от старого приёма, где даёт выигрыш, а где у неё есть граница, за которой стоит вернуться к циклу.

Одна строка вместо цикла загружает файл в массив целиком

Базовое применение лаконично до предела. Команда читает файл и раскладывает его строки по элементам массива, каждая строка - отдельный элемент.

mapfile -t строки < файл.txt
for строка in "${строки[@]}"; do
    echo "Строка: $строка"
done

Флаг -t тут почти обязателен, и про него стоит сказать отдельно. Он срезает завершающий символ переноса строки с каждого элемента. Без него каждая строка массива тащила бы за собой невидимый перенос, из-за чего сравнения вели бы себя странно: строка выглядела бы пустой или не совпадала бы с ожидаемым значением, хотя на вид всё верно. Поэтому -t ставят почти всегда.

Имена mapfile и readarray - полные синонимы. Это одна и та же встроенная команда под двумя названиями, выбор между ними чисто вкусовой. Обе требуют bash четвёртой версии или новее и недоступны в чистом POSIX-shell, что стоит держать в голове при переносе скриптов.

# Эти две строки делают абсолютно одно и то же
mapfile -t массив < файл.txt
readarray -t массив < файл.txt

Если имя массива не указать, команда складывает строки в массив по умолчанию с именем MAPFILE. На практике имя задают почти всегда, чтобы код читался яснее.

Скорость берётся из отсутствия перебора и подоболочки

Откуда вообще выигрыш? Старый цикл while read выполняет тело цикла для каждой строки заново. Сто тысяч строк - сто тысяч проходов через интерпретатор, сто тысяч вызовов встроенной команды read. Команда mapfile встроена в оболочку и читает входной поток разом, без покомандного перебора. На большом файле разница в скорости становится из теоретической вполне осязаемой.

# Старый способ: тело цикла исполняется для каждой строки
while IFS= read -r строка; do
    обработать "$строка"
done < большой_файл.txt

# Новый способ: одна команда читает всё сразу
mapfile -t строки < большой_файл.txt
for строка in "${строки[@]}"; do
    обработать "$строка"
done

Но скорость - не единственное и даже не главное преимущество. Цикл while read таит в себе классическую ловушку с подоболочкой. Когда вход цикла подаётся через канал от другой команды, цикл выполняется в отдельной подоболочке, и все переменные, изменённые внутри, после его завершения бесследно исчезают.

счётчик=0
cat файл.txt | while IFS= read -r строка; do
    (( счётчик++ ))
done
echo $счётчик    # 0 - изменения пропали в подоболочке

Команда mapfile, читающая из файла напрямую, этой болезни лишена - она работает в текущей оболочке, и массив остаётся доступным после чтения. Это устраняет целый класс багов, на которые натыкаются те, кто привык подавать данные в цикл через cat и канал.

Чтение из команды требует подстановки процесса, а не канала

Здесь прячется тонкость, которая ставит в тупик при первом столкновении. Если данные нужно прочитать не из файла, а из вывода другой команды, напрашивается канал. Но канал снова уводит mapfile в подоболочку, и массив теряется ровно так же, как терялись переменные в цикле.

# Так массив потеряется: канал создаёт подоболочку
ls | mapfile -t файлы
echo "${#файлы[@]}"    # 0 - массив пуст

Правильный путь - подстановка процесса, та самая конструкция с уголком и скобками, которая притворяется файлом. Она оставляет mapfile в текущей оболочке, и массив наполняется как надо.

# Так всё верно: подстановка процесса не создаёт подоболочку
mapfile -t файлы < <(ls)
echo "${#файлы[@]}"    # реальное число файлов

Между двумя уголками обязателен пробел. Первый - перенаправление ввода, вторая конструкция - подстановка процесса, и слипшись они дадут синтаксическую ошибку. Эта пара пробел-уголок - частый источник недоумения, но запомнив её один раз, о ней больше не спотыкаешься.

Опции тонко управляют тем, что и сколько читать

У команды богатый набор опций, закрывающий распространённые потребности без дополнительного кода. Флаг пропуска начальных строк снимает с файла заголовок - незаменимо при чтении таблиц с шапкой.

mapfile -t -s 1 данные < таблица.csv
# -s 1 отбрасывает первую строку, шапку таблицы

Флаг ограничения количества читает лишь заданное число строк, что удобно, когда нужна выборка из начала большого файла, а не весь он целиком.

mapfile -t -n 100 образец < огромный_лог.txt
# -n 100 прочитает только первую сотню строк

Есть опция начального индекса, позволяющая дописывать в массив с заданной позиции, а не с нуля. И есть механизм обратного вызова через флаг callback - функция, вызываемая через каждые несколько прочитанных строк, что даёт показывать прогресс при чтении очень длинного входа.

Отдельная ловушка связана с опцией разделителя. Она позволяет задать иной завершитель строки вместо переноса. Но если написать разделитель как двухсимвольную строку из обратной косой и буквы, команда воспримет его буквально, как два символа, а не как перенос строки, и чтение зависнет в ожидании несуществующего разделителя. Управляющий символ переноса задаётся через синтаксис ANSI-C с долларом перед кавычками, либо опцию просто опускают, ведь перенос и так стоит по умолчанию.

mapfile -t -d $'\n' строки < файл.txt   # верно: реальный перенос
# mapfile -t -d '\n'  - ошибка: буквальные два символа

Граница применимости проходит по объёму памяти

У всякого инструмента есть предел, и у mapfile он очевиден, стоит лишь задуматься о его природе. Команда читает весь вход в память разом и держит его там целиком в виде массива. Для файла на пару сотен мегабайт это незаметно. Для многогигабайтного лога - прямой путь к исчерпанию оперативной памяти, потому что весь файл придётся уместить в неё одновременно.

Тут старый цикл while read берёт реванш. Он обрабатывает строки потоком, по одной, и в каждый момент держит в памяти лишь текущую. Файл любого размера ему по плечу, потому что объём потребляемой памяти не зависит от размера входа.

Практический вывод складывается в простое правило выбора. Когда файл умещается в память и нужен произвольный доступ к строкам, их подсчёт, сортировка или многократный проход - mapfile выигрывает по всем статьям: быстрее, чище, без ловушки с подоболочкой. Когда файл огромен и достаточно обработать каждую строку однократно, не удерживая весь файл, - потоковый цикл while read остаётся единственным разумным выбором.

Разумно даже сочетать оба подхода: проверить размер файла заранее и выбрать инструмент по нему. Маленький - в массив через mapfile, большой - потоком через цикл.

Что стоит запомнить

Картина выстраивается ясно. Для чтения файла или вывода команды в массив mapfile с флагом -t - современный и предпочтительный инструмент. Он встроен, быстр на больших файлах за счёт отсутствия покомандного перебора, и не теряет данные в подоболочке, в отличие от цикла, питающегося через канал.

Ключевые привычки складываются в короткий список. Флаг -t срезает переносы и спасает сравнения от тихих сбоев. Чтение из команды идёт через подстановку процесса с обязательным пробелом перед уголком, а не через канал. Опции пропуска и ограничения строк закрывают работу с заголовками и выборками без лишнего кода.

Единственная настоящая граница - память. mapfile держит весь вход в ней целиком, поэтому на файлах в гигабайты эстафету принимает потоковый цикл. Стоит запомнить это разделение по объёму, и выбор между двумя подходами перестаёт быть гаданием. Инструмент хорош ровно там, где данные помещаются в память и с ними хочется работать как с целым, а не как с бесконечным потоком.