В синтаксисе оболочки живут два механизма, которые начинающие путают регулярно: команды в круглых скобках и команды в угловых, вписанные в аргументы. Оба выглядят как "выполнить что-то отдельно", оба запускают дополнительный процесс, но эффект для переменных сценария у них противоположный: один прячет изменения безвозвратно, другой оставляет их в вашем распоряжении. Разница между этими механизмами это разница между сценарием, который считает правильно, и сценарием, который считает в никуда. Статья разбирает устройство обоих механизмов, классическую ловушку конвейера с циклом, сравнение выводов без временных файлов и правила выбора.
Два механизма запуска команд в изоляции и их внешние отличия
Подоболочка, subshell, выглядит как список команд в круглых скобках. Оболочка создаёт свою копию, копия выполняет команды, копия исчезает, родитель продолжает как ни в чём не бывало. Всё, что было у копии, переменные, текущий каталог, окружение, умирает вместе с ней, если не было выведено явно.
Подстановка процесса, process substitution, выглядит как команда, обёрнутая в угловые скобки со скобками. Оболочка запускает команду и выдаёт её ввод или вывод наружу под видом пути к файлу, а команда-получатель читает этот путь как обычный файл. Никаких временных файлов, никакого мусора, и основной сценарий продолжает работать в своей же оболочке.
Внешне различие держится на назначении. Круглые скобки это самостоятельная конструкция: она занимает своё место в сценарии и возвращает один код. Угловая подстановка это аргумент или источник перенаправления: она живёт внутри другой команды и возвращает путь. Смешивание выглядит забавно: круглые скобки внутри угловых это нормально, а вот самостоятельная угловая конструкция без принимающей команды смысла не имеет.
Оба механизма запускают дополнительный процесс, и в этом их родство. Оба наследуют окружение родителя на момент запуска. И на этом сходство заканчивается, потому что дальше начинаются противоположности: видимость переменных, судьба текущего каталога и место, где выполняется принимающая сторона. Эти противоположности и есть предмет статьи.
Subshell и потеря изменений переменных в родительской оболочке
Механика подоболочки честна и жестка: всё, что изменилось внутри скобок, остаётся в скобках. Присваивание переменной, смена каталога, настройка окружения исчезают вместе с копией:
(cd /srv/app && make install)
echo "$PWD"
Родительский сценарий напечатает прежний каталог, хотя внутри скобок тот успел побывать в другом месте. Присваивания ведут себя так же: переменная, получившая значение внутри скобок, снаружи остаётся прежней или несуществующей. Поток вывода при этом открыт: всё, что подоболочка напечатала, долетает до родительского вывода, и именно так из неё достают сведения.
Наследование работает в одну сторону. Копия видит переменные родителя на момент своего создания, включая экспортированные и локальные, но обратной дороги у изменений нет. Команда завершения внутри скобок тоже замыкается в них: копия заканчивается, родитель продолжает сценарий со следующей строки.
Применение подоболочки вырастает из её свойств. Изоляция смены каталога для одной операции: вошли, собрали, вышли, родитель не заметил. Временное изменение окружения: разделятор полей, локаль, маска прав, установленные внутри скобок, не трогают остальной сценарий. Опасный участок с сомнительными присваиваниями: всё, что натворит код в скобках, останется в скобках. Для всего этого круглые скобки это правильный инструмент.
Диагностика попадания в подоболочку держится на внутреннем счётчике подуровней: он растёт внутри скобок и в вызовах внешних сценариев. Печать счётчика в подозрительных местах сценария за минуту отвечает на вопрос, почему переменная не доехала: если она менялась при ненулевом подуровне, ответ найден.
Process substitution как файл из ниоткуда
Подстановка процесса решает другую задачу: команде нужен файл, а файла нет, есть только вывод другой команды. Классика жанра, сравнение двух отсортированных списков без создания промежуточных файлов:
diff <(sort list-a.txt) <(sort list-b.txt)
Каждая угловая конструкция запускает свою команду и подставляет вместо себя путь вида дескриптора. Команда сравнения видит два пути, открывает их как файлы и работает как с файлами, не подозревая, что за ними стоят живые процессы сортировки. Временные файлы не создаются, очистка не нужна, гонка за имена не возникает.
Направление подстановки задаётся знаком. Угловая скобка с минусом отдаёт вывод команды наружу как читаемый файл. Скобка с плюсом наоборот, открывает принимающий канал, и команда-источник пишет в него, как в файл. Второй вариант встречается реже, и его домен это команды, которые упрямо требуют путь для записи.
Число одновременно живых подстановок в одной команде ограничивается здравым смыслом: каждая это процесс со своим дескриптором, и конструкция с половиной десятка углов читается уже с трудом. Практика держит до трёх подстановок на команду, а дальнейшие источники оформляют последовательными шагами с промежуточными переменными.
Сама команда внутри углов живёт по правилам подоболочки: её переменные и её состояние не возвращаются в сценарий. Но принимающая команда, которой подставили пути, работает в текущей оболочке, и её переменные никуда не деваются. Это различие стоит держать в голове как таблицу: внутри углов изоляция, снаружи ничего не изменилось.
Ограничение механики стоит знать заранее: подстановка требует поддержки дескрипторных путей в системе, и на современных платформах это есть везде, где живёт современная оболочка. Экзотические встраиваемые системы иногда не умеют, и сценарий, рассчитанный на них, проверяется на месте.
Классическая ловушка конвейера с циклом чтения
Ловушка, собравшая больше всего вопросов на форумах, выглядит так: цикл, читающий из конвейера, накапливает переменные, которые после цикла исчезают. Конвейер отправляет цикл в подоболочку, со всеми последствиями:
count=0
cat items.txt | while read -r line; do
count=$((count+1))
done
echo "$count"
Вывод напечатает ноль: цикл честно считал строки в своей копии, копия завершилась, счётчик похоронен вместе с ней. Родительский счётчик ждал своего часа и не дождался ни одного сложения. Лекарство построено на подстановке: цикл остаётся в текущей оболочке, а ввод приходит из перенаправления, источником которого стоит угловая конструкция:
count=0
while read -r line; do
count=$((count+1))
done < <(cat items.txt)
echo "$count"
Теперь цикл принадлежит сценарию, его счётчики живут в его переменных, и вывод честно печатает число строк. Угловая конструкция по-прежнему запускает свой процесс в изоляции, но её роль сузилась до поставщика ввода, а не хозяина цикла.
Правило читается одной строкой: цикл, накапливающий состояние, читает из перенаправления, а не из конвейера. Конвейер оставляют для конвейеров, где накопление не нужно, где вывод уходит дальше по трубе и переменные никому не нужны.
Отдельная вежливость к читателю: оба варианта выглядят почти одинаково, различие прячется в направлении символа сравнения. Комментарий в сценарии о том, почему выбран перенаправление, а не конвейер, экономит следующему читателю сеанс экспериментов, а сценарию репутацию загадочного.
Сравнение выводов команд без временных файлов
Домен подстановки, где она раскрывается ярче всего, это сравнение и соединение потоков. Сравнение фактического и желаемого состояния системы строится парой конструкций: слева углы с командой, запрашивающей текущее состояние, справа углы с чтением эталонного файла:
diff <(systemctl list-units --type=service --no-legend | awk '{print $1}' | sort) \
<(sort expected-services.txt)
Сверка конфигурации с её описанием, сравнение содержимого двух архивов по спискам имён, соединение колонок из двух источников: везде шаблон один, источник данных в углах, принимающая команда снаружи. Сценарии проверок превращаются из цепочек временных файлов в читаемые конструкции, где каждая угловая скобка показывает, откуда берётся её сторона сравнения.
Экономия выходит за пределы удобства. Временные файлы это имена, права, очистка и гонки параллельных запусков. Подстановка снимает весь класс: у источника нет имени, нечего чистить, нечему конфликтовать. Для сценариев, работающих по расписанию, отсутствие хвоста из временных файлов это разница между чистым каталогом и свалкой через год.
Дисциплина порядка дополняет картину: подстановки стартуют одновременно, и порядок завершения не гарантирован. Для сравнений и соединений это несущественно, принимающая команда ждёт обе стороны. Для сценариев, где важна очерёдность, подстановки не строят, а используют последовательные шаги.
Группировка команд в текущей оболочке против изоляции
Рядом с круглыми скобками живёт их вечный двойник, фигурные скобки. Группировка команд в фигурных скобках не создаёт подоболочки: команды выполняются в текущей оболочке, и все изменения, переменные, каталог, окружение, остаются в сценарии:
total=0
for f in *.log; do
lines=$(wc -l < "$f")
total=$((total+lines))
done
echo "$total"
Общее перенаправление для группы, общая ловушка, накопление переменных в цикле, всё это поводы выбрать фигурные скобки. Круглые скобки выбирают противоположное: изоляцию со сбросом. Практический тест выбора звучит вопросом: должны ли изменения пережить группу. Да, значит фигурные, нет, круглые.
Смешанный приём, круглые скобки внутри фигурных, покрывает сценарии с локальными вылазками: группа работает в текущей оболочке, а сомнительные шаги внутри уезжают в изоляцию и возвращаются только выводом. Схема читается как обеденный этикет: главное блюдо на столе, специи на отдельной тарелке.
Обратная сторона фигурных скобок это и их опасность: всё, что группа меняет, она меняет по-настоящему. Текущий каталог, переменные, окружение, маска прав, изменения применяются без возврата. Группировка в фигурных скобках это не безопасный песочный дворик, а просто способ записать несколько команд в одном месте, и дисциплина их содержимого остаётся обычной дисциплиной сценария.
Рабочие сценарии выбора между механизмами
Свод правил выбора, закрывающий большинство практических случаев, выглядит так:
- Изоляция смены каталога и окружения для одной операции это круглые скобки;
- Цикл, накапливающий переменные, читает из перенаправления с подстановкой;
- Сравнение и соединение выводов команд идёт через углы без временных файлов;
- Группировка команд с общим перенаправлением и сохранением состояния это фигурные скобки;
- Сомнительные участки с непредсказуемыми присваиваниями уезжают в подоболочку;
- Команды, требующие файловых путей, получают подстановки как аргументы.
За правилами стоит одна карта мира: внутри круглых скобок и внутри углов всегда подоболочка, снаружи всегда текущая оболочка. Кто держит карту, тот не путает механизмы, даже когда синтаксис поехал.
Проверка понимания умещается в минуту: два коротких опыта в интерактивной оболочке, присваивание в скобках с проверкой снаружи и цикл с перенаправлением из углов с проверкой счётчика. Кто проделал оба опыта руками, механизмы больше не перепутает, потому что знание о видимости переменных начинается не в чтении, а в пальцах.
Итоговое суждение точное и короткое. Разница между двумя конструкциями это разница в одну букву заголовка и в целую судьбу переменных: одни скобки возвращают всё, что вы сделали, другие не возвращают ничего. Сценарии, где эта разница осознана, читаются предсказуемо и работают одинаково из года в год, а сценарии, где механизмы перепутаны, узнаются по счётчикам, которые вечно показывают ноль, и по комментариям с словом почему-то в трёх местах.
Мелкая привычка с большим эффектом: комментарий рядом с каждой угловой подстановкой, одной строкой о её источнике. Конструкции плотные, источник легко забывается, а строка комментария держит сценарий читаемым для следующего дежурного.