Школа

← К программе

Синтаксис bash: переменные, кавычки, подстановки

Урок курса «Курс подготовки DevOps-инженеров»

Хронометраж: занятие 60 минут — теория 30 минут, практика 30 минут. Домашняя работа: 1–2 часа.

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


1. Что изучаем

После урока вы сможете:

  • объяснить разницу трёх видов кавычек и сказать, когда какие нужны;
  • предсказать, что произойдёт с переменной без кавычек, и назвать два механизма, которые её испортят;
  • пользоваться подстановками параметров вместо вызова внешних команд;
  • читать файл построчно так, чтобы данные не искажались;
  • объяснить разницу "$@" и "$*" и почему это важно в скриптах.

2. Зачем это нужно и где применяется

Bash — основной язык автоматизации в эксплуатации, потому что он есть везде. И он же язык, в котором легче всего написать код, работающий «в обычном случае» и ломающийся на пробеле в имени файла.

Скрипт сломался на данных. Классика: работал год, потом в каталоге появился файл с пробелом, и rm $FILE удалил не то. Причина не в редком случае, а в отсутствии кавычек — и это ловится пониманием, а не тестами.

Вы читаете чужие скрипты. В блоках 09–14 будет много готовых скриптов: в конвейерах, в плейбуках, в контейнерах. Читать ${VAR:-default} и ${PATH##*/} придётся постоянно.

Скрипты уже встречались. В уроках 02.05, 02.06 и 03.07 мы писали хуки и небольшие скрипты. Там работали интуитивно; здесь разбираемся, что именно происходит, — и в уроке 03.07 уже видели, чем это кончается: моя проверка секретов ложно срабатывала из-за поведения xargs с пустым вводом.

Обработка вывода команд. Вся работа в командной строке — это подстановки и разбиение на слова. Не понимая их, вы будете получать загадочные результаты и списывать это на «bash странный».

3. Теория (30 минут)

3.1. Что такое скрипт и с чего он начинается

Скрипт — текстовый файл с командами. Чтобы его можно было запускать как программу, нужны две вещи:

#!/usr/bin/env bash        # строка-указатель интерпретатора (shebang)
chmod +x script.sh          # право на исполнение (урок 02.04)

Почему #!/usr/bin/env bash, а не #!/bin/bash: путь к bash различается между системами (в macOS штатный /bin/bash — версии 3.2, а установленный из Homebrew лежит иначе). env находит его в PATH.

Отдельно про выбор интерпретатора. Если написать #!/bin/sh, скрипт будет выполняться не bash, а POSIX-совместимой оболочкой — и часть конструкций из этого урока не заработает. Мы уже видели это в уроке 02.01: в Alpine оболочка BusyBox ash, и command -v там принимает только один аргумент. Поэтому правило: если пишете на bash — указывайте bash, и не полагайтесь на то, что sh окажется им же.

Проверить, что за оболочка выполняет скрипт:

echo "$BASH_VERSION"      # непусто только в bash

3.2. Переменные

NAME="значение"        # без пробелов вокруг =
echo "$NAME"

Пробелы вокруг = — самая частая первая ошибка: NAME = "значение" bash понимает как вызов команды NAME с аргументами.

Три уровня видимости, которые надо различать:

Как задано Видно в скрипте Видно дочерним процессам
NAME=значение да нет
export NAME=значение да да
local NAME=значение (в функции) только в функции нет

Это прямое следствие урока 02.05: дочерний процесс получает копию окружения, а обычные переменные оболочки в окружение не входят. Отсюда понятно, почему переменная, заданная в скрипте, не видна вызванной из него программе, если не сделать export.

Про имена: по соглашению переменные окружения и «константы» пишут заглавными (PATH, HOME), локальные переменные скрипта — строчными. Это не требование bash, а способ не переопределить случайно системную переменную. Особенно опасно переопределить PATH.

3.3. Кавычки: три вида и главное правило

Это центральная тема урока — большая часть ошибок в bash растёт отсюда.

$ V="привет мир"
$ echo "$V"          # двойные: подстановки работают
привет мир
$ echo '$V'          # одинарные: всё буквально
$V
$ echo $V            # без кавычек: подстановка + РАЗБИЕНИЕ
привет мир

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

$ F="файл с пробелом.txt"
$ ls $F
ls: с: No such file or directory
ls: файл: No such file or directory        ← слово разбилось на три
$ ls "$F"
файл с пробелом.txt

Без кавычек bash делает с результатом подстановки две вещи:

  1. разбиение на слова (word splitting) по символам из IFS — по умолчанию пробел, табуляция, перевод строки;
  2. раскрытие шаблонов (globbing) — если в значении есть *, ?, [.

Второе тоже проверяется наглядно:

$ S="*"
$ echo $S
a.txt b.txt файл с пробелом.txt        ← превратилось в список файлов!
$ echo "$S"
*

Отсюда правило, которое стоит принять как безусловное:

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

Одинарные кавычки нужны, когда текст должен попасть буквально: регулярные выражения, шаблоны для find, тела команд для sed и awk. Мы уже пользовались этим в уроке 02.06: <<'EOF' вместо <<EOF там, где текст не должен подставляться.

Внутри двойных кавычек особый смысл сохраняют $, ` и \. Если нужен буквальный доллар, его экранируют: "цена \$5".

3.4. Подстановки параметров: работа со строками без внешних команд

Bash умеет обрабатывать строки сам. Это не только быстрее (нет запуска процесса), но и надёжнее — нет разбора вывода чужой команды.

Значения по умолчанию:

${VAR:-по-умолчанию}    # подставить, если не задана ИЛИ пуста
${VAR-по-умолчанию}     # подставить, только если НЕ ЗАДАНА
${VAR:=создать}         # подставить и присвоить
${VAR:?сообщение}       # прервать с ошибкой, если не задана

Разница двоеточия проверяется прямо:

$ BLANK=""
$ echo "${BLANK:-подставилось}"
подставилось
$ echo "${BLANK-НЕ подставилось}"
                              ← пусто: переменная задана, хоть и пустая

Форма ${VAR:?сообщение} — лучший способ потребовать обязательный параметр:

$ echo "${MUST:?не задана переменная MUST}"
script.sh: line 11: MUST: не задана переменная MUST

Работа со строками. Возьмём путь и разберём его без basename, dirname и sed:

P="/srv/app/logs/error.log"

${#P}            → 23                        # длина
${P##*/}         → error.log                 # имя файла (жадно с начала)
${P%/*}          → /srv/app/logs             # каталог (с конца)
${P%.log}        → /srv/app/logs/error       # убрать расширение
${P/logs/archive} → /srv/app/archive/error.log  # заменить первое вхождение
${P//\//_}       → _srv_app_logs_error.log   # заменить все
${P:5:3}         → app                       # срез: с 5-го символа, 3 символа

Правило запоминания: # убирает с начала (решётка стоит в начале строки на клавиатуре), % убирает с конца. Одинарный символ — нежадно (короткое совпадение), двойной — жадно.

Практическая польза очевидна в скриптах: вместо NAME=$(basename "$P") пишется NAME="${P##*/}", и это не запускает процесс. В цикле по тысяче файлов разница заметна.

3.5. Подстановка команд

D=$(date -u +%F)             # современная форма
D=`date -u +%F`              # устаревшая, вложенность неудобна

Форма $(...) предпочтительна: она читается и вкладывается.

$ echo "$(echo "внешний $(echo внутренний)")"
внешний внутренний

Два свойства, которые надо знать точно.

Подстановка отбрасывает переводы строк в конце. Проверено:

$ printf 'строка1\nстрока2\n\n\n' > multi.txt
$ V=$(cat multi.txt)
$ wc -c < multi.txt      # 30 байт в файле
$ echo "${#V}"           # 15 символов в переменной

Пятнадцать вместо тридцати: три завершающих перевода строки съедены. Это регулярный источник расхождений при сравнении «файл против переменной».

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

$ LIST=$(printf 'a\nb c\n')
$ for x in $LIST;   do echo "[$x]"; done      # [a] [b] [c] — разбилось!
$ for x in "$LIST"; do echo "[$x]"; done      # [a\nb c] — одна строка

3.6. Разбиение на слова и IFS

Переменная IFS (internal field separator) задаёт, по каким символам bash разбивает. По умолчанию это пробел, табуляция и перевод строки. Меняя её, можно разбирать структурированные строки:

$ LINE="root:x:0:0:root:/root:/bin/bash"
$ IFS=: read -r user _ uid _ _ home shell <<< "$LINE"
$ echo "$user $uid $home $shell"
root 0 /root /bin/bash

Здесь три приёма сразу: IFS=: действует только на эту команду; _ — соглашение «этот аргумент не нужен»; <<< — передача строки на вход (из урока 02.06).

Чтение файла построчно. Правильная форма выглядит громоздко, и у каждой части есть причина:

while IFS= read -r line; do
    printf '%s\n' "$line"
done < файл
  • IFS= (пустое) — не отрезать пробелы по краям строки;
  • -r — не обрабатывать обратный слэш как экранирование;
  • кавычки вокруг "$line" — не разбивать и не раскрывать шаблоны.

Что происходит без этого, проверено на файле со пробелом в начале строки и обратным слэшем:

--- while IFS= read -r (правильно) ---
  [ пробел в начале]
  [обратный\слэш]
--- while read (без IFS= и -r) ---
  [пробел в начале]          ← пробел съеден
  [обратныйслэш]             ← слэш съеден

Данные молча испорчены. Поэтому while IFS= read -r line заучивают целиком, как одну конструкцию.

Отдельно: не читайте файлы через for. Запись for line in $(cat файл) разбивает по словам, а не по строкам, и ломается на пробелах.

3.7. Массивы и "$@" против "$*"

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

files=("a.txt" "файл с пробелом.txt" "b.txt")   # три элемента
echo "${#files[@]}"        # 3 — число элементов
echo "${files[1]}"         # файл с пробелом.txt — по индексу (с нуля)
for f in "${files[@]}"; do printf '[%s]\n' "$f"; done

Ключевая форма — "${files[@]}" в кавычках: она раскрывается в отдельные элементы, каждый как одно слово, даже если внутри пробелы. Без кавычек (${files[@]}) снова происходит разбиение, и элемент с пробелом разваливается — та же ошибка, что в разделе 3.3, только с массивом.

Отсюда же разница двух особых переменных, которую надо знать точно, потому что она про аргументы скрипта. Внутри скрипта или функции $@ и $* содержат все переданные аргументы, но ведут себя по-разному в кавычках:

  • "$@" раскрывается в отдельные аргументы ("$1" "$2" …) — каждый сохраняется целиком;
  • "$*" склеивает всё в одну строку через первый символ IFS (обычно пробел).

Проверено на аргументе с пробелом:

show() { echo "через @:"; for a in "$@"; do echo "  [$a]"; done
         echo "через *:"; for a in "$*"; do echo "  [$a]"; done; }
show один "два слова" три
# через @:  [один] [два слова] [три]     ← три аргумента
# через *:  [один два слова три]         ← один!

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

3.8. Арифметика

$ N=7
$ echo "$(( N * 3 ))"     # 21
$ echo "$(( N / 2 ))"     # 3 — целочисленное деление!
$ echo "$(( N % 2 ))"     # 1
$ echo "$(( ++N ))"       # 8, и N теперь 8

Внутри $(( )) знак $ перед именем переменной не нужен, кавычки внутри не требуются.

Ключевое ограничение: bash считает только целыми числами. $(( 7 / 2 )) даёт 3, а не 3.5. Для дробных нужны внешние средства (awk, bc) — или, если расчётов много, это признак, что задача уже не для bash (об этом в разделе 3.9).

Сравнения в условиях разберём в уроке 04.02; здесь достаточно знать, что для чисел используется (( )), а для строк — [[ ]].

3.9. Когда bash — неподходящий инструмент

Честная граница применимости, чтобы не пришлось переписывать:

Признак Что делать
нужны дробные вычисления, статистика Python (блок 06)
разбор JSON или YAML jq/yq (урок 04.05), при сложной логике — Python
скрипт перевалил ~200–300 строк пора переписать на Python
нужны структуры данных сложнее массива строк Python
нужна работа с HTTP API с обработкой ошибок и повторами Python
нужны параллельные вычисления с синхронизацией не bash

Bash хорош там, где задача — склеить существующие программы: запустить, передать вывод дальше, проверить код возврата. Как только начинается собственная логика с данными, он становится хуже Python по всем параметрам, кроме доступности.

Практический ориентир: если в скрипте появляется третий вложенный цикл или разбор структурированных данных руками — остановитесь и посмотрите, не пора ли менять инструмент.

3.10. Примеры и варианты реализации

Задача: написать и запустить скрипт.

Среда Как решается Что нужно иметь
Локальный стенд Скрипт на своей машине; для проверки в «чужой» оболочке — контейнер bash, редактор
VPS / bare-metal То же; важно, что версия bash на сервере может быть старее сервер из блока 02
Yandex Cloud Скрипты в cloud-init и в конвейерах; там оболочка может быть не bash облачный аккаунт

Локальный стенд. Здесь и пишем. Полезно сразу завести привычку проверять скрипт в контейнере с другой оболочкой: docker run --rm -v "$PWD:/w" -w /w alpine:3 sh script.sh быстро показывает, что скрипт полагался на bash, хотя объявлен как sh.

VPS / bare-metal. Существенное отличие — версия bash. На старых системах может не быть конструкций из новых версий (например, ассоциативных массивов до bash 4). Проверять: bash --version. Отсюда практическое правило: если скрипт поедет на неизвестные машины, либо проверяйте версию в начале, либо не используйте свежие возможности.

Yandex Cloud. Скрипты попадают в cloud-init (урок 02.02) и в конвейеры CI. Там их выполняет не ваша интерактивная оболочка, а неинтерактивная, часто /bin/sh, и без вашего окружения. Отсюда две привычки: явный shebang и никакой опоры на переменные вашей сессии.

Что выбрать. Bash одинаков во всех средах; различаются его версия и то, какая оболочка окажется за sh. Поэтому существенны две вещи: указывать интерпретатор явно и проверять скрипт там, где он будет работать.

4. Практика на занятии (30 минут)

Шаг 1 (5 минут). Первый скрипт и окружение.

  • Что ввести:
mkdir -p ~/lab/04.01 && cd ~/lab/04.01
cat > env.sh <<'EOF'
#!/usr/bin/env bash
echo "версия bash: $BASH_VERSION"
LOCAL_VAR="только тут"
export EXPORTED_VAR="видно детям"
echo "внутри скрипта: LOCAL_VAR=$LOCAL_VAR EXPORTED_VAR=$EXPORTED_VAR"
bash -c 'echo "в дочернем: LOCAL_VAR=[$LOCAL_VAR] EXPORTED_VAR=[$EXPORTED_VAR]"'
EOF
chmod +x env.sh && ./env.sh
  • Зачем: увидеть разницу обычной и экспортированной переменной — дочерний процесс получает копию окружения, а не всех переменных оболочки.
  • Ожидаемый результат: внутри скрипта видны обе переменные; в дочернем bash -c LOCAL_VAR пуста ([]), а EXPORTED_VAR видна.
  • Если не так: LOCAL_VAR видна и в дочернем — использован <<EOF вместо <<'EOF', и bash подставил значение при создании файла; повторите с кавычками в heredoc.
  • Ответьте: почему LOCAL_VAR в дочернем процессе пуста? Как это связано с уроком 02.05?

Шаг 2 (8 минут). Кавычки: увидеть поломку.

  • Что ввести:
mkdir -p qt && cd qt && touch "файл с пробелом.txt" a.txt b.txt
F="файл с пробелом.txt"
ls $F                # сломается — прочитайте сообщение
ls "$F"              # работает
S="*"
echo $S              # раскрылось в список файлов!
echo "$S"            # звёздочка
V="привет мир"; echo "$V"; echo '$V'; echo $V
cd ..
  • Зачем: увидеть два механизма, портящих незакавыченную подстановку: разбиение на слова (пробел в имени) и раскрытие шаблона (значение *).
  • Ожидаемый результат: ls $F даёт три ошибки «No such file» (слово разбилось); ls "$F" находит файл; echo $S печатает список файлов, echo "$S" — звёздочку.
  • Если не так: echo $S напечатал *, а не список — в каталоге нет файлов; шаблон раскрывается только при наличии совпадений.
  • Ответьте: для трёх видов кавычек — работают ли подстановки, есть ли разбиение, раскрываются ли шаблоны?

Шаг 3 (7 минут). Подстановки параметров вместо внешних команд.

  • Что ввести:
P="/srv/app/logs/error.log"
echo "длина:      ${#P}"
echo "имя файла:  ${P##*/}"
echo "каталог:    ${P%/*}"
echo "без .log:   ${P%.log}"
echo "замена:     ${P/logs/archive}"
echo "все слэши:  ${P//\//_}"
echo "через basename: $(basename "$P")"
unset OPT; echo "по умолчанию: ${OPT:-значение}"
echo "обязательная: ${REQUIRED:?не задана}"   # прервётся — это ожидаемо
  • Зачем: обрабатывать строки средствами bash, без basename/dirname/sed — быстрее и надёжнее (нет разбора чужого вывода).
  • Ожидаемый результат: ${P##*/} даёт error.log, ${P%/*} — /srv/app/logs; совпадает с basename; последняя строка прерывает выполнение с текстом REQUIRED: не задана.
  • Если не так: ${P##*/} вернул весь путь — перепутаны # (с начала) и % (с конца).
  • Ответьте: чем ${P##*/} лучше $(basename "$P") в цикле по тысяче файлов?

Шаг 4 (7 минут). Чтение строк без порчи данных.

  • Что ввести:
printf ' пробел в начале\nобратный\\слэш\nобычная строка\n' > lines.txt
echo "--- правильно ---"
while IFS= read -r line; do printf '  [%s]\n' "$line"; done < lines.txt
echo "--- без IFS= и -r ---"
while read line; do printf '  [%s]\n' "$line"; done < lines.txt
echo "--- через for (так делать нельзя) ---"
for line in $(cat lines.txt); do printf '  [%s]\n' "$line"; done
LINE="root:x:0:0:root:/root:/bin/bash"
IFS=: read -r user _ uid _ _ home shell <<< "$LINE"
echo "$user $uid $home $shell"
  • Зачем: увидеть, что while read без IFS= и -r молча портит данные, а for по $(cat) читает по словам, — и почему while IFS= read -r заучивают целиком.
  • Ожидаемый результат: в «правильно» сохранены пробел в начале и обратный слэш; в «без IFS= и -r» они съедены; в «через for» строки разбиты по словам; разбор по : печатает root 0 /root /bin/bash.
  • Если не так: все три вывода одинаковы — в данных нет ведущих пробелов или слэша; используйте printf из шага дословно.
  • Ответьте: что именно испортилось во втором и третьем случае?

Шаг 5 (3 минуты). Подстановка команд и переводы строк.

  • Что ввести:
printf 'строка1\nстрока2\n\n\n' > multi.txt
V=$(cat multi.txt)
echo "в файле байт: $(wc -c < multi.txt)"
echo "в переменной символов: ${#V}"
  • Зачем: увидеть, что подстановка команд $(...) отбрасывает завершающие переводы строк — частая причина расхождения «файл против переменной».
  • Ожидаемый результат: в файле байт заметно больше, чем символов в переменной (три завершающих перевода строки съедены).
  • Если не так: числа равны — в файле не было завершающих пустых строк; добавьте \n\n в printf.
  • Ответьте: куда делись символы и когда это может создать проблему?

Проверка результата. Вы воспроизвели поломку из-за отсутствия кавычек в двух вариантах (пробел и шаблон), заменили basename подстановкой параметра, и увидели, как while read без флагов портит данные.

5. Домашняя работа (1–2 часа)

Задание. Напишите скрипт, устойчивый к «плохим» данным.

  1. Скрипт разбора путей. Образец для сверки — examples/04.01-bash-syntax/paths-reference.sh; открывайте после того, как напишете свой. В нём же оставлена настоящая находка shellcheck про SC2295 — случай, когда внутренние кавычки в ${base%."$ext"} обязательны. Напишите paths.sh, который принимает список путей аргументами и для каждого выводит: имя файла, каталог, расширение, имя без расширения. Только подстановками параметров, без basename, dirname, sed, awk. Проверьте на путях с пробелами, с точками в имени каталога, без расширения, с несколькими точками.
  2. Эксперимент про кавычки. Стенд для опытов — examples/04.01-bash-syntax/quoting-demo.sh: там незакавыченные переменные оставлены намеренно, и в начале файла объяснено, почему подавление shellcheck для всего файла записано именно так. Проверьте гипотезу: «отсутствие кавычек ломает скрипт двумя разными способами». Создайте каталог с файлами, включая файл с пробелом и файл с именем *, и покажите на своём выводе оба механизма: разбиение на слова и раскрытие шаблона. Оформите карточкой эксперимента из урока 01.07. Отдельно ответьте: почему файл с именем * особенно опасен?
  3. Эксперимент про while read. Проверьте, что while read line без IFS= и -r искажает данные. Подготовьте файл, где есть: строка с пробелами по краям, строка с обратным слэшем, строка с символом табуляции, пустая строка. Покажите разницу трёх способов чтения (while IFS= read -r, while read, for ... in $(cat)). Сведите в таблицу, что теряется.
  4. Обязательные и необязательные параметры. Напишите backup.sh, который берёт из окружения: SRC (обязательно, иначе внятная ошибка через ${VAR:?}), DST (по умолчанию /tmp/backup), KEEP (по умолчанию 7). Покажите три запуска: без SRC, только с SRC, со всеми переменными.
  5. Сравнение с внешними командами. Измерьте разницу: цикл на 2000 итераций, в котором имя файла получается через ${P##*/} против $(basename "$P"). Используйте time. Приведите числа и объясните разницу.
  6. Проверка в другой оболочке. Возьмите свой paths.sh, поменяйте shebang на #!/bin/sh и запустите в контейнере Alpine: docker run --rm -v "$PWD:/w" -w /w alpine:3 sh paths.sh /a/b.txt. Опишите, что сломалось и почему. Верните bash и объясните, что означает эта проверка для скриптов, которые поедут в контейнеры.
  7. Своя шпаргалка. Соберите таблицу подстановок параметров с примером на каждую (минимум десять форм), которые вы реально выполнили, — по шаблону examples/04.01-bash-syntax/expansion-cheatsheet.md. Ценность файла в колонке «результат»: пока не увидишь своими глазами, что ${x:-знач} и ${x:=знач} ведут себя по-разному, разница не запоминается.

Критерии приёмки.

  • paths.sh работает на всех четырёх видах путей; вывод приложен.
  • В paths.sh нет вызовов basename, dirname, sed, awk.
  • Карточка эксперимента показывает оба механизма поломки без кавычек.
  • Объяснено, почему файл с именем * особенно опасен.
  • Таблица потерь при чтении заполнена по четырём видам строк и трём способам чтения.
  • backup.sh выдаёт внятную ошибку без SRC и подставляет значения по умолчанию.
  • Приведены числа замера ${P##*/} против basename с объяснением разницы.
  • Описано, что сломалось при запуске под sh в Alpine.
  • Шпаргалка содержит минимум десять форм подстановок, все проверены запуском.

Файлы для работы: blocks/04-bash/examples/04.01-bash-syntax/ — примеры к этому уроку в репозитории курса.

6. Частые ошибки и подводные камни

Пробелы вокруг =. Симптом: NAME: command not found. Причина: NAME = "x" — это вызов команды. Что делать: NAME="x" без пробелов.

Подстановка без кавычек. Симптом: скрипт работает, пока в данных нет пробелов и шаблонов, потом ломается или удаляет не то. Причина: разбиение на слова и раскрытие шаблонов. Что делать: всегда "$VAR" и "$(команда)".

Чтение файла через for. Симптом: строки «разъезжаются» по словам. Причина: for идёт по словам, а не по строкам. Что делать: while IFS= read -r line.

while read без IFS= и -r. Симптом: молча теряются пробелы по краям и обратные слэши. Причина: read по умолчанию обрезает и экранирует. Что делать: заучить конструкцию целиком.

Ожидать дробную арифметику. Симптом: $(( 7 / 2 )) даёт 3. Причина: bash считает целыми. Что делать: awk/bc для дробей; при большом объёме расчётов — Python (блок 06).

Считать, что подстановка сохраняет переводы строк в конце. Симптом: сравнение файла с переменной не сходится. Причина: $(...) отбрасывает завершающие переводы строк. Что делать: знать об этом; сравнивать через diff или добавлять маркер.

Забыть export и удивляться. Симптом: переменная не видна вызванной программе. Причина: обычные переменные оболочки не входят в окружение (урок 02.05). Что делать: export для того, что нужно дочерним процессам.

Указать #!/bin/sh, а писать на bash. Симптом: скрипт работает у вас и падает в контейнере или на другой системе. Причина: за sh может быть другая оболочка. Что делать: #!/usr/bin/env bash; проверять в целевой среде.

Переопределить системную переменную. Симптом: перестают находиться команды. Причина: скрипт задал свою PATH (или IFS и не вернул). Что делать: локальные переменные строчными буквами; IFS менять только для одной команды.

7. Проверка усвоения

  1. Зачем нужен shebang и почему #!/usr/bin/env bash предпочтительнее #!/bin/bash?
  2. Что произойдёт при NAME = "значение" и почему?
  3. Чем NAME=x отличается от export NAME=x? Как это связано с уроком 02.05?
  4. Назовите три вида кавычек и для каждого — работают ли подстановки и происходит ли разбиение.
  5. Какие два механизма портят подстановку без кавычек? Приведите пример на каждый.
  6. Почему файл с именем * в каталоге опасен для скрипта без кавычек?
  7. Чем ${VAR:-x} отличается от ${VAR-x}?
  8. Как потребовать обязательную переменную одной конструкцией?
  9. Что делают ${P##*/} и ${P%/*}? Как запомнить, где #, а где %?
  10. Чем ${P##*/} лучше $(basename "$P")?
  11. Что происходит с переводами строк в конце при V=$(cat файл)?
  12. Разберите while IFS= read -r line: за что отвечает каждая часть?
  13. Почему нельзя читать файл через for line in $(cat файл)?
  14. Что даст $(( 7 / 2 )) и почему?
  15. Назовите три признака того, что задача уже не для bash.

8. Материалы

Первоисточники:

  • Bash Reference Manual — официальное руководство. Для этого урока: разделы Quoting, Shell Parameter Expansion, Word Splitting. Читается тяжело, но это единственный точный источник.
  • man bash — то же локально; поиск по разделу Parameter Expansion закрывает большинство вопросов.
  • Bash Reference Manual: Shell Parameter Expansion — все формы подстановок параметров в одном месте; стоит держать под рукой.

Практические руководства:

  • Google Shell Style Guide — соглашения о написании скриптов: когда bash уместен, а когда пора на другой язык, как оформлять. Короткий и полезный документ; часть правил мы применяем в этом блоке.
  • BashFAQ и BashPitfalls (Greg's Wiki) — разбор типовых ошибок с объяснениями. Список ловушек, из которых половина про кавычки; полезно прочитать целиком один раз.

Дальше в курсе:

  • Условия, циклы и функции — урок 04.02.
  • set -euo pipefail, trap и надёжность — урок 04.03 (частично уже разобрано в 02.06).
  • shellcheck находит большинство ошибок из этого урока автоматически — урок 04.07.

← Предыдущий: 03.08. Монорепозиторий и полирепозиторий; структура инфраструктурных репозиториев · Блок 04 · Следующий: 04.02. Условия, циклы, функции, коды возврата →

Материалы для практики

Скрипты, шпаргалки и заготовки к заданиям урока. Текст урока — на этой странице.