SSD Nodes Learn 🎉 VPS $4.99/মাস থেকে
নির্দেশিকা Matt Connorদ্বারা Matt Connor

Bash-এ $() বনাম backticks: command substitution বুঝুন

Bash-এ $(...) কেন subshell-এ চলে, ফলে cd ও variables কেন হারিয়ে যায়, backticks কেন nested command-এ জটিল হয় এবং shell state বজায় রাখতে process substitution কীভাবে ব্যবহার করবেন।

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

Bash command substitution কী করে

Bash command substitution $(command)-কে command standard output-এ যে text মুদ্রণ করেছে, তা দিয়ে প্রতিস্থাপন করে। Backtick ব্যবহার করা পুরোনো syntax-ও একই কাজ করে। যে বিষয়গুলো সাধারণত বিভ্রান্ত করে, সেগুলোর কারণ দুটি: command একটি subshell নামের পৃথক process-এ চলে, এবং output-এর শেষে থাকা প্রতিটি 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 newline-সহ 3 মুদ্রণ করেছে, newline বাদ দেওয়া হয়েছে, এবং count-এ আপনার চাওয়া দুইটি character সংরক্ষিত আছে। মনে রাখবেন, GNU wc standard input পড়ার সময় মুদ্রণের জন্য কোনো filename পায় না বলে wc -l < three.txt শুধু একটি সংখ্যা মুদ্রণ করে। এর পরিবর্তে wc -l three.txt লিখলে 3 three.txt capture হবে। এটি ভিন্ন একটি string এবং পরে arithmetic ব্যর্থ হওয়ার সাধারণ কারণ।

এই গাইডের বাকি অংশে এমন আচরণ ব্যাখ্যা করা হয়েছে যা কেউ সাধারণত প্রত্যাশা করে না। কারণ subshell একটি পৃথক process, আর পৃথক process আপনি যে shell-এ command লিখছেন সেটি পরিবর্তন করতে পারে না।

$() ব্যবহার করুন এবং backticks ব্যবহার বন্ধ করুন

উভয় ফর্মই বৈধ। $() POSIX-এর অংশ, তাই dash, ash এবং busybox sh—সবগুলোতেই এটি সমর্থিত। backticks লেখার পক্ষে এখন আর portability-সংক্রান্ত কোনো কারণ নেই। বরং এটি না লেখার দুটি নির্দিষ্ট কারণ আছে।

backticks nest করা যায় না

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

দ্বিতীয় লাইনটি echo hi শব্দগুলোকে literal text হিসেবে মুদ্রণ করেছে। shell পরের unescaped backtick পর্যন্ত স্ক্যান করে, তাই আপনার লেখা দ্বিতীয় backtick-টি প্রথম command substitution বন্ধ করেছে। shell আসলে echo command-টি কোনো argument ছাড়া চালিয়েছে। এটি একটি খালি লাইন মুদ্রণ করেছে, যার newline পরে সরিয়ে ফেলা হয়েছে। echo hi শব্দগুলো plain text হিসেবে অবশিষ্ট ছিল, আর শেষ জোড়া backtick একটি খালি command চালিয়েছে।

backticks nest করতে হলে প্রতিটি অভ্যন্তরীণ backtick escape করতে হবে:

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

প্রতিটি অতিরিক্ত স্তরে escaping আবার দ্বিগুণ হয়। $()-এ এসবের প্রয়োজন নেই, কারণ parser delimiter character খোঁজার বদলে parentheses মিলিয়ে দেখে।

command চালানোর আগে backticks আপনার backslash পরিবর্তন করে

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

একই অভ্যন্তরীণ command ভিন্ন output তৈরি করেছে। backticks-এর ভিতরে shell অভ্যন্তরীণ text parse করার আগে backslash escaping-এর একটি স্তর সরিয়ে দেয়। তাই single quotes কিছুই সুরক্ষিত রাখেনি। $()-এর ভিতরে parentheses-এর মধ্যকার text একটি সাধারণ script হিসেবে parse করা হয়। তাই single quotes প্রত্যাশিতভাবেই কাজ করে। এই সমস্যা sed এবং awk one-liner-এ সবচেয়ে বেশি দেখা যায়। সেখানে হারিয়ে যাওয়া একটি backslash কার্যকর pattern-কে নীরবে ভিন্ন pattern-এ পরিণত করতে পারে।

