Bash command substitution: $() hay backtick?
Hiểu vì sao cd và biến trong $() bị mất, newline cuối output bị loại bỏ, backtick khó lồng, và cách dùng process substitution đúng trong Bash.
Bash command substitution hoạt động như thế nào
Bash command substitution thay thế $(command) bằng văn bản mà command đó ghi ra standard output. Cú pháp cũ dùng backtick cũng thực hiện cùng chức năng. Mọi hành vi gây ngạc nhiên đều xuất phát từ 2 điểm: command chạy trong một process riêng gọi là subshell, và mọi newline ở cuối output đều bị loại bỏ.
mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"3Đó là toàn bộ chức năng này. wc -l ghi 3, theo sau là một newline; newline đó bị loại bỏ, còn count chứa 2 ký tự bạn cần. Lưu ý rằng wc -l < three.txt chỉ ghi ra một số đơn vì GNU wc đọc standard input nên không có filename để ghi ra. Thay vào đó, hãy viết wc -l three.txt; khi đó bạn capture được 3 three.txt. Đây là một chuỗi khác và là nguyên nhân phổ biến khiến phép tính số học bị lỗi về sau.
Phần còn lại của hướng dẫn này mô tả hành vi mà hầu hết mọi người không ngờ tới, vì subshell là một process riêng và một process riêng không thể thay đổi shell mà bạn đang nhập lệnh.
Dùng $() và ngừng dùng backtick
Cả hai dạng đều hợp lệ. $() thuộc POSIX, nên dash, ash và busybox sh đều hỗ trợ dạng này. Không còn lý do nào về tính tương thích để tiếp tục viết backtick, và có 2 lý do cụ thể để không dùng chúng.
Backtick không hỗ trợ lồng nhau
echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"hi
echo hiDòng thứ 2 in ra nguyên văn các từ echo hi. Shell quét tiếp đến backtick không được escape tiếp theo, nên backtick thứ 2 bạn nhập đã đóng cặp backtick thứ nhất. Lệnh thực sự được chạy là echo không có đối số, lệnh này in ra một dòng trống rồi newline của dòng đó bị loại bỏ, trở thành chuỗi rỗng. Các từ echo hi còn lại dưới dạng văn bản thuần, còn cặp backtick cuối cùng chạy một lệnh rỗng.
Để lồng backtick, bạn phải escape mọi backtick bên trong:
echo "`echo \`echo hi\``"hiMỗi cấp lồng thêm lại làm số lần escape tăng gấp đôi. $() không gặp vấn đề này vì parser ghép cặp dấu ngoặc thay vì quét tìm một ký tự phân cách.
Backtick thay đổi backslash trước khi chạy lệnh
echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"a\\b
a\bCùng một lệnh bên trong lại tạo ra output khác nhau. Bên trong backtick, shell loại bỏ một lớp escape của backslash trước khi parse phần văn bản bên trong, nên dấu nháy đơn không bảo vệ được gì. Bên trong $(), phần văn bản giữa 2 dấu ngoặc được parse như một script thông thường, nên dấu nháy đơn hoạt động như bạn mong đợi. Vấn đề này nghiêm trọng nhất trong các one-liner sed và awk, nơi một backslash bị mất có thể biến một pattern đang chạy đúng thành một pattern khác mà không báo lỗi.
Việc quoting cũng bắt đầu lại bên trong $(), nên bạn có thể lồng dấu nháy kép mà không cần escape chúng:
path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"/etc/nginxDạng tương đương dùng backtick cần \" bao quanh $path. Mỗi escape là một chỗ có thể phát sinh lỗi.
Vì sao cd bên trong $() không thay đổi shell hiện tại
Vì $(...) tạo một process mới. Subshell nhận một bản sao các biến và một bản sao thư mục làm việc của shell hiện tại. Nó thay đổi bản sao đó, in kết quả rồi thoát. Bản sao này cũng bị hủy khi subshell kết thúc.
cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd/tmp/subst-demo
/etc
/tmp/subst-demoKhông có lỗi nào xảy ra. cd đã chạy thành công, và pwd bên trong subshell thực sự đã in /etc. Chỉ là không có cách nào để thay đổi đó quay lại shell cha, vì subshell chỉ truyền cho shell cha standard output và exit status.
Phép gán biến cũng hoạt động theo cùng quy tắc:
count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"inside: 99
outside: 0Quy tắc này cũng giải thích một biến thể của lỗi mà người dùng thường gặp hơn nhiều. Biến thể đó liên quan đến pipeline thay vì command substitution:
n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"0Mỗi stage của pipeline chạy trong subshell riêng. Vì vậy, vòng lặp while chỉ tăng bản sao của n rồi thoát. Thay pipe bằng redirect để vòng lặp chạy trong shell hiện tại:
n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"3Bash có thể chạy stage cuối của pipeline trong shell hiện tại bằng shopt -s lastpipe, nhưng chỉ khi job control tắt. Điều này không bao giờ đúng trong interactive shell. Hãy dùng redirect.
Prompt đã biến mất ở đâu? Các lệnh tương tác bên trong $()
Phép thay thế lệnh chuyển standard output vào một pipe và giữ nguyên standard input. Một chương trình in câu hỏi ra standard output rồi chờ câu trả lời sẽ làm mất câu hỏi, chứ không dừng việc chờ. Terminal trông như bị treo.
ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
askUsername: deploy
deployTừ deploy ở dòng đầu tiên là nội dung bạn đã nhập. Bây giờ chạy cùng function bên trong phép thay thế:
v=$(ask)deployChỉ có các phím bạn tự nhập xuất hiện, do terminal driver echo lại thay vì chương trình. Prompt đã đi sang nơi khác:
echo "$v"Username: deployBây giờ prompt nằm bên trong biến và dính liền với câu trả lời, vì $() đã capture mọi thứ function ghi vào standard output. Nội dung bạn nhập vẫn đến được read, vì standard input chưa từng bị tác động. Đây chính xác là dấu hiệu của bug report ghi rằng “script của tôi bị treo và không in gì cả”.
Một số tool ghi prompt vào standard error hoặc trực tiếp vào /dev/tty để prompt vẫn hiển thị trong trường hợp này. Nhiều tool thì không. Nếu function bắt buộc phải chạy bên trong phép thay thế, hãy tự gửi prompt vào standard error:
ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"Username: deploy
deployStandard error không bị capture, nên prompt đến được terminal của bạn và chỉ câu trả lời được đưa vào v.
Vì sao ls hiển thị khác bên trong $()?
Vì ls gọi isatty trên file descriptor 1 và thay đổi định dạng output dựa trên kết quả đó. Tại prompt, file descriptor này là terminal của bạn, nên ls trải đều các tên theo cột trên dòng. Bên trong command substitution, nó là pipe, nên ls chuyển sang hiển thị mỗi tên trên một dòng.
mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"alpha
beta
delta
gammaCùng một kiểm tra cũng tắt màu trong grep --color=auto và tắt pager trong git. Đây là hành vi được thiết kế sẵn. Nhờ đó, script nhận được output ổn định, có thể đọc bằng máy mà không cần yêu cầu riêng.
Điều này cũng giải đáp một câu hỏi thường gặp về pipeline. ls | sort tại prompt và $(ls | sort) in ra cùng một nội dung, vì ls đều có pipe ở output trong cả hai trường hợp. Điểm thay đổi bên trong command substitution là stage cuối cùng của pipeline. sort không bao giờ kiểm tra terminal, nên output của nó không thay đổi. Nếu đặt một command có nhận biết terminal ở cuối pipeline thì output sẽ thay đổi. Vì vậy, pipeline bạn đã kiểm tra bằng mắt có thể hoạt động khác ngay khi được bọc trong $().
Có một cảnh báo liên quan: không phân tích output của ls trong script dù cách này có vẻ tiện. Tên file có thể chứa khoảng trắng và ký tự xuống dòng. Hãy dùng glob hoặc find -print0 cùng với read -d ''.
Các ký tự xuống dòng ở cuối bị âm thầm loại bỏ
Phép thay thế lệnh loại bỏ mọi ký tự xuống dòng ở cuối output. Không chỉ ký tự cuối cùng. Tất cả các ký tự đó.
cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c8
5File chứa hello cùng với ba ký tự xuống dòng, nên có 8 byte. Biến chứa hello, nên có 5 byte. Ba byte biến mất mà không có cảnh báo.
Việc loại bỏ này là có chủ đích và thường hữu ích. Nhờ đó, stamp=$(date -u +%Y%m%dT%H%M%SZ) tạo ra một đoạn tên file có thể sử dụng, thay vì tên chứa ký tự xuống dòng. Vì vậy, mẫu này an toàn khi dùng trong script backup restic được lập lịch:
stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"backup-20260804T031500Z.tar.gzTimestamp của bạn sẽ khác. Điều quan trọng là tên file chỉ nằm trên một dòng.
Đổi lại, bạn không thể dùng $() để di chuyển chính xác các byte của file. Nếu cần giữ các ký tự xuống dòng ở cuối, hãy thêm một ký tự sentinel bên trong phép thay thế rồi xóa nó sau đó:
v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c8x nằm sau các ký tự xuống dòng, nên không còn ký tự xuống dòng nào ở cuối để bị loại bỏ. Sau đó, ${v%x} xóa ký tự sentinel và giữ lại các byte ban đầu.
Có hai chi tiết liên quan. Khi đọc toàn bộ file, v=$(<blanks.txt) thực hiện cùng công việc mà không cần chạy cat, vì bash tự mở file. Nó cũng loại bỏ các ký tự xuống dòng ở cuối theo cách giống hệt. Ngược lại, here-string thêm một ký tự xuống dòng mà bạn không viết:
wc -c <<< 'abc'4Đặt kết quả trong dấu nháy, nếu không bash sẽ tách từ và mở rộng glob
Phép thay thế không có dấu nháy sẽ trải qua bước tách từ rồi đến bước mở rộng pathname. Phép thay thế có dấu nháy không trải qua cả hai bước này.
printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"a b c d
a b c
dKhi không có dấu nháy, bash tách output theo các ký tự trong IFS. Theo mặc định, đó là dấu cách, tab và dòng mới. Sau đó, echo nối 4 phần lại bằng các dấu cách đơn. Khi có dấu nháy, toàn bộ văn bản được giữ thành một từ. Ký tự tab và dòng mới bên trong vẫn nguyên vẹn.
Globbing là phần nguy hiểm hơn:
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
*Bản thân phép gán vẫn an toàn vì phép gán không thực hiện tách từ hoặc glob. Sự cố xảy ra tại echo $p, nơi * được mở rộng dựa trên thư mục hiện tại. Một script đọc pattern từ file cấu hình nhưng quên đặt dấu nháy sẽ xử lý tất cả file mà nó có thể nhìn thấy. Đặt dấu nháy quanh mọi phép mở rộng để loại bỏ toàn bộ nhóm lỗi này. Chỉ bỏ dấu nháy khi bạn thực sự muốn tách từ, trường hợp này hiếm gặp.
Vì sao local x=$(cmd) luôn trả về 0?
Vì local tự nó là một command, còn $? báo trạng thái của local, không phải trạng thái của phép thay thế lệnh bên trong nó.
check_bad() { local out=$(false); echo "status: $?"; }
check_badstatus: 0false thoát với mã 1, local khai báo biến thành công, và mã 1 bị loại bỏ. declare, export, typeset và readonly cũng hoạt động như vậy. set -e cũng không bắt được lỗi này, vì theo cách nhìn của shell thì không có thao tác nào thất bại.
Tách phần khai báo khỏi phần gán giá trị:
check_good() { local out; out=$(false); echo "status: $?"; }
check_goodstatus: 1Một phép gán thông thường ở cấp cao nhất đã báo cáo trạng thái của phép thay thế lệnh cuối cùng:
out=$(exit 3)
echo $?3Điều này đặc biệt quan trọng trong health check chạy dưới một systemd service và timer, nơi mã thoát bị che giấu khiến unit báo thành công ở mọi lần chạy, dù công việc mà nó cần kiểm tra chưa bao giờ được thực hiện.
Các cách thay thế trong shell mà bạn thực sự nên dùng
Hầu hết mọi người dùng $(...) vì muốn lưu dữ liệu vào một biến. Nhưng nhiều trường hợp, thứ họ thực sự cần là input chứ không phải capture. Bốn cách dưới đây giữ trạng thái trong shell hiện tại.
Redirect cho vòng lặp
cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txtgot: alpha
got: beta
got: gammaInput không tạo process mới, nên mọi giá trị mà phần thân vòng lặp thiết lập vẫn còn sau khi vòng lặp kết thúc.
Process substitution
while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)got: gamma
got: beta
got: alpha<(command) cung cấp một path, chẳng hạn /dev/fd/63, để đọc output của command. Command vẫn chạy trong process riêng. Vòng lặp while thì không, và đó chính là mục đích của cách này. Khoảng trắng trong < <( là bắt buộc: <<( được đọc là phần mở đầu của here-document và sẽ gây lỗi parse. Process substitution là tính năng của bash, nên script có #!/bin/sh trên Ubuntu hoặc Debian sẽ chạy bằng dash và bị lỗi. Hãy dùng #!/bin/bash.
Here-string
read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"alpha
beta gamma<<< đưa một string vào standard input của command. read chạy trong shell hiện tại, nên cả hai biến đều được thiết lập tại nơi bạn có thể sử dụng chúng. read -r a b <<< "$(some-command)" là cách thông thường để lấy hai field từ một dòng output.
mapfile cho toàn bộ file
mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"3
betamapfile, còn được viết là readarray, đọc một file vào array trong shell hiện tại. -t xóa newline ở cuối mỗi phần tử. Tính năng này cần bash 4 trở lên. Ubuntu 24.04 đi kèm bash 5.2, nên nó có trên mọi current server image.
Checklist trước khi commit script
- Viết
$(command)và đặt nó trong dấu ngoặc kép thành"$(command)", trừ khi bạn thực sự muốn tách chuỗi. - Giả định rằng các ký tự xuống dòng ở cuối đã bị loại bỏ. Thêm một ký tự sentinel nếu cần giữ lại chúng.
- Không đưa các lệnh tương tác vào phép thay thế, hoặc chuyển prompt của chúng sang standard error.
- Viết
local outtrên một dòng riêng khi trạng thái thoát củaout=$(command)có ý nghĩa. - Để gán biến từ input, dùng redirect hoặc process substitution thay vì pipe.
Các dạng này thường xuất hiện trong những script nhỏ đầu tiên mà mọi người viết khi thiết lập một VPS mới, và lỗi thường không hiển thị. Một script backup ghi prompt vào tên file, hoặc một health check che mất mã thoát, vẫn tiếp tục báo thành công. Chi phí tăng lên khi bạn chạy cùng một script trên nhiều server, vì output mà bạn chưa từng đọc giờ là output mà bạn chưa từng đọc trên 20 máy.
FAQ
Vì sao cd bên trong $() không thay đổi thư mục hiện tại?
$(...) chạy lệnh trong một subshell. Đây là một tiến trình riêng, giữ bản sao của thư mục làm việc và các biến của bạn. cd thay đổi bản sao đó, sau đó tiến trình kết thúc và bản sao bị hủy. Subshell chỉ có thể trả về standard output và exit status, nên không có cơ chế nào để thay đổi thư mục truyền tới shell cha. Nếu chỉ cần lấy đường dẫn thư mục, hãy dùng target=$(cd /etc && pwd) để capture đường dẫn rồi dùng "$target". Nếu muốn shell hiện tại chuyển thư mục, hãy chạy trực tiếp cd, không đặt nó trong command substitution.
Sự khác nhau giữa $() và backticks trong bash là gì?
Với các lệnh đơn giản, chúng cho cùng kết quả. Có hai khác biệt đáng chú ý. $() hỗ trợ lồng trực tiếp vì parser ghép các dấu ngoặc đơn, còn backticks cần escape một backtick ở mỗi cấp lồng. Backticks cũng loại bỏ một lớp escape bằng dấu gạch chéo ngược trước khi parse lệnh bên trong, nên ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`, vì vậy không có lý do về tính portable để dùng backticks.
Vì sao script của tôi bị treo, không có prompt, khi một lệnh yêu cầu nhập dữ liệu?
Command substitution chuyển standard output vào một pipe nhưng vẫn giữ standard input kết nối với terminal. Chương trình ghi prompt vào standard output sẽ bị capture vào biến, còn read phía sau vẫn chờ bạn nhập. Terminal chỉ hiển thị các ký tự bạn gõ, được terminal driver echo lại. Hãy đưa câu hỏi ra ngoài command substitution, hoặc cho prompt ghi vào standard error bằng printf 'Username: ' >&2 để prompt không bị capture.
Vì sao các dòng trống ở cuối biến của tôi biến mất?
Command substitution xóa mọi newline ở cuối, không chỉ newline cuối cùng. printf 'hello\n\n\n' > f; v=$(cat f) khiến v chứa năm byte, trong khi file chứa tám byte. Để giữ lại các byte này, hãy thêm một sentinel bên trong command substitution rồi xóa sentinel sau đó bằng v=$(cat f; printf x) theo sau là v=${v%x}. Sentinel nằm sau các newline, nên bash không còn gì ở cuối để xóa.
Vì sao local out=$(cmd) luôn báo thành công?
local là một command độc lập, còn $? sau dòng đó báo kết quả của việc local khai báo biến. Exit status của command substitution bị tiêu thụ và bỏ đi. Vì vậy set -e cũng sẽ không dừng script. declare, export, typeset và readonly cũng hoạt động như vậy. Hãy viết local out trên một dòng và out=$(cmd) trên dòng tiếp theo. Khi đó $? sẽ báo status thực của lệnh.