Bash & Shell Scripting · Part 6 — Arrays & String Manipulation
Indexed and associative bash arrays, string slicing, prefix/suffix removal, search/replace, case conversion, safe iteration, and parameter defaults — bilingual, with exercises.
This is Part 6 of a 10-part series that takes you from “I copy-paste commands” to writing robust, production-grade shell scripts {Đây là Phần 6 của series 10 bài đưa bạn từ “copy-paste lệnh” đến viết shell script chắc chắn, mức production}. Parts 1–5 covered the shell, variables, conditionals, loops, and functions {Phần 1–5 đã nói về shell, biến, điều kiện, vòng lặp, và hàm}. Now we tackle arrays and string manipulation — the bash features that turn a flat list of variables into structured data and let you reshape text without reaching for sed or awk {Giờ ta đi vào mảng và thao tác chuỗi — tính năng bash biến danh sách biến phẳng thành dữ liệu có cấu trúc và cho phép biến đổi text mà không cần sed hay awk}.
Arrays hold multiple values under one name; parameter expansion operators slice, trim, replace, and default strings — all in pure bash {Mảng giữ nhiều giá trị dưới một tên; các toán tử parameter expansion cắt, tỉa, thay thế, và gán mặc định cho chuỗi — tất cả trong bash thuần}. Master these and your scripts become shorter, clearer, and far less fragile around filenames and config keys {Nắm vững phần này, script ngắn hơn, rõ hơn, và ít vỡ hơn nhiều khi xử lý tên file và key cấu hình}.
Arrays & strings — the concept {Mảng & chuỗi — khái niệm}
Bash gives you two array types and a rich set of parameter expansion operators on strings {Bash có hai loại mảng và bộ parameter expansion phong phú trên chuỗi}:
- Indexed arrays — numbered slots
0,1,2, … like a list {Mảng chỉ số — ô đánh số0,1,2, … như một danh sách}. - Associative arrays — string keys mapped to values, like a hash map (bash 4+) {Mảng kết hợp — key chuỗi ánh xạ tới giá trị, như hash map (bash 4+)}.
- String operators — length, substring, prefix/suffix removal, search/replace, case change, and defaults — all applied via the same brace syntax you learned in Part 2 {Toán tử chuỗi — độ dài, substring, bỏ prefix/suffix, tìm/thay, đổi hoa thường, và default — cùng cú pháp ngoặc nhọn đã học ở Phần 2}.
Everything in this part is bash-specific — POSIX sh has no arrays and fewer expansion forms {Mọi thứ trong phần này chỉ có trên bash — POSIX sh không có mảng và ít dạng expansion hơn}. Keep #!/usr/bin/env bash in your shebang {Giữ #!/usr/bin/env bash trong shebang}.
Indexed arrays — create, access, iterate {Mảng chỉ số — tạo, truy cập, duyệt}
Declare and populate an indexed array in one line {Khai báo và gán mảng chỉ số trong một dòng}:
arr=(apple banana cherry)
Access a single element by index (zero-based) {Truy cập một phần tử theo index (bắt đầu từ 0)}:
echo "${arr[0]}" # apple
echo "${arr[2]}" # cherry
Get all elements as separate words — the @ subscript form {Lấy mọi phần tử thành các từ riêng — dạng subscript @}:
echo "${arr[@]}" # apple banana cherry (three words)
Count elements with the length operator on the array {Đếm phần tử bằng toán tử length trên mảng}:
echo "${#arr[@]}" # 3
List which indices are set (useful after sparse or selective deletes) {Liệt kê index nào đang có giá trị (hữu ích sau khi xóa chọn lọc hoặc mảng thưa)}:
echo "${!arr[@]}" # 0 1 2
Append without retyping the whole list {Thêm phần tử mà không gõ lại cả danh sách}:
arr+=(date)
echo "${arr[@]}" # apple banana cherry date
Slice a range of elements — same offset:length syntax as strings, but applied to the array expansion {Cắt một đoạn phần tử — cùng cú pháp offset:length như chuỗi, nhưng áp lên expansion mảng}:
echo "${arr[@]:1:2}" # banana cherry (start at index 1, take 2)
Iterate safely — always quote the expansion {Duyệt an toàn — luôn quote expansion}
The correct loop pattern from Part 4, now with the full picture {Mẫu vòng lặp đúng từ Phần 4, giờ với bức tranh đầy đủ}:
for item in "${arr[@]}"; do
echo "→ $item"
done
Quoting preserves each element as one word, even when values contain spaces {Quote giữ mỗi phần tử là một từ, kể cả khi giá trị có dấu cách}:
paths=("/tmp/my docs/report.pdf" "/var/log")
for p in "${paths[@]}"; do
echo "File: $p"
done
Without quotes, bash word-splits the expansion and you get four iterations instead of two {Không quote, bash word-split expansion và bạn có bốn lần lặp thay vì hai}. We revisit this in Mistakes beginners make {Ta quay lại phần này ở Lỗi người mới hay mắc}.
Practical example — build a command from an array {Ví dụ thực tế — ghép lệnh từ mảng}
#!/usr/bin/env bash
flags=(-v --recursive --exclude=.git)
# Pass the array as separate arguments to a command:
grep "${flags[@]}" "TODO" src/
# Or join into one string when you need it:
joined=$(IFS=','; echo "${flags[*]}")
echo "$joined" # -v,--recursive,--exclude=.git
The @ subscript form keeps each flag as its own argument; the * subscript form with IFS joins them {Dạng subscript @ giữ mỗi flag là một đối số; dạng subscript * với IFS nối chúng lại}. Pick @ for argument lists, * when you deliberately want one string {Chọn @ cho danh sách đối số, * khi cố ý muốn một chuỗi}.
Associative arrays — key/value maps {Mảng kết hợp — map key/value}
Associative arrays require an explicit declaration before use {Mảng kết hợp cần khai báo rõ trước khi dùng}:
declare -A map
map[user]=vinxi
map[role]=admin
map[env]=production
Read a value by key {Đọc giá trị theo key}:
echo "${map[user]}" # vinxi
echo "${map[role]}" # admin
Iterate over keys with the indirect-expansion form, then look up each value {Duyệt key bằng dạng indirect expansion, rồi tra từng value}:
for key in "${!map[@]}"; do
echo "$key → ${map[$key]}"
done
Practical example — simple config lookup {Ví dụ thực tế — tra cấu hình đơn giản}
#!/usr/bin/env bash
declare -A ports=(
[web]=8080
[api]=3000
[db]=5432
)
service="${1:-web}"
echo "Connecting to port ${ports[$service]:-unknown}"
The :- default on the lookup handles a missing key gracefully {Default :- trên lookup xử lý key không tồn tại một cách an toàn}.
String manipulation — length, slice, trim, replace {Thao tác chuỗi — độ dài, cắt, tỉa, thay}
All of the following work on ordinary string variables. Set up a sample {Tất cả dưới đây dùng trên biến chuỗi thường. Chuẩn bị ví dụ}:
str="Hello-World-Bash"
path="/var/www/html/index.html"
Length and substring {Độ dài và substring}
echo "${#str}" # 16
echo "${str:0:5}" # Hello (offset 0, length 5)
echo "${str:6}" # World-Bash (offset 6, rest of string)
echo "${str: -4}" # Bash (note the space before -4 — last 4 chars)
Prefix removal — # and ## {Bỏ prefix — # và ##}
# removes the shortest match; ## removes the longest {# bỏ match ngắn nhất; ## bỏ match dài nhất}:
echo "${path#/}" # var/www/html/index.html
echo "${path##*/}" # index.html (strip through last slash)
echo "${str#Hello-}" # World-Bash
echo "${str##Hello-}" # World-Bash (same here — only one match)
Suffix removal — % and %% {Bỏ suffix — % và %%}
% removes the shortest suffix match; %% removes the longest {% bỏ suffix match ngắn nhất; %% bỏ suffix match dài nhất}:
echo "${path%.html}" # /var/www/html/index
echo "${path%%/*}" # (empty — strips longest prefix through slash)
echo "${path%/*}" # /var/www/html
echo "${str%-Bash}" # Hello-World
echo "${str%%-*}" # Hello (strip from last dash)
Search and replace {Tìm và thay}
echo "${str/World/Earth}" # Hello-Earth-Bash (first match only)
echo "${str//-/_}" # Hello_World_Bash (all matches — global replace)
echo "${str/#Hello/Hi}" # Hi-World-Bash (anchor: start of string)
echo "${str/%Bash/Script}" # Hello-World-Script (anchor: end of string)
Case conversion (bash 4+) {Đổi hoa thường (bash 4+)}
msg="Hello World"
echo "${msg^^}" # HELLO WORLD (uppercase all)
echo "${msg,,}" # hello world (lowercase all)
echo "${msg^}" # Hello world (uppercase first char only)
echo "${msg,}" # hello World (lowercase first char only)
Defaults — :- when a value might be empty {Default — :- khi giá trị có thể rỗng}
name=""
echo "${name:-Guest}" # Guest (name is empty → use default)
echo "$name" # still empty — :- does not assign
: "${CONFIG_DIR:=/etc/myapp}" # assign default if unset OR empty
echo "$CONFIG_DIR" # /etc/myapp
:- substitutes for display; := assigns when unset or empty {:- thay thế để hiển thị; := gán khi unset hoặc rỗng}. You met the basics in Part 2; arrays and string ops use the same brace machinery {Bạn đã gặp cơ bản ở Phần 2; mảng và thao tác chuỗi dùng cùng cơ chế ngoặc nhọn}.
Quick reference — expansion operators compared {Tham chiếu nhanh — so sánh toán tử expansion}
| Operator {Toán tử} | What it does {Làm gì} | Example input → output {Ví dụ} |
|---|---|---|
# prefix | Remove shortest prefix match {Bỏ prefix match ngắn nhất} | path=/a/b/c → /b/c with pattern /a |
## prefix | Remove longest prefix match {Bỏ prefix match dài nhất} | path=/a/b/c → b/c with pattern /a/ |
% suffix | Remove shortest suffix match {Bỏ suffix match ngắn nhất} | file.tar.gz → file.tar with pattern .gz |
%% suffix | Remove longest suffix match {Bỏ suffix match dài nhất} | file.tar.gz → file with pattern .* |
/old/new | Replace first match {Thay match đầu tiên} | a-b-c → a_x_c replacing - with _ |
//old/new | Replace all matches {Thay mọi match} | a-b-c → a_x_x replacing - with _ |
^^ | Uppercase entire string {Viết hoa toàn chuỗi} | hello → HELLO |
,, | Lowercase entire string {Viết thường toàn chuỗi} | HELLO → hello |
:- default | Use fallback if unset or empty {Dùng fallback nếu unset hoặc rỗng} | unset x → default |
@ subscript | All array elements as separate words {Mọi phần tử mảng thành từ riêng} | 3-element array → 3 words |
* subscript | All elements joined (one word) {Mọi phần tử nối (một từ)} | 3-element array → 1 word |
Mistakes beginners make {Lỗi người mới hay mắc}
- ❌ Unquoted array expansion — looping with an unquoted @ subscript expansion word-splits and glob-expands each element, breaking paths with spaces and mangling
*in filenames {Expansion mảng không quote — lặp với expansion subscript @ không quote sẽ word-split và glob-expand từng phần tử, làm vỡ path có dấu cách và phá*trong tên file}:
# ❌ wrong — splits "my file.txt" into two iterations
for x in ${arr[@]}; do …
Always quote the @ subscript form in loops {Luôn quote dạng subscript @ trong vòng lặp}.
- ❌ Using
$arrinstead of the all-elements form — bare$arr(or"$arr") expands to only element 0, not the whole array {Dùng$arrthay vì dạng all-elements —$arrtrần (hoặc"$arr") chỉ bung phần tử 0, không phải cả mảng}. Use the@subscript when you mean every element {Dùng subscript@khi bạn muốn mọi phần tử}. - ❌ Forgetting
declare -Abefore using associative arrays — without it,map[key]=valueis treated as a plain string variable namedmap[key], not a hash map {Quêndeclare -Atrước khi dùng mảng kết hợp — không có nó,map[key]=valueđược coi là biến chuỗi tênmap[key], không phải hash map}. - ❌ Mixing up
#/##(prefix) with%/%%(suffix) — remember:#is “left/start”,%is “right/end” (like comment#at line start and%modulo at the end of an expression) {Nhầm#/##(prefix) với%/%%(suffix) — nhớ:#là “trái/đầu”,%là “phải/cuối”}. - ❌ Expecting
:-to assign — it only substitutes for that expansion; use:=when you need to persist the default {Tưởng:-sẽ gán — nó chỉ thay thế cho expansion đó; dùng:=khi cần lưu default lại}.
Exercises {Bài tập}
Try each before opening the solution {Thử từng bài trước khi mở lời giải}.
- Create an indexed array of three server hostnames. Print each on its own line, then print the count and a slice of the first two elements {Tạo mảng chỉ số ba hostname server. In từng cái một dòng, rồi in số lượng và slice hai phần tử đầu}.
- Build an associative array mapping service names (
web,api,db) to port numbers. Accept a service name as$1and print its port, orunknownif the key is missing {Tạo mảng kết hợp map tên service (web,api,db) sang số port. Nhận tên service qua$1và in port, hoặcunknownnếu key không có}. - Given
filename="backup-2025-02-10.tar.gz", use only parameter expansion (no external tools) to extract: the full name without.gz, the base name without any extension, and an uppercase version {Chofilename="backup-2025-02-10.tar.gz", chỉ dùng parameter expansion (không tool ngoài) để lấy: tên đầy đủ bỏ.gz, tên gốc bỏ mọi extension, và bản viết hoa}.
Solution {Lời giải}
#!/usr/bin/env bash
# Exercise 1
servers=(web-01 api-02 db-03)
for host in "${servers[@]}"; do
echo "Host: $host"
done
echo "Count: ${#servers[@]}"
echo "First two: ${servers[@]:0:2}"#!/usr/bin/env bash
# Exercise 2
declare -A ports=(
[web]=8080
[api]=3000
[db]=5432
)
service="${1:-}"
if [[ -n "${ports[$service]+x}" ]]; then
echo "Port for $service: ${ports[$service]}"
else
echo "unknown"
fi#!/usr/bin/env bash
# Exercise 3
filename="backup-2025-02-10.tar.gz"
no_gz="${filename%.gz}" # backup-2025-02-10.tar
base="${filename%%.*}" # backup-2025-02-10
upper="${filename^^}" # BACKUP-2025-02-10.TAR.GZ
echo "without .gz: $no_gz"
echo "base name: $base"
echo "uppercase: $upper"The +x indirect subscript test (shown in Exercise 2) checks whether the key exists without triggering set -u errors on a missing subscript {Test indirect subscript +x (trong Bài 2) kiểm tra key có tồn tại mà không gây lỗi set -u khi subscript không có}.
Takeaway {Điều cốt lõi}
Indexed arrays store ordered lists; associative arrays store key/value maps — both need the right subscript (@ for separate words, [n] or [key] for one slot) and quotes when iterating {Mảng chỉ số lưu danh sách có thứ tự; mảng kết hợp lưu map key/value — cả hai cần subscript đúng (@ cho từ riêng, [n] hoặc [key] cho một ô) và quote khi duyệt}. String operators let you trim paths, swap separators, and default missing config without spawning subprocesses {Toán tử chuỗi cho phép tỉa path, đổi separator, và default config thiếu mà không cần subprocess}. Keep every expansion example in quotes until you have a deliberate reason not to {Giữ mọi expansion trong quote cho đến khi bạn cố ý không quote}.