SSD Nodes Learn 🎉 VPS من $4.99/شهر
الأدلة Matt Connorبقلم Matt Connor

استبدال الأوامر في Bash: الفرق بين $() و backticks

افهم لماذا تختفي تغييرات cd والمتغيرات داخل subshell، ولماذا يفشل تداخل backticks، وتعلّم حِيَل process substitution للحفاظ على الحالة في الصدفة.

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

ما الذي يفعله استبدال الأوامر في Bash

يستبدل استبدال الأوامر في Bash $(command) بالنص الذي طبعته تلك الأوامر إلى المخرج القياسي. وتؤدي الصيغة الأقدم باستخدام backticks المهمة نفسها. تنشأ كل السلوكيات المفاجئة من حقيقتين: يُشغَّل الأمر في عملية منفصلة تُسمى 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 عملية منفصلة، ولأن العملية المنفصلة لا تستطيع تغيير الصدفة التي تكتب فيها.

استخدم $() وتوقف عن استخدام backticks

كلا الشكلين صالح. $() موجود في POSIX، لذلك تدعمه dash وash وbusybox sh. لم يعد هناك سبب يتعلق بقابلية النقل لكتابة backticks، وهناك سببان عمليان لتجنبها.

لا يمكن تداخل backticks

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

طبع السطر الثاني الكلمات الحرفية echo hi. تبحث الصدفة shell إلى الأمام عن أول backtick غير مسبوق بمحرف هروب، لذلك أغلقت علامة backtick الثانية التي كتبتها الأولى. كان الأمر الذي نُفِّذ فعلياً هو echo من دون وسيطات، فطبع سطراً فارغاً أزيلت منه محرفية السطر الجديد، وأصبح فارغاً تماماً. بقيت الكلمات echo hi كنص عادي، ثم نفّذت مجموعة backticks الأخيرة أمراً فارغاً.

لتداخل backticks، يجب أن تهرب كل علامة داخلية:

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

يتضاعف الهروب المطلوب مع كل مستوى إضافي. لا تحتاج $() إلى ذلك، لأن المحلل يطابق الأقواس بدلاً من البحث عن محرف فاصل.

تغيّر backticks محارف الهروب العكسية قبل تشغيل الأمر

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

أنتج الأمر الداخلي نفسه مخرجات مختلفة. داخل backticks، تزيل الصدفة طبقة واحدة من هروب محارف الهروب العكسية قبل تحليل النص الداخلي، لذلك لم تحمِ علامات الاقتباس المفردة أي شيء. أما داخل $()، فيُحلَّل النص بين القوسين باعتباره script عادياً، لذلك تعمل علامات الاقتباس المفردة بالطريقة المتوقعة. تظهر هذه المشكلة بوضوح أكبر في سطرَي sed وawk المختصرين، حيث يحوّل فقدان محرف هروب نمطاً عاملاً إلى نمط مختلف بصمت.

يبدأ الاقتباس أيضاً من جديد داخل $()، ما يعني أنه يمكنك تداخل علامات الاقتباس المزدوجة من دون تهريبها:

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

يحتاج المكافئ باستخدام backticks إلى \" حول $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

لا تظهر إلا ضغطات المفاتيح التي أدخلتها، وقد عكستها terminal driver بدلاً من البرنامج. لقد ذهب المحث إلى مكان آخر:

echo "$v"
Username: deploy

أصبح المحث الآن داخل المتغير، ملتصقاً بالإجابة، لأن $() التقط كل ما كتبته الدالة إلى المخرجات القياسية. وما كتبته ما زال يصل إلى read، لأن الإدخال القياسي لم يتغير. هذه هي العلامة الدقيقة للخلل الذي يوصف بعبارة: «يتجمّد البرنامج النصي ولا يطبع شيئاً».

تكتب بعض الأدوات المحث إلى standard error أو مباشرة إلى /dev/tty، ولذلك يبقى ظاهراً في هذه الحالة. لكن أدوات كثيرة لا تفعل ذلك. إذا كان يجب إبقاء الدالة داخل إحلال، فأرسل محثها إلى standard error بنفسك:

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

لا تُلتقط standard error، لذلك يصل المحث إلى الطرفية، بينما تصل الإجابة وحدها إلى v.

لماذا يبدو ls مختلفاً داخل $()؟

لأن ls يستدعي isatty على واصف الملف 1، ويغيّر تنسيق مخرجاته استناداً إلى النتيجة. في موجه الأوامر، يكون واصف الملف هذا هو الطرفية، لذلك يوزّع ls الأسماء على السطر في أعمدة. داخل substitution، يكون هذا الواصف 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. هذه ميزة. فهي تتيح للبرنامج النصي الحصول على مخرجات مستقرة قابلة للمعالجة آلياً من دون طلب ذلك صراحةً.

