Язык shell: кавычки, подстановки, коды возврата

TL;DR: Shell обрабатывает строку до передачи команде: раскрывает переменные, разворачивает glob (*), разбивает по пробелам. Одинарные кавычки '...' отключают всё. Двойные "..." — только glob. Условия (if) работают на кодах возврата команд: 0 = true, не-0 = false. [ — это обычная программа, а не синтаксис.

Зачем это знать

Без понимания того, как shell разбирает строку, вы будете писать скрипты вслепую — «работает, но не знаю почему» или «ломается, и не понимаю где». Конкретно:

  • grep r.*t /etc/passwd работает… пока в текущей директории не появятся файлы r.input, r.output — тогда shell развернёт r.*t в имена файлов до передачи grep
  • echo $100 выведет 00, потому что shell подставит $1 (пустую переменную) + 00
  • if [ $var = "hi" ] упадёт с ошибкой, если $var пустая — потому что [ получит [ = hi ]

Как shell обрабатывает строку

Каждая строка проходит через цепочку подстановок до того, как команда её увидит:

Вы вводите:   echo "Hello $USER" *.txt
                      │              │
                      ▼              ▼
1. Раскрытие      "Hello alice"   *.txt
   переменных     ($USER → alice)
                      │              │
                      ▼              ▼
2. Раскрытие      "Hello alice"   notes.txt todo.txt
   glob (*)                       (файлы в текущей директории)
                      │              │    │
                      ▼              ▼    ▼
3. Разбиение      Параметр 1     Пар.2  Пар.3
   по пробелам    "Hello alice"
                      │              │    │
                      ▼              ▼    ▼
echo получает:   echo "Hello alice" notes.txt todo.txt

Команда echo не знает, что были переменные и glob — она видит только готовые аргументы. Именно поэтому кавычки критичны: они управляют тем, какие шаги shell выполняет.

Кавычки: три уровня защиты

Одинарные кавычки '...' — полная защита

Всё внутри одинарных кавычек — литерал. Shell ничего не раскрывает, не подставляет, не разбивает:

echo '$USER'           # → $USER  (буквально)
echo 'Hello   World'   # → Hello   World  (пробелы сохранены)
grep 'r.*t' /etc/passwd # → grep ищет regex r.*t (не имена файлов!)
echo '$100'            # → $100  (не пытается подставить $1)

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

Двойные кавычки "..." — частичная защита

Внутри двойных кавычек shell раскрывает переменные ($VAR) и подстановки команд ($(cmd)), но не раскрывает glob (*, ?) и не разбивает по пробелам:

echo "Hello $USER"              # → Hello alice  (переменная раскрыта)
echo "Files: *.txt"             # → Files: *.txt  (glob НЕ раскрыт)
echo "There are $(wc -l < f) lines"  # → подстановка команды работает
echo "Path: $PATH"             # → Path: /usr/bin:/usr/local/bin:...

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

Обратная косая черта \ — защита одного символа

echo \$USER            # → $USER  (экранирован один $)
echo "Price: \$100"    # → Price: $100  (внутри двойных кавычек)
echo don\'t            # → don't  (одинарная кавычка вне кавычек)

Сводка

СинтаксисПеременныеGlob (*)ПробелыКогда использовать
'text'❌ не раскрываетСохраняетRegex, литеральные строки
"text"✅ раскрываетСохраняетСтроки с переменными
\xЭкранирует один символОтдельные спецсимволы
без кавычекРазбиваетТолько когда нужны glob и word splitting

Одинарная кавычка внутри одинарных кавычек

Это единственный сложный случай — внутри '...' нельзя поставить '. Два решения:

# Через обратную косую черту (вне кавычек)
echo 'it'\''s working'        # it's working
#     ^^^  ^^^^^^^^^^
#     lit  \'  lit
 
# Через двойные кавычки
echo "it's working"            # it's working

Glob: раскрытие имён файлов

Shell раскрывает шаблоны *, ?, [...] в имена существующих файлов до передачи команде:

# В директории: notes.txt  todo.txt  readme.md
 
echo *.txt             # → notes.txt todo.txt  (shell подставил)
echo '*.txt'           # → *.txt  (литерал, без раскрытия)
 
ls *.md                # shell раскроет в: ls readme.md
rm *.log               # если *.log не совпадает ни с чем → ошибка (или литерал *.log)

Подвох с regex: grep r.*t file — shell может раскрыть r.*t в имена файлов, если они совпадут. Всегда кавычьте regex:

grep 'r.*t' /etc/passwd    # ✅ grep получает regex r.*t
grep r.*t /etc/passwd      # ⚠️ может сломаться, если есть файл r.output

Коды возврата: 0 = успех

Каждая Unix-программа при завершении возвращает числовой код. Shell хранит его в $?:

ls /etc > /dev/null
echo $?                    # → 0 (успех)
 
ls /nonexistent > /dev/null 2>&1
echo $?                    # → 1 (ошибка: нет такого файла)

0 = успех, всё остальное = ошибка. Это фундаментальное соглашение Unix.

Нюанс: не-0 не всегда ошибка

Некоторые программы используют разные коды для разных ситуаций:

ПрограммаКод 0Код 1Код 2
grepНайдено совпадениеНет совпадений (не ошибка!)Реальная ошибка
diffФайлы идентичныФайлы различаются (не ошибка!)Реальная ошибка

Поэтому grep -q pattern file || echo "not found" — это нормальная логика, а не обработка ошибки.

exit в скриптах

# Завершить скрипт с кодом ошибки
if [ ! -f "$CONFIG" ]; then
    echo "$0: config file not found: $CONFIG" >&2
    exit 1
fi
 
# Код возврата 0 (успех) — по умолчанию, если скрипт дошёл до конца

Важно: $? перезаписывается

ls /nonexistent 2>/dev/null
echo $?          # → 2 (ошибка ls)
echo $?          # → 0 (успех предыдущего echo!)

Если нужен код — сохраняйте сразу: result=$?.

Условные операторы работают на кодах возврата

Это ключевой инсайт: if в shell — не сравнение значений, а проверка кода возврата команды.

if command; then
    # выполняется если command вернула 0 (успех)
else
    # выполняется если command вернула не-0 (ошибка)
fi

Поэтому if работает с любой командой, не только с [:

# if с grep (код 0 = найдено)
if grep -q root /etc/passwd; then
    echo "root exists"
fi
 
# if с ping
if ping -c 1 -W 2 8.8.8.8 > /dev/null 2>&1; then
    echo "Internet is up"
fi
 
# if с mkdir
if mkdir -p /tmp/myapp; then
    echo "Directory ready"
fi

Точка с запятой перед then — это разделитель команд, а не синтаксис if. Без неё shell передаст then как аргумент команде [, что приведёт к непонятной ошибке. Альтернатива — then на отдельной строке:

if [ "$1" = "hi" ]; then    # ; отделяет команду [ от ключевого слова then
    echo "hi"
fi
 
# Эквивалент без ;
if [ "$1" = "hi" ]
then
    echo "hi"
fi

[ — это программа, а не синтаксис

Когда вы пишете if [ $x = "hi" ], символ [ — это настоящая программа (/usr/bin/[), также известная как test. Она сравнивает аргументы и возвращает 0 (true) или 1 (false):

# Эти записи эквивалентны:
if [ "$x" = "hi" ]; then ...
if test "$x" = "hi"; then ...
 
# [ — это команда, пробелы обязательны:
[ "$x" = "hi" ]     # ✅ четыре аргумента: "$x", =, "hi", ]
["$x" = "hi"]       # ❌ shell ищет команду ["$x"

Именно поэтому пробелы вокруг [ и ] обязательны — это аргументы команды, а не скобки в синтаксисе.

[ vs [[

[[ — это ключевое слово bash (не внешняя программа), работает надёжнее:

[ ... ] (test)[[ ... ]] (bash)
СтандартPOSIX (любой sh)Только bash
Без кавычек у переменныхЛомается при пустых значенияхРаботает (но кавычки всё равно хорошая привычка)
RegexНет[[ $str =~ ^[0-9]+$ ]]
Логика-a (AND), -o (OR)&&, ||
# POSIX (переносимый) — всегда кавычки
if [ "$var" = "value" ]; then ...
 
# Bash — надёжнее, поддерживает regex
if [[ $var == value ]]; then ...
if [[ $input =~ ^[0-9]+$ ]]; then echo "число"; fi

Производительность: хотя /usr/bin/[ существует как отдельная программа, в bash (и большинстве современных sh) test/[ встроен в оболочку — shell не запускает отдельный процесс для каждой проверки.

Операторы test / [

[ поддерживает десятки проверок. Они делятся на три категории: файлы, строки, числа.

Проверка файлов — тип:

ОператорTrue, если…
-e fileФайл существует (любого типа)
-f fileОбычный файл (не каталог, не устройство)
-d fileКаталог
-h fileСимволическая ссылка
-b fileБлочное устройство
-c fileСимвольное устройство
-p fileИменованный канал (FIFO)
-S fileСокет

Нюанс с symlink: все проверки кроме -h проверяют объект, на который указывает ссылка, а не саму ссылку. Если link → обычный файл, то [ -f link ] вернёт true.

Проверка файлов — права и свойства:

ОператорTrue, если…
-r fileЕсть право на чтение
-w fileЕсть право на запись
-x fileЕсть право на исполнение
-s fileФайл не пустой (размер > 0)
-u fileУстановлен бит setuid
-g fileУстановлен бит setgid
-k fileУстановлен sticky bit

Сравнение файлов (бинарные):

ОператорTrue, если…
f1 -nt f2f1 новее f2 (newer than)
f1 -ot f2f1 старше f2 (older than)
f1 -ef f2Одинаковый inode (жёсткая ссылка)
# Практические примеры
[ -f "$CONFIG" ] || error "Config not found"
[ -d "$BACKUP_DIR" ] || mkdir -p "$BACKUP_DIR"
[ -x "$BINARY" ] || error "$BINARY is not executable"
[ "$SRC" -nt "$OBJ" ] && recompile "$SRC"    # пересобрать, если исходник новее

Строки:

ОператорTrue, если…
str1 = str2Строки равны
str1 != str2Строки не равны
-z strСтрока пустая
-n strСтрока не пустая

Числа:

ОператорЗначение
-eqРавно
-neНе равно
-ltМеньше
-gtБольше
-leМеньше или равно
-geБольше или равно

Ключевое различие = vs -eq: оператор = сравнивает строки, -eq сравнивает числа:

[ "01" = "1" ]     # false!  (строки "01" и "1" различаются)
[ "01" -eq "1" ]   # true    (числа 01 и 1 равны)
[ "10" -gt "9" ]   # true    (числовое сравнение)
# Но: [ "10" > "9" ] — НЕ сравнение, а перенаправление в файл "9"!

Логика внутри [: -a (AND), -o (OR), ! (NOT):

# -a и -o — внутри одной команды [
[ -f "$FILE" -a -r "$FILE" ]        # файл существует И читаем
[ "$1" = "hi" -o "$1" = "bye" ]     # одно ИЛИ другое
 
# ! — инверсия
[ ! -d "$DIR" ] && mkdir -p "$DIR"  # если НЕ каталог → создать
[ ! "$1" = "hi" ]                   # не равно (эквивалент !=)
 
# В [[ используйте && и || вместо -a и -o:
[[ -f "$FILE" && -r "$FILE" ]]

Логические операторы && и ||

Работают на кодах возврата — это сокращённая форма if:

# && — выполнить вторую команду, если первая успешна (код 0)
mkdir -p /tmp/app && echo "Created"    # echo выполнится только при успехе mkdir
 
# || — выполнить вторую команду, если первая неуспешна (код не-0)
cd /app || exit 1                      # exit выполнится только если cd не удался
 
# Комбинация (паттерн: сделай или умри)
cd "$DEPLOY_DIR" || { echo "Cannot cd to $DEPLOY_DIR" >&2; exit 1; }

elif — цепочка условий

elif позволяет проверять несколько условий последовательно. Выполняется только первая истинная ветка:

if [ "$1" = "hi" ]; then
    echo "Привет"
elif [ "$1" = "bye" ]; then
    echo "Пока"
elif [ "$1" = "help" ]; then
    usage
else
    echo "Неизвестная команда: $1" >&2
    exit 1
fi

Совет: если elif-веток больше двух-трёх — переходите на case, он читается лучше и работает через сопоставление с шаблонами, а не коды возврата.

case — множественное ветвление

case сравнивает значение с шаблонами (glob, не regex). Каждая ветка завершается ;;:

case "$1" in
    start)
        echo "Starting..."
        ;;
    stop|restart)              # | = ИЛИ (несколько паттернов)
        echo "Stopping..."
        ;;
    -h|--help)
        usage
        ;;
    *)                         # default (любое значение)
        echo "Unknown: $1" >&2
        exit 1
        ;;
esac

case — основной способ обработки подкоманд и форматов в скриптах. Часто встречается внутри getopts (см. write-scripts).

Циклы

В shell два вида циклов, и они работают по-разному: for итерирует по списку слов, while проверяет код возврата команды.

for — итерация по списку

for присваивает переменной каждое значение из списка и выполняет тело цикла:

for str in one two three four; do
    echo "$str"
done
# → one, two, three, four (по одному на строку)

Список после in — это обычные слова, которые shell обрабатывает по стандартным правилам (раскрытие переменных, glob). Именно glob делает for мощным инструментом для работы с файлами:

# Итерация по файлам — главная сила for в shell
for f in /etc/*.conf; do
    [ -f "$f" ] || continue       # пропустить, если glob не раскрылся
    echo "Config: $f ($(wc -l < "$f") lines)"
done
 
# Диапазон (bash)
for i in {1..5}; do echo "$i"; done
 
# C-style (bash) — для числовых итераций
for ((i=0; i<10; i++)); do
    echo "Step $i"
done

Ключевое отличие: for не проверяет коды возврата — он просто перебирает список. Если список пуст (glob ничего не нашёл), тело цикла не выполняется.

Подробнее о формах for с практическими примерами → 04-shell-and-scripting.

while — цикл на коде возврата

Как и if, цикл while проверяет код возврата команды-условия:

# while выполняется, пока команда возвращает 0 (успех)
while command; do
    # тело цикла
done

Поэтому while работает с любой командой, не только с [:

# while с grep — читать файл, пока в последних 10 строках есть "firstline"
while tail -10 "$FILE" | grep -q firstline; do
    echo "newline" >> "$FILE"
done
 
# while с read — построчное чтение (самый частый паттерн)
while IFS= read -r line; do
    echo "Line: $line"
done < /etc/hosts

until — инверсия while: выполняется, пока команда возвращает не-0:

until ping -c 1 -W 2 server.local > /dev/null 2>&1; do
    echo "Waiting for server..."
    sleep 5
done
echo "Server is up!"

break выходит из цикла досрочно, continue переходит к следующей итерации.

Рекомендация Уорда: если вам понадобился сложный while — возможно, пора переключиться на Python или awk. for с glob-ом (for f in *.txt) — это сила shell. while с арифметикой или сложными условиями — уже не его территория.

Shebang: как запускаются скрипты

#!/bin/sh           # POSIX shell (максимальная переносимость)
#!/bin/bash         # Bash (массивы, [[ ]], regex, ${var,,})
#!/usr/bin/env bash # Bash из PATH (не привязан к /bin/bash)
#!/usr/bin/env python3  # Python

Когда вы запускаете ./myscript, ядро читает первую строку, видит #! и запускает указанный интерпретатор: фактически выполняется /bin/sh myscript.

#!/usr/bin/env bashenv ищет bash в текущем $PATH. Удобно, если bash установлен не в /bin/bash (macOS, нестандартные системы). Минус: первый найденный bash может оказаться не тем, что нужен.

# Сделать скрипт исполняемым
chmod +x script.sh       # чтение + выполнение для всех
chmod 700 script.sh      # чтение + выполнение только для владельца

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

ЛовушкаПочемуРешение
echo $10000$1 подставляется (пусто) + 00echo '$100' или echo "\$100"
[ $var = hi ] при пустом $var[ получает [ = hi ] (нет первого аргумента)[ "$var" = hi ] (всегда кавычки!). В legacy-скриптах встречается [ x"$var" = x"hi" ] — префикс x гарантирует непустой аргумент даже в старых shell без корректной обработки "". Сегодня кавычек достаточно
grep r.*t file ломается случайноShell развернул r.*t в имена файловgrep 'r.*t' file
if [$var = "hi"][ — программа, нужны пробелыif [ "$var" = "hi" ]
echo $? дважды → второй раз 0$? перезаписан успешным echoresult=$? сразу после команды
Скрипт не запускаетсяНет shebang или chmod +x#!/bin/bash + chmod +x

Связанные материалы

  • shell-internals — подоболочки, sourcing, exec (уровень процессов)
  • write-scripts — шаблон скрипта, аргументы, getopts, trap, отладка
  • 04-shell-and-scripting — первый скрипт, переменные, циклы, функции