SSD Nodes Learn 🎉 VPS от $4.99/мес
Руководства Matt ConnorАвтор: Matt Connor · Обновлено 2026-08-07

Bash: разница между $( ) и обратными кавычками

Узнайте, почему $( ) предпочтительнее обратных кавычек. Разберем работу subshell, потерю переменных при cd и проблемы с вложенностью, которые возникают в старом синтаксисе.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, August 4, 2026.

Что делает подстановка команд в bash

Подстановка команд в bash заменяет $(command) текстом, который эта команда вывела в стандартный поток вывода. Старый синтаксис с обратными апострофами выполняет ту же задачу. Все неожиданные для пользователей моменты вытекают из двух фактов: команда выполняется в отдельном процессе, называемом subshell, а все символы переноса строки в конце вывода отбрасываются.

mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"
3

Это и есть вся функциональность. wc -l вывела 3 с последующим переносом строки, перенос был удален, и count содержит два нужных вам символа. Заметьте, что wc -l < three.txt выводит только число, так как GNU wc при чтении стандартного ввода не имеет имени файла для вывода. Используйте wc -l three.txt, чтобы получить 3 three.txt — это другая строка, и именно она часто становится причиной ошибок при последующих арифметических операциях.

Остальная часть этого руководства описывает поведение, которого никто не ожидает, поскольку subshell является отдельным процессом, а отдельный процесс не может изменить оболочку, в которой вы работаете.

Используйте $() вместо обратных кавычек

Обе формы синтаксически верны. $() входит в стандарт POSIX, поэтому dash, ash и busybox sh полностью его поддерживают. Больше нет причин использовать обратные кавычки ради переносимости, зато есть две веские причины от них отказаться.

Обратные кавычки не поддерживают вложенность

echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"
hi
echo hi

Вторая строка вывела буквально слова echo hi. Оболочка ищет следующую неэкранированную обратную кавычку, поэтому вторая кавычка закрыла первую. Фактически была выполнена команда echo без аргументов, которая вывела пустую строку, после чего символ переноса строки был удален. Слова echo hi остались как обычный текст, а последняя пара кавычек выполнила пустую команду.

Чтобы вложить обратные кавычки, необходимо экранировать каждую внутреннюю кавычку:

echo "`echo \`echo hi\``"
hi

Каждый следующий уровень вложенности удваивает количество экранирующих символов. $() не требует ничего подобного, так как парсер сопоставляет скобки, а не ищет символ-разделитель.

Обратные кавычки меняют обратные косые черты до запуска команды

echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"
a\\b
a\b

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

Кавычки внутри $() также начинают работать «с чистого листа», что позволяет вкладывать двойные кавычки без их экранирования:

path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"
/etc/nginx

Эквивалент с обратными кавычками требует \" вокруг $path. Каждое экранирование — это потенциальное место для ошибки.

Почему команда cd внутри $() не меняет текущую директорию оболочки

Потому что $(...) создает новый процесс. Дочерняя оболочка (subshell) получает копию ваших переменных и копию текущей рабочей директории. Она меняет свою копию, выводит результат и завершается. Копия уничтожается вместе с ней.

cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd
/tmp/subst-demo
/etc
/tmp/subst-demo

Никакой ошибки не произошло. Команда cd выполнилась, а pwd внутри дочерней оболочки действительно вывела /etc. Просто нет способа передать это изменение обратно, так как единственное, что дочерняя оболочка возвращает родительскому процессу — это стандартный вывод и код завершения.

Присваивание переменных работает точно так же:

count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"
inside: 99
outside: 0

Это же правило объясняет версию данной ошибки, с которой пользователи сталкиваются гораздо чаще и которая связана с конвейером (pipeline), а не с подстановкой:

n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"
0

Каждый этап конвейера выполняется в собственной дочерней оболочке, поэтому цикл while увеличил копию n и завершился. Замените конвейер перенаправлением, и цикл будет выполнен в текущей оболочке:

n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"
3

Bash может выполнять последний этап конвейера в текущей оболочке с помощью shopt -s lastpipe, но только когда управление заданиями (job control) отключено, что никогда не бывает верно для интерактивной оболочки. Используйте перенаправление.