وهذا يجيب أيضاً عن سؤال يطرحه المستخدمون حول pipelines. يعرض ls | sort في موجه الأوامر و$(ls | sort) الشيء نفسه، لأن ls كان يرسل مخرجاته إلى pipe في الحالتين. ما يتغير داخل substitution هو المرحلة الأخيرة من 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 بالإضافة إلى 3 أحرف سطر جديدة، أي 8 بايتات. ويحتوي المتغير على hello، أي 5 بايتات. اختفت 3 بايتات من دون أي تحذير.

إزالة هذه الأحرف مقصودة، وتكون مفيدة عادةً. فهي التي تجعل 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 يفتح الملف بنفسه. كما أنه يزيل أحرف السطر الجديدة في النهاية بالطريقة نفسها. أما السلاسل النصية هنا-التيّة فتعمل بالاتجاه المعاكس، إذ تضيف حرف سطر جديد لم تكتبه:

wc -c <<< 'abc'
4

ضع النتيجة بين علامتي اقتباس، وإلا فسيجري bash تقسيمها وتوسيع الأنماط

يمر الاستبدال غير الموضوع بين علامتي اقتباس بتقسيم الكلمات، ثم بتوسيع أسماء المسارات. أما الاستبدال الموضوع بين علامتي اقتباس فلا يمر بأيٍّ منهما.

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 الأجزاء الأربعة باستخدام مسافات مفردة. أما عند وضع النص بين علامتي اقتباس، فقد وصل ككلمة واحدة مع بقاء علامة الجدولة والسطر الجديد الداخلي كما هما.

ويُعد توسيع الأنماط الجزء الأكثر خطورة:

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
status: 0

انتهى false بالحالة 1، بينما نجح local في تعريف المتغير، ثم أُسقطت القيمة 1. تتصرف declare وexport وtypeset وreadonly بالطريقة نفسها. ولن تلتقطها set -e أيضاً، لأن شيئاً لم يفشل من وجهة نظر الصدفة.

افصل تعريف المتغير عن إسناد القيمة:

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

يُبلغ الإسناد العادي في المستوى الأعلى عن حالة آخر استبدال أمر له:

out=$(exit 3)
echo $?
3

يظهر هذا الأمر بوضوح في فحص سلامة يعمل ضمن خدمة ومؤقت systemd، حيث تجعل حالة الخروج المحجوبة الوحدة تُبلغ عن النجاح في كل تشغيل، مع أن العملية التي كان من المفترض أن تتحقق منها لم تُنفَّذ قط.

البدائل داخل الصدفة التي تحتاج إليها فعلاً

يلجأ معظم المستخدمين إلى $(...) لأنهم يريدون وضع البيانات في متغير. لكن ما يحتاجون إليه غالباً هو الإدخال، لا الالتقاط. تحافظ هذه الصيغ الأربع على الحالة في الصدفة الحالية.

إعادة توجيه في الحلقة

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

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) مهمة.
  • لتعيين المتغيرات من الإدخال، استخدم إعادة توجيه أو استبدال العمليات بدلاً من pipe.

تظهر هذه الأنماط في أولى البرامج النصية الصغيرة التي يكتبها الأشخاص عند إعداد VPS جديد، وتظل حالات الفشل صامتة. يواصل برنامج نصي للنسخ الاحتياطي التبليغ عن النجاح إذا التقط مطالبةً داخل اسم ملف، كما يواصل فحص الحالة التبليغ عن النجاح إذا أخفى رمز الخروج. وتزداد التكلفة عندما تشغّل البرنامج النصي نفسه على عدة خوادم، لأن الناتج الذي لم تقرأه يصبح الآن ناتجاً لم تقرأه على عشرين جهازاً.

FAQ

لماذا لا يغيّر cd داخل $() الدليل الحالي؟

يشغّل $(...) الأمر في subshell، وهي عملية منفصلة تحتفظ بنسخة من دليل العمل والمتغيرات لديك. يغيّر cd تلك النسخة، ثم تنتهي العملية وتُحذف النسخة. لا يستطيع subshell إرجاع سوى مخرجاته القياسية وحالة الخروج، لذلك لا توجد آلية لانتقال تغيير الدليل إلى shell الأب. إذا أردت الحصول على الدليل نفسه، فالتقطه باستخدام target=$(cd /etc && pwd) واستخدم "$target". وإذا أردت نقل shell، فشغّل 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 خلفها منتظراً إدخالك. لا تعرض الطرفية سوى الأحرف التي تكتبها، بعد أن يعكسها مشغّل الطرفية. انقل السؤال إلى خارج الاستبدال، أو اجعل المطالبة تُكتب إلى standard error باستخدام printf 'Username: ' >&2 حتى لا تُلتقط.

لماذا اختفت الأسطر الفارغة في نهاية المتغير؟

يزيل استبدال الأوامر كل newline في النهاية، وليس الأخير فقط. يترك printf 'hello\n\n\n' > f; v=$(cat f) المتغير v محتفظاً بخمسة bytes، بينما يحتوي الملف على ثمانية. للاحتفاظ بها، أضف 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