Тема 13

Основы сценариев Bash

Основы сценариев Bash

Из предыдущих тем читатель знает командную строку как диалог: человек вводит команду, оболочка её выполняет и печатает результат. Для разового действия этого достаточно, но рутинные задачи — переименовать сотню файлов, проверить каталог проекта, собрать отчёт из журналов — требуют повторять одни и те же команды. Тема 8 показала, что оболочка сама читает файлы настройки и выполняет команды из них. Тема 5 ввела конвейеры и код возврата, а тема 2 — шаблоны имён файлов. Всё это составляет основу программирования на Bash. Сценарий (англ. script) — текстовый файл, в котором записана последовательность команд; оболочка выполняет её так же, как если бы команды были введены с клавиатуры. Вводное изложение командной строки и её возможностей есть в книге Shotts 1, которая служит общим ориентиром для курса.

Тема решает три задачи. Первая — понять, как оболочка превращает строку, написанную программистом, в список аргументов для программы. Эта часть вызывает больше всего ошибок новичков, и разбирать её удобнее всего на примерах с пробелами в именах файлов. Вторая — освоить переменные, раскрытие параметров, подстановку команд и арифметику, а также аргументы скрипта, ввод, вывод и код выхода. Третья — научиться писать сценарии, которые при ошибке останавливаются, а не продолжают работу с испорченными данными; для этого служит набор опций set -euo pipefail.

Изложение построено от простого к сложному. Сначала рассматривается скрипт как файл: shebang, права на выполнение и три способа запуска. Затем разбираются переменные и кавычки — главный источник ошибок. После этого идут раскрытие параметров и аргументы скрипта, а тема завершается строгим режимом оболочки. Условия и циклы намеренно отложены до темы 14: для понимания механизмов, которые действуют в любом сценарии, они не нужны.

Скрипт как файл

Shebang и права на выполнение

Сценарий Bash — обычный текстовый файл. Расширение .sh ничего не определяет: операционная система не смотрит на имя, а решает, чем выполнять файл, по его первым байтам. Если файл начинается с последовательности #! (англ. shebang), остаток первой строки считается путём к интерпретатору. Для сценариев Bash принято писать следующую строку:

#!/usr/bin/env bash

Здесь /usr/bin/env ищет bash в PATH, поэтому строка работает на системах, где bash установлен не в /bin/bash. Тема рассматривает именно bash, поэтому строка указывает на него, а не на sh.

Создадим первый сценарий в каталоге ~/demo11. Конструкция <<'MDEND' записывает несколько строк в файл (подробно её разбирает тема 5); здесь важно только то, что файл появился.

mkdir -p ~/demo11 && cd ~/demo11
cat > hello.sh <<'MDEND'
#!/usr/bin/env bash
echo "Привет, $USER! Сейчас в каталоге $(pwd)"
MDEND
./hello.sh
bash: ./hello.sh: Permission denied

Сообщение «Permission denied» означает, что у файла нет права на выполнение. Права видны в выводе ls -l, который подробно описан в темах 2 и 4.

ls -l hello.sh
-rw-r--r-- 1 student student 88 Oct 12 13:16 hello.sh

Первый символ строки — тип файла (- означает обычный файл), далее три группы по три символа: права владельца, группы и остальных. В rw- нет x, поэтому ядро отказывает в запуске. Добавим право на выполнение и повторим запуск.

chmod +x hello.sh
ls -l hello.sh
./hello.sh
-rwxr-xr-x 1 student student 88 Oct 12 13:16 hello.sh
Привет, student! Сейчас в каталоге /home/student/demo11

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

bash hello.sh
Привет, student! Сейчас в каталоге /home/student/demo11

Разница важна при отладке. Запуск bash hello.sh не зависит от shebang и прав, поэтому он удобен для проверки сценария, который ещё не сделан исполняемым. Запуск ./hello.sh проверяет, что сценарий работает так, как его будут вызывать пользователи. Строку shebang можно посмотреть командой head -n 1 hello.sh.