Куда пропадает приглашение командной строки? Интерактивные команды внутри $()

Подстановка команды перенаправляет стандартный вывод в канал (pipe), не затрагивая стандартный ввод. Программа, которая выводит вопрос в стандартный вывод и ожидает ответа, теряет этот вопрос, но не само ожидание. Терминал при этом выглядит зависшим.

ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
ask
Username: deploy
deploy

Слово deploy на первой строке — это то, что вы ввели. Теперь выполните ту же функцию внутри подстановки:

v=$(ask)
deploy

Отображаются только ваши нажатия клавиш, которые эхо-выводит драйвер терминала, а не сама программа. Приглашение ушло в другое место:

echo "$v"
Username: deploy

Приглашение теперь находится внутри переменной, приклеенное к ответу, так как $() захватила всё, что функция записала в стандартный вывод. Ваш ввод всё равно дошёл до read, так как стандартный ввод не был затронут. Это точный признак ошибки, описываемой как «мой скрипт зависает и ничего не выводит».

Некоторые инструменты записывают приглашения в стандартный поток ошибок или напрямую в /dev/tty, поэтому они сохраняются при подстановке. Многие этого не делают. Если функция должна оставаться внутри подстановки, направьте её приглашение в стандартный поток ошибок самостоятельно:

ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"
Username: deploy
deploy

Стандартный поток ошибок не захватывается, поэтому приглашение доходит до вашего терминала, а в v попадает только ответ.

Почему вывод ls выглядит иначе внутри $()?

Потому что ls вызывает isatty для дескриптора файла 1 и меняет формат вывода в зависимости от результата. В командной строке этот дескриптор указывает на ваш терминал, поэтому ls распределяет имена по строке в несколько колонок. Внутри подстановки это канал (pipe), поэтому ls переключается на вывод по одному имени в строке.

mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"
alpha
beta
delta
gamma

Эта же проверка отключает цвета в grep --color=auto и отключает пейджер в git. Это штатная функция. Она гарантирует, что скрипт получит стабильный машиночитаемый вывод без необходимости запрашивать его отдельно.

Это также отвечает на вопрос, который часто задают о конвейерах (pipelines). ls | sort в командной строке и $(ls | sort) выводят одно и то же, так как у ls в обоих случаях на выходе был канал. Внутри подстановки меняется последний этап конвейера. sort никогда не проверяет наличие терминала, поэтому его вывод не меняется. Если поставить команду, чувствительную к терминалу, в конец конвейера, её поведение изменится. Именно поэтому конвейер, который вы проверили визуально, может работать иначе, как только вы обернете его в $().

Важное предостережение: не парсите вывод ls в скриптах, даже если это кажется удобным. Имена файлов могут содержать пробелы и символы новой строки. Используйте glob или find -print0 с read -d ''.

Завершающие символы новой строки, которые исчезают незаметно

Подстановка команды удаляет все символы новой строки в конце вывода. Не только последний. Все до единого.

cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c
8
5

Файл содержит hello и три символа новой строки, итого 8 байт. Переменная содержит hello, то есть 5 байт. Три байта исчезли без предупреждения.

Это удаление является намеренным и обычно полезным. Именно благодаря ему stamp=$(date -u +%Y%m%dT%H%M%SZ) создает пригодный для использования фрагмент имени файла, а не имя, содержащее разрыв строки. Поэтому данный шаблон безопасен, например, в скрипте для запланированного резервного копирования restic:

stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"
backup-20260804T031500Z.tar.gz

Ваша метка времени будет отличаться. Важно то, что имя состоит из одной строки.

Минус в том, что вы не можете использовать $() для перемещения точных байтов файла. Если вам нужны завершающие символы новой строки, добавьте контрольный символ внутри подстановки, а затем удалите его:

v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c
8

Символ x находится после символов новой строки, поэтому завершающих пустых строк для удаления не остается. ${v%x} затем убирает контрольный символ и оставляет исходные байты.

