SSD Nodes Learn 🎉 VPS החל מ־$5.50/חודש
מדריכים Matt Connorמאת Matt Connor · עודכן 2026-08-13

Bash: $(...) לעומת גרשיים הפוכים (backticks)

למדו מדוע $(...) רץ ב-subshell וגורם לאובדן משתנים, מדוע backticks מקשים על קינון פקודות, ואיך לבצע החלפת פקודה בצורה נכונה ב-Bash מבלי לשבור את ה-state של המעטפת.

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

מה עושה החלפת פקודה ב-bash

החלפת פקודה (command substitution) ב-bash מחליפה את $(command) בטקסט שהפקודה הדפיסה לפלט התקני (standard output). הכתיב הישן יותר המשתמש בגרשיים הפוכים (backticks) מבצע את אותה פעולה. כל מה שמפתיע משתמשים נובע משתי עובדות: הפקודה רצה בתהליך נפרד הנקרא 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 ואחריו שורה חדשה; השורה החדשה הוסרה, ו-count מכיל את שני התווים שרציתם. שימו לב ש-wc -l < three.txt מדפיס מספר בלבד, כיוון ש-GNU wc הקורא מהקלט התקני אינו מקבל שם קובץ להדפסה. כתבו wc -l three.txt במקום זאת, ותקבלו את 3 three.txt; זוהי מחרוזת שונה וסיבה נפוצה לכשלים בחישובים אריתמטיים בהמשך.

שאר המדריך הזה עוסק בהתנהגות שאף אחד לא מצפה לה, כיוון ש-subshell הוא תהליך נפרד, ותהליך נפרד אינו יכול לשנות את ה-shell שבו אתם מקלידים.

השתמשו ב-$() והפסיקו להשתמש בגרשיים הפוכים (backticks)

שתי הצורות תקינות. $() הוא חלק מתקן POSIX, ולכן dash, ash ו-busybox sh כולם תומכים בו. אין עוד סיבה הקשורה לניידות (portability) לכתוב גרשיים הפוכים, וישנן שתי סיבות קונקרטיות להימנע מכך.

גרשיים הפוכים אינם מאפשרים קינון (nesting)

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

השורה השנייה הדפיסה את המילים המילוליות echo hi. המעטפת (shell) סורקת קדימה עד לגרש ההפוך הבא שאינו מלווה בתו בריחה (unescaped), ולכן הגרש השני שהקלדת סגר את הראשון. הפקודה שרצה בפועל הייתה echo ללא ארגומנטים, שהדפיסה שורה ריקה שתו המעבר שלה הוסר. המילים echo hi נותרו כטקסט פשוט, וזוג הגרשיים האחרון הריץ פקודה ריקה.

כדי לקנן גרשיים הפוכים עליך לבצע בריחה (escape) לכל גרש פנימי:

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

כל רמה נוספת מכפילה שוב את הצורך בתווי בריחה. $() אינו זקוק לכל זה, כיוון שהמנתח (parser) מתאים סוגריים במקום לסרוק אחר תו מפריד.

גרשיים הפוכים משנים את תווי ה-backslash לפני הרצת הפקודה

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

אותה פקודה פנימית הפיקה פלט שונה. בתוך גרשיים הפוכים, המעטפת מסירה שכבה אחת של תווי בריחה לפני שהטקסט הפנימי מנותח, ולכן הגרשיים הבודדים לא הגנו על דבר. בתוך $() הטקסט שבין הסוגריים מנותח כסקריפט רגיל, ולכן גרשיים בודדים מתנהגים כפי שניתן לצפות. בעיה זו מורגשת במיוחד ב-one-liners של sed ו-awk, שבהם אובדן של backslash הופך תבנית תקינה לתבנית שונה באופן שקט.

ציטוט (quoting) מתחיל מחדש בתוך $(), מה שאומר שניתן לקנן גרשיים כפולים ללא צורך בתווי בריחה:

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

