jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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${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:

QuoteExpansion?Dùng khi
'single'Không — mọi thứ literalChuỗi cố định, regex, path có $
"double"$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ặc awk '{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 splittingpathname expansion (globbing). Bash tách theo IFS (mặc định space, tab, newline) và expand *?:

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ải for 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:

echo "$USER has $(ls | wc -l) files, $((2+3)) done" "$USER" variable → vinxi $(ls | wc -l) command → 12 $((2+3)) math → 5 vinxi has 12 files, 5 done
One line, three expansions: $USER (variable), $(ls | wc -l) (command), $((2+3)) (arithmetic) — then echo runs on the final string

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
$0Tên script / cách gọi
$1$9Chí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}${var:=default}

Hai dạng trông giống nhưng khác hành vi:

DạngNếu var chưa set hoặc rỗngGán vào var?
${var:-default}Dùng default chỉ cho lần expand nàyKhông
${var:=default}Dùng default — 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ạy name như lệnh.
  • $var khô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}$10$1 rồ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.

  1. Viết greet.sh nhận tên ở $1, in Hello, <name>! dùng "$1"; mặc định World nếu không có đối số.
  2. Tạo files="one two three" và lặp với for f in $files vs for f in "$files" — xem mỗi cách lặp bao nhiêu lần.
  3. Viết wrapper run.sh in mỗi đối số một dòng bằng vòng lặp "$@", rồi gọi echo "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: 3

Bà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.