Learning Platform
Глоссарий Troubleshooting
Урок 19.02 · 22 мин
Средний
BashArraysAssociative arraysdeclareData structures

Массивы: indexed и associative

Bash долго был языком одиночных переменных. До версии 2.0 у тебя были только строки. Сейчас в bash есть массивы (indexed) и ассоциативные массивы (associative, появились в bash 4.0, 2009). Это не Python list/dict, но 80% типичных DE-задач они решают: список файлов для обработки, счётчики по категориям, маппинги “имя -> путь”, дедупликация.

Без массивов Junior пишет циклы через $IFS-разбиение строк, и каждый скрипт превращается в минное поле. С массивами — код становится читаемым, надёжным и быстрым (массивы в памяти, в отличие от cat файлов).

В этом уроке: indexed array, associative array, идиомы для DE — group-by counter, дедуп, lookup table.


Indexed arrays: основы

Indexed array — это массив с ключами-числами, начиная с 0.

# Создание:
fruits=(apple banana cherry)

# Доступ к элементу:
echo "${fruits[0]}"   # apple
echo "${fruits[1]}"   # banana

# Все элементы:
echo "${fruits[@]}"   # apple banana cherry

# Длина:
echo "${#fruits[@]}"  # 3
WARNING

ВСЕГДА используй "${arr[@]}" в двойных кавычках — это сохраняет элементы как отдельные слова. Без кавычек bash сделает word splitting и apple banana cherry превратятся в три отдельных слова (а если в элементе есть пробел — разобьётся на части). Это правило номер один при работе с массивами.

Доступ к индексам

Синтаксис обращения к массиву
`${arr[0]}`Первый элемент. Индексы с нуля
`${arr[-1]}`Последний элемент (bash 4.3+). Эквивалент `${arr[${#arr[@]}`-1]}
`${arr[@]}`Все элементы как отдельные слова. ОБЯЗАТЕЛЬНО в кавычках: '`${arr[@]}`'
`${arr[*]}`Все элементы как ОДНА строка, склеенная первым символом IFS. Редко нужно. НЕ путать с @
`${#arr[@]}`Длина массива (количество элементов)
`${!arr[@]}`Все ИНДЕКСЫ. Полезно если есть пропуски: arr[0]=a, arr[5]=b — индексы 0 и 5
`${arr[@]:1:2}`Slice: 2 элемента начиная с индекса 1. Аналог Python arr[1:3]
`${arr[@]/old/new}`Substitution: применить s/old/new/ ко всем элементам

Создание разными способами

# Литерал:
arr=(one two three)

# По индексу (можно с пропусками):
arr[0]="zero"
arr[10]="ten"
echo "${#arr[@]}"   # 2 (только определённые элементы)
echo "${!arr[@]}"   # 0 10

