تفاوت $() و backtick در Bash و اثر subshell
یاد بگیرید چرا cd و تغییر متغیرها داخل $() باقی نمیمانند، backtickها چگونه تودرتو شدن را دشوار میکنند و چه ترفندهایی وضعیت shell را حفظ میکنند.
جایگزینی دستور در bash چه کاری انجام میدهد
جایگزینی دستور در Bash، $(command) را با متنی که آن دستور در خروجی استاندارد چاپ کرده است جایگزین میکند. شکل قدیمیتر این قابلیت با استفاده از backtickها نیز همین کار را انجام میدهد. هر چیزی که برای کاربران غیرمنتظره است، از دو واقعیت ناشی میشود: دستور در فرایندی جداگانه به نام subshell اجرا میشود و تمام newlineهای انتهای خروجی آن حذف میشوند.
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 را چاپ کرد و پس از آن یک newline قرار داشت؛ newline حذف شد و count دو کاراکتری را نگه میدارد که میخواستید. توجه کنید که wc -l < three.txt فقط یک عدد خام چاپ میکند، زیرا GNU wc هنگام خواندن ورودی استاندارد، نام فایلی برای چاپ ندارد. بهجای آن wc -l three.txt را بنویسید تا 3 three.txt را دریافت کنید؛ این دو یک رشته متفاوت هستند و چنین تفاوتی یکی از علتهای رایج شکست عملیات حسابی در مراحل بعد است.
ادامه این راهنما به رفتاری میپردازد که معمولاً کسی انتظارش را ندارد، زیرا subshell یک فرایند جداگانه است و یک فرایند جداگانه نمیتواند shellای را که در آن مشغول واردکردن دستور هستید تغییر دهد.
از () استفاده کنید و استفاده از backtick را متوقف کنید
هر دو شکل معتبر هستند. $() در POSIX وجود دارد، بنابراین dash، ash و busybox sh همگی از آن پشتیبانی میکنند. دیگر هیچ دلیل قابلحملی برای نوشتن backtick وجود ندارد و 2 دلیل مشخص برای استفاده نکردن از آن وجود دارد.
backtickها تودرتو نمیشوند
echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"hi
echo hiخط دوم کلمات literalِ echo hi را چاپ کرد. shell تا رسیدن به backtick بعدی که escape نشده است، جلو میرود؛ بنابراین دومین backtick که وارد کردید، اولین مورد را بست. دستوری که واقعاً اجرا شد echo بدون آرگومان بود؛ این دستور یک خط خالی چاپ کرد و سپس newline آن حذف شد و به هیچ تبدیل شد. کلمات echo hi بهصورت متن ساده باقی ماندند و جفت نهایی backtickها نیز یک دستور خالی را اجرا کرد.
برای تودرتو کردن backtickها باید همه موارد داخلی را escape کنید:
echo "`echo \`echo hi\``"hiهر سطح اضافی، تعداد escapeها را دوباره 2 برابر میکند. $() به هیچیک از این کارها نیاز ندارد، چون parser بهجای جستوجوی یک نویسه delimiter، پرانتزها را با هم تطبیق میدهد.
backtickها پیش از اجرای command، backslashهای شما را تغییر میدهند
echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"a\\b
a\bهمان command داخلی، خروجی متفاوتی تولید کرد. در backtickها، shell پیش از parse کردن متن داخلی، یک لایه از escape مربوط به backslash را حذف میکند؛ بنابراین single quoteها از چیزی محافظت نکردند. در $()، متن بین پرانتزها مانند یک script معمولی parse میشود؛ بنابراین single quoteها همان رفتاری را دارند که انتظار دارید. این مسئله در one-linerهای sed و awk بیشتر مشکلساز میشود؛ زیرا حذف یک backslash میتواند یک pattern سالم را بیصدا به pattern متفاوتی تبدیل کند.
Quoting در داخل $() نیز از ابتدا شروع میشود؛ بنابراین میتوانید double quoteها را بدون escape کردن، تودرتو قرار دهید:
path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"/etc/nginxمعادل backtick به \" در اطراف $path نیاز دارد. هر escape، فرصتی برای ایجاد خطاست.
چرا اجرای 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همین قاعده نسخهای از این خطا را نیز توضیح میدهد که بسیار بیشتر رخ میدهد و بهجای substitution، به pipeline مربوط است:
n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"0هر مرحله از pipeline در پوستهٔ فرعی خودش اجرا میشود؛ بنابراین حلقهٔ while یک کپی از n را افزایش داد و سپس خارج شد. pipe را با redirect جایگزین کنید تا حلقه در پوستهٔ شما اجرا شود:
n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"3Bash میتواند آخرین مرحلهٔ یک pipeline را با shopt -s lastpipe در پوستهٔ فعلی اجرا کند، اما این کار فقط زمانی ممکن است که job control خاموش باشد؛ وضعیتی که در پوستهٔ تعاملی هرگز برقرار نیست. از redirect استفاده کنید.
مکان prompt کجاست؟ اجرای تعاملی دستورات درون $()
Command substitution خروجی استاندارد را به یک pipe هدایت میکند و ورودی استاندارد را بدون تغییر باقی میگذارد. برنامهای که سؤال خود را در خروجی استاندارد چاپ میکند و سپس منتظر پاسخ میماند، سؤال را از دست میدهد، نه حالت انتظار را. در نتیجه، terminal ظاهراً متوقف میشود.
ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
askUsername: deploy
deployکلمه deploy در خط اول همان چیزی است که وارد کردهاید. اکنون همان function را درون یک substitution اجرا کنید:
v=$(ask)deployفقط کلیدهایی که خودتان فشار میدهید دیده میشوند؛ terminal driver آنها را echo میکند، نه برنامه. prompt به جای دیگری رفته است:
echo "$v"Username: deployاکنون prompt داخل variable قرار گرفته و به answer چسبیده است، چون $() هر چیزی را که function در خروجی استاندارد نوشته، capture کرده است. ورودی شما همچنان به read رسیده است، چون ورودی استاندارد هرگز دستکاری نشده بود. این دقیقاً همان نشانهای است که در گزارش خطا با عبارت «script من متوقف میشود و چیزی چاپ نمیکند» توصیف میشود.
بعضی ابزارها prompt را در standard error یا مستقیماً در /dev/tty مینویسند تا در این وضعیت نیز نمایش داده شود. بسیاری از ابزارها این کار را نمیکنند. اگر یک function باید داخل substitution باقی بماند، prompt آن را خودتان به standard error ارسال کنید:
ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"Username: deploy
deploystandard error capture نمیشود؛ بنابراین prompt به terminal شما میرسد و فقط answer در v قرار میگیرد.
چرا خروجی ls داخل $() متفاوت به نظر میرسد؟
زیرا ls، isatty را روی file descriptor 1 فراخوانی میکند و قالب خروجی خود را بر اساس پاسخ تغییر میدهد. در prompt، این descriptor همان terminal شماست؛ بنابراین ls نامها را در ستونهای چندتایی در یک خط پخش میکند. داخل substitution، این descriptor یک 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 غیرفعال میسازد. این یک قابلیت است. به این معناست که script بدون نیاز به درخواست صریح، خروجی پایدار و قابلخواندن برای ماشین دریافت میکند.
این موضوع به پرسشی درباره pipelineها نیز پاسخ میدهد. ls | sort در prompt و $(ls | sort) یک چیز را نمایش میدهند، زیرا ls در هر دو حالت روی خروجی خود یک pipe داشته است. چیزی که داخل substitution تغییر میکند، آخرین مرحلهٔ pipeline است. sort هرگز وجود terminal را بررسی نمیکند؛ بنابراین خروجی آن تغییر نمیکند. اگر commandای را که به terminal وابسته است در انتهای pipeline قرار دهید، خروجی تغییر میکند. به همین دلیل، pipelineای که با مشاهدهٔ مستقیم آزمایش کردهاید ممکن است بهمحض آنکه آن را داخل $() قرار میدهید، رفتار متفاوتی داشته باشد.
از این موضوع یک هشدار مهم نتیجه میشود: حتی اگر پردازش خروجی ls در script ساده به نظر برسد، این کار را انجام ندهید. نام فایلها میتوانند شامل فاصله و newline باشند. از glob یا find -print0 همراه با read -d '' استفاده کنید.
خطجدیدهای انتهایی که بیسروصدا حذف میشوند
Command substitution تمام خطجدیدهای انتهای خروجی را حذف میکند؛ نه فقط آخرین مورد، بلکه همه آنها را.
cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c8
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مقدار timestamp شما متفاوت خواهد بود. نکته مهم این است که نام در یک خط قرار داشته باشد.
هزینه این رفتار آن است که نمیتوانید از $() برای جابهجایی بایتهای دقیق یک فایل استفاده کنید. اگر به خطجدیدهای انتهایی نیاز دارید، داخل substitution یک نویسه sentinel اضافه کنید و بعد آن را حذف کنید:
v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c8x بعد از خطجدیدها قرار میگیرد؛ بنابراین دیگر خطجدیدی در انتها باقی نمیماند که حذف شود. سپس ${v%x} نویسه sentinel را حذف میکند و بایتهای اصلی را باقی میگذارد.
دو نکته مرتبط دیگر وجود دارد. برای خواندن یک فایل کامل، v=$(<blanks.txt) همین کار را بدون اجرای cat انجام میدهد، چون bash فایل را مستقیماً باز میکند. خطجدیدهای انتهایی نیز به همان شکل حذف میشوند. در مقابل، here-string در جهت مخالف عمل میکند و یک خطجدید اضافه میکند که شما ننوشتهاید:
wc -c <<< 'abc'4نتیجه را داخل نقلقول قرار دهید؛ در غیر این صورت bash آن را با word splitting و globbing پردازش میکند
یک substitution بدون نقلقول ابتدا از word splitting و سپس از pathname expansion عبور میکند. یک substitution داخل نقلقول هیچکدام از این دو مرحله را طی نمیکند.
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 تقسیم کرد؛ این نویسهها بهطور پیشفرض شامل فاصله، tab و newline هستند. سپس echo چهار بخش را با فاصلههای تکی به هم پیوند داد. در حالت داخل نقلقول، متن بهصورت یک word و با حفظ tab و newline داخلی دریافت شد.
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
*خود assignment ایمن بود، زیرا assignmentها مشمول word splitting یا globbing نمیشوند. آسیب در echo $p رخ داد؛ جایی که * بر اساس دایرکتوری جاری expand شد. اسکریپتی که یک pattern را از فایل configuration میخواند و نقلقولها را فراموش میکند، بدون مشکل روی تمام فایلهایی که میبیند عمل خواهد کرد. هر expansion را داخل نقلقول قرار دهید تا این دسته از باگها کاملاً برطرف شود. نقلقولها را فقط زمانی حذف کنید که واقعاً به splitting نیاز دارید؛ این حالت نادر است.
چرا local x=$(cmd) همیشه مقدار 0 برمیگرداند؟
زیرا local خود یک command است و $? وضعیت local را گزارش میکند، نه وضعیت substitution را که درون آن قرار داشته است.
check_bad() { local out=$(false); echo "status: $?"; }
check_badstatus: 0false با وضعیت 1 خارج شد، local در تعریف متغیر موفق بود و مقدار 1 از بین رفت. declare، export، typeset و readonly نیز همین رفتار را دارند. set -e هم آن را شناسایی نمیکند، زیرا از دید shell هیچ عملی شکست نخورده است.
تعریف متغیر را از assignment جدا کنید:
check_good() { local out; out=$(false); echo "status: $?"; }
check_goodstatus: 1یک assignment ساده در سطح اصلی، وضعیت آخرین command substitution خود را گزارش میکند:
out=$(exit 3)
echo $?3این موضوع در health checkهایی اهمیت بیشتری دارد که تحت یک systemd service و timer اجرا میشوند. در این حالت، exit code پنهان میشود و unit در هر اجرا موفقیت را گزارش میکند، در حالی که کاری که باید بررسی میکرد هرگز انجام نشده است.
جایگزینهای درونshell که واقعاً به آنها نیاز دارید
بیشتر افراد به سراغ $(...) میروند، چون میخواهند داده را در یک متغیر قرار دهند. اما اغلب چیزی که واقعاً میخواهند، ورودی است، نه capture. این 4 شکل، وضعیت را در shell فعلی شما حفظ میکنند.
redirect روی loop
cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txtgot: alpha
got: beta
got: gammaبرای ورودی، هیچ process جداگانهای ایجاد نمیشود؛ بنابراین هر چیزی که بدنهٔ loop تنظیم کند، پس از پایان loop نیز باقی میماند.
Process substitution
while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)got: gamma
got: beta
got: alpha<(command) یک path در اختیار شما میگذارد، مانند /dev/fd/63، که خروجی command را میخواند. خود command همچنان در process جداگانهٔ خود اجرا میشود. اما loop مربوط به while اینطور نیست و نکتهٔ اصلی همین است. وجود فاصله در < <( ضروری است: <<( بهعنوان شروع یک here-document تفسیر میشود و parse نخواهد شد. Process substitution قابلیت bash است؛ بنابراین script دارای #!/bin/sh در Ubuntu یا Debian تحت dash اجرا میشود و با خطا مواجه خواهد شد. از #!/bin/bash استفاده کنید.
Here-stringها
read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"alpha
beta gamma<<< یک string را به standard input یک command میفرستد. read در shell شما اجرا میشود؛ بنابراین هر دو متغیر در همان جایی تنظیم میشوند که میتوانید از آنها استفاده کنید. read -r a b <<< "$(some-command)" روش معمول برای بیرونکشیدن 2 field از یک خط خروجی است.
mapfile برای فایلهای کامل
mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"3
betamapfile که با نام readarray نیز شناخته میشود، یک فایل را در یک array در shell فعلی میخواند. -t newline انتهایی هر element را حذف میکند. این قابلیت به bash 4 یا جدیدتر نیاز دارد و Ubuntu 24.04، bash 5.2 را عرضه میکند؛ بنابراین در هر image فعلی سرور در دسترس است.
فهرستی برای بررسی پیش از نهاییکردن اسکریپت
$(command)را بنویسید و آن را بهصورت"$(command)"درون نقلقول قرار دهید؛ مگر اینکه مشخصاً به تفکیک نیاز داشته باشید.- فرض کنید newlineهای انتهایی حذف شدهاند. اگر به آنها نیاز دارید، یک کاراکتر sentinel اضافه کنید.
- دستورات تعاملی را از substitutionها خارج نگه دارید یا promptهای آنها را به standard error ارسال کنید.
- هنگامی که وضعیت خروج
out=$(command)اهمیت دارد،local outرا در خطی مستقل بنویسید. - برای تنظیم متغیرها از ورودی، بهجای pipe از redirect یا process substitution استفاده کنید.
این الگوها در نخستین اسکریپتهای کوچکی ظاهر میشوند که افراد هنگام راهاندازی یک VPS جدید مینویسند و خطاها بیصدا باقی میمانند. اسکریپت backup که یک prompt را درون نام فایل ذخیره کرده است، یا health check که یک exit code را پنهان کرده است، همچنان موفقیت را گزارش میکند. وقتی همان اسکریپت را روی چند سرور اجرا میکنید، هزینه بیشتر میشود؛ زیرا خروجیای که آن را نمیخواندید، اکنون روی بیست ماشین نیز خروجیای است که نمیخوانید.
FAQ
چرا اجرای cd داخل $() دایرکتوری جاری من را تغییر نمیدهد؟
$(...) فرمان خود را در یک subshell اجرا میکند. subshell فرایندی جداگانه است که یک کپی از دایرکتوری کاری و متغیرهای شما دارد. cd همان کپی را تغییر میدهد، سپس فرایند خارج میشود و کپی از بین میرود. subshell فقط میتواند خروجی استاندارد و وضعیت خروج را برگرداند؛ بنابراین سازوکاری برای انتقال تغییر دایرکتوری به shell والد وجود ندارد. اگر خود دایرکتوری را میخواهید، آن را با target=$(cd /etc && pwd) دریافت و با "$target" استفاده کنید. اگر میخواهید shell شما جابهجا شود، cd را مستقیماً و بدون قرار دادن آن داخل substitution اجرا کنید.
تفاوت $() و backticks در bash چیست؟
این دو برای فرمانهای ساده نتیجه یکسانی تولید میکنند، اما در 2 مورد مهم تفاوت دارند. $() مستقیماً تودرتو میشود، زیرا parser پرانتزها را تطبیق میدهد؛ اما backticks برای هر سطح تودرتو به یک backtick escapeشده نیاز دارند. backticks همچنین پیش از parse شدن فرمان داخلی، یک لایه از escape مربوط به backslash را حذف میکنند؛ بنابراین ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh` و در نتیجه برای استفاده از backticks استدلالی از نظر portability وجود ندارد.
چرا وقتی یک فرمان سؤال میپرسد، script من بدون نمایش prompt متوقف میشود؟
Command substitution خروجی استاندارد را به یک pipe هدایت میکند، اما ورودی استاندارد را به ترمینال شما متصل نگه میدارد. برنامهای که prompt خود را در خروجی استاندارد چاپ میکند، آن prompt را داخل متغیر ذخیره میکند، در حالی که read در پشت آن همچنان منتظر ورودی شماست. ترمینال فقط نویسههایی را که تایپ میکنید و terminal driver آنها را echo میکند، نشان میدهد. سؤال را خارج از substitution قرار دهید، یا با printf 'Username: ' >&2 کاری کنید prompt در خروجی خطا نوشته شود تا capture نشود.
چرا خطهای خالی انتهای متغیر من ناپدید شدند؟
Command substitution همه newlineهای انتهایی را حذف میکند، نه فقط آخرین newline را. printf 'hello\n\n\n' > f; v=$(cat f) باعث میشود v پنج بایت داشته باشد، در حالی که فایل 8 بایت دارد. برای حفظ آنها، داخل substitution یک sentinel اضافه کنید و سپس با v=$(cat f; printf x) و بعد v=${v%x} آن را حذف کنید. sentinel بعد از newlineها قرار میگیرد؛ بنابراین چیزی در انتها باقی نمیماند که bash حذف کند.
چرا local out=$(cmd) همیشه موفقیت را گزارش میکند؟
local خودش یک فرمان مستقل است و $? پس از آن خط گزارش میکند که آیا local در تعریف متغیر موفق بوده است یا نه. وضعیت خروج substitution مصرف و دور انداخته میشود؛ بنابراین set -e نیز script را متوقف نمیکند. declare، export، typeset و readonly نیز همین رفتار را دارند. local out را در یک خط و out=$(cmd) را در خط بعد بنویسید تا $? سپس وضعیت واقعی را گزارش کند.