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.
This is Part 2 of a 10-part series on Bash and shell scripting for developers {Đây là Phần 2 của series 10 bài về Bash và shell scripting dành cho developer}. Part 1 covered the shell, shebang, and your first script {Phần 1 đã nói về shell, shebang, và script đầu tiên}. Here we tackle variables, quoting, and expansion — the mechanics that turn a one-liner into a reliable script {Ở đâ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}.
Get these right and you avoid the most common bash bugs: broken filenames, surprise word-splitting, and empty strings that silently pass tests {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}.
Assigning variables — no spaces around = {Gán biến — không có khoảng trắng quanh =}
In bash, a variable is a name that holds a string {Trong bash, biến là tên chứa một chuỗi}. Assignment is simple — but the syntax is strict {Gán giá trị đơn giản — nhưng cú pháp rất chặt}:
name="Alice"
count=42
path=/tmp/logs
No spaces around = {Không có khoảng trắng quanh =}. This is wrong and bash will treat it as running a command named name with argument =Alice {Sai — bash sẽ coi đó là chạy lệnh tên name với đối số =Alice}:
name = "Alice" # ERROR: command 'name' not found
To read a variable, prefix it with $ {Để đọc biến, thêm $ phía trước}:
echo $name
echo "Hello, $name"
Variable names: letters, digits, underscores; must not start with a digit {Tên biến: chữ, số, gạch dưới; không được bắt đầu bằng số}. Convention: UPPER_SNAKE for constants/env, lower_snake or camelCase for locals {Quy ước: UPPER_SNAKE cho hằng/env, lower_snake hoặc camelCase cho biến cục bộ}.
${var} — why braces matter {${var} — vì sao ngoặc nhọn quan trọng}
$name and ${name} usually do the same thing {$name và ${name} thường cho kết quả giống nhau}. Braces become required when bash cannot tell where the variable name ends {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
Braces also unlock parameter expansion operators (defaults, substring, etc.) {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
Rule {Quy tắc}: use
"${var}"in scripts — quotes plus braces — for clarity and safety {trong script dùng"${var}"— vừa quote vừa ngoặc — cho rõ ràng và an toàn}.
Single quotes vs double quotes {Dấu nháy đơn vs dấu nháy kép}
Bash treats quote types very differently {Bash xử lý hai loại quote rất khác nhau}:
| Quote {Quote} | Expansion? {Expansion?} | Typical use {Dùng khi} |
|---|---|---|
'single' | No — everything literal {Không — mọi thứ literal} | Fixed strings, regex, paths with $ {Chuỗi cố định, regex, path có $} |
"double" | Yes — $var, $(cmd), `cmd`, $((math)) {Có — $var, $(cmd), `cmd`, $((math))} | Almost all interpolated strings {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 ...
When to use each {Khi nào dùng loại nào}:
- Use double quotes for strings that include variables or command output {Dùng nháy kép cho chuỗi có biến hoặc output lệnh}.
- Use single quotes when you want zero expansion — e.g.
grep 'price=$'orawk '{print $1}'{Dùng nháy đơn khi không muốn expansion — vdgrep 'price=$'hoặcawk '{print $1}'}. - For a string that mixes literal and expanded parts, break out of single quotes briefly {Cho chuỗi vừa literal vừa expanded, thoát nháy đơn tạm thời}:
'It'\''s fine'or prefer double quotes with escaped inner quotes {hoặc ưu tiên nháy kép và escape quote bên trong}.
Why you should almost always quote "$var" {Vì sao gần như luôn nên quote "$var"}
An unquoted variable undergoes word splitting and pathname expansion (globbing) {Biến không quote sẽ bị word splitting và pathname expansion (globbing)}. Bash splits on IFS (spaces, tabs, newlines by default) and expands * and ? {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"
Another classic failure {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
Globbing surprise {Bất ngờ từ globbing}:
pattern="*.log"
echo $pattern # lists every .log file in the directory!
echo "$pattern" # *.log — literal, as intended
Rule {Quy tắc}: quote every variable expansion unless you intentionally want word splitting (rare) {quote mọi expansion biến trừ khi bạn cố ý muốn word splitting (hiếm)}. In loops:
for f in "$@", notfor f in $@{Trong vòng lặp:for f in "$@", không phảifor f in $@}.
Expansion — how bash rewrites your line {Expansion — bash viết lại dòng lệnh thế nào}
Before bash runs a command, it expands the line: variables, commands, arithmetic, globs, and more {Trước khi chạy lệnh, bash expand dòng: biến, lệnh, số học, glob, v.v.}. Three expansions you use every day {Ba expansion bạn dùng hàng ngày}:
Command substitution — $(...) {Command substitution — $(...)}
$(command) runs command in a subshell and replaces itself with stdout {$(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"
Legacy backticks `command` work too but nest badly and are harder to read {Backtick `command` cũng chạy nhưng lồng nhau khó và khó đọc}. Prefer $() {Ưu tiên $()}:
# Prefer this:
count=$(ls | wc -l)
# Not this:
count=`ls | wc -l`
Quote the substitution when the result may contain spaces {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 — $((...)) {Arithmetic expansion — $((...))}
$((expression)) evaluates integer math {$((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))
No need for expr or bc for simple integer ops {Không cần expr hay bc cho phép toán số nguyên đơn giản}. For floats, call awk or bc {Với số thực, dùng awk hoặc bc}.
Positional and special variables {Biến vị trí và biến đặc biệt}
When you run ./deploy.sh prod --force, bash stores arguments in positional parameters {Khi chạy ./deploy.sh prod --force, bash lưu đối số trong positional parameters}:
| Variable {Biến} | Meaning {Ý nghĩa} |
|---|---|
$0 | Script name / how it was invoked {Tên script / cách gọi} |
$1 … $9 | First nine arguments {Chín đối số đầu} |
${10} | Tenth argument — braces required past 9 {Đối số thứ 10 — cần ngoặc sau 9} |
$# | Number of arguments (excluding $0) {Số đối số (không tính $0)} |
$@ | All arguments as separate words {Mọi đối số, từng word riêng} |
$* | All arguments as one string (splitting depends on quoting) {Mọi đối số thành một chuỗi (tách phụ thuộc quote)} |
$? | Exit code of the last command {Exit code của lệnh cuối} |
$$ | PID of the current shell {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 "$*" — use "$@" {"$@" vs "$*" — dùng "$@"}
Inside double quotes, "$@" preserves each argument as its own word {Trong nháy kép, "$@" giữ mỗi đối số là một word riêng}. "$*" joins them into one word (with the first character of IFS between) {"$*" 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
Pass "$@" when forwarding arguments to another command {Chuyển "$@" khi chuyển tiếp đối số sang lệnh khác}:
exec git "$@" # ./wrapper.sh log --oneline → git log --oneline
Default values — ${var:-default} and ${var:=default} {Giá trị mặc định — ${var:-default} và ${var:=default}}
Two forms look alike but behave differently {Hai dạng trông giống nhưng khác hành vi}:
| Form {Dạng} | If var unset or empty {Nếu var chưa set hoặc rỗng} | Assigns to var? {Gán vào var?} |
|---|---|---|
${var:-default} | Use default for this expansion only {Dùng default chỉ cho lần expand này} | No {Không} |
${var:=default} | Use default {Dùng default} | Yes — sets var for later {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"
The lone : is a no-op command — it evaluates the expansion as a side effect {: đơn lẻ là lệnh no-op — đánh giá expansion như side effect}. Related operators you’ll meet later {Toán tử liên quan gặp sau}: ${var:+alternate} (use alternate if set), ${var:?message} (fatal error if unset) {${var:+alternate} (dùng alternate nếu đã set), ${var:?message} (lỗi fatal nếu chưa set)}.
Shell variables vs environment variables {Biến shell vs biến môi trường}
A variable you assign in a script lives in the current shell only {Biến gán trong script chỉ sống trong shell hiện tại}. Child processes (commands you run) do not see it unless you export it {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 marks a variable for subprocesses {export VAR=value đánh dấu biến cho subprocess}. env VAR=val command sets it for one command only {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
Convention: export config the app needs (PATH, HOME, API_KEY); keep script internals unexported (loop_index, tmp_file) {Quy ước: export config app cần; giữ biến nội bộ script không export}.
readonly — lock a variable {readonly — khóa biến}
Mark constants so they cannot be reassigned {Đá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 is equivalent {declare -r VAR=val tương đương}. Use for paths and limits that must not change mid-run {Dùng cho path và giới hạn không được đổi giữa chừng}.
Putting it together — a small deploy helper {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: $?"
Every user-supplied or computed value is quoted {Mọi giá trị từ user hoặc tính toán đều được quote}. Defaults use ${var:-default} {Default dùng ${var:-default}}. Command substitution is quoted where paths matter {Command substitution được quote khi liên quan path}.
Mistakes beginners make {Lỗi người mới hay mắc}
- ❌ Spaces around
=in assignment (name = "x") — bash tries to runnameas a command {Khoảng trắng quanh=khi gán — bash cố chạynamenhư lệnh}. - ❌ Unquoted
$varwith paths or user input — word splitting and globbing break filenames {$varkhông quote với path hoặc input user — word splitting và globbing làm hỏng tên file}. - ❌ Using
$*or unquoted$@instead of"$@"when forwarding arguments — loses separate words {Dùng$*hoặc$@không quote thay vì"$@"khi chuyển đối số — mất từng word riêng}. - ❌ Single quotes when you meant expansion (
echo '$HOME') {Nháy đơn khi muốn expansion (echo '$HOME')}. - ❌ Forgetting
${10}—$10is$1followed by literal0{Quên${10}—$10là$1rồi tới chữ0}. - ❌ Assuming a child script sees your variables without
export{Tưởng script con thấy biến của bạn mà khôngexport}.
Exercises {Bài tập}
Try each before opening the solution {Thử từng bài trước khi mở lời giải}.
- Write
greet.shthat takes a name as$1and printsHello, <name>!using"$1"; default toWorldif no argument {Viếtgreet.shnhận tên ở$1, inHello, <name>!dùng"$1"; mặc địnhWorldnếu không có đối số}. - Create
files="one two three"and loop withfor f in $filesvsfor f in "$files"— observe how many iterations each runs {Tạofiles="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}. - Write a wrapper
run.shthat echoes each argument on its own line by looping"$@", then callsecho "Total: $#"{Viết wrapperrun.shin mỗi đối số một dòng bằng vòng lặp"$@", rồi gọiecho "Total: $#"}.
Solution {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: 3Exercise 2 shows why unquoted $var splits and quoted "$var" keeps one word {Bài 2 cho thấy $var không quote bị tách còn "$var" giữ một word}. Exercise 3 shows "$@" preserving arguments with spaces {Bài 3 cho thấy "$@" giữ đối số có space}.
Takeaway {Điều cốt lõi}
Assign with no spaces around =; read with "${var}" almost everywhere {Gán không space quanh =; đọc bằng "${var}" gần như mọi nơi}. Single quotes are literal; double quotes expand variables and $(...) {Nháy đơn là literal; nháy kép expand biến và $(...)}. Forward arguments with "$@", use ${var:-default} for safe defaults, and export only what child processes need {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}. Master expansion and quoting now — conditionals in Part 3 build directly on this {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}.