Три способа запуска

Сценарий запускается тремя способами: ./скрипт, bash скрипт и source скрипт (или эквивалентной точкой . скрипт). Первые два создают новый процесс оболочки, третий выполняет команды в текущей оболочке. Чтобы увидеть разницу, создадим сценарий, который меняет каталог и переменную.

cd ~/demo11
cat > where.sh <<'MDEND'
#!/usr/bin/env bash
cd /etc
GREETING=hello
echo "в скрипте: $(pwd), GREETING=$GREETING"
MDEND
chmod +x where.sh

Сценарий переходит в /etc и задаёт переменную GREETING. Проверим, что изменится в вызывающей оболочке после каждого способа запуска. Все шаги выполнены в одной сессии bash -c, чтобы состояние оболочки было видно между командами.

bash -c 'cd ~/demo11; echo "до: $(pwd), GREETING=${GREETING:-не задана}"; ./where.sh; echo "после ./where.sh: $(pwd), GREETING=${GREETING:-не задана}"; bash where.sh; echo "после bash: $(pwd), GREETING=${GREETING:-не задана}"; source where.sh; echo "после source: $(pwd), GREETING=$GREETING"'
до: /home/student/demo11, GREETING=не задана
в скрипте: /etc, GREETING=hello
после ./where.sh: /home/student/demo11, GREETING=не задана
в скрипте: /etc, GREETING=hello
после bash: /home/student/demo11, GREETING=не задана
в скрипте: /etc, GREETING=hello
после source: /etc, GREETING=hello

Результат объясняется моделью процессов, которую подробно рассматривает тема 7. Когда оболочка запускает ./where.sh или bash where.sh, она создаёт дочерний процесс — ещё один экземпляр bash. Дочерний процесс получает копию окружения, работает со своим текущим каталогом и своими переменными, а по завершении уничтожается. Его изменения остаются только в нём: родительская оболочка их не видит. Поэтому после ./where.sh каталог и переменная в родительской оболочке прежние.

Команда source читает файл и выполняет его строки в самой текущей оболочке, без создания дочернего процесса. Поэтому cd /etc и присваивание GREETING сохраняются после выполнения. Из этого следует практическое правило. Обычные сценарии запускаются через ./ или bash: они изолированы и не портят окружение. Команда source нужна для файлов настройки, которые должны менять окружение текущей сессии (тема 8), и для библиотек функций, подключаемых в начале другого сценария. Если подключаемый файл содержит команду exit, она закроет интерактивную сессию, поэтому такой файл нельзя подключать через source без необходимости.

На рисунке показано, какая оболочка выполняет команды в каждом из трёх случаев: дочерний процесс для ./ и bash, текущая оболочка для source.

Процесс, в котором выполняется сценарий, при запуске через ./ и bash и при source
Рисунок 1. Дочерний процесс против текущей оболочки

Присваивание и его ошибки

Переменная в Bash создаётся присваиванием вида имя=значение. Главное правило синтаксиса: вокруг знака = не должно быть пробелов. Проверим это на практике.

bash -c 'x=1; echo "$x"; x = 1; echo "код $?"'
1
bash: line 1: x: command not found
код 127

Строка x = 1 для оболочки — не присваивание. Это команда с именем x и двумя аргументами = и 1. Такой команды нет, поэтому оболочка выводит сообщение «command not found» и возвращает код 127. Тот же эффект возникает в сценарии:

printf 'x = 1\necho "дальше"\n' > assign.sh
bash assign.sh
echo "код $?"
assign.sh: line 1: x: command not found
дальше
код 0

Вторая строка вывода показывает, что сценарий продолжил работу после ошибки. Оболочка не останавливается на первой ошибке по умолчанию, а код выхода всего сценария равен коду последней команды (здесь echo вернул 0). Эта особенность станет важна в разделе о строгом режиме. Сообщение об ошибке пробела легко пропустить среди других строк, поэтому проверять сценарий полезно не только по коду возврата, но и по тексту вывода.

