SSD Nodes Learn 🎉 VPS เริ่ม $5.50/เดือน
คู่มือ Matt Connorโดย Matt Connor · อัปเดตเมื่อ 2026-08-13

ความแตกต่างระหว่าง $( ) กับ backticks ใน Bash

เรียนรู้ทำไม $( ) ถึงทำงานใน subshell ทำให้คำสั่ง cd หรือตัวแปรไม่คงอยู่ พร้อมเหตุผลที่ควรเลิกใช้ backticks รวมถึงเทคนิคการจัดการ process substitution เพื่อรักษาค่าใน shell

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

การทำงานของ command substitution ใน Bash

Bash command substitution จะแทนที่ $(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 ตามด้วย newline ซึ่ง newline นั้นถูกตัดออกไป และ count ก็จะเก็บอักขระสองตัวที่คุณต้องการไว้ โปรดสังเกตว่า wc -l < three.txt พิมพ์เพียงตัวเลขเปล่าๆ ออกมา เพราะ GNU wc ที่อ่านจาก standard input ไม่มีชื่อไฟล์ให้พิมพ์ ให้เขียน wc -l three.txt แทน แล้วคุณจะจับค่า 3 three.txt ได้ ซึ่งเป็นสตริงที่ต่างออกไปและเป็นสาเหตุทั่วไปที่ทำให้การคำนวณล้มเหลวในภายหลัง

ส่วนที่เหลือของคู่มือนี้คือพฤติกรรมที่ไม่มีใครคาดคิด เนื่องจาก subshell เป็นกระบวนการแยกต่างหาก และกระบวนการแยกต่างหากไม่สามารถเปลี่ยนแปลง shell ที่คุณกำลังใช้งานอยู่ได้

ใช้ $() แทนการใช้ backtick

ทั้งสองรูปแบบสามารถใช้งานได้ถูกต้อง $() เป็นมาตรฐาน POSIX ดังนั้น dash, ash และ busybox sh จึงรองรับทั้งหมด ไม่มีเหตุผลด้านความเข้ากันได้ (portability) ที่จะยังคงใช้ backtick อีกต่อไป และมีเหตุผลที่เป็นรูปธรรมสองประการที่ไม่ควรใช้

Backtick ไม่สามารถซ้อนกันได้

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

บรรทัดที่สองแสดงผลลัพธ์เป็นคำว่า echo hi ออกมาตรงๆ เชลล์จะสแกนไปข้างหน้าเพื่อหา backtick ตัวถัดไปที่ไม่ได้ถูก escape ดังนั้น backtick ตัวที่สองที่คุณพิมพ์จึงไปปิดการทำงานของตัวแรก คำสั่งที่ถูกรันจริงคือ echo โดยไม่มีอาร์กิวเมนต์ ซึ่งแสดงผลเป็นบรรทัดว่างที่ถูกตัด newline ออกไปจนหมด ส่วนคำว่า echo hi ถูกทิ้งไว้เป็นข้อความธรรมดา และคู่ของ backtick สุดท้ายก็รันคำสั่งว่างเปล่า

หากต้องการซ้อน backtick คุณต้อง escape backtick ทุกตัวที่อยู่ด้านใน:

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

การเพิ่มระดับการซ้อนจะทำให้จำนวนการ escape เพิ่มขึ้นเป็นทวีคูณ $() ไม่จำเป็นต้องทำเช่นนี้เลย เพราะตัวแยกวิเคราะห์ (parser) จะจับคู่วงเล็บแทนการสแกนหาอักขระตัวคั่น

Backtick เปลี่ยนแปลง backslash ก่อนที่คำสั่งจะทำงาน

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

คำสั่งภายในชุดเดียวกันให้ผลลัพธ์ที่แตกต่างกัน ภายใน backtick เชลล์จะลบชั้นของการ escape ด้วย backslash ออกหนึ่งชั้น ก่อนที่ ข้อความภายในจะถูกแยกวิเคราะห์ ดังนั้น single quote จึงไม่ได้ป้องกันอะไรเลย ภายใน $() ข้อความระหว่างวงเล็บจะถูกแยกวิเคราะห์เหมือนสคริปต์ปกติ ดังนั้น single quote จึงทำงานตามที่คุณคาดหวัง ปัญหานี้ส่งผลกระทบมากที่สุดในบรรทัดคำสั่งเดียวของ sed และ awk ซึ่งการสูญหายของ backslash จะเปลี่ยนรูปแบบที่ทำงานได้ให้กลายเป็นรูปแบบอื่นโดยไม่มีการแจ้งเตือน

การใส่ quote จะเริ่มต้นใหม่ภายใน $() ซึ่งหมายความว่าคุณสามารถซ้อน double quote ได้โดยไม่ต้อง escape:

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

รูปแบบที่เทียบเท่ากันของ backtick จำเป็นต้องใช้ \" ครอบ $path การ escape ทุกครั้งคือจุดที่อาจเกิดข้อผิดพลาดได้

เหตุใดการใช้ cd ภายใน $() จึงไม่เปลี่ยนไดเรกทอรีของ shell ปัจจุบัน

เนื่องจาก $(...) จะทำการ fork กระบวนการใหม่ขึ้นมา subshell จะได้รับสำเนาของตัวแปรและสำเนาของไดเรกทอรีทำงานปัจจุบันของคุณไป มันจะเปลี่ยนไดเรกทอรีในสำเนาของมัน พิมพ์ผลลัพธ์ออกมา แล้วจบการทำงาน สำเนาเหล่านั้นจะถูกทำลายไปพร้อมกับกระบวนการ

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

ไม่มีสิ่งใดล้มเหลว คำสั่ง cd ทำงานได้ถูกต้อง และ pwd ภายใน subshell ก็ได้พิมพ์ /etc ออกมาจริง เพียงแต่ไม่มีช่องทางใดที่การเปลี่ยนแปลงนั้นจะส่งกลับมายัง shell หลักได้ เนื่องจากสิ่งที่ subshell ส่งคืนให้ parent มีเพียง standard output และ exit status เท่านั้น

การกำหนดค่าตัวแปรก็มีพฤติกรรมในลักษณะเดียวกัน:

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 ถูกปิดอยู่ ซึ่งไม่เป็นความจริงใน interactive shell ให้ใช้การ redirect แทน

Prompt หายไปไหน? คำสั่งแบบโต้ตอบภายใน $()

การทำ command substitution จะเปลี่ยนทิศทาง standard output ไปยัง pipe แต่จะปล่อย standard input ไว้ตามเดิม โปรแกรมที่พิมพ์คำถามออกทาง standard output แล้วรอรับคำตอบจะทำให้คำถามนั้นหายไป แต่ตัวโปรแกรมยังคงรอรับค่าอยู่ ทำให้ terminal ดูเหมือนค้างไป

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

คำว่า deploy ในบรรทัดแรกคือสิ่งที่คุณพิมพ์ลงไป ตอนนี้ลองรันฟังก์ชันเดิมภายใน substitution:

v=$(ask)
deploy

มีเพียงสิ่งที่คุณพิมพ์เท่านั้นที่ปรากฏขึ้น ซึ่งเป็นการ echo โดย terminal driver ไม่ใช่โดยตัวโปรแกรม Prompt หายไปที่อื่น:

echo "$v"
Username: deploy

ตอนนี้ prompt อยู่ภายในตัวแปรและติดอยู่กับคำตอบ เพราะ $() ได้จับทุกอย่างที่ฟังก์ชันเขียนออกทาง standard output ไว้ สิ่งที่คุณพิมพ์ยังคงส่งไปถึง read ได้ เพราะ standard input ไม่เคยถูกแตะต้อง นี่คือลักษณะเฉพาะของรายงานบั๊กที่ระบุว่า "สคริปต์ของฉันค้างและไม่พิมพ์อะไรออกมาเลย"

เครื่องมือบางอย่างเขียน prompt ออกทาง standard error หรือส่งตรงไปยัง /dev/tty ทำให้ prompt เหล่านั้นรอดพ้นจากปัญหานี้ แต่หลายเครื่องมือไม่ได้ทำเช่นนั้น หากฟังก์ชันจำเป็นต้องอยู่ภายใน substitution ให้ส่ง prompt ของคุณไปยัง standard error ด้วยตัวเอง:

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

Standard error ไม่ถูกจับไว้ ดังนั้น prompt จึงแสดงผลที่ terminal ของคุณ และมีเพียงคำตอบเท่านั้นที่ถูกเก็บไว้ใน v

เหตุใด ls จึงแสดงผลต่างออกไปเมื่ออยู่ภายใน $()?

เนื่องจาก ls จะเรียกใช้ isatty บน file descriptor 1 และเปลี่ยนรูปแบบการแสดงผลตามคำตอบที่ได้รับ เมื่ออยู่ที่พรอมต์ descriptor นี้คือเทอร์มินัลของคุณ ls จึงแสดงชื่อไฟล์เรียงเป็นคอลัมน์เต็มบรรทัด แต่เมื่ออยู่ภายใน command 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 นี่เป็นคุณสมบัติที่ตั้งใจออกแบบมา เพื่อให้สคริปต์ได้รับผลลัพธ์ที่เสถียรและอ่านได้ด้วยเครื่องโดยไม่ต้องร้องขอเพิ่มเติม

สิ่งนี้ยังตอบคำถามที่พบบ่อยเกี่ยวกับ pipeline ได้ด้วย การรัน ls | sort ที่พรอมต์และการรัน $(ls | sort) จะให้ผลลัพธ์เหมือนกัน เพราะ ls มี pipe อยู่ที่ output ในทั้งสองกรณี สิ่งที่เปลี่ยนไปเมื่ออยู่ภายใน substitution คือขั้นตอน สุดท้าย ของ pipeline ส่วน sort ไม่เคยตรวจสอบว่าเป็นเทอร์มินัลหรือไม่ ผลลัพธ์จึงไม่เคยเปลี่ยน หากคุณวางคำสั่งที่รับรู้เทอร์มินัลไว้ที่ท้าย pipeline ผลลัพธ์จะเปลี่ยนไป ซึ่งเป็นเหตุผลว่าทำไม pipeline ที่คุณทดสอบด้วยตาเปล่าอาจทำงานต่างออกไปทันทีที่คุณครอบมันด้วย $()

คำเตือนที่ตามมาจากเรื่องนี้คือ อย่าพยายาม parse ผลลัพธ์ของ ls ในสคริปต์แม้ว่าจะดูสะดวกก็ตาม เนื่องจากชื่อไฟล์อาจมีช่องว่างและขึ้นบรรทัดใหม่ ให้ใช้ glob หรือใช้ find -print0 ร่วมกับ read -d '' แทน

อักขระขึ้นบรรทัดใหม่ที่ส่วนท้ายซึ่งหายไปอย่างเงียบเชียบ

การทำ 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 พร้อมกับอักขระขึ้นบรรทัดใหม่ 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

ค่า timestamp ของคุณจะแตกต่างออกไป สิ่งสำคัญคือชื่อไฟล์ต้องเป็นบรรทัดเดียว

ข้อเสียคือคุณไม่สามารถใช้ $() เพื่อย้ายไบต์ทั้งหมดของไฟล์ได้ หากคุณต้องการรักษาอักขระขึ้นบรรทัดใหม่ที่ส่วนท้ายไว้ ให้เพิ่มอักขระตัวคั่น (sentinel character) เข้าไปภายใน substitution แล้วค่อยตัดออกในภายหลัง:

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 จะทำการแยกคำและขยายชื่อไฟล์ (glob)

การแทนที่ค่าโดยไม่ใส่เครื่องหมายคำพูดจะผ่านกระบวนการแยกคำ (word splitting) และตามด้วยการขยายชื่อไฟล์ (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
*

การกำหนดค่าตัวแปรในตัวอย่างนี้ปลอดภัยเนื่องจากการกำหนดค่าจะไม่เกิดการแยกคำหรือการขยายชื่อไฟล์ ความเสียหายเกิดขึ้นที่ echo $p ซึ่ง * ถูกขยายผลเทียบกับไดเรกทอรีปัจจุบัน สคริปต์ที่อ่านรูปแบบ (pattern) จากไฟล์ตั้งค่าแล้วลืมใส่เครื่องหมายคำพูดจะทำงานกับทุกไฟล์ที่มองเห็นโดยไม่ตั้งใจ การใส่เครื่องหมายคำพูดครอบทุกการขยายค่าจะช่วยขจัดข้อผิดพลาดประเภทนี้ไปได้ทั้งหมด ให้ละเว้นการใส่เครื่องหมายคำพูดเฉพาะในกรณีที่คุณต้องการการแยกคำจริงๆ เท่านั้น ซึ่งพบได้น้อยมาก

เหตุใดคำสั่ง local x=$(cmd) จึงส่งค่ากลับเป็น 0 เสมอ

เนื่องจาก local เป็นคำสั่งในตัวมันเอง และ $? จะรายงานสถานะของ local ไม่ใช่สถานะของคำสั่งที่อยู่ภายใน substitution นั้น

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

การกำหนดค่าแบบปกติที่ระดับบนสุด (top level) จะรายงานสถานะของคำสั่ง substitution ล่าสุดให้โดยอัตโนมัติ:

out=$(exit 3)
echo $?
3

เรื่องนี้มีความสำคัญอย่างยิ่งในการตรวจสอบสถานะ (health check) ที่ทำงานภายใต้ systemd service และ timer ซึ่งหากค่า exit code ถูกบดบัง จะทำให้ unit รายงานว่าทำงานสำเร็จทุกครั้ง ทั้งที่งานที่ควรจะตรวจสอบนั้นไม่ได้เกิดขึ้นจริง

ทางเลือกใน shell ที่คุณต้องการใช้งานจริง

คนส่วนใหญ่มักเลือกใช้ $(...) เพราะต้องการเก็บข้อมูลไว้ในตัวแปร แต่บ่อยครั้งสิ่งที่ต้องการจริงๆ คือการรับข้อมูลเข้า (input) ไม่ใช่การจับค่า (capture) รูปแบบทั้ง 4 นี้จะช่วยคงสถานะไว้ใน 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 ใหม่สำหรับ input ดังนั้นสิ่งที่กำหนดไว้ในตัวลูปจะยังคงอยู่หลังจากลูปทำงานเสร็จสิ้น

การทำ 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 ซึ่งอ่านค่าจาก output ของคำสั่งนั้นๆ ตัวคำสั่งยังคงทำงานใน process ของตัวเอง แต่ลูป while จะไม่ทำเช่นนั้น ซึ่งนั่นคือจุดประสงค์หลัก ช่องว่างใน < <( เป็นสิ่งที่จำเป็น เพราะ <<( จะถูกตีความว่าเป็นจุดเริ่มต้นของ here-document และจะทำให้การ parse ผิดพลาด Process substitution เป็นฟีเจอร์ของ bash ดังนั้นสคริปต์ที่มี #!/bin/sh บน Ubuntu หรือ Debian จะทำงานภายใต้ dash และจะล้มเหลว ให้ใช้ #!/bin/bash แทน

การใช้ Here-strings

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

<<< จะส่งสตริงหนึ่งชุดไปยัง standard input ของคำสั่ง read จะทำงานใน shell ของคุณ ดังนั้นตัวแปรทั้งสองจึงถูกกำหนดค่าไว้ในจุดที่คุณสามารถเรียกใช้งานได้ read -r a b <<< "$(some-command)" เป็นวิธีปกติในการดึงข้อมูลสองฟิลด์ออกจากบรรทัด output หนึ่งบรรทัด

การใช้ mapfile สำหรับไฟล์ทั้งไฟล์

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

mapfile หรือที่เรียกอีกชื่อว่า readarray จะอ่านไฟล์เข้าไปเก็บใน array ภายใน shell ปัจจุบัน -t จะลบอักขระขึ้นบรรทัดใหม่ที่ต่อท้ายแต่ละสมาชิกออก คำสั่งนี้ต้องการ bash เวอร์ชัน 4 ขึ้นไป และเนื่องจาก Ubuntu 24.04 มาพร้อมกับ bash 5.2 จึงสามารถใช้งานได้บน image ของเซิร์ฟเวอร์ในปัจจุบันทุกตัว

รายการตรวจสอบก่อนที่คุณจะคอมมิตสคริปต์

  • เขียน $(command) และใส่เครื่องหมายคำพูดเป็น "$(command)" เว้นแต่คุณต้องการแยกส่วนโดยเฉพาะ
  • สมมติว่าอักขระขึ้นบรรทัดใหม่ที่ส่วนท้ายถูกตัดออกไปแล้ว ให้เพิ่มอักขระ sentinel หากคุณต้องการเรียกคืน
  • หลีกเลี่ยงการใช้คำสั่งแบบโต้ตอบ (interactive commands) ภายในส่วนการแทนที่ หรือส่งข้อความแจ้งเตือนไปยัง standard error แทน
  • เขียน local out ไว้ในบรรทัดของตัวเองเมื่อสถานะการจบการทำงาน (exit status) ของ out=$(command) มีความสำคัญ
  • ในการตั้งค่าตัวแปรจากอินพุต ให้ใช้การเปลี่ยนทิศทาง (redirect) หรือ process substitution แทนการใช้ pipe

รูปแบบเหล่านี้มักปรากฏในสคริปต์ขนาดเล็กชุดแรกที่ผู้คนเขียนเมื่อเริ่ม ตั้งค่า VPS ใหม่ และความล้มเหลวที่เกิดขึ้นมักจะเงียบเชียบ สคริปต์สำรองข้อมูลที่บันทึกข้อความแจ้งเตือน (prompt) ลงในชื่อไฟล์ หรือการตรวจสอบสถานะสุขภาพ (health check) ที่ปิดบังรหัสสถานะการจบการทำงาน จะยังคงรายงานผลว่าสำเร็จ ต้นทุนความเสียหายจะเพิ่มขึ้นเมื่อคุณ รันสคริปต์ชุดเดียวกันบนเซิร์ฟเวอร์หลายเครื่อง เพราะผลลัพธ์ที่คุณไม่เคยอ่านนั้น กลายเป็นผลลัพธ์ที่คุณไม่เคยอ่านบนเครื่องจำนวน 20 เครื่อง

FAQ

ทำไมการใช้ cd ภายใน $() ถึงไม่เปลี่ยนไดเรกทอรีปัจจุบันของฉัน?

$(...) จะรันคำสั่งใน subshell ซึ่งเป็นกระบวนการแยกต่างหากที่ถือสำเนาของไดเรกทอรีทำงานและตัวแปรของคุณไว้ การใช้ cd จะเปลี่ยนสำเนานั้น จากนั้นกระบวนการจะสิ้นสุดลงและสำเนานั้นจะถูกทิ้งไป subshell สามารถส่งคืนได้เพียง standard output และสถานะการทำงาน (exit status) เท่านั้น จึงไม่มีกลไกใดที่ทำให้การเปลี่ยนไดเรกทอรีส่งผลกลับมายัง shell หลักได้ หากคุณต้องการไดเรกทอรีนั้น ให้เก็บค่าไว้ด้วย target=$(cd /etc && pwd) แล้วใช้ "$target" แทน หากคุณต้องการให้ shell ของคุณย้ายตำแหน่ง ให้รัน cd โดยตรงโดยไม่ต้องใช้ substitution ครอบไว้

$() และ backticks ใน bash ต่างกันอย่างไร?

ทั้งสองให้ผลลัพธ์เหมือนกันสำหรับคำสั่งทั่วไป แต่มีความแตกต่างที่สำคัญสองประการคือ $() สามารถซ้อนกันได้โดยตรงเนื่องจากตัวแยกวิเคราะห์ (parser) จับคู่วงเล็บได้ ในขณะที่ backticks ต้องใช้ backslash หลีก (escape) backtick ในทุกระดับของการซ้อน นอกจากนี้ backticks ยังตัด 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) ที่จะสนับสนุนการใช้ backticks

ทำไมสคริปต์ของฉันถึงค้างโดยไม่มี prompt เมื่อคำสั่งถามคำถาม?

Command substitution จะเปลี่ยนทิศทาง standard output ไปยัง pipe แต่ยังคงเชื่อมต่อ standard input ไว้กับเทอร์มินัลของคุณ โปรแกรมที่พิมพ์ prompt ออกทาง standard output จะถูกจับค่า prompt นั้นเข้าไปในตัวแปร ในขณะที่ read ที่อยู่เบื้องหลังยังคงรอการตอบสนองจากคุณ เทอร์มินัลจะแสดงเฉพาะตัวอักษรที่คุณพิมพ์ซึ่งถูกสะท้อนโดยไดรเวอร์ของเทอร์มินัล ให้ย้ายคำถามออกมานอก substitution หรือทำให้ prompt เขียนไปยัง standard error ด้วย printf 'Username: ' >&2 เพื่อไม่ให้มันถูกจับค่าไป

ทำไมบรรทัดว่างที่ท้ายตัวแปรของฉันถึงหายไป?

Command substitution จะลบบรรทัดว่าง (newline) ที่ต่อท้ายออกทั้งหมด ไม่ใช่แค่บรรทัดสุดท้ายบรรทัดเดียว printf 'hello\n\n\n' > f; v=$(cat f) จะทำให้ v เหลือข้อมูลเพียง 5 ไบต์ ในขณะที่ไฟล์ต้นฉบับมี 8 ไบต์ หากต้องการเก็บไว้ ให้เพิ่ม sentinel ต่อท้ายภายใน substitution แล้วค่อยตัดออกภายหลังด้วย v=$(cat f; printf x) ตามด้วย v=${v%x} ตัว sentinel จะวางอยู่หลังบรรทัดว่าง ทำให้ bash ไม่มีอะไรให้ลบที่ส่วนท้าย

ทำไม local out=$(cmd) ถึงรายงานว่าสำเร็จเสมอ?

local เป็นคำสั่งในตัวมันเอง และ $? หลังจากบรรทัดนั้นจะรายงานว่า local ประกาศตัวแปรสำเร็จหรือไม่ สถานะการทำงานของ substitution จะถูกใช้ไปและทิ้งไป ซึ่งหมายความว่า set -e จะไม่หยุดสคริปต์ของคุณ declare, export, typeset และ readonly ก็มีพฤติกรรมเช่นเดียวกัน ให้เขียน local out ในบรรทัดหนึ่ง และ out=$(cmd) ในบรรทัดถัดไป แล้ว $? จึงจะรายงานสถานะที่แท้จริงออกมา

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