$()-এর ভিতরেও quoting নতুন করে শুরু হয়। তাই escape না করেই double quotes nest করা যায়:

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

backtick-এর সমতুল্য রূপে $path-কে ঘিরে \" প্রয়োজন। প্রতিটি escape ভুল করার একটি সম্ভাব্য জায়গা।

$() ভিতরে cd আপনার shell পরিবর্তন করে না

কারণ $(...) একটি নতুন process fork করে। subshell আপনার variable এবং working directory-এর একটি copy পায়। এটি নিজের copy পরিবর্তন করে, কিছু output দেয় এবং exit করে। copy-টিও process-এর সঙ্গে শেষ হয়ে যায়।

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

কোনো কিছু ব্যর্থ হয়নি। cd কাজ করেছে, এবং subshell-এর ভেতরের pwd সত্যিই /etc print করেছে। কিন্তু এই পরিবর্তন parent process-এ ফেরত যাওয়ার কোনো পথ নেই। কারণ subshell parent-কে কেবল standard output এবং exit status দেয়।

Variable assignment-ও একইভাবে কাজ করে:

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

একই নিয়ম এই সমস্যার আরও প্রচলিত রূপটিও ব্যাখ্যা করে। সেখানে command substitution-এর পরিবর্তে pipeline থাকে:

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

Pipeline-এর প্রতিটি stage নিজের subshell-এ চলে। তাই while loop n-এর একটি copy increment করে এবং তারপর exit করে। Pipe-এর পরিবর্তে redirect ব্যবহার করলে loop আপনার shell-এই চলে:

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

shopt -s lastpipe ব্যবহার করলে Bash pipeline-এর শেষ stage বর্তমান shell-এ চালাতে পারে। তবে এটি কেবল job control বন্ধ থাকলে কাজ করে, যা interactive shell-এ কখনো সত্য নয়। Redirect ব্যবহার করুন।

prompt কোথায় গেল? $()–এর ভেতরে interactive command

একটি command substitution standard output-কে একটি pipe-এ পাঠায় এবং standard input অপরিবর্তিত রাখে। কোনো program যদি standard output-এ প্রশ্ন দেখিয়ে উত্তর পাওয়ার জন্য অপেক্ষা করে, তাহলে প্রশ্নটি হারিয়ে যায়, অপেক্ষা নয়। terminal-কে তখন স্থবির মনে হয়।

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

প্রথম লাইনের deploy শব্দটি আপনি লিখেছিলেন। এবার একই function substitution-এর ভেতরে চালান:

v=$(ask)
deploy

শুধু আপনার নিজের keystroke দেখা যায়। এগুলো program নয়, terminal driver echo করে। prompt অন্য কোথাও চলে গেছে:

echo "$v"
Username: deploy

এখন prompt-টি variable-এর ভেতরে আছে এবং উত্তরের সঙ্গে যুক্ত হয়েছে, কারণ $() function standard output-এ যা লিখেছে, সবকিছু capture করেছে। আপনার typing এখনও read-এ পৌঁছেছে, কারণ standard input-এ কোনো পরিবর্তন করা হয়নি। “my script hangs and prints nothing” ধরনের bug report-এর সঠিক লক্ষণ এটাই।

কিছু tool prompt standard error-এ অথবা সরাসরি /dev/tty-এ লেখে, তাই এই সমস্যা হয় না। অনেক tool তা করে না। কোনো function-কে substitution-এর ভেতরে রাখতেই হলে prompt নিজেই standard error-এ পাঠান:

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

standard error capture হয় না। তাই prompt আপনার terminal-এ পৌঁছায় এবং শুধু উত্তরটি v-এ যায়।

$()-এর ভিতরে ls আলাদা দেখায় কেন?

কারণ ls ফাইল descriptor 1-এ isatty কল করে এবং তার উত্তরের ভিত্তিতে output format পরিবর্তন করে। Prompt-এ ওই descriptor আপনার terminal, তাই ls নামগুলো line-এ column আকারে সাজায়। Substitution-এর ভিতরে এটি একটি pipe, তাই ls প্রতি line-এ একটি করে নাম দেখায়।

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