החלופה עם גרשיים הפוכים דורשת \" סביב $path. כל תו בריחה הוא הזדמנות לטעות.

מדוע הפקודה cd בתוך $() לא משנה את ה-shell הנוכחי שלך

הסיבה היא ש-$(...) יוצר תהליך (process) חדש. ה-subshell מקבל עותק של המשתנים שלך ועותק של ספריית העבודה הנוכחית. הוא משנה את העותק שלו, מדפיס משהו, ומסתיים. העותק מושמד יחד איתו.

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

שום דבר לא נכשל. ה-cd עבד, ו-pwd בתוך ה-subshell אכן הדפיס את /etc. פשוט אין דרך שבה השינוי הזה יחזור חזרה, מכיוון שהדברים היחידים ש-subshell מעביר להורה שלו הם ה-standard output וסטטוס היציאה.

השמת משתנים מתנהגת באותו אופן:

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

אותו כלל מסביר גרסה של באג זה שאנשים נתקלים בה בתדירות גבוהה הרבה יותר, הכוללת pipeline במקום substitution:

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

כל שלב ב-pipeline רץ ב-subshell משלו, לכן לולאת ה-while הגדילה עותק של n ולאחר מכן הסתיימה. החליפו את ה-pipe בהפניה (redirect) והלולאה תרוץ בתוך ה-shell שלכם:

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

Bash יכול להריץ את השלב האחרון של pipeline ב-shell הנוכחי באמצעות shopt -s lastpipe, אך רק כאשר job control כבוי, מה שלא קורה לעולם ב-shell אינטראקטיבי. השתמשו ב-redirect.

לאן נעלמה שורת הפקודה (Prompt)? פקודות אינטראקטיביות בתוך $()

החלפת פקודה (command substitution) מפנה את הפלט התקני (standard output) לתוך צינור (pipe) ומשאירה את הקלט התקני (standard input) ללא שינוי. תוכנית שמדפיסה שאלה לפלט התקני ואז ממתינה לתשובה, מאבדת את השאלה, אך לא את ההמתנה. הטרמינל נראה קפוא.

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

המילה deploy בשורה הראשונה היא מה שהקלדת. כעת הרץ את אותה פונקציה בתוך החלפה:

v=$(ask)
deploy

רק הקשות המקלדת שלך מופיעות, כשהן מוצגות על ידי מנהל הטרמינל ולא על ידי התוכנית. שורת הפקודה עברה למקום אחר:

echo "$v"
Username: deploy

שורת הפקודה נמצאת כעת בתוך המשתנה, צמודה לתשובה, מכיוון ש-$() לכד את כל מה שהפונקציה כתבה לפלט התקני. ההקלדה שלך עדיין הגיעה ל-read, מכיוון שהקלט התקני מעולם לא השתנה. זהו בדיוק הסימן המזהה של דיווח הבאג שאומר "הסקריפט שלי נתקע ולא מדפיס כלום".

כלים מסוימים כותבים שורות פקודה לשגיאה תקנית (standard error) או ישירות ל-/dev/tty, כך שהן שורדות את התהליך. רבים אינם עושים זאת. אם פונקציה חייבת להישאר בתוך החלפה, שלח את שורת הפקודה שלה לשגיאה תקנית בעצמך:

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

שגיאה תקנית אינה נלכדת, לכן שורת הפקודה מגיעה לטרמינל שלך ורק התשובה נוחתת ב-v.

מדוע הפלט של ls נראה שונה בתוך $()?

מכיוון ש-ls קורא ל-isatty על מתאר קובץ (file descriptor) 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 היה צינור בפלט שלו בשני המקרים. מה שמשתנה בתוך החלפת פקודה הוא השלב האחרון בצינור. sort לעולם לא בודק אם הוא מחובר לטרמינל, לכן הפלט שלו לעולם אינו משתנה. אם תציבו פקודה המודעת לטרמינל בסוף הצינור, הפלט אכן ישתנה; זו הסיבה שצינור שבדקתם ויזואלית עלול להתנהג אחרת ברגע שתעטפו אותו ב-$().

