Ветвления, циклы, функции и массивы
Тема 13 показала, как устроен скрипт: переменные, подстановки, кавычки и аргументы командной строки. Скрипт, который только выполняет команды одну за другой, мало чем отличается от списка команд, записанного в файл. Настоящая автоматизация начинается тогда, когда программа принимает решения и повторяет действия: проверяет, существует ли файл, перебирает все файлы каталога, читает журнал построчно, выбирает ветку обработки по расширению. Этими средствами управления ходом выполнения и посвящена настоящая тема.
В Bash управляющие конструкции отличаются от привычных языков программирования одной важной особенностью. Условие в if — это не логическое выражение, а команда: оболочка запускает её и смотрит на код возврата. Команда test, квадратные скобки и (( )) — лишь удобные способы записать проверку, которая сама тоже является командой или конструкцией, вычисляющей истину или ложь. Поэтому сначала мы рассмотрим код возврата, затем условные конструкции и проверки, затем циклы, функции и массивы. В последнем разделе разберём подоболочки — тонкое место, из-за которого переменная, изменённая внутри конвейера, оказывается «потерянной».
Все примеры в теме выполнены в Bash 5.1 под Ubuntu, вывод приведён без изменений. Путь к домашнему каталогу сокращён до ~, а имя пользователя заменено на student.
Условия и код возврата
Оператор if и код возврата команд
Каждая команда по завершении сообщает оболочке число — код возврата (англ. exit status). Значение 0 означает успех, любое ненулевое значение — неудачу; в тексте оно доступно через специальную переменную $? 1. Это соглашение заметно отличается от привычного «истина и ложь»: нуль здесь означает «всё хорошо». Запомнить это удобно так: код возврата — это ответ программы на вопрос «получилось ли?».
Конструкция if выполняет команду, стоящую после слова if, и выбирает ветку по её коду возврата. Если код равен 0, выполняется ветка then; иначе — ветка else, если она есть. Сама проверка может быть любой командой, в том числе утилитой поиска. Рассмотрим журнал приложения и проверим, есть ли в нём строки с ошибками:
printf 'INFO: запуск\nERROR: диск заполнен\n' > app.log
if grep -q "ERROR" app.log; then
echo "найдена ошибка"
else
echo "ошибок нет"
fi
найдена ошибка
Опция -q подавляет вывод grep: нас интересует только код возврата, а не строки. grep возвращает 0, если нашёл совпадение, 1 — если не нашёл, 2 — при ошибке. Проверим это напрямую, выведя $? сразу после вызова:
grep -q "FATAL" app.log; echo "код возврата grep: $?"
grep -q "ERROR" app.log; echo "код возврата grep: $?"
код возврата grep: 1
код возврата grep: 0
Обратите внимание на порядок: $? содержит код последней выполненной команды, поэтому его нужно прочитать до любой следующей команды. Отрицание записывается восклицательным знаком: if ! grep -q "ERROR" app.log; then выполнит ветку, если ошибок нет. Конструкция elif добавляет промежуточные проверки, и в них так же можно использовать любую команду.
Важно понимать, что if не интересуется тем, что вывела команда, — только числом, которое она вернула. Поэтому проверка if ls /nonexistent ведёт себя так же, как проверка существования файла, но сообщение об ошибке ls при этом всё равно будет напечатано в поток ошибок. Чтобы скрыть сообщение, перенаправляют поток 2 в /dev/null, как было показано в теме 5.
Команды test, [ ], [[ ]] и (( ))
Для проверок, которые неудобно записать через grep, в Bash есть несколько способов. Исторически первым появилась внешняя команда test. Её вызов в форме [ выражение ] — синонимичная запись: открывающая квадратная скобка сама является командой, и закрывающая скобка требуется как последний аргумент. Поэтому между скобками и выражением обязательны пробелы 5. В Bash есть и встроенная версия test, и отдельная программа /usr/bin/[; встроенная выполняется быстрее и именно её использует интерпретатор при выполнении скрипта.
Конструкция [[ выражение ]] — ключевое слово Bash, а не команда. Оболочка разбирает её содержимое сама, поэтому внутри не нужны кавычки для защиты от разбиения на слова, а операторы <, >, =~ и сопоставление с шаблоном распознаются правильно. Конструкция (( выражение )) предназначена только для целочисленной арифметики: внутри можно писать >, <=, == и % без экранирования, а результат выражения ненулевого значения даёт код возврата 0 3. Эти четыре формы сведены в таблицу.
| Форма | Тип | Сравнение строк | Сравнение чисел | Шаблоны и регулярные выражения | Особенности |
|---|---|---|---|---|---|
test выр | встроенная команда | =, != | -eq, -lt, -gt и т. д. | нет | переносима в POSIX sh |
[ выр ] | синоним test | =, != | -eq, -lt, -gt и т. д. | нет | < и > — перенаправления |
[[ выр ]] | ключевое слово Bash | =, !=, <, > (по алфавиту) | -eq, -lt, -gt и т. д. | == с шаблоном, =~ с ERE | не разбивает слова; только Bash и ksh |
(( выр )) | арифметика Bash | — | ==, <, >, % и т. д. | нет | переменные без $; только целые |
Сравнение четырёх форм и их поведения с пустой переменной показано на рисунке.
Проверим, как работают эти формы на практике. Сначала поведение test и [ ] при обычных файлах:
test -f app.log; echo "test -f: $?"
[ -d app.log ]; echo "[ -d ]: $?"
[[ -s app.log ]]; echo "[[ -s ]]: $?"
(( 7 > 3 )); echo "(( 7 > 3 )): $?"
test -f: 0
[ -d ]: 1
[[ -s ]]: 0
(( 7 > 3 )): 0
Каталог app.log не существует, поэтому проверка -d вернула 1. Файл непустой, поэтому -s вернул 0. Арифметическое сравнение 7 > 3 истинно, и код возврата 0.
Теперь ловушка, с которой сталкиваются почти все новички. Переменная пуста, и в формате [ ] она подставляется без кавычек:
x=""
[ $x = "y" ]; echo "[ \$x = y ] с пустым x: $?"
[[ $x = "y" ]]; echo "[[ \$x = y ]] с пустым x: $?"
[ "$x" = "y" ]; echo "[ \"\$x\" = y ]: $?"
bash: [: =: unary operator expected
[ $x = y ] с пустым x: 2
[[ $x = y ]] с пустым x: 1
[ "$x" = y ]: 1
Оболочка подставила пустую строку, и команда test получила два аргумента: = и y. Для неё это выглядит как одноместная проверка с оператором =, которого не существует, поэтому она завершилась с ошибкой и кодом 2. В [[ ]] подстановка не разбивает слова, так что пустая строка сохраняется как операнд, и сравнение корректно даёт 1. Самое надёжное правило: в [ ] всегда пишите переменные в кавычках 4.
Особо опасна запись < внутри [ ]. Здесь < — не оператор сравнения, а перенаправление ввода, и оболочка попытается открыть файл:
[ "a" < "b" ]; echo "код: $?"
ls -1
[[ "a" < "b" ]]; echo "[[ a < b ]]: $?"
rm -f b
bash: b: No such file or directory
код: 1
app.log
[[ a < b ]]: 0
Команда [ получила только аргумент "a", и проверка «строка не пустая» дала бы истину, но оболочка сначала пыталась открыть файл b и не смогла, поэтому вся команда завершилась с ошибкой. Вывод ls показывает, что файл b не был создан: при неудачном открытии никакого файла не появилось. Для сравнения строк в [ ] используйте ту же запись, но с экранированием, либо, что проще, [[ ]].
Сравнение строк и чисел тоже различается. В [[ ]] оператор > сравнивает строки по алфавиту, а не по числовому значению:
[ 10 -gt 9 ]; echo "[ 10 -gt 9 ]: $?"
[[ 10 > 9 ]]; echo "[[ 10 > 9 ]] строковое: $?"
[[ 10 > 2 ]]; echo "10 > 2 как строка: $?"
(( 10 > 2 )); echo "(( 10 > 2 )): $?"
[ 10 -gt 9 ]: 0
[[ 10 > 9 ]] строковое: 1
10 > 2 как строка: 1
(( 10 > 2 )): 0
Строка «10» идёт перед «9» по алфавиту, поскольку символ 1 меньше 9, поэтому [[ 10 > 9 ]] ложно. Правило простое: для чисел используйте -eq, -lt и т. д. в [ ] или (( )), а для строк — [[ ]] с операторами сравнения строк.
Проверки файлов, строк и чисел
Большинство практических условий сводится к трём группам проверок. Ниже перечислены основные из них. Все они работают как в [ ], так и в [[ ]]; для чисел дополнительно доступна (( )).
| Проверка | Истина, если | Пример |
|---|---|---|
-e путь | путь существует (любого типа) | [ -e ~/lab ] |
-f путь | существует обычный файл | [ -f app.log ] |
-d путь | существует каталог | [ -d ~/lab ] |
-L путь | существует символическая ссылка | [ -L current ] |
-s путь | файл существует и не пуст | [ -s app.log ] |
-r, -w, -x путь | есть право чтения, записи, выполнения | [ -x script.sh ] |
-z строка | строка пуста | [ -z "$name" ] |
-n строка | строка не пуста | [ -n "$name" ] |
a = b, a != b | строки равны или различны | [[ $ext == txt ]] |
a -eq b, -ne | числа равны или различны | [ "$n" -eq 0 ] |
a -lt b, -le, -gt, -ge | числа меньше, не больше, больше, не меньше | [ "$n" -ge 10 ] |
Для файлов важно помнить, что проверка существования и проверка доступа — разные вещи. Файл может существовать, но быть недоступным для записи. Типичный скрипт проверяет входной файл и каталог назначения до начала работы:
mkdir -p out
for path in app.log out missing.txt; do
if [ -f "$path" ]; then
echo "$path: обычный файл"
elif [ -d "$path" ]; then
echo "$path: каталог"
else
echo "$path: нет такого файла"
fi
done
app.log: обычный файл
out: каталог
missing.txt: нет такого файла
Проверка числа аргументов — частый первый шаг в скрипте. Аргумент $# содержит их количество, и его удобно сравнивать арифметически:
if (( $# < 1 )); then
echo "использование: $0 ФАЙЛ" >&2
exit 2
fi
Здесь $0 — имя самого скрипта, а сообщение выводится в поток ошибок, как было описано в теме 5. Код выхода 2 — соглашение для ошибок использования, его стоит запомнить.
Оператор case и шаблоны
Когда ветвлений много и все они проверяют одно значение, удобнее case. Значение сравнивается с шаблонами по порядку, и выполняется первая подходящая ветка, которая заканчивается ;;. Шаблоны — те же шаблоны глоббинга, что и в теме 2: * подходит к любой строке, ? — к одному символу, квадратные скобки задают класс символов, а | объединяет варианты:
for f in a.jpg b.PNG c.tar.gz d.txt e.md README; do
case "$f" in
*.jpg|*.png|*.PNG) t=image ;;
*.tar.gz|*.zip) t=archive ;;
*.txt|*.md) t=text ;;
*.*) t=other ;;
*) t=no-extension ;;
esac
printf '%-10s %s\n' "$f" "$t"
done
a.jpg image
b.PNG image
c.tar.gz archive
d.txt text
e.md text
README no-extension
Порядок ветвей важен: *.* стоит после более точных вариантов, иначе c.tar.gz попал бы в категорию «другое». Ветка *) — запасная, она срабатывает, если ни один шаблон не подошёл. Заметим, что case сравнивает строку с шаблоном, но не требует, чтобы строка была именем файла: его можно использовать для любых значений, например для аргументов командной строки. Ключи case удобно выносить в переменные в кавычках, как в примере выше.
Регулярные выражения: оператор =~ и BASH_REMATCH
Оператор =~ внутри [[ ]] сравнивает строку с расширенным регулярным выражением (ERE) и возвращает 0 при совпадении. Регулярное выражение справа от оператора не берут в кавычки: часть в кавычках сравнивается буквально. Части выражения в круглых скобках образуют группы, и после успешного сравнения оболочка записывает найденный текст в массив BASH_REMATCH: элемент 0 содержит всё совпадение, элементы 1, 2 и далее — группы по порядку.
ip="192.168.0.17"
if [[ $ip =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "совпало: ${BASH_REMATCH[0]}"
echo "первый октет: ${BASH_REMATCH[1]}, последний: ${BASH_REMATCH[4]}"
fi
совпало: 192.168.0.17
первый октет: 192, последний: 17
Длинное выражение удобно хранить в переменной и подставлять без кавычек: так не приходится экранировать пробелы и скобки внутри [[ ]].
re='^[a-z]+\.(txt|md)$'
for f in notes.txt "my notes.txt" README.md; do
if [[ $f =~ $re ]]; then echo "$f: расширение ${BASH_REMATCH[1]}"; else echo "$f: не подходит"; fi
done
notes.txt: расширение txt
my notes.txt: не подходит
README.md: не подходит
Имя с пробелом не подошло, потому что пробела нет в классе [a-z], а README.md отвергнут из-за заглавных букв. Регулярное выражение проверяет только форму строки. Ограничения на значения, например «число не больше 255», проверяют отдельно арифметикой над элементами BASH_REMATCH.
Меню на select
Конструкция select строит простое текстовое меню. Она печатает пронумерованный список слов в поток ошибок, выводит приглашение из переменной PS3 и читает строку ввода. Введённый текст попадает в переменную REPLY, а если номер верен, переменная цикла получает соответствующее слово. Цикл повторяется, пока не выполнится break или не закончится ввод. Выбор удобно обрабатывать оператором case по $REPLY:
cat > menu.sh <<'MDEND'
#!/usr/bin/env bash
PS3="выберите пункт: "
select item in "дата" "каталог" "выход"; do
case $REPLY in
1) date +%Y ;;
2) pwd ;;
3) break ;;
*) echo "нет такого пункта: $REPLY" ;;
esac
done
echo "выбрано последним: ${item:-ничего}"
MDEND
printf '1\n7\n3\n' | bash menu.sh 2>&1
1) дата
2) каталог
3) выход
выберите пункт: 2026
выберите пункт: нет такого пункта: 7
выберите пункт: выбрано последним: выход
Ответы поданы через конвейер, поэтому они не видны в выводе, и результат команды печатается сразу после приглашения. Список пунктов выведен один раз: select повторяет его только тогда, когда пользователь вводит пустую строку, а после каждого ответа печатает лишь приглашение PS3. Неверный номер 7 оставляет переменную item пустой, но REPLY сохраняет введённый текст, и ветка *) сообщает об ошибке.
Циклы
Цикл for по списку и по файлам
Цикл for перебирает слова заданного списка. Если список состоит из шаблонов, оболочка раскрывает их до выполнения цикла, то есть глоббинг работает так же, как при вводе команды. Поэтому для обхода файлов используют шаблон, а не вывод ls. Сравним две записи на каталоге с файлом, в имени которого есть пробел:
touch "report one.txt" notes.txt photo.jpg
for f in *.txt; do echo "[$f]"; done
for f in $(ls *.txt); do echo "[$f]"; done
[notes.txt]
[report one.txt]
[notes.txt]
[report]
[one.txt]
Первый вариант правильно обработал файл с пробелом, потому что шаблон раскрылся в два слова, одно из которых содержит пробел, и кавычки в шаблоне не нужны. Во втором варианте ls вывел имя файла строкой, а подстановка команды разбила её по пробелам, как и любой результат подстановки без кавычек. В результате цикл обработал два несуществующих файла: report и one.txt. Правило запоминается легко: имена файлов получайте через глоббинг и всегда заключайте их в кавычки при использовании.
Если шаблон ничего не нашёл, поведение по умолчанию неудобно: цикл выполнится один раз с буквальным текстом шаблона. Опция nullglob меняет это поведение, и тогда пустой шаблон даёт пустой список:
for f in *.pdf; do echo "[$f]"; done
shopt -s nullglob
for f in *.pdf; do echo "pdf: $f"; done
echo "конец"
shopt -u nullglob
[*.pdf]
конец
Без nullglob цикл выполнился с буквальной строкой *.pdf, и её «обработка» могла бы привести к ошибке. В скрипте, который работает с файлами, shopt -s nullglob почти всегда нужна. Вывод цикла после включения опции пуст, поскольку PDF-файлов в каталоге нет. Заметим, что в примере команда shopt -u возвращает опцию в исходное состояние.
Шаблон * по умолчанию не включает скрытые файлы, имена которых начинаются с точки. Опция dotglob добавляет их в результат глоббинга. Проверим это в отдельном каталоге:
mkdir -p dotdemo && cd dotdemo
touch visible.txt .hidden.txt
for f in *.txt; do echo "без dotglob: $f"; done
shopt -s dotglob
for f in *.txt; do echo "с dotglob: $f"; done
shopt -u dotglob
cd ..
без dotglob: visible.txt
с dotglob: .hidden.txt
с dotglob: visible.txt
Опция пригодится в лабораторной работе, где скрытый файл тоже нужно разложить по каталогам.
Цикл в стиле языка C задаётся конструкцией for (( )) с тремя выражениями: инициализацией, условием и шагом. Здесь переменные пишутся без $, как и в (( )):
for (( i = 0; i < 3; i++ )); do printf 'i=%d ' "$i"; done
echo
i=0 i=1 i=2
Такой цикл удобен для счёта, когда нужны индексы, например для обхода массива по позициям. Для простого перебора файлов и слов он не нужен.
Чтение файла построчно: while IFS= read -r
Чтение файла построчно — одна из самых частых задач скрипта 2. Стандартный приём выглядит так: while read читает строку из потока ввода, и цикл продолжается, пока команда read возвращает 0. Но у read есть два подводных камня, и без их учёта данные искажаются. Создадим файл с пробелами в начале строки, обратной косой чертой и строкой без перевода в конце:
printf ' первая строка \nвторая\\нет\nthird\\\nfourth\n' > text.txt
cat -E text.txt
первая строка $
вторая\нет$
third\$
fourth$
Опция cat -E отмечает конец каждой строки знаком $, поэтому видны пробелы в конце первой строки и обратная косая черта в конце третьей. Сначала прочитаем файл командой без -r и без IFS=:
while read line; do echo "<$line>"; done < text.txt
<первая строка>
<втораянет>
<thirdfourth>
Здесь проявились два эффекта. Во-первых, read без IFS= разбивает строку по символам из переменной IFS и удаляет пробелы в начале и в конце, поэтому отступ первой строки пропал. Во-вторых, без -r обратная косая черта работает как символ экранирования: пара \ и перевода строки означает продолжение строки, и две строки склеились в одну. Обратный слэш перед «н» исчез, а перевод строки после third\ тоже был съеден. Файл читается неправильно.
Теперь добавим -r, который отключает экранирование, и оставим IFS по умолчанию:
while read -r line; do echo "<$line>"; done < text.txt
<первая строка>
<вторая\нет>
<third\>
<fourth>
Обратный слэш сохранился, склейки строк больше нет, но пробелы в начале первой строки по-прежнему удалены. Наконец, полная запись:
while IFS= read -r line; do echo "<$line>"; done < text.txt
< первая строка >
<вторая\нет>
<third\>
<fourth>
Теперь строки совпадают с исходным файлом. Оба элемента важны: IFS= сохраняет пробелы, а -r сохраняет обратную косую черту. Вторая ловушка связана с концом файла. Если последняя строка не заканчивается переводом строки, команда read возвращает ненулевой код, и цикл эту строку не выполняет:
printf 'a\nb' > nonl.txt
while IFS= read -r line; do echo "<$line>"; done < nonl.txt
echo "конец"
<a>
конец
Строка b потеряна, хотя она есть в файле. Решение — дополнительная проверка на непустой остаток: while IFS= read -r line || [[ -n $line ]]; do. Это условие выполнит тело цикла и для последней строки без перевода. Заметим, что правильная запись цикла одновременно решает проблему пробелов, обратных косых черт и последней строки, и её стоит запомнить как готовый шаблон.
Цикл while удобно использовать и с конструкцией until, которая выполняется, пока условие ложно. Рассмотрим until с арифметическим условием:
n=0
until (( n >= 3 )); do n=$(( n + 1 )); echo "n=$n"; done
n=1
n=2
n=3
Выбор между while и until — вопрос читаемости: until удобнее, когда условие выражает «делать, пока не достигнуто».
Команды break и continue
Команда break прерывает ближайший цикл, а continue переходит к следующей итерации. Оба принимают необязательное число — сколько уровней вложенности затронуть. Пример перебирает числа от 1 до 6, пропускает чётные и останавливается, когда число превышает 4:
for i in 1 2 3 4 5 6; do
(( i % 2 == 0 )) && continue
(( i > 4 )) && break
echo "нечётное $i"
done
нечётное 1
нечётное 3
Конструкция (( условие )) && continue — частый приём: && выполняет вторую команду только при коде возврата 0, то есть при истинном условии. Заметим, что до числа 6 цикл не дошёл вовсе: на числе 5 сработала команда break.
Функции
Объявление функций, аргументы и local
Функция — именованный блок команд, который можно вызывать по имени, как внешнюю программу. Объявление записывается как имя() { команды; }. Внутри функции аргументы доступны через $1, $2 и т. д., как и аргументы скрипта, а $# и $@ работают так же. Аргументы функции не связаны с аргументами скрипта, и эти два набора не смешиваются.
Главная особенность переменных в функции: по умолчанию они глобальные. Они видны во всём скрипте и могут случайно перезаписать переменную вызывающего кода. Проверим на примере:
g=global
f1() { g=changed; l=inside; echo "в функции: l=$l g=$g"; }
f1
echo "после: g=$g l=[${l:-пусто}]"
в функции: l=inside g=changed
после: g=changed l=[inside]
Переменная g изменилась снаружи функции, и переменная l тоже осталась глобальной. Чтобы ограничить переменную телом функции, её объявляют командой local. Перед проверкой удалим глобальную l, оставшуюся от предыдущего примера:
unset l
g=global
f2() { g=changed; local l=inside; echo "в функции: l=$l g=$g"; }
f2
echo "после: g=$g l=[${l:-пусто}]"
в функции: l=inside g=changed
после: g=changed l=[пусто]
Переменная l исчезла после выхода из функции, а g изменилась: local защищает только то, что объявлено как локальное. Рекомендация простая: в каждой функции объявляйте локальными все промежуточные переменные, а аргументы копируйте в локальные имена в начале тела. Тогда функция не зависит от состояния вызывающего кода и её легко проверить отдельно 6.
Функции применяют тот же механизм проверок, что и внешние команды: if функция аргумент выбирает ветку по коду возврата функции. Значение параметра по умолчанию задаётся обычным раскрытием ${1:-значение} из темы 13:
greet() { local name=${1:-мир}; echo "привет, $name"; return 3; }
greet Анна
greet
привет, Анна
привет, мир
Эта функция завершилась командой return 3, которая задаёт код возврата функции. Проверим его:
greet Анна; echo "код=$?"
привет, Анна
код=3
Возврат значения: через вывод и через код возврата
У функции есть два канала для результата, и их нужно различать. Код возврата задаётся командой return и принимает значения от 0 до 255. Он подходит для ответа «да или нет» и для кодов ошибок, но не для данных. Вывод функции — это всё, что она печатает в стандартный поток вывода, и его можно перехватить подстановкой команды $(…). Для данных используется именно вывод, а код возврата — для статуса:
out=$(greet Анна); code=$?
echo "вывод=[$out] код=$code"
вывод=[привет, Анна] код=3
Подстановка $(…) выполнила функцию в подоболочке, перехватила её вывод и сохранила его в переменную. Код возврата при этом доступен через $? сразу после присваивания. Заметим, что значения, переданные через return, ограничены диапазоном 0–255: большее значение берётся по модулю 256, а отрицательное тоже приводится к этому диапазону: −1 превращается в 255, −200 — в 56. Причина в том, что код завершения в Unix хранится в одном байте.
r() { return "$1"; }
r 256; echo "256 -> $?"
r -1; echo "-1 -> $?"
256 -> 0
-1 -> 255
Функции, предназначенные для проверки условия, удобно использовать прямо в if. Пример — проверка чётности:
is_even() { (( $1 % 2 == 0 )); }
is_even 4 && echo "4 чётное"
is_even 5 || echo "5 нечётное"
4 чётное
5 нечётное
Здесь функция возвращает результат арифметической проверки, а не текст. Такой стиль делает код короче и ближе к привычным предикатам из других языков.
Массивы
Индексные массивы
Переменная обычного вида хранит одну строку. Массив хранит набор элементов с номерами, которые начинаются с 0. Массив создаётся присваиванием списка в круглых скобках. Главная трудность — раскрытие: элементы массива часто содержат пробелы, и правильная запись определяет, сохранится ли структура. Создадим массив, один из элементов которого содержит пробелы:
arr=(alpha "two words" gamma)
echo "размер: ${#arr[@]}"
размер: 3
Конструкция ${#arr[@]} возвращает число элементов, а ${arr[@]} — все элементы. Чтобы каждый элемент остался отдельным словом, нужны двойные кавычки: "${arr[@]}". Сравним три записи:
for x in "${arr[@]}"; do echo "<$x>"; done
for x in ${arr[@]}; do echo "<$x>"; done
printf '<%s>\n' "${arr[*]}"
<alpha>
<two words>
<gamma>
<alpha>
<two>
<words>
<gamma>
<alpha two words gamma>
В первом варианте элементы сохранились, во втором без кавычек слово «two words» разбилось на два элемента, как и любой результат подстановки без кавычек. Запись "${arr[*]}" склеивает все элементы в одну строку через первый символ IFS — пробел. Правило: для обхода элементов используйте "${arr[@]}", а ${arr[*]} — только для вывода одной строкой.
Индексы массива доступны через ${!arr[@]}. Добавление элемента выполняется записью arr+=(значение), а удаление — командой unset. Важно, что после удаления индексы не переупаковываются: номер удалённого элемента остаётся пустым.
arr+=(delta)
echo "после добавления: ${arr[3]} всего ${#arr[@]}"
unset 'arr[1]'
echo "после unset: индексы ${!arr[@]} значения: ${arr[@]}"
declare -p arr
после добавления: delta всего 4
после unset: индексы 0 2 3 значения: alpha gamma delta
declare -a arr=([0]="alpha" [2]="gamma" [3]="delta")
Аргумент unset взят в одинарные кавычки, чтобы оболочка не раскрывала arr[1] как шаблон глоббинга. Команда declare -p показывает внутреннее представление переменной, и это удобный способ проверить, что именно хранится в массиве. Пустой индекс 1 в выводе отсутствует, так что ${#arr[@]} даёт 3, а не 4. Если нужен плотный массив без пропусков, его пересоздают из списка.
Ассоциативные массивы
Массив, индексы которого — строки, называется ассоциативным. Он объявляется командой declare -A до первого использования. Ассоциативный массив идеален для подсчёта: ключом служит значение, а значением — счётчик. Посчитаем расширения файлов в списке:
declare -A cnt=()
for ext in txt jpg txt pdf txt; do
cnt[$ext]=$(( ${cnt[$ext]:-0} + 1 ))
done
for k in "${!cnt[@]}"; do echo "$k=${cnt[$k]}"; done | sort
echo "ключей: ${#cnt[@]}"
declare -p cnt
jpg=1
pdf=1
txt=3
ключей: 3
declare -A cnt=([pdf]="1" [jpg]="1" [txt]="3" )
Конструкция ${cnt[$ext]:-0} даёт 0 для ещё не встречавшегося ключа, что позволяет не проверять наличие элемента перед увеличением. Порядок ключей в ассоциативном массиве не определён: declare -p вывел pdf раньше txt, а цикл — в другом порядке. Если вывод должен быть упорядочен, его сортируют внешней утилитой sort, как в примере. Заметим, что ключи в ассоциативном массиве могут содержать пробелы и любые символы, что делает массив удобным для статистики по произвольным полям файла.
Подоболочки и потерянная переменная
Почему переменная теряется в конвейере
Одна из самых коварных ошибок в Bash появляется при обработке файла через конвейер. Скрипт считает ошибки в журнале, и результат всегда оказывается нулевым, хотя строки с ошибками печатаются. Воспроизведём её:
printf 'ERROR a\nINFO b\nERROR c\n' > log.txt
count=0
grep ERROR log.txt | while read -r line; do count=$((count+1)); done
echo "count=$count"
count=0
Причина в том, что каждая команда конвейера выполняется в отдельной подоболочке (англ. subshell). Оболочка создаёт копию себя для каждой части конвейера, как было показано в теме 5. Цикл while работает в своей копии, и его изменения переменной count остаются в ней. Когда конвейер завершается, копия уничтожается, а родительская оболочка видит прежнее значение 0.
Схема процессов для этого конвейера показана на рисунке.
Подоболочку можно наблюдать через переменную BASH_SUBSHELL, которая показывает уровень вложенности:
echo "$BASH_SUBSHELL"
( echo "в подоболочке: $BASH_SUBSHELL" )
echo x | { echo "в конвейере: $BASH_SUBSHELL"; }
0
в подоболочке: 1
в конвейере: 1
Фигурные скобки группируют команды, но в конвейере они тоже работают в подоболочке. Скобки ( ) создают подоболочку явно. Заметим, что простая группа { } без конвейера выполняется в текущей оболочке, поэтому присваивания в ней сохраняются.
Способы обхода: перенаправление, подстановка процессов, lastpipe
Существует три приёма, которые решают проблему. Первый и самый простой — отказаться от конвейера и перенаправить файл в цикл: while ... done < log.txt. Тогда цикл выполняется в текущей оболочке, и переменная сохраняется:
count=0
while read -r line; do count=$((count+1)); done < log.txt
echo "count=$count"
count=3
Второй приём — подстановка процессов < <(команда). Команда выполняется в фоне, а её вывод подаётся в перенаправление цикла как файл. Цикл остаётся в текущей оболочке, и при этом сохраняется фильтрация grep:
count=0
while read -r line; do count=$((count+1)); done < <(grep ERROR log.txt)
echo "count=$count"
count=2
Этот приём обычно самый удобный: он сохраняет конвейер в читаемой форме и не требует специальных опций. Третий приём — опция lastpipe, которая разрешает выполнять последнюю команду конвейера в текущей оболочке. Она работает только в скриптах, поскольку требует отключённого управления заданиями:
#!/usr/bin/env bash
shopt -s lastpipe
count=0
grep ERROR log.txt | while read -r line; do count=$((count+1)); done
echo "count=$count"
count=2
Опция lastpipe в интерактивной оболочке не действует, поскольку там включено управление заданиями. Проверим это явно, включив режим монитора set -m:
bash -c 'shopt -s lastpipe; set -m; c=0; echo x | while read l; do c=1; done; echo "c=$c"'
c=0
Поэтому в скриптах lastpipe удобна, а в интерактивной работе лучше обходиться < <(…). Выбор приёма зависит от задачи: если данные читаются из одного файла, перенаправление < file самое простое; если нужна фильтрация, используйте подстановку процессов.
Итоги темы
В этой теме управление ходом выполнения построено на одном принципе: решения принимаются по коду возврата команды. Оператор if выбирает ветку по результату любой программы, а проверки test, [ ], [[ ]] и (( )) записывают обычные условия в удобной форме. Таблица сравнения показывает, что [[ ]] и (( )) безопаснее [ ] — в них не разбиваются слова и правильно работают операторы сравнения. Оператор case выбирает ветку по шаблону, и его порядок ветвей важен. Циклы for и while обходят файлы и строки, при этом для файлов нужен глоббинг, а для чтения строк — while IFS= read -r, который сохраняет пробелы, обратные косые черты и последнюю строку.
Функции сохраняют локальность переменных через local и возвращают результат двумя способами: данные через вывод, статус — через код возврата. Массивы позволяют хранить списки и словари; их обход требует кавычек "${arr[@]}". Подоболочки, которые создаёт каждая часть конвейера, объясняют потерянную переменную и подсказывают три способа её сохранить. Следующая тема, 13, добавит к этим средствам надёжность: обработку ошибок, ловушки сигналов и отладку. Там же понадобятся циклы и функции из текущей темы, поскольку обработчики ошибок и очистка временных файлов сами пишутся как функции.
Литература
- Shotts W. The Linux Command Line: A Complete Introduction. 2nd ed. — No Starch Press, 2019. Открытая версия: https://linuxcommand.org/tlcl.php
- Robbins A., Beebe N. H. F. Classic Shell Scripting. — O'Reilly Media, 2005.
- Free Software Foundation. GNU Bash Reference Manual. — https://www.gnu.org/software/bash/manual/
- Wooledge G. Bash FAQ и Bash Pitfalls. — https://mywiki.wooledge.org/BashPitfalls
- The Open Group. The Open Group Base Specifications Issue 7 (IEEE Std 1003.1-2017), Shell Command Language. — https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html
- Google. Shell Style Guide. — https://google.github.io/styleguide/shellguide.html