# Из вывода команды:
files=( $(ls /tmp/*.csv) )    # ОПАСНО: word splitting на пробелы
mapfile -t files < <(find /tmp -name '*.csv')   # БЕЗОПАСНО

# readarray (синоним mapfile, bash 4+):
readarray -t lines < input.txt   # каждая строка файла — элемент
TIP

mapfile -t (или readarray -t) — золотой стандарт для чтения построчного вывода в массив. -t убирает trailing newline у каждой строки. Безопаснее, чем arr=( $(cmd) ), потому что не подвержен word splitting.

Append (добавление элементов)

arr=(one two)
arr+=(three)                    # arr = (one two three)
arr+=(four five)                # arr = (one two three four five)
arr[${#arr[@]}]="six"           # явный append через индекс длины

+= — главный оператор для построения массивов в цикле:

errors=()
for log in /var/log/app/*.log; do
    if grep -q ERROR "$log"; then
        errors+=("$log")
    fi
done

echo "Found ${#errors[@]} files with errors"
printf '%s\n' "${errors[@]}"

Iteration по массивам

fruits=(apple banana cherry)

# По значениям:
for f in "${fruits[@]}"; do
    echo "Processing: $f"
done

# По индексам:
for i in "${!fruits[@]}"; do
    echo "$i: ${fruits[$i]}"
done

# C-style:
for ((i=0; i<${#fruits[@]}; i++)); do
    echo "$i: ${fruits[$i]}"
done
for-loop варианты
for x infor x in "`${arr[@]}`" — итерация по значениям. Самый частый и читаемый паттерн
for i infor i in "`${!arr[@]}`" — итерация по индексам. Нужно когда тебе нужны и индекс, и значение
for ((;;))C-style для случаев когда нужен численный счётчик с шагом. Полезно для inverted loops

Реальный DE-кейс: list of files to process

Задача: обработать все CSV-файлы за вчерашний день из директории, безопасно по отношению к пробелам в именах.

#!/bin/bash
set -euo pipefail
IFS=$'\n\t'

# Найти все вчерашние CSV:
mapfile -t files < <(find /data/incoming -name '*.csv' -mtime -1 -type f)

echo "Found ${#files[@]} files to process"

# Если пусто — выйти:
if [ "${#files[@]}" -eq 0 ]; then
    echo "Nothing to process, exiting"
    exit 0
fi

# Обработать каждый:
for f in "${files[@]}"; do
    echo "Processing: $f"
    python3 /opt/etl/process.py "$f"
done

echo "All ${#files[@]} files processed"

Почему это правильно:

  1. mapfile корректно читает имена с пробелами, не подвержен IFS-разбиению.
  2. "${files[@]}" в кавычках сохраняет элементы как отдельные слова.
  3. Проверка ${#files[@]} -eq 0 — graceful exit на пустой вход.
  4. Передача "$f" в python — экранирование на случай пробелов.

Associative arrays: хэш-мапы

С bash 4.0+ есть associative arrays — словари key->value. Создаются через declare -A:

declare -A user_email

user_email["alice"]="[email protected]"
user_email["bob"]="[email protected]"
user_email["carol"]="[email protected]"

echo "${user_email[alice]}"     # [email protected]
echo "${user_email[bob]}"       # [email protected]

# Все ключи:
echo "${!user_email[@]}"        # alice bob carol (порядок НЕ гарантирован)

# Все значения:
echo "${user_email[@]}"

# Количество ключей:
echo "${#user_email[@]}"        # 3
WARNING

Обязательно объявить через declare -A ПЕРЕД использованием. Без declare bash будет считать arr[key] обращением к indexed-массиву с ключом 0 (arithmetic evaluation). Это самая частая ошибка с associative arrays.

Проверка существования ключа

declare -A config
config[host]="localhost"
config[port]="5432"

# Проверка через -v (bash 4.3+):
if [ -v config[host] ]; then
    echo "host is set: ${config[host]}"
fi

# Альтернатива: проверить через :+ оператор
if [ -n "${config[host]:+x}" ]; then
    echo "host is set"
fi

Удаление ключа

unset 'config[port]'   # одинарные кавычки чтобы не было globbing

DE-паттерн 1: group-by counter

Самая частая задача: посчитать количество вхождений каждого значения. Без associative arrays — мучение через sort | uniq -c. С ними — просто.

Пример: посчитать HTTP status codes из nginx-логов.

#!/bin/bash
set -euo pipefail

declare -A status_counts

while IFS= read -r line; do
    # Извлечь статус-код (9-й столбец access.log)
    status=$(echo "$line" | awk '{print $9}')
    # Увеличить счётчик:
    status_counts[$status]=$(( ${status_counts[$status]:-0} + 1 ))
done < /var/log/nginx/access.log

# Распечатать результаты:
for status in "${!status_counts[@]}"; do
    echo "$status: ${status_counts[$status]}"
done
Group-by через associative array

Bash-эквивалент Pandas groupby().count(). За один проход по файлу.

200`${status_counts[200]:-0}` читает текущее значение с дефолтом 0. Это idiom инкремента — увеличить или создать
++
200: 1248После обработки всех строк — словарь со счётчиками
404Для нового ключа :-0 даёт 0, после += становится 1
++
404: 37Сортировка не гарантирована — используй sort после если важен порядок

Ключевой трюк: ${status_counts[$status]:-0} — взять текущее значение или 0 если ключа нет. Без :-0 при первом обращении к новому ключу с set -u будет ошибка.

GROUP BY и COUNT в SQL — тот же паттерн агрегации
NOTE

Для больших файлов awk быстрее: awk '{counts[$9]++} END {for (s in counts) print s, counts[s]}' access.log. Bash-цикл с read работает ~1000 строк/сек, awk — миллионы строк/сек. Bash подходит для маленьких объёмов или когда логика сложная. О выборе bash vs awk vs python — в уроке 04 и в capstone.


DE-паттерн 2: дедуп через associative array

Удалить дубликаты, сохраняя порядок первого появления:

declare -A seen
unique=()

while IFS= read -r line; do
    if [ ! -v seen[$line] ]; then
        seen[$line]=1
        unique+=("$line")
    fi
done < input.txt

printf '%s\n' "${unique[@]}" > deduplicated.txt

Альтернатива через awk: awk '!seen[$0]++' input.txt > deduplicated.txt — короче и быстрее. Но если логика сложнее (например, дедуп по конкретному полю с pre-processing) — bash с associative array читаем.


DE-паттерн 3: lookup table

Маппинг env -> bucket name для multi-tenant pipeline:

declare -A bucket_for_env=(
    [dev]="acme-data-dev"
    [staging]="acme-data-staging"
    [prod]="acme-data-prod-eu-central-1"
)

ENV="${1:-dev}"

if [ ! -v bucket_for_env[$ENV] ]; then
    echo "Unknown env: $ENV. Valid: ${!bucket_for_env[@]}" >&2
    exit 1
fi

BUCKET="${bucket_for_env[$ENV]}"
echo "Using bucket: $BUCKET"
aws s3 cp data.json "s3://$BUCKET/raw/data.json"

Заметь синтаксис инициализации associative array: declare -A name=([k1]=v1 [k2]=v2). Удобно для статических конфигов.


Различия indexed vs associative

Indexed vs Associative
IndexedКлючи — целые числа от 0. Порядок сохраняется. Используется как list/array
AssociativeКлючи — произвольные строки. Порядок НЕ гарантирован (bash hash-table internal). Используется как dict/map
КогдаСписок файлов, аргументов, items, last-N values — линейная коллекция
КогдаCounter, cache, lookup table, mapping (key -> value)
Bash 2+Indexed arrays есть с bash 2.0 (1996)
Bash 4+Associative arrays с bash 4.0 (2009). На macOS pre-Sequoia /bin/bash = 3.2 (!) — нужен #!/usr/bin/env bash или brew install bash
WARNING

macOS gotcha: системный /bin/bash на старых macOS — версия 3.2 (Apple не обновляет из-за GPLv3). Associative arrays там не работают. Используй #!/usr/bin/env bash (берёт bash из PATH, обычно brew-версия 5.3+) или установи bash через brew install bash + переключи путь.


Передача массива в функцию

В bash нельзя передать массив как аргумент напрямую — функции получают строки. Два паттерна:

# 1. Развёрнутый массив (теряется группировка):
my_func() {
    for x in "$@"; do
        echo "Got: $x"
    done
}

arr=(one "two with space" three)
my_func "${arr[@]}"   # передаём все элементы как отдельные аргументы
# 2. Через nameref (bash 4.3+):
my_func() {
    local -n arr_ref=$1   # nameref на переданное имя
    for x in "${arr_ref[@]}"; do
        echo "Got: $x"
    done
}

myarr=(one "two with space" three)
my_func myarr   # передаём ИМЯ массива

Nameref удобнее для associative arrays (где разворачивание теряет ключи).


Подводные камни

1. Обращение без кавычек

arr=("one two" "three")
for x in ${arr[@]}; do echo "$x"; done
# Output:
#   one
#   two
#   three
# Разорвало "one two" на части!

for x in "${arr[@]}"; do echo "$x"; done
# Output:
#   one two
#   three
# Корректно

2. ${arr[*]} vs ${arr[@]}

${arr[*]} склеивает все элементы в одну строку через первый символ IFS. Полезно редко (например, для join). Не путай с ${arr[@]}.

arr=(a b c)

IFS=','
echo "${arr[*]}"     # a,b,c (склеено)
echo "${arr[@]}"     # a b c (отдельные слова)

3. Indexed array создаётся при первом присваивании

# Это ОК (создаёт indexed array):
arr[0]="zero"
arr[1]="one"

# Это создаст БЛОБ если до этого был declare -A arr:
declare -A arr
arr[0]="zero"
echo "${arr[0]}"   # zero (но это associative со строковым ключом "0", не indexed!)

4. Удаление элемента не сдвигает индексы

arr=(a b c d)
unset 'arr[1]'
echo "${arr[@]}"   # a c d
echo "${#arr[@]}"  # 3 (длина)
echo "${!arr[@]}"  # 0 2 3 (пропуск!)

# Если нужно компактировать:
arr=("${arr[@]}")  # пересборка убирает пропуски
echo "${!arr[@]}"  # 0 1 2

Bash 5.3: новая фича GLOBSORT

В bash 5.3 (июль 2025) добавили опцию GLOBSORT — управление сортировкой при глоб-разворачивании в массивы:

# Bash 5.3+:
GLOBSORT=mtime
files=(*.csv)   # отсортированы по mtime (старые first)

GLOBSORT=numeric
files=(file*.csv)   # числовая сортировка (file2 < file10)

GLOBSORT=name   # default

Раньше нужно было городить find -printf '%T+ %p' | sort | cut. Теперь — встроено. Это удобно для DE-задач типа “обработать файлы в порядке их создания”.


  • Урок 01 (preamble) — почему IFS критичен для quoting "${arr[@]}".
  • Урок 03 (trap) — массивы для tracking временных ресурсов.
  • Урок 04 (getopts) — массивы для накопления списочных опций (например, --input file1 --input file2).
  • Модуль 07 (sed/awk) — awk-эквивалент группировки/дедупа часто быстрее.

Попробуй сам

  1. Создай массив дней недели, выведи длину, выведи все элементы.
days=(пн вт ср чт пт сб вс)
echo "${#days[@]}"
printf '%s\n' "${days[@]}"
  1. Прочитай содержимое /etc/passwd в массив через mapfile. Выведи первые 5 строк.
mapfile -t users < /etc/passwd
printf '%s\n' "${users[@]:0:5}"
  1. Посчитай распределение shells в /etc/passwd через associative array:
declare -A shells
while IFS=: read -r _ _ _ _ _ _ shell; do
    shells[$shell]=$(( ${shells[$shell]:-0} + 1 ))
done < /etc/passwd

for s in "${!shells[@]}"; do
    echo "$s: ${shells[$s]}"
done | sort -t: -k2 -rn
  1. Создай lookup table env -> backup-path, попробуй с тремя env и с несуществующим.

  2. (bash 5.3+) Попробуй GLOBSORT=mtime; arr=(/tmp/*) и проверь порядок через printf.


Проверка знанийKnowledge check
Junior пишет скрипт для подсчёта error-уровней в логе (DEBUG/INFO/WARN/ERROR/FATAL). У него получился цикл `for line in $(cat /var/log/app.log); do levels[$(echo $line | cut -d' ' -f3)]++; done`. Что не так и как написать правильно?
ОтветAnswer
Проблемы: 1) for line in \$(cat file) — word-splits КАЖДОЕ слово как отдельный элемент (не строку!). Скрипт обрабатывает не строки, а отдельные слова из лога. 2) Без declare -A levels bash считает levels indexed-массивом, и ключи 'DEBUG'/'INFO' интерпретируются как арифметические выражения (всегда 0). Все счётчики попадут в levels[0]. 3) levels[\$key]++ без инициализации с set -u упадёт. 4) cat бесполезен (UUOC). Правильный вариант: bash\nset -euo pipefail\ndeclare -A levels\nwhile IFS= read -r line; do\n level=\$(awk '{print \$3}' <<< \"\$line\")\n levels[\$level]=\$(( \${levels[\$level]:-0} + 1 ))\ndone < /var/log/app.log\nfor lvl in \"\${!levels[@]}\"; do\n echo \"\$lvl: \${levels[\$lvl]}\"\ndone\n Ключевые исправления: while read для построчного чтения, declare -A для нормальной hash-map, \${var:-0} для инициализации счётчика, кавычки везде. И для real production — awk-вариант awk '{counts[\$3]++} END {for(k in counts) print k, counts[k]}' /var/log/app.log будет в 100 раз быстрее.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. В чём разница между `"${arr[@]}"` и `"${arr[*]}"`?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5