אזהרה אחת נובעת מכך: אל תנתחו (parse) את הפלט של ls בתוך סקריפט, גם אם זה נראה נוח. שמות קבצים עלולים להכיל רווחים ושורות חדשות. השתמשו ב-glob, או ב-find -print0 יחד עם read -d ''.

תווי השורה החדשה שמושמטים בשקט

החלפת פקודה (command substitution) מסירה את כל תווי השורה החדשה (newlines) בסוף הפלט. לא רק את האחרון, אלא את כולם.

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

חותמת הזמן שלכם תהיה שונה. מה שחשוב הוא שהשם מופיע בשורה אחת.

המחיר הוא שלא ניתן להשתמש ב-$() כדי להעביר את הבתים המדויקים של קובץ. אם אתם זקוקים לתווי השורה החדשה שבסוף, הוסיפו תו סימון (sentinel) בתוך ההחלפה והסירו אותו לאחר מכן:

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 יבצע פיצול ו־globbing

החלפה ללא מירכאות עוברת פיצול מילים ולאחריו הרחבת נתיבים (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
*

ההשמה עצמה הייתה בטוחה, כיוון שהשמות אינן עוברות פיצול מילים או 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, שכן קוד יציאה מוסתר גורם ליחידה לדווח על הצלחה בכל הרצה, בעוד שהפעולה שהייתה אמורה להתבצע לא קרתה מעולם.

חלופות ה-shell שבאמת כדאי להכיר

רוב האנשים פונים ל-$(...) כי הם רוצים לשמור נתונים בתוך משתנה. לעיתים קרובות, מה שהם באמת צריכים הוא קלט, ולא לכידה (capture). ארבע השיטות הבאות שומרות על המצב (state) בתוך ה-shell הנוכחי שלכם.

הפניה מחדש (redirect) בלולאה

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

לא נוצר תהליך נפרד עבור הקלט, לכן כל שינוי שמתבצע בתוך גוף הלולאה נשמר גם לאחריה.

החלפת תהליך (Process substitution)

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 רץ בתוך ה-shell שלכם, לכן שני המשתנים מוגדרים וזמינים לשימוש. read -r a b <<< "$(some-command)" היא הדרך המקובלת לשלוף שני שדות משורה אחת של פלט.

שימוש ב-mapfile עבור קבצים שלמים

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

mapfile, שניתן לכתוב גם כ-readarray, קורא קובץ לתוך מערך (array) בתוך ה-shell הנוכחי. -t מסיר את תו הירידה לשורה (newline) בסוף כל איבר. פקודה זו דורשת bash גרסה 4 ומעלה; מכיוון ש-Ubuntu 24.04 מגיעה עם bash 5.2, היא זמינה בכל תמונת שרת מודרנית.

רשימת תיוג לפני הרצת הסקריפט

  • כתבו את $(command), והשתמשו במירכאות כפי שמופיע ב-"$(command)" אלא אם כן אתם זקוקים במפורש לפיצול.
  • הניחו שתווי ירידת שורה בסוף מחרוזת מוסרים. הוסיפו תו בקרה (sentinel) אם אתם זקוקים להם בחזרה.
  • הרחיקו פקודות אינטראקטיביות מהחלפות (substitutions), או שלחו את ההנחיות שלהן ל-standard error.
  • כתבו את local out בשורה נפרדת כאשר קוד היציאה של out=$(command) הוא בעל חשיבות.
  • כדי להגדיר משתנים מתוך קלט, השתמשו ב-redirect או ב-process substitution במקום ב-pipe.

תבניות אלו מופיעות בסקריפטים הראשונים שאנשים כותבים כאשר הם מבצעים הגדרה של VPS חדש, והכשלים נותרים שקטים. סקריפט גיבוי שלכד הנחיה (prompt) לתוך שם קובץ, או בדיקת תקינות שהסתירה קוד יציאה, ימשיכו לדווח על הצלחה. המחיר עולה כאשר אתם מריצים את אותו סקריפט על פני כמה שרתים, כיוון שהפלט שמעולם לא קראתם הוא כעת פלט שמעולם לא קראתם בעשרים מכונות שונות.

FAQ

מדוע הפקודה cd בתוך $() לא משנה את ספריית העבודה הנוכחית שלי?

$(...) מריץ את הפקודה שלו בתוך subshell, שהוא תהליך נפרד המחזיק עותק של ספריית העבודה והמשתנים שלך. ה-cd משנה את העותק הזה, לאחר מכן התהליך מסתיים והעותק נמחק. subshell יכול להחזיר רק את הפלט הסטנדרטי שלו ואת סטטוס היציאה, לכן אין מנגנון שיאפשר לשינוי הספרייה להשפיע על ה-shell האב. אם ברצונך לקבל את נתיב הספרייה, שמור אותו באמצעות target=$(cd /etc && pwd) והשתמש ב-"$target". אם ברצונך שה-shell שלך יעבור ספרייה, הרץ את cd ישירות, ללא מעטפת של substitution סביבו.

מה ההבדל בין $() לבין גרשיים הפוכים (backticks) ב-bash?

הם מפיקים את אותה תוצאה עבור פקודות פשוטות, אך נבדלים בשני היבטים משמעותיים. $() מאפשר קינון ישיר, כיוון שה-parser מזהה סוגריים, בעוד שגרשיים הפוכים דורשים בריחה (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`, כך שאין טיעון בעד ניידות (portability) לטובת גרשיים הפוכים.

מדוע הסקריפט שלי נתקע ללא שורת פקודה כאשר פקודה שואלת שאלה?

Command substitution מפנה את הפלט הסטנדרטי לתוך pipe אך משאיר את הקלט הסטנדרטי מחובר לטרמינל שלך. תוכנית שמדפיסה את ההנחיה (prompt) שלה לפלט הסטנדרטי גורמת לכך שההנחיה נלכדת בתוך המשתנה, בעוד ה-read שמאחוריה עדיין ממתין לקלט ממך. הטרמינל מציג רק את התווים שאתה מקליד, כפי שהם משוקפים על ידי מנהל הטרמינל. העבר את השאלה מחוץ ל-substitution, או גרם להנחיה להיכתב ל-standard error באמצעות printf 'Username: ' >&2 כך שהיא לא תיקלט.

מדוע השורות הריקות בסוף המשתנה שלי נעלמו?

Command substitution מסיר את כל השורות הריקות בסוף, לא רק את האחרונה שבהן. printf 'hello\n\n\n' > f; v=$(cat f) משאיר את v כשהוא מכיל חמישה בתים בעוד הקובץ מכיל שמונה. כדי לשמור עליהן, הוסף תו סימון (sentinel) בתוך ה-substitution והסר אותו לאחר מכן עם v=$(cat f; printf x) ואחריו v=${v%x}. תו הסימון נמצא אחרי השורות הריקות, כך שאין דבר בסוף ש-bash יכול להסיר.

מדוע local out=$(cmd) תמיד מדווח על הצלחה?

local היא פקודה בפני עצמה, ו-$? אחרי השורה הזו מדווח האם local הצליחה בהגדרת המשתנה. סטטוס היציאה של ה-substitution נצרך ונזרק, מה שאומר גם ש-set -e לא יעצור את הסקריפט. declare, export, typeset ו-readonly מתנהגים באותו אופן. כתוב local out בשורה אחת ו-out=$(cmd) בשורה הבאה, ואז $? ידווח על הסטטוס האמיתי.

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