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.

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${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)) {$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=$' or awk '{print $1}' {Dùng nháy đơn khi không muốn expansion — vd grep 'price=$' hoặc awk '{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 splittingpathname 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 *?}:

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 "$@", not for f in $@ {Trong vòng lặp: for f in "$@", không phải for 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}:

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 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}
$0Script name / how it was invoked {Tên script / cách gọi}
$1$9First 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}${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 { — 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 run name as a command {Khoảng trắng quanh = khi gán — bash cố chạy name như lệnh}.
  • ❌ Unquoted $var with paths or user input — word splitting and globbing break filenames {$var khô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}$10 is $1 followed by literal 0 {Quên ${10}$10$1 rồ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ông export}.

Exercises {Bài tập}

Try each before opening the solution {Thử từng bài trước khi mở lời giải}.

  1. Write greet.sh that takes a name as $1 and prints Hello, <name>! using "$1"; default to World if no argument {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. Create files="one two three" and loop with for f in $files vs for f in "$files" — observe how many iterations each runs {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. Write a wrapper run.sh that echoes each argument on its own line by looping "$@", then calls echo "Total: $#" {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: $#"}.
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: 3

Exercise 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}.