В Bash есть два типа массивов: индексированные с числовыми индексами от нуля и ассоциативные со строковыми ключами. Первые объявляются через declare -a, вторые через declare -A. Индексированный массив закрывает задачи, где важен порядок: список хостов для обхода, набор конфигурационных файлов, аргументы скрипта. Ассоциативный массив нужен там, где значение ищут по имени: соответствие окружений и адресов, таблица портов по сервисам, маппинг пользователей на роли.
Выбор определяется одним вопросом: нужен ли доступ по имени вместо номера. Если да, объявляйте declare -A. Массивы поддерживаются в Bash, ассоциативные появились только в версии 4.0. В POSIX sh массивов нет вообще, поэтому скрипт с массивами требует shebang #!/bin/bash, иначе интерпретатор разберёт конструкцию ARR=(a b c) как синтаксическую ошибку.
Набор операций одинаков для обоих типов: объявление, добавление, чтение, удаление, перебор. Различия проявляются в способе адресации элемента и в поведении при удалении, и именно на этих местах чаще всего ломаются скрипты автоматизации.
Что такое массивы в Bash и зачем они нужны
Массив в Bash хранит несколько значений под одним именем. Обращение к элементу идёт через фигурные скобки и квадратные скобки с индексом или ключом. Это позволяет держать список хостов, путей или параметров в одной переменной и обходить его циклом вместо копирования одинаковых строк кода.
Индексированные массивы: числовые индексы от нуля
Индексированный массив хранит значения по целым номерам, отсчёт начинается с нуля. Объявление: declare -a ARR=(1 2 3). Доступ к первому элементу: ${ARR[0]}. Индексы могут быть разрежёнными: ARR[5]=x создаст элемент с номером 5, не заполняя позиции с 1 по 4.
Без declare массив тоже создастся: ARR=(a b c) работает. Явное declare -a фиксирует намерение и защищает от случайного переопределения переменной под другой тип позже в скрипте.
Ассоциативные массивы: строковые ключи
Ассоциативный массив хранит значения по строковым ключам. Объявление: declare -A MAP. Заполнение: MAP[host]=web01, MAP[env]=prod. Доступ: ${MAP[host]}. Ключи регистрозависимы, MAP[Host] и MAP[host] дают две разные пары.
Ошибка, которая встречается чаще всего: пропущенный declare -A. Без объявления Bash создаст индексированный массив, а ключ попытается вычислить как арифметическое выражение. Скрипт не упадёт сразу, но данные окажутся не там, где ожидалось. Проверить фактический тип помогает declare -p MAP: для ассоциативного массива вывод начинается с declare -A.
Объявление и инициализация: declare -a и declare -A
Оба типа объявляются через declare с указанием типа. Перечень опций команды и правила подстановок приведены в справочнике по Bash.
Инициализация одной строкой и поэлементно
Одной строкой: ARR=(one two three) для индексированного массива и MAP=([a]=1 [b]=2) для ассоциативного. В ассоциативном варианте ключи указываются в квадратных скобках внутри круглых.
Поэлементно: ARR[0]=one; ARR[1]=two; MAP[host]=web01; MAP[env]=prod. Для ассоциативного массива declare -A должен быть выполнен до первого присваивания, иначе Bash создаст индексированный массив.
Полезные опции declare: -r, -x, -g, -i
- -r делает массив доступным только для чтения: declare -ar ARR=(1 2 3). Повторное присваивание завершится ошибкой, что удобно для констант вроде списка разрешённых портов.
- -x экспортирует массив в дочерние процессы, например когда массив читает вызываемый из скрипта бинарник.
- -g объявляет глобальный массив внутри функции, минуя локальную область видимости.
- -i задаёт целочисленный тип элементов, и тогда значения вычисляются как арифметические выражения: declare -ai N=(1+1 2*2) даст элементы 2 и 4.
Добавление, чтение и удаление элементов
Добавление в конец индексированного массива: ARR+=(new). Запись по конкретному индексу: ARR[10]=x. Для ассоциативного массива добавление всегда идёт по ключу: MAP[key]=value.
Чтение всех элементов и всех ключей
- ${ARR[@]} возвращает все значения индексированного массива.
- ${!ARR[@]} возвращает все индексы.
- ${MAP[@]} возвращает все значения ассоциативного массива.
- ${!MAP[@]} возвращает все ключи.
Кавычки обязательны: "${ARR[@]}" сохраняет каждый элемент отдельным словом, тогда как без кавычек элементы с пробелами разобьются. Строка "web 01" без кавычек превратится в два элемента, и цикл выполнит лишнюю итерацию.
Удаление и переиндексация
unset ARR[2] удаляет элемент, но индекс 2 остаётся дырой. ${#ARR[@]} уменьшится, а ${!ARR[@]} покажет пропуск в нумерации. Переиндексация выполняется присваиванием ARR=("${ARR[@]}"): массив получает непрерывные индексы от нуля.
В ассоциативном массиве unset MAP[key] удаляет пару целиком. Дыр не возникает, потому что ключи не образуют последовательности.
Подсчёт элементов: почему ${#arr} вводит в заблуждение
Правильный синтаксис подсчёта: ${#arr[@]} и ${#arr[*]}
Количество элементов индексированного массива даёт ${#ARR[@]}. Для ассоциативного массива ${#MAP[@]} возвращает число пар. Пример: ARR=(a b c), команда echo ${#ARR[@]} выведет 3. Для declare -A M=([x]=1 [y]=2) команда echo ${#M[@]} выведет 2.
${#ARR[*]} в контексте подсчёта даёт тот же результат, что и ${#ARR[@]}. Разница между @ и * проявляется в других контекстах, где важно, разбиваются ли элементы на отдельные слова.
Типичная ошибка: ${#arr} вместо ${#arr[@]}
${#ARR} возвращает длину символов нулевого элемента, а не количество элементов. Для ARR=(alpha beta) команда echo ${#ARR} выведет 5, потому что в слове alpha пять символов. Правильный подсчёт даёт echo ${#ARR[@]} и результат 2. Это отдельное поведение массивов, о котором предупреждает описание подстановок в Bash.
Условие if [ ${#ARR} -eq 1 ] для массива ARR=(a b c) сработает ложно: три односимвольных элемента дают длину первого, равную единице. Правильная форма проверки количества: if [ ${#ARR[@]} -eq 0 ]; then echo "Массив пуст"; fi.
Перебор элементов в циклах
Перебор индексированного массива
По значениям: for item in "${ARR[@]}"; do echo "$item"; done. Этот вариант подходит, когда нужен только сам элемент. По индексам: for i in "${!ARR[@]}"; do echo "$i: ${ARR[$i]}"; done, когда нужно знать позицию или менять элемент на месте.
Изменение на месте, например перевод значений в верхний регистр: for i in "${!ARR[@]}"; do ARR[$i]="${ARR[$i]^^}"; done.
Перебор ассоциативного массива
Обход по ключам: for key in "${!MAP[@]}"; do printf '%s=%s\n' "$key" "${MAP[$key]}"; done. Внутри цикла доступно и имя ключа, и значение.
Порядок обхода ассоциативного массива не гарантирован. Для стабильного вывода ключи прогоняют через sort: for key in $(printf '%s\n' "${!MAP[@]}" | sort); do echo "$key"; done. Такой вариант безопасен только для ключей без пробелов, иначе word splitting разобьёт имя на части.
Передача массивов в функции и аргументы
Массив передаётся в функцию так же, как список аргументов: myfunc "${ARR[@]}". Внутри функции "$@" восстанавливает элементы как отдельные аргументы, а local copy=("$@") собирает их обратно в массив.
Передача массива в функцию через "${arr[@]}"
Шаблон: process() { local items=("$@"); for i in "${items[@]}"; do echo "$i"; done; }; process "${ARR[@]}". Ключевая деталь, кавычки вокруг "${ARR[@]}" при вызове: без них элементы с пробелами разобьются ещё до входа в функцию. local items защищает массив от изменения глобальной области видимости и от конфликта с одноимённой переменной вызывающего кода.
Разница "$@" и "$*" на практике
"$@" разворачивается в отдельные слова, по одному на аргумент. "$*" склеивает аргументы в одну строку, разделитель, первый символ IFS, по умолчанию пробел. Скрипт, вызванный с аргументами "a b" c, внутри получит через "$@" два аргумента, а через "$*" один. Без кавычек и $@, и $* теряют границы и проходят через word splitting и pathname expansion: имя файла *.log развернётся в список совпадений. Передавать список элементов стоит через "$@", правила поведения описаны в справке по подстановкам Bash.
Кавычки, подстановки и защита от ошибок
Значения по умолчанию и проверка параметров
Позиционные параметры: $1, $2 и далее обозначают аргументы, переданные скрипту или функции; $# возвращает их количество, $0 хранит имя скрипта. Отладочная строка echo "Скрипт: $0, аргументов: $#" сразу показывает, что получил сценарий на вход. Записать все аргументы в одну переменную позволяет замена $1 на $* в соответствующем месте разбора, пример есть в основах создания Bash-скриптов.
${VAR:-default} возвращает default, если переменная не задана или пуста, но саму переменную не меняет. ${VAR:=default} присваивает значение переменной и возвращает его. ${VAR:?msg} выводит сообщение об ошибке и завершает скрипт, если переменная пуста: echo ${CONFIG:?Файл конфигурации не задан}.
Для массивов подстановки применяются к отдельным элементам: ${ARR[0]:-нет значения}. Проверка на пустоту массива выполняется по количеству элементов, а не по длине первого элемента.
printf вместо echo для форматированного вывода
printf "%s\n" "${ARR[@]}" печатает по одному элементу на строку и корректно обрабатывает значения с пробелами. Пара ключ-значение выводится так: printf '%s=%s\n' "$key" "${MAP[$key]}". Спецификаторы %s, %d, %f, %x, %b покрывают строки, целые числа, дробные значения, шестнадцатеричный вид и escape-последовательности.
echo интерпретирует управляющие последовательности по-разному в зависимости от реализации, поэтому для вывода значений из внешних источников printf надёжнее. Результат команды присваивается переменной через $(cmd): этот синтаксис легко вкладывается и читается лучше обратных кавычек, что отмечено в руководстве по основам Bash.
Практические сценарии: от списка хостов до карты конфигураций
Сбор файлов в массив через ARR=($(ls *.conf)) работает до первого имени с пробелом: подстановка команды проходит word splitting, и один путь превращается в несколько элементов. Надёжный вариант, mapfile: команда mapfile -t CONFIGS читает строки из стандартного ввода, поэтому список подаётся перенаправлением, например mapfile -t CONFIGS < <(find /etc -maxdepth 1 -name '*.conf'). Ключ -t отбрасывает завершающие переводы строк.
Список хостов и обход в цикле
HOSTS=(web01 web02 db01); for h in "${HOSTS[@]}"; do ssh "$h" uptime; done. Проверка доступности: if ! ssh "$h" true; then echo "$h недоступен"; fi. Проверка по коду возврата дешевле, чем разбор текста ошибки ssh.
Карта конфигураций по окружениям
declare -A ENV; ENV[prod]=10.0.0.1; ENV[stage]=10.0.1.1; target=${1:?Укажите окружение}; echo "${ENV[$target]:?Неизвестное окружение}". Обращение к несуществующему ключу ассоциативного массива без подстановки :? вернёт пустую строку и ошибку не покажет. Проверка через ${ENV[$target]:?msg} останавливает скрипт сразу и экономит время на отладке.
Функция, выполняющая команду на каждом хосте: run_all() { local hosts=("$@"); for h in "${hosts[@]}"; do printf '%s: ' "$h"; ssh "$h" uptime; done; }; run_all "${HOSTS[@]}". Массив передаётся одним вызовом, кавычки сохраняют границы элементов.
Подводные камни и отладка
Разрежённые индексы и переиндексация
ARR=(a b c); unset ARR[1]; echo ${!ARR[@]} выведет 0 2, а ${#ARR[@]} даст 2. Цикл по "${!ARR[@]}" пропустит позицию 1 без ошибки, но код, обращающийся к ARR[1] напрямую, получит пустую строку. Переиндексация ARR=("${ARR[@]}") возвращает непрерывную нумерацию 0 1. Запускать её стоит только при реальной необходимости: она пересобирает массив целиком.
Проверка версии Bash и совместимости
Ассоциативные массивы требуют Bash 4.0 и выше, команда bash --version показывает установленную версию в первой строке. В macOS по умолчанию поставляется Bash 3.2, где declare -A завершится ошибкой. Варианты решения: установить свежий Bash через Homebrew или переписать участок на zsh. В POSIX sh массивов нет, поэтому shebang #!/bin/bash обязателен.
Отладка: set -x печатает каждую выполняемую команду с раскрытыми подстановками, declare -p ARR показывает тип и текущее содержимое, echo ${!ARR[@]} выводит список индексов или ключей. Проверить, не занято ли имя системной командой, позволяет type: вывод type test сообщит test is a shell builtin, и такое же имя для массива приведёт к путанице, о чём напоминает базовое руководство по Bash.
Начните работу с двух привычек: объявляйте declare -A до первого присваивания и заключайте в кавычки каждую подстановку "${ARR[@]}". Эти два правила закрывают основную часть ошибок при работе с массивами в скриптах администрирования.