একই পরীক্ষা grep --color=auto-এ colour বন্ধ করে এবং git-এ pager বন্ধ করে। এটি একটি feature। এর ফলে কোনো script-কে আলাদা করে অনুরোধ না করেই stable machine-readable output পাওয়া যায়।

এটি pipeline নিয়ে করা একটি সাধারণ প্রশ্নেরও উত্তর দেয়। Prompt-এ ls | sort এবং $(ls | sort) একই জিনিস print করে, কারণ উভয় ক্ষেত্রেই ls-এর output-এ pipe ছিল। Substitution-এর ভিতরে যে বিষয়টি পরিবর্তিত হয়, তা হলো pipeline-এর শেষ stage। sort কখনো terminal পরীক্ষা করে না, তাই তার output পরিবর্তিত হয় না। Pipeline-এর শেষে terminal-aware কোনো command বসালে output পরিবর্তিত হয়। তাই চোখে দেখে পরীক্ষা করা কোনো pipeline-কে $()-এর ভিতরে রাখলেই তার আচরণ আলাদা হতে পারে।

এ থেকে একটি সতর্কতা মনে রাখুন: সুবিধাজনক মনে হলেও script-এ ls-এর output parse করবেন না। Filename-এ space এবং newline থাকতে পারে। একটি glob ব্যবহার করুন, অথবা find -print0-এর সঙ্গে read -d '' ব্যবহার করুন।

শেষে থাকা newline নীরবে মুছে যায়

Command substitution আউটপুটের শেষে থাকা প্রতিটি newline মুছে দেয়। শুধু শেষেরটি নয়। সবগুলো।

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 এবং আরও তিনটি newline আছে, তাই মোট 8 byte। ভেরিয়েবলটিতে hello আছে, তাই 5 byte। কোনো সতর্কতা ছাড়াই তিনটি byte হারিয়ে গেছে।

এই মুছে ফেলা ইচ্ছাকৃত এবং সাধারণত উপকারী। এর কারণেই stamp=$(date -u +%Y%m%dT%H%M%SZ) line break-সহ নামের পরিবর্তে ব্যবহারযোগ্য filename fragment তৈরি করে। তাই scheduled restic backup script-এর মতো ব্যবহারে এই pattern নিরাপদ:

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

আপনার timestamp ভিন্ন হবে। গুরুত্বপূর্ণ বিষয় হলো, নামটি এক লাইনে থাকবে।

এই আচরণের কারণে $() ব্যবহার করে কোনো ফাইলের exact byte স্থানান্তর করা যায় না। trailing newline প্রয়োজন হলে substitution-এর ভেতরে একটি sentinel character যোগ করুন এবং পরে সেটি মুছে ফেলুন:

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

x newline-গুলোর পরে থাকে। তাই মুছে ফেলার মতো কোনো trailing newline অবশিষ্ট থাকে না। এরপর ${v%x} sentinel মুছে দেয় এবং মূল byte-গুলো রেখে যায়।

আরও দুটি সম্পর্কিত বিষয় আছে। পুরো ফাইল পড়ার জন্য v=$(<blanks.txt) একই কাজ করে, তবে cat চালায় না, কারণ bash নিজেই ফাইলটি খোলে। এটিও একইভাবে trailing newline মুছে দেয়। অন্যদিকে here-string বিপরীতভাবে কাজ করে এবং আপনার লেখা না থাকা একটি newline যোগ করে:

wc -c <<< 'abc'
4

ফলাফল উদ্ধৃত করুন, নইলে bash সেটিকে split ও glob করবে

উদ্ধৃতিহীন 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-এ থাকা অক্ষরগুলোর ভিত্তিতে output ভাগ করেছে। ডিফল্টভাবে এগুলো হলো space, tab এবং newline। এরপর echo চারটি অংশকে single space দিয়ে যুক্ত করেছে। উদ্ধৃতি থাকলে text একটি 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-এ, যেখানে * বর্তমান directory-এর বিরুদ্ধে expand হয়েছে। কোনো script config file থেকে একটি pattern পড়ে উদ্ধৃতি বাদ দিলে, সেটি দৃশ্যমান প্রতিটি file-এর ওপর কাজ করতে পারে। প্রতিটি expansion উদ্ধৃত করুন; তাহলে এই ধরনের bug পুরোপুরি এড়ানো যায়। সত্যিই splitting প্রয়োজন হলে তবেই উদ্ধৃতি বাদ দিন। এমন পরিস্থিতি বিরল।