Пустое значение задаётся присваиванием без правой части: y=. Переменная при этом существует и пуста.

bash -c 'y= ; echo "[$y]"'
[]

Скобки в команде echo показывают границы пустой строки: между ними ничего нет.

Переменные, кавычки и раскрытие командной строки

Переменные и подстановка

Переменная оболочки — именованная ячейка памяти, значение которой читается через знак доллара: $имя. Фигурные скобки ${имя} нужны там, где за именем идёт текст, не входящий в имя: ${file}_backup отличается от $file_backup, которое читает переменную file_backup. Фигурные скобки рекомендуется писать всегда, когда имя окружено текстом.

Переменные бывают обычными и экспортируемыми. Обычная переменная видна только в текущей оболочке и не передаётся дочерним процессам. Экспортируемая переменная (команда export, тема 8) попадает в окружение и становится доступной программам, которые запускаются из оболочки. В сценариях чаще используются обычные переменные: они не загрязняют окружение и не перекрывают чужие настройки.

Имена переменных состоят из латинских букв, цифр и подчёркивания, не начинаются с цифры и различаются по регистру: Name и name — разные переменные. Значения могут содержать любые символы, в том числе кириллицу и пробелы, но пробелы в значении требуют кавычек при присваивании: name="Отчёт за май", а не name=Отчёт за май, где вторая часть строки становится отдельной командой.

