Bash & Shell Scripting · Part 2 — Variables, Quoting & Expansion
Variables without spaces around =, quoting rules, ${var} braces, command and arithmetic expansion, positional args, defaults, export, and why "$var" beats bare $var — bilingual, with exercises.
Đây là Phần 2 của series 10 bài về Bash và shell scripting dành cho developer. Phần 1 đã nói về shell, shebang, và script đầu tiên. Ở đây ta đi vào biến, quoting, và expansion — cơ chế biến một dòng lệnh thành script đáng tin.
Nắm vững phần này, bạn tránh được các bug bash phổ biến nhất: tên file vỡ, word-splitting bất ngờ, và chuỗi rỗng lọt qua kiểm tra.
Gán biến — không có khoảng trắng quanh =
Trong bash, biến là tên chứa một chuỗi. Gán giá trị đơn giản — nhưng cú pháp rất chặt:
name="Alice"
count=42
path=/tmp/logs
Không có khoảng trắng quanh =. Sai — bash sẽ coi đó là chạy lệnh tên name với đối số =Alice:
name = "Alice" # ERROR: command 'name' not found
Để đọc biến, thêm $ phía trước:
echo $name
echo "Hello, $name"
Tên biến: chữ, số, gạch dưới; không được bắt đầu bằng số. Quy ước: UPPER_SNAKE cho hằng/env, lower_snake hoặc camelCase cho biến cục bộ.
${var} — vì sao ngoặc nhọn quan trọng
$name và ${name} thường cho kết quả giống nhau. Ngoặc nhọn bắt buộc khi bash không biết tên biến kết thúc ở đâu:
file="report"
echo "$file.txt" # report.txt — correct
echo "$filetxt" # empty or wrong — bash looks for variable filetxt
echo "${file}.txt" # report.txt — braces disambiguate
Ngoặc nhọn còn mở khóa các toán tử parameter expansion (default, substring, v.v.):
echo "${name:-Guest}" # use Guest if name is unset or empty
echo "${#name}" # length of name
Quy tắc: trong script dùng
"${var}"— vừa quote vừa ngoặc — cho rõ ràng và an toàn.
Dấu nháy đơn vs dấu nháy kép
Bash xử lý hai loại quote rất khác nhau:
| Quote | Expansion? | Dùng khi |
|---|---|---|
'single' | Không — mọi thứ literal | Chuỗi cố định, regex, path có $ |
"double" | Có — $var, $(cmd), `cmd`, $((math)) | Hầu hết chuỗi có nội suy |
user="vinxi"
echo 'Hello $user' # Hello $user — literal dollar sign
echo "Hello $user" # Hello vinxi — variable expanded
echo 'Today is $(date)' # Today is $(date)
echo "Today is $(date)" # Today is Sun Jan 13 ...
Khi nào dùng loại nào:
- Dùng nháy kép cho chuỗi có biến hoặc output lệnh.
- Dùng nháy đơn khi không muốn expansion — vd
grep 'price=$'hoặcawk '{print $1}'. - Cho chuỗi vừa literal vừa expanded, thoát nháy đơn tạm thời:
'It'\''s fine'hoặc ưu tiên nháy kép và escape quote bên trong.
Vì sao gần như luôn nên quote "$var"
Biến không quote sẽ bị word splitting và pathname expansion (globbing). Bash tách theo IFS (mặc định space, tab, newline) và expand * và ?:
files="report Q1.pdf"
# Unquoted — TWO arguments to rm (dangerous if you meant one file):
rm $files
# Quoted — ONE argument:
rm "$files"
Một lỗi kinh điển khác:
filename="my report.txt"
cat $filename # tries: cat my and report.txt — fails
cat "$filename" # cat "my report.txt" — works
Bất ngờ từ globbing:
pattern="*.log"
echo $pattern # lists every .log file in the directory!
echo "$pattern" # *.log — literal, as intended
Quy tắc: quote mọi expansion biến trừ khi bạn cố ý muốn word splitting (hiếm). Trong vòng lặp:
for f in "$@", không phảifor f in $@.
Expansion — bash viết lại dòng lệnh thế nào
Trước khi chạy lệnh, bash expand dòng: biến, lệnh, số học, glob, v.v.. Ba expansion bạn dùng hàng ngày:
Command substitution — $(...)
$(command) chạy command trong subshell và thay bằng stdout:
today=$(date +%Y-%m-%d)
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
echo "Deploying on $today from branch $branch"
Backtick `command` cũng chạy nhưng lồng nhau khó và khó đọc. Ưu tiên $():
# Prefer this:
count=$(ls | wc -l)
# Not this:
count=`ls | wc -l`
Quote substitution khi kết quả có thể chứa space:
latest=$(ls -t downloads/ | head -1)
cp "downloads/$latest" /tmp/ # safe even if filename has spaces
Arithmetic expansion — $((...))
$((expression)) tính toán số nguyên:
a=10
b=3
echo $((a + b)) # 13
echo $((a / b)) # 3 (integer division)
echo $((a % b)) # 1
# Increment
((count++))
count=$((count + 1))
Không cần expr hay bc cho phép toán số nguyên đơn giản. Với số thực, dùng awk hoặc bc.
Biến vị trí và biến đặc biệt
Khi chạy ./deploy.sh prod --force, bash lưu đối số trong positional parameters:
| Biến | Ý nghĩa |
|---|---|
$0 | Tên script / cách gọi |
$1 … $9 | Chín đối số đầu |
${10} | Đối số thứ 10 — cần ngoặc sau 9 |
$# | Số đối số (không tính $0) |
$@ | Mọi đối số, từng word riêng |
$* | Mọi đối số thành một chuỗi (tách phụ thuộc quote) |
$? | Exit code của lệnh cuối |
$$ | PID của shell hiện tại |
#!/usr/bin/env bash
# greet.sh
echo "Script: $0"
echo "First arg: $1"
echo "Second arg: $2"
echo "Arg count: $#"
echo "All args: $@"
./greet.sh Alice Bob
# Script: ./greet.sh
# First arg: Alice
# Second arg: Bob
# Arg count: 2
"$@" vs "$*" — dùng "$@"
Trong nháy kép, "$@" giữ mỗi đối số là một word riêng. "$*" gộp thành một word (ký tự đầu của IFS ở giữa):
# myscript.sh "$@"
for arg in "$@"; do
echo ">>$arg<<"
done
# ./myscript.sh "hello world" foo
# >>hello world<<
# >>foo<<
# If you used "$*" instead:
# >>hello world foo<< — one argument, broken loop
Chuyển "$@" khi chuyển tiếp đối số sang lệnh khác:
exec git "$@" # ./wrapper.sh log --oneline → git log --oneline
Giá trị mặc định — ${var:-default} và ${var:=default}
Hai dạng trông giống nhưng khác hành vi:
| Dạng | Nếu var chưa set hoặc rỗng | Gán vào var? |
|---|---|---|
${var:-default} | Dùng default chỉ cho lần expand này | Không |
${var:=default} | Dùng default | Có — set var cho sau này |
# :- read-only default (var stays unset)
echo "Hello, ${name:-Guest}"
# := assign default if missing
: "${PORT:=3000}" # common pattern: set PORT once, safely
echo "Listening on $PORT"
: đơn lẻ là lệnh no-op — đánh giá expansion như side effect. Toán tử liên quan gặp sau: ${var:+alternate} (dùng alternate nếu đã set), ${var:?message} (lỗi fatal nếu chưa set).
Biến shell vs biến môi trường
Biến gán trong script chỉ sống trong shell hiện tại. Process con (lệnh bạn chạy) không thấy trừ khi bạn export:
SECRET=abc123 # shell variable — children cannot see it
export API_URL=https://api.example.com # environment variable
./child.sh # child.sh sees API_URL, not SECRET
# child.sh
echo "API_URL=$API_URL"
echo "SECRET=$SECRET" # empty
export VAR=value đánh dấu biến cho subprocess. env VAR=val command set cho một lệnh:
DEBUG=1 ./run.sh # DEBUG visible only inside run.sh's environment for that invocation
Quy ước: export config app cần; giữ biến nội bộ script không export.
readonly — khóa biến
Đánh dấu hằng để không gán lại được:
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly MAX_RETRIES=3
MAX_RETRIES=5 # bash: MAX_RETRIES: readonly variable
declare -r VAR=val tương đương. Dùng cho path và giới hạn không được đổi giữa chừng.
Gắn lại — script deploy nhỏ
#!/usr/bin/env bash
set -euo pipefail
readonly APP_NAME="${1:-myapp}"
readonly ENV="${2:-staging}"
readonly BUILD_DIR="$(pwd)/dist"
readonly TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
echo "[$TIMESTAMP] Deploying $APP_NAME to $ENV"
echo "Build dir: $BUILD_DIR"
echo "Args received ($#): $*"
if [[ ! -d "$BUILD_DIR" ]]; then
echo "Missing $BUILD_DIR" >&2
exit 1
fi
tar -czf "/tmp/${APP_NAME}-${ENV}-${TIMESTAMP}.tar.gz" -C "$BUILD_DIR" .
echo "Done. Exit code: $?"
Mọi giá trị từ user hoặc tính toán đều được quote. Default dùng ${var:-default}. Command substitution được quote khi liên quan path.
Lỗi người mới hay mắc
- Khoảng trắng quanh
=khi gán — bash cố chạynamenhư lệnh. $varkhông quote với path hoặc input user — word splitting và globbing làm hỏng tên file.- Dùng
$*hoặc$@không quote thay vì"$@"khi chuyển đối số — mất từng word riêng. - Nháy đơn khi muốn expansion (
echo '$HOME'). - Quên
${10}—$10là$1rồi tới chữ0. - Tưởng script con thấy biến của bạn mà không
export.
Bài tập
Thử từng bài trước khi mở lời giải.
- Viết
greet.shnhận tên ở$1, inHello, <name>!dùng"$1"; mặc địnhWorldnếu không có đối số. - Tạo
files="one two three"và lặp vớifor f in $filesvsfor f in "$files"— xem mỗi cách lặp bao nhiêu lần. - Viết wrapper
run.shin mỗi đối số một dòng bằng vòng lặp"$@", rồi gọiecho "Total: $#".
Lời giải
#!/usr/bin/env bash
# greet.sh
name="${1:-World}"
echo "Hello, ${name}!"chmod +x greet.sh
./greet.sh Alice # Hello, Alice!
./greet.sh # Hello, World!files="one two three"
for f in $files; do echo "unquoted: >>$f<<"; done
# unquoted: >>one<<
# unquoted: >>two<<
# unquoted: >>three<< — three iterations (word splitting)
for f in "$files"; do echo "quoted: >>$f<<"; done
# quoted: >>one two three<< — one iteration (single argument)#!/usr/bin/env bash
# run.sh
for arg in "$@"; do
echo ">>${arg}<<"
done
echo "Total: $#"chmod +x run.sh
./run.sh 'hello world' foo bar
# >>hello world<<
# >>foo<<
# >>bar<<
# Total: 3Bài 2 cho thấy $var không quote bị tách còn "$var" giữ một word. Bài 3 cho thấy "$@" giữ đối số có space.
Điều cốt lõi
Gán không space quanh =; đọc bằng "${var}" gần như mọi nơi. Nháy đơn là literal; nháy kép expand biến và $(...). Chuyển đối số bằng "$@", dùng ${var:-default} cho default an toàn, và export chỉ những gì process con cần. Nắm expansion và quoting ngay — phần điều kiện ở Phần 3 xây trực tiếp trên nền này.