local x=$(cmd) সবসময় 0 রিটার্ন করে কেন?

কারণ local নিজেই একটি command, এবং $? local-এর status জানায়, তার মধ্যে থাকা substitution-এর status নয়।

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

false 1 রিটার্ন করে শেষ হয়েছিল, local variable ঘোষণা করতে সফল হয়েছিল, এবং 1 বাতিল হয়ে যায়। declare, export, typeset এবং readonly একইভাবে কাজ করে। set -e-ও এটি ধরতে পারবে না, কারণ shell-এর দৃষ্টিতে কোনো কিছু ব্যর্থ হয়নি।

declaration এবং assignment আলাদা করুন:

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

top level-এ সাধারণ assignment তার শেষ command substitution-এর status জানায়:

out=$(exit 3)
echo $?
3

একটি systemd service ও timer-এর অধীনে চলা health check-এ এটি সবচেয়ে গুরুত্বপূর্ণ। masked exit code-এর কারণে unit প্রতিবার সফলতার status জানাতে পারে, যদিও যে কাজ যাচাই করার কথা ছিল তা কখনো সম্পন্ন হয়নি।

আপনি যে in-shell বিকল্পগুলো সত্যিই ব্যবহার করতে চান

বেশিরভাগ মানুষ $(...) ব্যবহার করেন, কারণ তারা কোনো data variable-এ রাখতে চান। কিন্তু অনেক সময় তাদের আসলে দরকার হয় input, capture নয়। এই চারটি পদ্ধতি বর্তমান shell-এ state ধরে রাখে।

loop-এ redirect

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