Специальные переменные оболочки, которые понадобятся в теме, перечислены ниже. Аргументы скрипта ($0, $1, $#) рассмотрены в отдельном разделе.

  • $? — код возврата последней команды: 0 означает успех, остальные значения — ошибку;
  • $HOME — домашний каталог пользователя;
  • $USER — имя текущего пользователя;
  • $PWD — текущий каталог;
  • $$ — номер процесса оболочки (PID).

Переменная $? применяется в разделе о строгом режиме, где проверяется поведение при ошибках.

Кавычки и этапы раскрытия

Кавычки — главный инструмент надёжного Bash и главный источник ошибок. Чтобы понять, почему кавычки нужны, необходимо знать, что оболочка делает со строкой до запуска программы. Этот процесс называется раскрытием командной строки (англ. expansion). Он состоит из нескольких этапов, выполняемых в фиксированном порядке. По руководству GNU Bash 2 и стандарту POSIX 3 этапы таковы:

  1. раскрытие параметров, подстановка команд и арифметическое раскрытие: $f, $(...), $((...)) заменяются значениями;
  2. разделение на слова: результат подстановки, не заключённый в кавычки, делится по символам из переменной IFS (по умолчанию пробел, табуляция и перевод строки);
  3. глоббинг: слова, содержащие *, ? и классы символов, заменяются именами подходящих файлов;
  4. удаление кавычек: кавычки, которые управляли предыдущими этапами, удаляются, и программа получает чистые слова.

Порядок важен. Разделение на слова происходит после подстановки переменной, поэтому значение с пробелом превращается в два слова, даже если в исходной строке кавычек не было. Глоббинг происходит после разделения, поэтому результат подстановки может быть разбит на несколько шаблонов. Кавычки отключают соответствующие этапы. Проверим это на файле с пробелом в имени.

cd ~/demo11
printf 'содержимое\n' > "my file.txt"
f="my file.txt"
cat $f
cat: my: No such file or directory
cat: file.txt: No such file or directory

Программа cat получила два аргумента — my и file.txt — и не нашла ни одного из них. Строка my file.txt была разбита на этапе 2. Сравним с версией в кавычках:

cat "$f"
содержимое

Двойные кавычки сохранили значение целиком, и cat получил одно слово — имя файла. Удобно видеть итоговый набор слов с помощью printf, которая печатает каждый аргумент по формату [%s]; квадратные скобки здесь принадлежат формату, а не синтаксису сценария.

printf '[%s]\n' $f
printf '[%s]\n' "$f"
printf '[%s]\n' '$f'
[my]
[file.txt]
[my file.txt]
[$f]

Третья строка показывает, что одинарные кавычки запрещают раскрытие вообще: '$f' — буквальная строка $f. Двойные кавычки разрешают раскрытие параметров и подстановку команд, но запрещают разделение на слова и глоббинг. Одинарные кавычки запрещают всё.

Глоббинг заслуживает отдельного наблюдения. В каталоге два файла с расширением .txt, один из которых имеет пробел в имени. Шаблон раскрывается оболочкой до запуска программы, поэтому имя с пробелом не разбивается: результат глоббинга уже является отдельными словами.

printf 'a.txt\n' > a.txt
printf '[%s]\n' *.txt
printf '[%s]\n' "*.txt"
[a.txt]
[my file.txt]
[*.txt]

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

Чтобы увидеть этапы раскрытия «изнутри», удобно включить режим трассировки set -x (англ. xtrace). Оболочка печатает команду уже после всех раскрытий, перед её выполнением, с префиксом +.

bash -c 'f="my file.txt"; set -x; cat $f; cat "$f"; set +x' 2>&1
+ cat my file.txt
cat: my: No such file or directory
cat: file.txt: No such file or directory
+ cat 'my file.txt'
содержимое
+ set +x

Трассировка показывает разницу без догадок: в первом случае cat получила два слова, во втором — одно слово в кавычках (его печать в трассировке заключена в одинарные кавычки только для того, чтобы показать пробел). Этапы раскрытия удобно представлять схемой: на рисунке показано, как строка cat $f превращается в список аргументов программы.

Этапы раскрытия командной строки для команды cat с переменной, содержащей пробел
Рисунок 2. Этапы раскрытия командной строки

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

Подстановка команд и арифметика

Подстановка команд (англ. command substitution) записывается как $(команда) и заменяется выводом команды без завершающих переводов строк. Вложенные подстановки допустимы. Результат подстановки — обычная строка, которую оболочка подвергает всем этапам раскрытия, поэтому $(...) тоже нуждается в кавычках, если результат может содержать пробелы.

bash -c 'cd ~/demo11; echo "Сейчас $(date +%Y) год, файлов в каталоге: $(ls | wc -l)"'
Сейчас 2026 год, файлов в каталоге: 5

Арифметическое раскрытие (англ. arithmetic expansion) записывается как $(( выражение )). Внутри можно использовать переменные без знака доллара; допустимы сложение, вычитание, умножение, целочисленное деление и остаток от деления.

bash -c 'x=5; echo $(( x * x + 1 )) $(( 17 / 5 )) $(( 17 % 5 )); echo "$(( 7 * 6 ))"'
26 3 2
42

Арифметика в Bash целочисленная: 17 / 5 даёт 3, дробная часть отбрасывается. Для дробных чисел нужна другая программа, например awk (тема 6) или bc. Сравнения чисел и строк в арифметическом раскрытии не используются: для условий служат конструкции, которые появятся в теме 14.

Раскрытие параметров

Таблица форм

Раскрытие параметров (англ. parameter expansion) — набор записей вида ${имя оператор аргумент}, которые позволяют получить значение по умолчанию, длину строки, фрагмент пути или результат замены, не вызывая внешних программ. Формы делятся на три группы: подстановка значения при отсутствии или пустоте переменной, работа с шаблонами у начала и конца строки, замена подстроки и изменение регистра. Они заметно экономят время там, где иначе понадобились бы sed или basename, и хорошо читаются в сценариях.

ЗаписьНазначение
${v:-x}значение v, а если v не задана или пуста — x (сама переменная не меняется)
${v:=x}то же, но переменной присваивается x
${v:+x}x, если v задана и не пуста, иначе пустая строка
${#v}длина значения в символах
${v#p}значение без кратчайшего префикса, подходящего под шаблон p
${v##p}значение без самого длинного префикса, подходящего под p
${v%p}значение без кратчайшего суффикса, подходящего под p
${v%%p}значение без самого длинного суффикса, подходящего под p
${v/a/b}первая подстрока a заменяется на b
${v//a/b}все подстроки a заменяются на b
${v^^}перевод всех букв в верхний регистр (нужен bash версии 4 и выше)
${v,,}перевод всех букв в нижний регистр

Двоеточие в записях ${v:-x}, ${v:=x}, ${v:+x} меняет поведение: без двоеточия проверяется только отсутствие переменной, с двоеточием — отсутствие или пустота. Шаблоны p в формах #, ##, %, %% — те же, что в глоббинге: * обозначает любую последовательность символов, ? — один символ. Знак / внутри шаблона или замены экранируется обратной косой чертой, как в примере ${path//\//_}.

Примеры

Примеры выполним в одном сценарии, чтобы результаты были воспроизводимы. Значения выбраны так, чтобы разница между формами стала видна: путь a/b/c.tar.gz содержит два слеша и две точки.

cd ~/demo11
cat > param.sh <<'MDEND'
#!/usr/bin/env bash
unset empty
path="a/b/c.tar.gz"
name="Report draft"
echo "1: ${empty:-по умолчанию}"
echo "2: [${empty:=задано}] теперь empty=$empty"
echo "3: ${empty:+есть значение}"
echo "4: ${#path}"
echo "5: ${path#*/}"
echo "6: ${path##*/}"
echo "7: ${path%.*}"
echo "8: ${path%%.*}"
echo "9: ${name/draft/final}"
echo "10: ${path//\//_}"
echo "11: ${name^^}"
echo "12: ${name,,}"
MDEND
chmod +x param.sh
./param.sh
1: по умолчанию
2: [задано] теперь empty=задано
3: есть значение
4: 12
5: b/c.tar.gz
6: c.tar.gz
7: a/b/c.tar
8: a/b/c
9: Report final
10: a_b_c.tar.gz
11: REPORT DRAFT
12: report draft

Строка 1 показывает подстановку по умолчанию: переменная empty не задана, и оболочка использует запасное значение, не меняя переменную. Строка 2 делает то же, но с присваиванием: после подстановки переменная empty получает значение «задано», что видно в следующей строке вывода. Строка 3 выводит альтернативу ${v:+x} — текст только тогда, когда переменная непуста; теперь она непуста, поэтому текст выводится.

Строки 5–8 демонстрируют четыре формы удаления фрагментов. Шаблон */ в форме # снимает кратчайший префикс до первого слеша, поэтому остаётся b/c.tar.gz. Форма ## снимает самый длинный префикс до последнего слеша и оставляет имя файла c.tar.gz. Для суффиксов %.* снимает кратчайший хвост, начинающийся с последней точки, и даёт a/b/c.tar, а %%.* снимает самый длинный хвост, начиная с первой точки, и даёт a/b/c. Такие выражения заменяют basename и dirname в простых случаях, но сложные пути с несколькими точками нужно проверять внимательно: как видно из строки 8, форма %% может отрезать больше, чем предполагалось.

Строка 9 выполняет замену первого вхождения, строка 10 — всех вхождений (двойная косая черта). Строки 11 и 12 меняют регистр. Они полезны при нормализации имён файлов, и именно их будем использовать в лабораторной работе при переименовании по шаблону.

Раскрытие параметров само по себе не меняет переменную, за исключением ${v:=x}. Поэтому запись ${v:=x} в сценарии, который не должен изменять окружение, является сюрпризом для читателя. Пример со строкой 2 показывает, что побочный эффект виден даже в следующей строке вывода.

Аргументы, ввод и вывод

Аргументы скрипта

Аргументы, переданные сценарию при запуске, доступны в переменных $1, $2 и так далее. Переменная $0 содержит имя, под которым вызван сценарий, а $# — количество аргументов. Аргументы с номерами больше девяти записываются в фигурных скобках: ${10}.

Главная тонкость связана с кавычками. Записи "$@" и "$*" выглядят похоже, но ведут себя по-разному. "$@" раскрывается в отдельные слова, сохраняя границы каждого аргумента. "$*" объединяет все аргументы в одну строку через первый символ IFS (по умолчанию пробел). Проверим на сценарии.

cd ~/demo11
cat > args.sh <<'MDEND'
#!/usr/bin/env bash
echo "имя: $0"
echo "число аргументов: $#"
echo "первый: $1, второй: $2"
echo 'через "$@":'
printf '<%s>\n' "$@"
echo 'через "$*":'
printf '<%s>\n' "$*"
MDEND
chmod +x args.sh
./args.sh "один" два "три четыре"
имя: ./args.sh
число аргументов: 3
первый: один, второй: два
через "$@":
<один>
<два>
<три четыре>
через "$*":
<один два три четыре>

Аргументов три: один, два и три четыре (в кавычках). "$@" передал printf три слова, и строка три четыре сохранилась целиком. "$*" дал одну строку из четырёх слов: пробел между три и четыре стал неотличим от пробела между аргументами. Поэтому правило простое: в сценариях почти всегда нужно "$@". Конструкция "$*" подходит только для вывода в виде одной строки, например в сообщении пользователю.

Без кавычек $@ даёт ещё одну ошибку разделения на слова: строка три четыре разбивается на два слова, и printf получает четыре аргумента. Этот случай рассмотрен в лабораторной работе. Общие правила передачи аргументов и их разбора описаны в руководстве Robbins и Beebe 6. Соответствие между записью и набором слов показано на рисунке.

Раскрытие переменных $@ и $* для трёх аргументов сценария
Рисунок 3. Аргументы скрипта: $@ против $*

Сдвиг аргументов

Команда shift сдвигает позиционные параметры влево: $2 становится $1, $3 — $2 и так далее, а $# уменьшается на единицу. Она удобна для разбора аргументов по порядку, когда первые из них — имена команд или флаги.

cd ~/demo11
cat > shift.sh <<'MDEND'
#!/usr/bin/env bash
echo "до shift: \$1=$1, \$#=$#"
shift
echo "после shift: \$1=$1, \$#=$#"
shift 2
echo "после shift 2: \$1=${1:-пусто}, \$#=$#"
MDEND
chmod +x shift.sh
./shift.sh a b c
до shift: $1=a, $#=3
после shift: $1=b, $#=2
после shift 2: $1=пусто, $#=0

Команда shift 2 сдвигает на два места. Когда аргументов не осталось, $1 пуста; запись ${1:-пусто} показывает этот случай через раскрытие параметров. Если сдвинуть больше аргументов, чем есть, bash не меняет параметров и возвращает код 1, но сообщения не печатает, поэтому результат shift в таких случаях проверяют явно.

Ввод, вывод и код выхода

Для ввода с клавиатуры служит команда read. Опция -r запрещает интерпретацию обратной косой черты в строке, поэтому её почти всегда пишут. Опция -p печатает приглашение без перевода строки. Введённое значение попадает в переменную, имя которой указано последним аргументом.

Для вывода в сценариях предпочтительна printf, а не echo. Команда echo по-разному ведёт себя в разных реализациях: например, интерпретирует первый аргумент -n как опцию, а не как текст. Такой случай проверим на значении -n.

cd ~/demo11
cat > greet.sh <<'MDEND'
#!/usr/bin/env bash
read -r -p "Введите имя: " name
printf 'Здравствуйте, %s!\n' "$name"
printf 'Длина имени: %d символов\n' "${#name}"
MDEND
chmod +x greet.sh
echo "Анна Петровна" | ./greet.sh
bash -c 'v="-n"; echo "$v" "конец"; echo; printf "%s\n" "$v"'
Здравствуйте, Анна Петровна!
Длина имени: 13 символов
конец
-n

Приглашение «Введите имя» в выводе отсутствует: команда read -p печатает его только тогда, когда ввод идёт с терминала, а здесь имя пришло по конвейеру. При запуске ./greet.sh из терминала приглашение появится в той же строке, где пользователь вводит имя. Количество символов 13 посчитано через ${#name}. Вторая команда показывает, что echo приняла значение -n за опцию: текст конец выведен, а переменная -n не напечатана. Команда printf "%s\n" печатает значение как есть. Поэтому в сценариях выводимые значения передаются через printf с форматом %s.

Код выхода сценария задаёт команда exit. Значение от 0 до 255 возвращается вызывающей оболочке и доступно через $?. Команда exit немедленно завершает сценарий, поэтому строки после неё не выполняются.

cd ~/demo11
printf '#!/usr/bin/env bash\necho "работаю"\nexit 3\necho "не выполнится"\n' > exit.sh
chmod +x exit.sh
./exit.sh
echo "код: $?"
работаю
код: 3

Код 0 означает успех, любое другое значение — ошибку. Этим пользуются вызывающие сценарии и конвейеры: если программа вернула ненулевой код, это сигнал о проблеме. Разные коды ошибок позволяют отличать причины друг от друга, и это важно для автоматизации (тема 16).

Строгий режим

Опция -e

По умолчанию сценарий продолжает работу после ошибки, как показано в разделе о присваивании. Для большинства сценариев это опасно: после неудачного копирования скрипт может удалить исходные файлы, а после неудачного cd — выполнить команду в неправильном каталоге. Опция set -e (англ. errexit) завершает сценарий при первой команде, которая вернула ненулевой код.

cd ~/demo11
cat > strict1.sh <<'MDEND'
#!/usr/bin/env bash
set -e
echo "шаг 1"
grep -q 'нет такого' args.sh
echo "шаг 2"
MDEND
bash strict1.sh
echo "код $?"
шаг 1
код 1

grep не нашёл строку и вернул 1, поэтому сценарий завершился до печати «шаг 2». Код выхода 1 совпадает с кодом последней неудачной команды. Важно понимать границы этой опции. set -e не срабатывает в условиях и в левой части && и ||, потому что эти конструкции сами обрабатывают код возврата. Проверим это.

bash -c 'set -e; false && echo x; echo "после && выжил"; false || echo "обработано"; x=$(false); echo "после присваивания"'
echo "код $?"
после && выжил
обработано
код 1

Команда false && echo x вернула ненулевой код, но это не ошибка для set -e: сценарий продолжил работу. Следующая строка false || echo обработано тоже не остановила сценарий. Последняя строка показывает исключение в другую сторону: присваивание x=$(false) возвращает код подстановки, поэтому сценарий остановился, и строка «после присваивания» не напечатана. Это поведение удобно, но его нужно знать, иначе ожидаемая остановка может не произойти.

Опция -u

Опция set -u (англ. nounset) делает ошибкой обращение к переменной, которая не задана. Без неё опечатка в имени переменной тихо превращается в пустую строку, и команда выполняется с неверными аргументами. Особенно опасно это для путей: при пустой переменной $dir выражение "$dir/" даёт /, и команда вроде rm -rf "$dir/" начинает работать с корнем файловой системы. Опция -u останавливает сценарий раньше, чем это произойдёт.

cd ~/demo11
printf '#!/usr/bin/env bash\nset -u\necho "$nosuch"\n' > unb.sh
bash unb.sh
echo "код $?"
unb.sh: line 3: nosuch: unbound variable
код 1

Сообщение содержит имя сценария, номер строки и имя переменной. Для случаев, когда переменная обязана быть задана, bash предлагает форму ${v:?сообщение}: она завершает сценарий с пояснением, если v не задана или пуста. Такой способ предпочтительнее set -u там, где нужно объяснить причину остановки.

Опция pipefail

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

bash -c 'false | true; echo "без pipefail: $?"; set -o pipefail; false | true; echo "с pipefail: $?"'
без pipefail: 0
с pipefail: 1

Опция set -o pipefail делает код конвейера равным коду первой ненулевой команды. Она связана с конвейерами из темы 5: без неё, например, cat файл | grep слово | wc -l может выдать число при неудачном чтении файла, и ошибка окажется незамеченной. Комбинация set -euo pipefail объединяет три опции: -e останавливает сценарий при ошибке, -u запрещает неопределённые переменные, pipefail учитывает ошибки внутри конвейера. Её принято ставить в начале каждого сценария, который работает с файлами.

Ловушки и границы

Опции строгого режима не решают всех проблем, и их нельзя считать гарантией безопасности. Опция -e не срабатывает в условиях и в левой части && и ||, как показано выше, и не всегда видна внутри функций, вызванных в таких местах. Опция -u не замечает переменных, которые заданы пустыми; для этого служит форма ${v:?} или явная проверка. Опция pipefail есть не во всех оболочках: например, sh в Ubuntu (dash) её не поддерживает, поэтому сценарий с pipefail нужно запускать через bash.

Для проверки качества сценария помимо строгого режима полезны синтаксическая проверка bash -n и статический анализатор ShellCheck 5. Первая находит ошибки разбора без выполнения, второй указывает на типичные ловушки, включая некавыченные переменные, которые рассмотрены выше. Подробный список таких ловушек ведётся в руководстве Wooledge 4, а правила оформления сценариев описаны в руководстве по стилю Google 7. Строгий режим и кавычки вместе составляют основу надёжного сценария; остальные средства — ловушки, журналы, блокировки — рассматриваются в теме 15.

Итоговое правило этой темы можно сформулировать так: каждый сценарий начинается с shebang и set -euo pipefail, каждая переменная и каждый результат подстановки пишутся в двойных кавычках, аргументы передаются через "$@", а вывод — через printf. Эти привычки предотвращают большинство ошибок, с которыми сталкиваются начинающие.

Итоги темы

Сценарий Bash — файл с командами, который выполняется в дочернем процессе при запуске через ./ или bash и в текущей оболочке при запуске через source. Понимание этого различия необходимо, чтобы предсказать, какие изменения каталога и переменных сохранятся после выполнения. Присваивание не допускает пробелов вокруг знака равенства, а ошибка пробела проявляется как сообщение «command not found», которое легко пропустить.

Главный механизм, определяющий поведение сценариев, — раскрытие командной строки. Оболочка подставляет переменные и результаты команд, делит строку на слова, раскрывает шаблоны и только затем удаляет кавычки. Двойные кавычки сохраняют значение, одинарные запрещают любое раскрытие, а отсутствие кавычек приводит к разделению файла с пробелом на два аргумента. Раскрытие параметров позволяет обойтись без внешних программ при работе с путями, именами и регистром, а аргументы скрипта передаются через "$@", которое сохраняет границы каждого аргумента.

Строгий режим set -euo pipefail превращает незамеченные ошибки в остановку сценария. Опции не решают всех проблем, но вместе с кавычками они устраняют большинство типичных поломок. В следующей теме к этим средствам добавятся условия, циклы и функции, которые позволят писать сценарии, обрабатывающие каталоги и файлы без ручного перечисления. Лабораторная работа закрепляет материал на практике: в ней есть сценарий с ошибками разделения на слова, который предстоит найти и исправить.

Литература

  1. Shotts W. The Linux Command Line: A Complete Introduction. 2nd ed. — No Starch Press, 2019. Открытая версия: https://linuxcommand.org/tlcl.php
  2. Free Software Foundation. GNU Bash Reference Manual. — https://www.gnu.org/software/bash/manual/
  3. 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
  4. Wooledge G. Bash FAQ и Bash Pitfalls. — https://mywiki.wooledge.org/BashPitfalls
  5. ShellCheck — инструмент статического анализа shell-скриптов. — https://www.shellcheck.net/
  6. Robbins A., Beebe N. H. F. Classic Shell Scripting. — O'Reilly Media, 2005.
  7. Google. Shell Style Guide. — https://google.github.io/styleguide/shellguide.html