Две связанные детали. Для чтения всего файла v=$(<blanks.txt) выполняет ту же работу без запуска cat, так как bash открывает файл самостоятельно. Он удаляет завершающие символы новой строки точно так же. А here-strings работают в обратном направлении, добавляя символ новой строки, который вы не указывали:

wc -c <<< 'abc'
4

Заключайте результат в кавычки, иначе bash выполнит разбиение по словам и раскрытие путей

Некавыченная подстановка проходит через разбиение по словам, а затем через раскрытие путей (pathname expansion). Подстановка в кавычках не проходит ни через то, ни через другое.

printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"
a b c d
a b	c
d

Без кавычек bash разбил вывод по символам из IFS, которыми по умолчанию являются пробел, табуляция и перевод строки, а echo затем объединил четыре части одиночными пробелами. В кавычках текст был передан как одно слово, сохранив табуляцию и внутренний символ перевода строки.

Раскрытие шаблонов (globbing) — это более опасная часть:

mkdir -p /tmp/glob-demo
cd /tmp/glob-demo
touch one.txt two.txt
printf '*\n' > pattern.txt
p=$(cat pattern.txt)
echo $p
echo "$p"
one.txt pattern.txt two.txt
*

Само присваивание было безопасным, так как при присваивании не происходит разбиения по словам или раскрытия шаблонов. Ущерб был нанесен в echo $p, где * был раскрыт относительно текущего каталога. Скрипт, который считывает шаблон из конфигурационного файла и «забывает» про кавычки, будет успешно применять его к каждому файлу, который «видит». Заключайте в кавычки каждое раскрытие, и целый класс ошибок исчезнет. Оставляйте переменные без кавычек только в тех редких случаях, когда вам действительно требуется разбиение.

Почему local x=$(cmd) всегда возвращает 0?

Потому что local — это сама по себе команда, и $? сообщает статус выполнения local, а не статус подстановки, которая в ней содержалась.

check_bad() { local out=$(false); echo "status: $?"; }
check_bad

local x=$(false)
echo $?

status: 0

false завершилась с кодом 1, local успешно выполнила объявление переменной, и единица была отброшена. declare, export, typeset и readonly ведут себя точно так же. set -e также не перехватит ошибку, так как с точки зрения оболочки ничего не завершилось сбоем.

Разделите объявление и присваивание:

check_good() { local out; out=$(false); echo "status: $?"; }
check_good

local x
x=$(false)
echo $?

status: 1

Обычное присваивание на верхнем уровне уже возвращает статус последней подстановки команды:

out=$(exit 3)
echo $?

x=$(false)
echo $?

3

Это критически важно для проверок работоспособности, выполняемых в рамках systemd service и timer, где скрытый код завершения приводит к тому, что юнит отчитывается об успехе при каждом запуске, хотя работа, которую он должен был проверить, так и не была выполнена.

Альтернативы для командной оболочки, которые вам действительно нужны

Большинство пользователей выбирают $(...), когда хотят сохранить данные в переменную. Часто им на самом деле нужен ввод данных, а не их захват. Эти четыре способа позволяют сохранить состояние в текущей оболочке.

Перенаправление в цикле

cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txt
got: alpha
got: beta
got: gamma

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

Подстановка процесса

while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)
got: gamma
got: beta
got: alpha