input-এর জন্য কোনো process তৈরি হয় না। তাই loop body যে state সেট করে, তা 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-এর output পড়ে। Command-টি নিজস্ব process-এ চলবে। তবে while loop-টি তা করবে না, এবং এটাই এই পদ্ধতির মূল সুবিধা। < <(-এ space আবশ্যক। <<(-কে here-document-এর শুরু হিসেবে পড়া হয় এবং এটি parse হবে না। Process substitution হলো bash-এর feature। তাই Ubuntu বা Debian-এ #!/bin/sh-সহ কোনো script dash-এর অধীনে চলবে এবং ব্যর্থ হবে। #!/bin/bash ব্যবহার করুন।

Here-string

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

<<< একটি string command-এর standard input-এ পাঠায়। read আপনার shell-এ চলে, তাই উভয় variable সেই scope-এ সেট হয় যেখানে আপনি সেগুলো ব্যবহার করতে পারবেন। একটি output line থেকে দুটি field নেওয়ার স্বাভাবিক পদ্ধতি হলো read -r a b <<< "$(some-command)"

সম্পূর্ণ file-এর জন্য mapfile

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

mapfile, যার আরেক নাম readarray, বর্তমান shell-এ একটি file পড়ে array-তে রাখে। -t প্রতিটি element-এর শেষের newline সরিয়ে দেয়। এর জন্য bash 4 বা পরবর্তী version প্রয়োজন। Ubuntu 24.04-এ bash 5.2 ships, তাই বর্তমান যেকোনো server image-এ এটি available।

স্ক্রিপ্ট commit করার আগে একটি checklist

  • $(command) লিখুন এবং "$(command)" হিসেবে quote করুন, যদি বিশেষভাবে splitting প্রয়োজন না হয়।
  • ধরে নিন trailing newline আর নেই। সেগুলো ফিরিয়ে আনতে হলে একটি sentinel character যোগ করুন।
  • substitution-এর মধ্যে interactive command রাখবেন না। অথবা prompt-গুলো standard error-এ পাঠান।
  • out=$(command)-এর exit status গুরুত্বপূর্ণ হলে local out আলাদা লাইনে লিখুন।
  • input থেকে variable সেট করতে pipe-এর বদলে redirect বা process substitution ব্যবহার করুন।

নতুন VPS সেটআপ করার সময় মানুষ যে ছোট স্ক্রিপ্টগুলো প্রথম লেখে, সেখানে এই pattern-গুলো প্রায়ই দেখা যায়। কিন্তু এসবের ব্যর্থতা নীরবে থেকে যায়। কোনো backup script prompt-কে filename হিসেবে সংরক্ষণ করলে, অথবা কোনো health check exit code আড়াল করলে, সেটি success রিপোর্ট করতেই থাকে। আপনি যখন একই স্ক্রিপ্ট একাধিক server-এ চালান, তখন খরচ আরও বাড়ে। কারণ যে output আপনি পড়েননি, এখন বিশটি machine-এর output-ও আপনি পড়েননি।

FAQ

$()-এর ভিতরে cd চালালে আমার বর্তমান directory কেন পরিবর্তন হয় না?

$(...) তার command একটি subshell-এ চালায়। এটি আলাদা একটি process, যেখানে আপনার working directory এবং variables-এর একটি copy থাকে। cd সেই copy-টি পরিবর্তন করে। এরপর process শেষ হয় এবং copy-টি বাতিল হয়ে যায়। একটি subshell কেবল তার standard output এবং exit status ফেরত দিতে পারে। তাই directory পরিবর্তনের তথ্য parent shell-এ পৌঁছানোর কোনো ব্যবস্থা নেই। নিজে directory-টি পেতে চাইলে target=$(cd /etc && pwd) দিয়ে সেটি capture করে "$target" ব্যবহার করুন। আপনার shell-কে সরাতে চাইলে substitution ছাড়া সরাসরি cd চালান।

bash-এ $() এবং backticks-এর মধ্যে পার্থক্য কী?

সহজ command-এর ক্ষেত্রে দুটিই একই ফল দেয়। তবে দুটি গুরুত্বপূর্ণ পার্থক্য আছে। $() সরাসরি nested করা যায়, কারণ parser parentheses মিলিয়ে দেখে। অন্যদিকে backticks-এ প্রতিটি nesting level-এর জন্য escaped backtick প্রয়োজন। Inner command parse করার আগে backticks backslash escaping-এর একটি স্তরও সরিয়ে দেয়। তাই ` 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-ভিত্তিক কোনো কারণ নেই।

কোনো command প্রশ্ন করলে prompt ছাড়া আমার script কেন আটকে থাকে?

Command substitution standard output একটি pipe-এ redirect করে। কিন্তু standard input আপনার terminal-এর সঙ্গে সংযুক্তই থাকে। কোনো program তার prompt standard output-এ লিখলে সেই prompt variable-এর মধ্যে capture হয়। এদিকে তার পেছনের read আপনার উত্তরের জন্য অপেক্ষা করতে থাকে। Terminal driver আপনার টাইপ করা অক্ষরগুলো echo করায় terminal-এ কেবল সেগুলো দেখা যায়। প্রশ্নটি substitution-এর বাইরে রাখুন। অথবা printf 'Username: ' >&2 দিয়ে prompt-টি standard error-এ লিখুন, যাতে সেটি capture না হয়।

আমার variable-এর শেষে থাকা blank line-গুলো কেন হারিয়ে গেল?

Command substitution শেষের শুধু একটি নয়, সব trailing newline সরিয়ে দেয়। printf 'hello\n\n\n' > f; v=$(cat f)-এর পরে v-এ পাঁচটি byte থাকে, যদিও file-এ আটটি byte থাকে। এগুলো রাখতে হলে substitution-এর ভিতরে একটি sentinel যোগ করুন। এরপর v=$(cat f; printf x) এবং v=${v%x} ব্যবহার করে sentinel-টি সরিয়ে ফেলুন। Sentinel-টি newline-গুলোর পরে থাকে। তাই bash-এর সরিয়ে ফেলার মতো শেষে আর কিছু থাকে না।

local out=$(cmd) সব সময় success কেন জানায়?

local নিজেই একটি command। ওই line-এর পরে $? জানায়, variable ঘোষণা করতে local সফল হয়েছে কি না। Substitution-এর exit status ব্যবহার করে ফেলে দেওয়া হয়। তাই set -e-ও script থামাবে না। declare, export, typeset এবং readonly-এর আচরণও একই। এক line-এ local out এবং পরের line-এ out=$(cmd) লিখুন। এরপর $? প্রকৃত status জানাবে।

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