Создание и добавление в массив, mapfile vs arr+=(input) — это одно и то же, или я что-то упускаю?

Вопрос или проблема

Существует ли случай, когда mapfile имеет преимущества перед arr+=(input)?

Простые примеры

имя массива mapfile, arr:

mkdir {1,2,3}

mapfile -t arr < <(ls)

declare -p arr

вывод:

declare -a arr=([0])="1" [1]="2" [2]="3")

Правка:

изменено название ниже; в теле было y как имя массива, но в заголовке было arr как имя, что могло бы привести к путанице.

y+=(input)

IFS=$'\n'

y+=($(ls))

declare -p y

вывод:

declare -a y=([0])="1" [1]="2" [2]="3")

Преимущество mapfile в том, что вам не нужно беспокоиться о разбиении слов, думаю.

Другим способом вы можете избежать разбиения слов, установив IFS=$'\n', хотя для этого примера ничего страшного.

Второй пример кажется проще писать, может, я упускаю что-то?

Они вовсе не одинаковые, даже после IFS=$'\n'.

Специфично для bash (хотя этот синтаксис был заимствован из zsh¹):

arr=( $(cmd) )

(arr+=( $(cmd) ) используется для добавления элементов в массив; поэтому сравнивается с keys=( -1 "${!arr[@]}" ); readarray -tO "$(( ${keys[@]: -1} + 1))" arr < <(cmd)²).

