SSD Nodes Learn 🎉 VPS від $5.50/міс
Посібники Matt ConnorВід Matt Connor · Оновлено 2026-08-13

Підстановка команд у Bash: $() чи зворотні лапки

Дізнайтеся, чому $(...) запускає subshell, а cd і змінні не зберігаються, чому зворотні лапки погано вкладаються та коли допомагає process substitution.

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 є окремим процесом, а окремий процес не може змінити shell, у якому ви вводите команди.

Використовуйте $() і відмовтеся від зворотних апострофів

Обидві форми є коректними. $() входить до 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 усередині $() не змінює вашу оболонку

Тому що $(...) створює новий процес. Дочірня оболонка отримує копію змінних і копію поточного робочого каталогу. Вона змінює свою копію, виводить результат і завершує роботу. Разом із нею зникає і ця копія.

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

Це саме правило пояснює варіант цієї помилки, з яким значно частіше стикаються на практиці. Він виникає під час використання конвеєра, а не підстановки:

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, але лише коли керування завданнями вимкнене. В інтерактивній оболонці це ніколи не так. Використовуйте перенаправлення.

Куди зникло запрошення? Інтерактивні команди всередині $()

Підстановка команди перенаправляє стандартний вивід у 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 і вимикає pager у git. Це передбачена функція. Завдяки їй скрипт отримує стабільний машинно-читаний вивід без додаткового запиту на потрібний формат.

Це також пояснює запитання, яке часто виникає щодо pipeline. ls | sort у командному рядку та $(ls | sort) виводять однакові дані, оскільки ls в обох випадках мав pipe на виході. Усередині підстановки змінюється остання стадія pipeline. sort ніколи не перевіряє наявність термінала, тому його вивід не змінюється. Якщо розмістити в кінці pipeline команду, яка враховує наявність термінала, результат зміниться. Саме тому pipeline, який ви перевірили візуально, може поводитися інакше одразу після обгортання в $().

Із цього випливає важливе попередження: не аналізуйте вивід 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-string працюють у протилежному напрямку: вони додають символ нового рядка, якого ви не записували:

wc -c <<< 'abc'
4

Цитуйте результат, інакше bash розділить його на слова та виконає globbing

Незакритий у лапки результат підстановки проходить поділ на слова, а потім розширенням імен шляхів. Результат у лапках не проходить жоден із цих етапів.

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
*

Саме присвоєння було безпечним, оскільки під час присвоєння не виконуються поділ на слова та globbing. Проблема виникла в echo $p, де * було розгорнуто відносно поточного каталогу. Скрипт, який читає шаблон із конфігураційного файлу та не використовує лапки, безперешкодно опрацює кожен доступний йому файл. Беріть у лапки кожну підстановку — це усуває весь цей клас помилок. Не використовуйте лапки лише тоді, коли вам справді потрібен поділ на слова. Таке трапляється рідко.

Чому local x=$(cmd) завжди повертає 0?

Тому що local — це окрема команда, а $? повідомляє статус local, а не статус підстановки, яку вона містила.

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

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

Розділіть оголошення змінної та присвоєння:

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

Звичайне присвоєння на верхньому рівні вже повертає статус останньої підстановки команди:

out=$(exit 3)
echo $?
3

Це особливо важливо для перевірки працездатності, яка виконується в сервісі та таймері systemd, де замаскований код завершення змушує unit повідомляти про успіх під час кожного запуску, хоча робота, яку він мав перевірити, фактично не виконувалася.

Альтернативи в поточній оболонці, які вам справді потрібні

Більшість користувачів застосовує $(...), коли хоче отримати дані у змінній. Але часто насправді потрібні вхідні дані, а не захоплення виводу. Ці чотири форми зберігають стан у поточній оболонці.

Перенаправлення для циклу

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).
  • Щоб присвоювати змінним значення з вхідних даних, використовуйте перенаправлення або process substitution замість конвеєра.

Такі конструкції часто з’являються в перших невеликих скриптах, які пишуть під час налаштування нового VPS, а помилки залишаються непомітними. Скрипт резервного копіювання, який записав запит у ім’я файлу, або health check, який приховав код завершення, продовжує повідомляти про успіх. Проблема стає серйознішою, коли ви запускаєте той самий скрипт на кількох серверах, оскільки вивід, який ви не читали раніше, тепер залишається непрочитаним на двадцяти машинах.

FAQ

Чому cd усередині $() не змінює поточний каталог?

$(...) виконує команду в subshell — окремому процесі, який має копію поточного каталогу та змінних. cd змінює цю копію, після чого процес завершується, а копію видалено. Subshell може повернути лише стандартний вивід і статус завершення, тому зміна каталогу не може передатися батьківській оболонці. Якщо потрібен сам каталог, збережіть його за допомогою 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, що містить п’ять байтів, тоді як файл містить вісім. Щоб зберегти їх, додайте sentinel усередині підстановки, а потім видаліть його за допомогою v=$(cat f; printf x) з наступним v=${v%x}. Sentinel розташований після символів нового рядка, тому наприкінці немає нічого, що bash міг би видалити.

Чому local out=$(cmd) завжди повідомляє про успіх?

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

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