Изучаем bash “по месту”: работа с аргументами, безопасными переменными и функциями
Разберём практики, которые чаще всего ломают bash-скрипты: парсинг аргументов, quoting, set -euo pipefail, функции и обработка ошибок. В конце — шаблон-скелет скрипта для повторного использования.
Содержание
Изучаем bash “по месту”: работа с аргументами, безопасными переменными и функциями
Bash часто воспринимают как “скрипты для разовых задач”. На практике же многие команды быстро превращаются в маленькие программы: появляются десятки аргументов, условия, обработка ошибок, логирование, повторяемость. И именно в этот момент “обычный bash” начинает ломаться — не из‑за отсутствия знаний, а из‑за типичных технологических ошибок: неудачный парсинг аргументов, отсутствие quoting, слепая опора на set -e, некорректная работа с переменными и ошибками в функциях.
Ниже — разбор практик, которые чаще всего приводят к багам, и набор решений “по месту”: какие приемы использовать, как организовать скрипт, как сделать его предсказуемым. В конце будет шаблон-скелет, который можно переиспользовать под свои задачи.
Почему bash-скрипты ломаются: реальные причины
Разберем несколько распространенных сценариев.
1) Аргументы и пробелы: “оно же строка”
Самая частая причина неожиданных падений — забытый quoting.
Пусть есть команда:
cp $source $dest
Если $source содержит пробелы ("my file.txt"), bash разобьёт строку на слова, подставив параметры неверно. Правильный минимум:
cp "$source" "$dest"
Правило в bash простое: везде, где возможны пробелы, подстановка, пустые значения, glob’ы — используйте кавычки. Исключения есть, но они требуют осознанности.
2) Парсинг аргументов: “пробовали, но как-то работает”
Часто встречается самописный парсер на цикле shift и “костылях” вида:
while [ $# -gt 0 ]; do
case $1 in
--mode=*) mode="${1#*=}" ;;
--mode) mode="$2"; shift ;;
*) echo "unknown"; exit 1 ;;
esac
shift
done
Это работает до тех пор, пока:
- не появляются аргументы без значения,
- значения начинаются с
-, - нужно поддержать повторяющиеся флаги,
- формат усложняется (например,
--pathи--path=...одновременно).
В bash есть инструменты, которые уменьшают “случайность” (в частности, getopts, а для длинных опций — обычно приходится писать небольшую обвязку, или использовать getopt системный).
3) set -e без понимания семантики
set -e (“exit on error”) — не “магическая гарантия”. В bash поведение зависит от контекста: команда в if, while, в составе &&/||, в некоторых конструкциях не обязательно вызывает немедленный выход.
Добавьте к этому unset var, и вот вы уже ловите либо “скрипт внезапно падает”, либо “ошибка тихо проходит”.
На практике чаще всего рекомендуют комбинацию:
set -euo pipefail
Но использовать ее нужно вместе с дисциплиной: аккуратно работать с переменными, проверять условия и ошибки в функциях.
4) Ошибки в функциях “не те, где вы думаете”
Частая проблема — функции возвращают ошибки, но их результат игнорируется. Или наоборот: функция делает что-то “опасное” и полагается на глобальные переменные.
В bash функции могут возвращать код через return (0..255). Если функция хочет “остановить весь скрипт”, это лучше сделать явно: например, через return 1 и обработку в вызывающем месте.
5) Пайплайны и скрытые ошибки
cmd1 | cmd2 в bash по умолчанию возвращает код последней команды (cmd2), а не cmd1. Поэтому важна опция pipefail:
set -o pipefailзаставляет bash возвращать ненулевой статус, если хотя бы одна команда в пайплайне завершилась с ошибкой.
Базовый набор “безопасности” для bash: set -euo pipefail и дисциплина
Стандартная практика для скриптов “как для маленькой программы”:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
Разберем.
-e: выйти при ошибке. Но помним про исключения (команды вif/while, части&&/||, и т.п.).-u: ошибка при обращении к неинициализированной переменной.pipefail: учитываем ошибку в пайплайне.IFS=$'\n\t': уменьшает риск “случайного” разбиения по пробелам/табам приfor/read(при правильном quoting это не обязательно, но как усилитель — полезно).
Важно: IFS и set -u требуют аккуратности. Если вы используете массивы/строки, всегда задавайте значения по умолчанию и не допускайте обращения к переменным “возможно не задано”.
Аргументы: как парсить правильно и предсказуемо
1) Минимальный принцип: “каждый флаг — это контракт”
Сконструируйте интерфейс скрипта:
- Какие обязательные аргументы?
- Какие значения — строки, числа, пути?
- Есть ли флаги типа
--dry-run,--verbose,--timeout? - Допускаются ли повторения (
--tagмногократно)? - Что делать, если пользователь передал неправильное?
Эта логика должна отражаться в коде: валидация важна так же, как и парсинг.
2) Используем getopts для коротких опций
getopts удобен для -m, -p, -v и прочих коротких. Он не любит длинные опции, но для скриптов с минимальным интерфейсом — отличный вариант.
Пример: допустим, у нас -m MODE, -p PATH, -v.
#!/usr/bin/env bash
set -euo pipefail
mode="auto"
path="."
verbose=0
while getopts ":m:p:v" opt; do
case "$opt" in
m) mode="$OPTARG" ;;
p) path="$OPTARG" ;;
v) verbose=1 ;;
\?) echo "Usage: $0 [-m mode] [-p path] [-v]" >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
# Остаток аргументов — позиционные
# "$@" нужно обрабатывать с quoting
for item in "$@"; do
echo "item: $item"
done
Ключевые моменты:
"$opt"и"$OPTARG"— всегда quoting.shift $((OPTIND - 1))правильно переносит указатель на позиционные параметры.
3) Длинные опции --long: разумная практика через case + перебор
Если вам нужны длинные опции (--mode, --path), в bash обычно делают цикл while [[ $# -gt 0 ]], распознавая:
--flag(без значения),--key=value,--key value.
Пример “надежного” парсинга для набора флагов:
#!/usr/bin/env bash
set -euo pipefail
mode="auto"
path="."
verbose=0
timeout=""
inputs=()
usage() {
cat >&2 <<'EOF'
Usage: script.sh [options] [--] <inputs...>
Options:
--mode <auto|strict> Режим работы
--path <path> Путь к ресурсам
--timeout <seconds> Таймаут операции
--verbose Подробный вывод
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
[[ $# -ge 2 ]] || { echo "Missing value for --mode" >&2; usage; exit 2; }
mode="$2"
shift 2
;;
--mode=*)
mode="${1#*=}"
shift 1
;;
--path)
[[ $# -ge 2 ]] || { echo "Missing value for --path" >&2; usage; exit 2; }
path="$2"
shift 2
;;
--path=*)
path="${1#*=}"
shift 1
;;
--timeout)
[[ $# -ge 2 ]] || { echo "Missing value for --timeout" >&2; usage; exit 2; }
timeout="$2"
shift 2
;;
--timeout=*)
timeout="${1#*=}"
shift 1
;;
--verbose)
verbose=1
shift 1
;;
--)
shift
# Остаток — позиционные входы, даже если начинается с '-'
while [[ $# -gt 0 ]]; do
inputs+=("$1")
shift
done
;;
-h|--help)
usage
exit 0
;;
*)
# Не распознали флаг — либо позиционные, либо ошибка
inputs+=("$1")
shift 1
;;
esac
done
# Валидации
case "$mode" in
auto|strict) ;;
*) echo "Invalid --mode: $mode" >&2; exit 2 ;;
esac
if [[ -n "$timeout" ]]; then
# Число: проверим что это целое >= 0
[[ "$timeout" =~ ^[0-9]+$ ]] || { echo "--timeout must be a non-negative integer" >&2; exit 2; }
fi
# Для наглядности
(( verbose )) && echo "mode=$mode path=$path timeout=$timeout inputs=${#inputs[@]}"
Заметки:
- Обработку “--” полезно предусмотреть: это стандарт POSIX — после
--значения считаются позиционными и не трактуются как флаги. - Используйте массив для входов (
inputs=()), иначе позиционные аргументы с пробелами/спецсимволами могут стать проблемой. - Валидация лучше отдельным блоком (или функцией
validate_*).
Безопасные переменные и quoting: что именно делать
1) Quoting — по умолчанию
Практическое правило:
- Всегда криша/склейка:
"$var"при подстановке. - Всегда при использовании параметров команд:
cmd "$var". - При тестах в
[[ ... ]]: тоже quoting:[[ -n "$var" ]].
Пример частой ошибки:
if [ -z $mode ]; then
...
fi
Если mode пустой или unset, это может дать ошибку синтаксиса. Правильно:
if [[ -z "${mode:-}" ]]; then
...
fi
"${mode:-}" — безопасное значение по умолчанию (в отличие от $mode при set -u).
2) Когда нужен “неочевидный” default: ${var:-default} vs ${var:?message}
${var:-default}— мягко: еслиvarпустой или не задан, подставимdefault.${var:?message}— жестко: еслиvarне задан или пустой, скрипт упадет с message.
В сочетании с set -u это мощный механизм “контрактов”.
Пример: обязательный параметр:
required_path="${path:?--path is required}"
3) read и массивы: не ломаем пробелы
Если нужно прочитать строку целиком (включая пробелы), используйте read -r и правильный IFS.
while IFS= read -r line; do
echo "LINE: $line"
done < <(some_command)
-r отключает интерпретацию backslash как escape-секвенций.
Функции: как писать их “как в коде”, а не “как в заметках”
1) Функции должны быть маленькими и с явными входами/выходами
Лучше избегать зависимости от глобального состояния. Если функция нужна для преобразования или валидации — передайте параметры аргументами:
parse_seconds() {
local s="$1"
[[ "$s" =~ ^[0-9]+$ ]] || return 1
printf '%s\n' "$s"
}
Тут функция:
- принимает
s, - валидирует,
- возвращает ошибку через
return 1, - при успехе выводит результат через stdout.
Так проще использовать и тестировать.
2) Ошибки: return вместо exit внутри функций
В большинстве случаев exit внутри функции делает управление менее предсказуемым (вы не всегда знаете, откуда функция вызвана). Поэтому разумнее:
- функция возвращает код,
- вызывающая сторона решает, что делать дальше.
Пример:
ensure_file_exists() {
local f="$1"
[[ -f "$f" ]] || { echo "File not found: $f" >&2; return 1; }
}
# Вызов:
if ! ensure_file_exists "$path"; then
exit 1
fi
При set -e поведение if ! func; then будет корректным: вы контролируете ветку ошибки.
3) trap для централизованной обработки
Для реальных скриптов полезно перехватывать аварийные завершения и показывать контекст. Например, логировать строку ошибки и команду.
on_error() {
local exit_code=$?
local line_no=$1
echo "Error: command failed with exit code $exit_code at line $line_no" >&2
}
trap 'on_error $LINENO' ERR
С set -euo pipefail такой подход особенно полезен: иначе вы получаете “скрипт умер”, но без точного места.
Обработка ошибок без паники: практики, которые работают
1) Там, где ошибка — это ожидаемое условие, обрабатывайте явно
Не пытайтесь “убить” set -e постоянными || true. Если ошибка ожидаема — обработайте её как ветвь.
Например, “файл может отсутствовать”:
if [[ -f "$file" ]]; then
cat "$file"
else
echo "No such file, skipping: $file" >&2
fi
Если же ошибка действительно должна считаться фатальной — пусть set -e поможет, но убедитесь, что команды и контекст корректны.
2) Используйте &&/|| только осознанно
Пайплайны и логические операторы имеют свою семантику с set -e. Если вы используете:
cmd && echo "ok" || echo "fail"
то получите не тот контроль, который ожидаете, особенно если cmd возвращает ненулевой статус в разных местах. Лучше явно:
if cmd; then
echo "ok"
else
echo "fail" >&2
fi
3) Валидация входных данных до выполнения опасных операций
Пример: вы собираетесь удалять файлы по шаблону. Сначала проверьте:
- что каталог существует,
- что это именно каталог, а не файл,
- что он не совпадает с
/или пустым значением, - что включен режим подтверждения (
--force).
Даже в bash это можно сделать дисциплинированно и безопасно.
Типичные подводные камни (короткий чеклист)
- Забытый quoting:
$varвместо"$var". - Пустые значения при
set -u: используйте${var:-}или${var:?}. - Пайплайны без
pipefail: скрытые ошибки. - Парсинг аргументов без проверки “что если нет значения”.
- Игнор кода возврата функций при
set -e. exitвнутри функций вместоreturn.- Некорректные
read/циклы, которые разбивают строку по пробелам.
Шаблон-скелет bash-скрипта: минимально надежный каркас для повторного использования
Ниже — скелет, который объединяет:
- строгие режимы,
- разбор аргументов (длинные опции),
- валидацию,
- функции,
- централизованный
trap, - скелет main-логики.
Можно вставлять и адаптировать.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
trap 'on_error $LINENO' ERR
on_error() {
local line_no=$1
local exit_code=$?
echo "Error: exit code $exit_code at line $line_no" >&2
}
usage() {
cat >&2 <<'EOF'
Usage: myscript.sh [options] [--] <inputs...>
Options:
--mode <auto|strict>
--path <path>
--timeout <seconds>
--verbose
-h, --help
EOF
}
validate_mode() {
local m="$1"
case "$m" in
auto|strict) return 0 ;;
*) echo "Invalid --mode: $m" >&2; return 1 ;;
esac
}
parse_timeout() {
local t="$1"
[[ -n "$t" ]] || { echo "timeout is empty" >&2; return 1; }
[[ "$t" =~ ^[0-9]+$ ]] || { echo "--timeout must be a non-negative integer" >&2; return 1; }
return 0
}
# --- defaults ---
Комментарии
Пока нет комментариев