Делает:

  1. Выполняет cmd в подпроцессе с его stdout, открытым на записывающем конце канала.
  2. Одновременно родительский процесс оболочки читает с другого конца канала и:
    • удаляет символы NUL и завершающие символы новой строки
    • разделяет полученную строку на основе содержимого специальной переменной $IFS. Для тех символов в $IFS, которые являются пробелами, например, новой строкой, поведение более сложное:
      • начальные и конечные удаляются (в случае новой строки, конечные уже удалены подстановкой команды, как показано выше)
      • последовательности из одного или нескольких считаются одним разделителем. Например, вывод printf '\n\n\na\n\n\nb\n\n\n' разделяется на только два элемента: a и b.
    • каждое из этих слов затем подлежит генерации имен файлов, также известной как расширение путей, чье поведение зависит от ряда опций, включая noglob, nullglob, failglob, extglob, globasciiranges, globstar, nocaseglob. Это относится к тем словам, которые содержат такие символы, как *, ?, [, и в некоторых версиях bash \, и больше, если extglob включен.
  3. Затем полученные слова присваиваются как элементы массива $arr.

Пример:

bash-5.1$ touch x '\x' '?x' aX $'foo\n\n\n\n*'
bash-5.1$ IFS=$'\n'
bash-5.1$ ls | cat
aX
foo

*
?x
\x
x
bash-5.1$ arr=( $(ls) )
bash-5.1$ typeset -p arr
declare -a arr=([0]="aX" [1]="foo" [2]="aX" [3]=$'foo\n\n\n\n*' [4]="?x" [5]="\\x" [6]="x" [7]="?x" [8]="\\x" [9]="\\x" [10]="x")

Как вы видите, файл $'foo\n\n\n\n*' был разбит на foo и *, и * был расширен до списка файлов в текущем рабочем каталоге, что объясняет, почему мы получаем как foo, так и $'foo\n\n\n\n*', аналогично для ?x, что объясняет, почему мы получаем \x (показано как "\\x") трижды, так как есть строка \x в выводе ls, и она совпадает и с *, и с ?x.

С bash 5.0, мы получаем:

bash-5.0$ arr=( $(ls) )
bash-5.0$ typeset -p arr
declare -a arr=([0]="aX" [1]="foo" [2]="aX" [3]=$'foo\n\n\n\n*' [4]="?x" [5]="\\x" [6]="x" [7]="?x" [8]="\\x" [9]="x" [10]="x")

С \x только дважды, но x трижды, так как в этой версии обратная косая черта была глобальной операцией, даже когда не следовала за другой глобальной операцией, поэтому \x как глоб совпадает с x.

После shopt nocaseglob, мы получаем:

bash-5.1$ shopt -s nocaseglob
bash-5.1$ arr=( $(ls) )
bash-5.1$ typeset -p arr
declare -a arr=([0]="aX" [1]="foo" [2]="aX" [3]=$'foo\n\n\n\n*' [4]="?x" [5]="\\x" [6]="x" [7]="aX" [8]="?x" [9]="\\x" [10]="\\x" [11]="x")

С aX показано трижды, так как оно также совпадает с ?x.

После shopt -s failglob:

bash-5.0$ shopt -s failglob
bash-5.0$ arr=( $(printf '\\z\n') )
bash: no match: \z
bash-5.0$ arr=( $(printf 'WTF?') )
bash: no match: WTF?

И arr=( $(echo '/*/*/*/*/../../../../*/*/*/*/../../../../*/*/*/*') )

Выходит из памяти после того, как делает вашу систему непригодной для использования в течение нескольких минут.

Итак, в общем, IFS=$'\n'; arr=( $(cmd) ) не сохраняет строки вывода cmd в массиве, но имена файлов, полученные в результате расширения непустых строк вывода cmd, которые обрабатываются как шаблоны.


С mapfile или его менее вводящим в заблуждение псевдонимом readarray:

readarray -t arr < <(cmd)
  1. как и выше выполняет cmd в подпроцессе с его stdout, открытым на записывающем конце канала.
  2. <(...) разворачивается в нечто похожее на /dev/fd/63 или /proc/self/fd/63, где 63 — это файловый дескриптор родительской оболочки, открытый на читающем конце этого канала.
  3. с направлением < коротким для 0<, этот /dev/fd/63 открывается для чтения на fd 0, что означает, что stdin readarray также будет читающим концом этого канала.
  4. readarray читает каждую строку из этого канала (одновременно с записью в него cmd), отбрасывает разделитель строк (-t) и сохраняет ее (до первого NUL, если содержит любой, по крайней мере в текущих версиях bash) в новом элементе массива $arr.

Таким образом, в конце $arr, при условии, что cmd не выводит NUL, будет содержать содержимое каждой строки вывода cmd, независимо от того, пустые они или нет, содержат ли они символы шаблонов или нет.

С примером выше:

bash-5.1$ readarray -t arr < <(ls)
bash-5.1$ typeset -p arr
declare -a arr=([0]="aX" [1]="foo" [2]="" [3]="" [4]="" [5]="*" [6]="?x" [7]="\\x" [8]="x")

Это согласуется с тем, что мы видели в выводе ls | cat ранее, но это все же неверно, если намерение состояло в том, чтобы получить список файлов в текущем рабочем каталоге. Вывод ls невозможно обработать постфактум, если вы не используете некоторые расширения GNU, такие как --quoting-style=shell-always или --zero в последних версиях (9.0 и выше):

bash-5.2$ readarray -td '' arr < <(ls --zero)
bash-5.2$ typeset -p arr
declare -a arr=([0]="aX" [1]=$'foo\n\n\n\n*' [2]="?x" [3]="\\x" [4]="x")

На этот раз, readarray сохраняет содержимое записей, разделенных NUL-символами, в $arr. IFS=$'\0' не может использоваться в bash, так как bash не может сохранять NUL в своих переменных.

Или:

bash-5.1$ eval "arr=( $(ls --quoting-style=shell-always) )"
bash-5.1$ typeset -p arr
declare -a arr=([0]="aX" [1]=$'foo\n\n\n\n*' [2]="?x" [3]="\\x" [4]="x")

В любом случае, правильный способ получить список не скрытых файлов в текущем каталоге в массив будет следующий:

shopt -s nullglob
shopt -u failglob
arr=( * )

Вы бы прибегнули к ls --zero только если бы вы хотели, например, чтобы список был отсортирован по размеру или времени модификации, что недоступно в bash-глобах (в отличие от zsh).

Как в:

zsh недавние GNU bash + GNU coreutils
new_to_old=( *.txt(Nom) ) readarray -td '' new_to_old < <(ls -td --zero -- *.txt)
four_largest=( *.txt(NOL[1,4]) ) readarray -td '' four_largest < <(ls -tdrS --zero -- *.txt | head -zn4)

Еще одно отличие между a=($(cmd)) и readarray < <(cmd) заключается в статусе выполнения, который в первом случае соответствует cmd, а во втором – readarray. В последних версиях bash вы можете получить существующий статус cmd в последнем случае с помощью wait "$!"; cmd_status=$?.


¹ синтаксис arr=( ... ) взят из zsh (bash не имел массивов до версии 2.0 в 1996 году), но стоит отметить, что в zsh подстановка команды, хотя она также удаляет завершающие символы новой строки и подвержена удалению $IFS, не отбрасывает NULы (NUL даже включен в значение по умолчанию $IFS) и не подвержена глобальному расширению, как в других оболочках, подобных Bourne, что делает ее более безопасной оболочкой в целом.

² readarray или mapfile не имеют режима добавления, но в последних версиях вы можете указать индекс первого элемента, с которого следует начинать хранить элементы с помощью -O, как показано здесь. Чтобы определить индекс последнего элемента в bash (где массивы редкие, как в ksh!), это чертовски сложно. Здесь, чтобы добавить строки вывода cmd в $arr, вместо столь сложного кода вы можете прочитать эти строки во временный массив с readarray -r tmp < <(cmd) и добавить элементы в $arr с arr+=( "${tmp[@]}" ). Также стоит отметить, что если переменная arr была объявлена как скалярная или ассоциативная, поведение будет варьироваться.

.

Ответ или решение

В мире программирования на Bash создание и добавление элементов в массивы имеет большое значение для автоматизации и управления данными. Несмотря на кажущуюся простоту, существует несколько методов, каждый из которых имеет свои особенности и применение. Рассмотрим два из них: использование команды mapfile и оператора arr+=.

Теория

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

В отличие от этого, выражение arr+=($(cmd)) выполняет объединение массивов, генерируя список элементов на основе выполнения команды в скобках. Этот подход склонен использовать переменную IFS для разделения строк и потенциально может страдать от проблем, связанных с разделением слов (word splitting) и расширением глобов (globbing).

Пример

Рассмотрим два примера.

  1. Использование mapfile:
mkdir {1,2,3}

mapfile -t arr < <(ls)

declare -p arr

Этот скрипт создает три директории и с помощью mapfile считывает их имена из вывода команды ls, заполняя массив arr.

  1. Использование arr+=:
IFS=$'\n'
y+=($(ls))
declare -p y

Этот скрипт делает то же самое, используя оператор добавления к массиву и переменную IFS, чтобы разделить строки на основе новых строк.

Применение

Теперь, когда мы обсудили базовые теории и привели примеры, давайте поговорим о применении. Почему может возникнуть необходимость использовать один метод над другим?

  1. Управление отделением слов и расширением глобов: mapfile не требует установки переменной IFS, упростив процесс управления входными данными. Это особенно полезно, когда строки содержат пробелы или специальные символы.

  2. Производительность и читаемость: В скриптинге, где требуется высокая производительность или сложные данные, mapfile может быть предпочтительнее, так как он работает с потоками данных напрямую, без лишних преобразований.

  3. Обход сбоев: В случае, если данные из команды содержат неожиданные символы и могут вызвать сбой при обработке через оператор arr+=, использование mapfile будет более устойчивым.

Таким образом, при выборе между mapfile и arr+= необходимо учитывать природу ваших данных, специфику выполняемых операций и требования к обработке строки. Если вам важна точность и чистота данных, mapfile предлагает более надежное решение. С другой стороны, простота и легкость написания arr+= могут быть достаточно удобными в менее требовательных сценариях.

Таким образом, различия между этими двумя подходами связаны не только с синтаксисом, но и с фундаментальной разницей в том, как данные обрабатываются, что и формирует разнообразие их применения.

Оцените материал
Добавить комментарий

Капча загружается...