<(command) предоставляет путь, например /dev/fd/63, из которого считывается вывод команды. Сама команда по-прежнему выполняется в собственном процессе. Цикл while в отдельном процессе не выполняется, в этом и заключается основная цель. Пробел в < <( обязателен: <<( будет воспринят как начало here-document и приведет к ошибке синтаксического анализа. Подстановка процесса — это функция bash, поэтому скрипт с #!/bin/sh в Ubuntu или Debian будет выполняться через dash и завершится ошибкой. Используйте #!/bin/bash.

Here-strings

read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"
alpha
beta gamma

<<< передает строку на стандартный ввод команды. read выполняется в вашей оболочке, поэтому обе переменные будут установлены и доступны для использования. read -r a b <<< "$(some-command)" — это стандартный способ извлечения двух полей из одной строки вывода.

mapfile для обработки файлов целиком

mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"
3
beta

mapfile, также известный как readarray, считывает файл в массив в текущей оболочке. -t удаляет завершающий символ новой строки из каждого элемента. Для работы требуется bash версии 4 или новее; Ubuntu 24.04 поставляется с bash 5.2, поэтому данная функция доступна в любом актуальном образе сервера.

Контрольный список перед фиксацией скрипта

  • Пишите $(command) и заключайте его в кавычки как "$(command)", если только вам не требуется разделение.
  • Учитывайте, что завершающие символы новой строки удаляются. Добавьте контрольный символ, если они вам нужны.
  • Не используйте интерактивные команды внутри подстановок или направляйте их приглашения в стандартный поток ошибок.
  • Пишите local out на отдельной строке, если важен код завершения out=$(command).
  • Для установки переменных из входных данных используйте перенаправление или подстановку процесса вместо конвейера.

Эти конструкции часто встречаются в первых небольших скриптах, которые пишут пользователи при настройке нового VPS, и ошибки в них остаются незамеченными. Скрипт резервного копирования, который записал приглашение в имя файла, или проверка работоспособности, скрывшая код выхода, продолжают сообщать об успехе. Издержки возрастают, когда вы запускаете один и тот же скрипт на нескольких серверах, поскольку вывод, который вы не читали, теперь становится выводом, который вы не читаете на двадцати машинах.

FAQ

Почему команда cd внутри $() не меняет текущую рабочую директорию?

$(...) выполняет команду в подоболочке (subshell), которая является отдельным процессом, содержащим копию вашей рабочей директории и переменных окружения. cd меняет эту копию, после чего процесс завершается, а копия отбрасывается. Подоболочка может вернуть только стандартный вывод и код завершения, поэтому механизм передачи изменения директории родительской оболочке отсутствует. Если вам нужно получить путь к директории, сохраните его с помощью target=$(cd /etc && pwd) и используйте "$target". Если вы хотите, чтобы ваша оболочка перешла в директорию, выполните cd напрямую, без использования подстановки.

В чем разница между $() и обратными кавычками в bash?

Для простых команд они дают одинаковый результат, но различаются в двух важных аспектах. $() поддерживает прямую вложенность, так как парсер корректно обрабатывает скобки, в то время как для обратных кавычек требуется экранирование каждого уровня вложенности. Кроме того, обратные кавычки удаляют один уровень экранирования обратной косой чертой перед тем, как внутренняя команда будет разобрана, поэтому ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`, поэтому аргументов в пользу переносимости обратных кавычек не существует.

Почему мой скрипт зависает без приглашения к вводу, когда команда задает вопрос?

Подстановка команды перенаправляет стандартный вывод в канал (pipe), но оставляет стандартный ввод подключенным к вашему терминалу. Программа, выводящая приглашение в стандартный вывод, отправляет его в переменную, в то время как read за ней продолжает ожидать вашего ввода. Терминал отображает только те символы, которые вы вводите, эхо которых обеспечивает драйвер терминала. Вынесите вопрос за пределы подстановки или направьте приглашение в стандартный поток ошибок с помощью printf 'Username: ' >&2, чтобы оно не было захвачено.

Почему пустые строки в конце моей переменной исчезли?

Подстановка команды удаляет все завершающие символы новой строки, а не только последний. printf 'hello\n\n\n' > f; v=$(cat f) приводит к тому, что v содержит пять байтов, хотя в файле их восемь. Чтобы сохранить их, добавьте маркер в конце подстановки, а затем удалите его с помощью v=$(cat f; printf x) и v=${v%x}. Маркер располагается после символов новой строки, поэтому в конце не остается ничего, что bash мог бы удалить.

Почему local out=$(cmd) всегда сообщает об успешном выполнении?

local — это самостоятельная команда, и $? после этой строки сообщает лишь о том, успешно ли local объявил переменную. Код завершения подстановки при этом поглощается и отбрасывается, что также означает, что set -e не остановит выполнение скрипта. declare, export, typeset и readonly ведут себя аналогично. Запишите local out на одной строке, а out=$(cmd) на следующей, и тогда $? сообщит реальный код завершения.

#bash#shell-scripting#linux#subshell#coreutils