03 — Arithmetic Engine, Test Conditionals & Expansion Pipelines
Arithmetic Evaluation: ((...)) vs $((...))
bash
# ── Two forms, two purposes ──
# ((expr)) → evaluates expr; returns exit status (0 if result != 0, 1 if result == 0)
# $((expr)) → evaluates expr; EXPANDS to the result (used in commands/assignments)
x=10
((x = x + 5)) # x is now 15 — assignment in arithmetic context (no $ needed)
((x++)) # 16 — postfix increment (returns old value, increments after)
((++x)) # 17 — prefix increment (increments first, returns new value)
((x *= 2)) # 34 — compound assignment
((x %= 5)) # 4 — 34 % 5 = 4
# ── As a condition (returns exit status) ──
((x > 10)) && echo "big" # big — exit 0 (true) since 17 > 10
((x < 10)) || echo "not big" # not big — exit 1 (false), so || fires
# ── As expansion (returns the value) ──
result=$((x * 3)) # result is 12 — $((...)) expands to the computed value
echo "$((17 % 5))" # 2 — modulo
echo "$((17 / 5))" # 3 — integer division (truncates toward zero)
echo "$((-17 / 5))" # -3 — truncates toward ZERO (not floor!), so -17/5 = -3.4 → -3
echo "$((2 ** 10))" # 1024 — exponentiation
echo "$((5 << 3))" # 40 — bitwise left shift (5 * 2^3)
echo "$((0xFF))" # 255 — hex literal
echo "$((0b1010))" # 10 — binary literal (Bash 2.21+... wait, actually Bash 4.1+? No, 0b is NOT supported)
# ^-- 0b prefix is NOT supported in Bash arithmetic. Use $((2#1010)) instead.
echo "$((2#1010))" # 10 — base#number notation (2# = binary, 8# = octal, 16# = hex)
echo "$((8#777))" # 511 — octal
echo "$((16#FF))" # 255 — hex (same as 0xFF)
# ── Variables don't need $ inside ((...)) — but they CAN have it ──
a=5
((a + 1)) # evaluates a+1=6, returns 0 (non-zero result)
echo "$((a + 1))" # 6 — same
echo "$(($a + 1))" # 6 — also works (but unnecessary, and can cause issues with array refs)
Anti-Pattern: Using expr or let for Arithmetic
bash
# ❌ SLOW — expr forks a subprocess per operation
x=$(expr 5 + 3) # forks /usr/bin/expr — slow, error-prone with special chars
x=$(expr "$a" \* "$b") # must escape * — it's a glob! And spaces required around operators.
# ❌ LEGACY — let (works but ((...)) is clearer and more powerful)
let x=5+3 # works, but ((x = 5 + 3)) is the modern idiom
# ✅ CORRECT — ((...)) and $((...)) are in-process, no subprocess
((x = 5 + 3)) # assignment — no subprocess
x=$((5 + 3)) # expansion — no subprocess
# Benchmark: 100,000 iterations
# x=$(expr 1 + 1): ~12s (forking 100k subprocesses)
# x=$((1 + 1)): ~0.3s (in-process) — 40x faster
((...)) Exit Status Quirk
bash
# ── ((expr)) returns exit 0 if the RESULT is non-zero, exit 1 if the RESULT is zero ──
# This is C-style "0 is false, non-zero is true" — but it can bite you:
((0)) # exit 1 — result is 0, which is "false"
((1)) # exit 0 — result is 1, which is "true"
((x = 0)) # exit 1 — the ASSIGNMENT result is 0, so exit 1!
# ^-- with `set -e`, ((x = 0)) would EXIT the script (because exit status is 1)!
# Fix: ((x = 0)) || true — explicitly allow zero result.
# ── Increment to zero is a trap ──
x=1
((x--)) # x is now 0, but the EXPRESSION returns the OLD value (1) → exit 0
((x--)) # x is now -1, expression returns 0 (old value) → exit 1!
# ^-- set -e would kill the script here because the expression evaluated to 0.
# ── Safe pattern: always || true or use the assignment form ──
((x++)) || true # always safe — can't trigger set -e
: $((x++)) # : is a no-op that discards the expansion — no exit status issue
Test Conditionals: [[... ]] vs [... ]
bash
# ── [ ] is POSIX test (a command, not syntax) — needs careful quoting ──
# ── [[ ]] is Bash-extended test (syntax, not a command) — safe without quoting ──
# ── String comparison ──
str="hello"
[[ $str == "hello" ]] # true — no quoting needed (no word splitting in [[ ]])
[[ "$str" == "hello" ]] # also true — quoting is safe but unnecessary
[ "$str" = "hello" ] # true — MUST quote in [ ] (or [ $str = hello ] breaks with empty str)
# Note: [ ] uses `=` for string equality. [[ ]] accepts both `=` and `==` (use == for clarity).
# ── Pattern matching (glob, RIGHT side only) ──
file="report_2024.txt"
[[ $file == *.txt ]] # true — glob pattern on right side
[[ $file == report_* ]] # true — prefix glob
[[ $file == *2024* ]] # true — substring glob
[[ $file != *.log ]] # true — negated glob
# ── Regex matching (extended regex, RIGHT side) ──
email="user@example.com"
re='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
[[ $email =~ $re ]] # true — store regex in variable for readability (and to avoid quoting issues)
echo "${BASH_REMATCH[0]}" # user@example.com — full match
echo "${BASH_REMATCH[1]}" # (empty — no capture groups in this regex)
date="2024-01-15"
[[ $date =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ ]]
echo "${BASH_REMATCH[1]}" # 2024 — first capture group
echo "${BASH_REMATCH[2]}" # 01 — second capture group
echo "${BASH_REMATCH[3]}" # 15 — third capture group
# ── ⚠️ Don't quote the regex — quoting makes it a literal string ──
[[ $date =~ "^([0-9]{4})" ]] # ✗ matches literal "^([0-9]{4})" — not a regex
[[ $date =~ ^([0-9]{4}) ]] # ✓ regex match (but hard to read inline)
[[ $date =~ $re ]] # ✓ BEST — store in variable, reference unquoted
# ── && and || inside [[ ]] (not -a / -o) ──
[[ -f "$file" && -r "$file" ]] # true if file exists AND is readable
[[ $x -gt 0 || $x -lt -10 ]] # true if x > 0 OR x < -10
# In [ ], you'd need: [ -f "$file" -a -r "$file" ] (deprecated, error-prone)
# ── Integer comparison ──
# Both [[ ]] and [ ] support -eq, -ne, -lt, -le, -gt, -ge for integers
# But (( )) is better for arithmetic comparisons:
[[ $x -gt 5 ]] # works
(( x > 5 )) # cleaner, and supports arithmetic expressions: (( x > 5 && y < 10 ))
File Test Operators (Complete Reference)
bash
# ── Existence and type ──
[[ -e $f ]] # exists (any type: file, dir, symlink, socket, device)
[[ -f $f ]] # exists AND is a regular file (follows symlinks)
[[ -d $f ]] # exists AND is a directory
[[ -L $f ]] # is a symbolic link (does NOT follow — tests the link itself)
[[ -S $f ]] # is a socket
[[ -p $f ]] # is a named pipe (FIFO)
[[ -b $f ]] # is a block device
[[ -c $f ]] # is a character device
# ── Permissions (tests the EFFECTIVE user's permissions) ──
[[ -r $f ]] # readable
[[ -w $f ]] # writable
[[ -x $f ]] # executable / searchable (for dirs)
[[ -u $f ]] # has setuid bit
[[ -g $f ]] # has setgid bit
[[ -k $f ]] # has sticky bit
# ── Content ──
[[ -s $f ]] # exists AND size > 0 (not empty)
# ── Comparison ──
[[ $f1 -nt $f2 ]] # f1 is NEWER than f2 (by mtime — or exists when f2 doesn't)
[[ $f1 -ot $f2 ]] # f1 is OLDER than f2
[[ $f1 -ef $f2 ]] # f1 and f2 are the SAME file (same inode — hard links or same path)
# ── Edge: -e follows symlinks, -L doesn't ──
ln -s /nonexistent broken_link
[[ -e broken_link ]] # false — target doesn't exist
[[ -L broken_link ]] # true — the link itself exists
# To check if a symlink points to a valid target:
[[ -L $link && -e $link ]] # is a symlink AND target exists
Command Substitution: $(...) Internals
bash
# ── $(...) runs a command in a subshell, captures stdout, strips trailing newlines ──
date_output=$(date +%Y-%m-%d) # "2024-01-15" — trailing newline stripped
multiline=$(echo -e "a\nb\nc") # "a\nb\nc" — internal newlines preserved, trailing stripped
# ── Nested command substitution (backticks can't nest cleanly) ──
# ❌ LEGACY — backticks (hard to nest, hard to read)
files=`ls \`pwd\``
# ✅ MODERN — $(...) nests cleanly
files=$(ls $(pwd))
inner=$(echo "outer: $(echo "inner: $(date +%H:%M)")") # "outer: inner: 14:30"
# ── Trailing newline stripping is significant ──
output=$(printf 'line1\nline2\n\n\n') # trailing \n\n\n are ALL stripped → "line1\nline2"
# To preserve: add a sentinel and strip it:
output=$(printf 'line1\nline2\n\n\n'; printf 'X') # "line1\nline2\n\n\nX"
output="${output%X}" # "line1\nline2\n\n\n" — preserved!
# ── Command substitution runs in a subshell — variables don't leak ──
x=before
output=$(x=after; echo "$x") # output is "after"
echo "$x" # before — the subshell's x=after didn't leak
# ── `set -e` is inherited by subshells in Bash 4.4+ (inherit_errexit) ──
set -e
# Without inherit_errexit: x=$(false); echo "reached" → "reached" (false's failure is masked!)
# With inherit_errexit: x=$(false); echo "reached" → script exits (false propagates)
shopt -s inherit_errexit # enable this (Bash 4.4+) — makes $(...) respect set -e
Process Substitution: <(...) and >(...)
bash
# ── <(cmd) exposes cmd's output as a file descriptor (/dev/fd/63 or a named pipe) ──
# The cmd runs in a subshell; its stdout connects to a fd that looks like a file.
# ── Diff two command outputs without temp files ──
diff <(sort file1.txt) <(sort file2.txt)
# Equivalent without process substitution:
# sort file1.txt > /tmp/a; sort file2.txt > /tmp/b; diff /tmp/a /tmp/b; rm /tmp/a /tmp/b
# ── Feed a while loop without the subshell trap ──
# ❌ PIPE TRAP: while loop runs in a subshell — variables don't persist
echo "1 2 3" | while read -r num; do total=$((total + num)); done
echo "$total" # empty — subshell variable lost
# ✅ PROCESS SUBSTITUTION: while loop runs in the MAIN shell
total=0
while read -r num; do
total=$((total + num))
done < <(echo "1 2 3")
echo "$total" # 6 — persists (no subshell for the while body)
# ── Compare files from different servers ──
diff <(ssh server1 cat /etc/hosts) <(ssh server2 cat /etc/hosts)
# ── >(...) writes to a command's stdin (less common) ──
# Tee output to both a file and a pipeline:
make 2> >(grep -i error >&2) > build.log
# stderr goes to grep (which sends matches to stderr), stdout goes to build.log
# ── Multiple process substitutions ──
paste <(cut -d, -f1 data.csv) <(cut -d, -f3 data.csv) # side-by-side from different columns
# ── Edge: process substitution fds are ephemeral ──
# The /dev/fd/63 path is valid only during the command's execution.
# Storing it in a variable and using it later FAILS:
fd=$(<(echo hello)) # fd="/dev/fd/63" — but the fd is already closed!
cat "$fd" # cat: /dev/fd/63: No such file or directory
Brace Expansion: Code Generation Before Parsing
bash
# ── Brace expansion happens FIRST (before variable expansion, before globbing) ──
# It generates strings — no files need to exist (unlike globs).
echo {1..5} # 1 2 3 4 5 — numeric range
echo {a..e} # a b c d e — alpha range
echo {01..10} # 01 02 03 ... 10 — zero-padded (Bash 4+)
echo {5..1} # 5 4 3 2 1 — reverse range
echo {a..z..2} # a c e g i k m o q s u w y — step 2 (Bash 4+)
echo {1..10..3} # 1 4 7 10 — step 3 (Bash 4+)
# ── List expansion ──
echo {foo,bar,baz} # foo bar baz
echo file{1..3}.txt # file1.txt file2.txt file3.txt — prefix/suffix
mkdir -p project/{src,lib,test}/{bin,lib} # creates project/src/bin, project/src/lib, ... (6 dirs)
# ── Brace expansion is NOT variable-aware (happens before variable expansion) ──
prefix="file"
echo {$prefix,backup} # {file,backup} — brace expansion sees literal $prefix (not its value)
echo ${prefix}{.txt,.log} # file.txt file.log — brace expansion on the SUFFIX works (prefix already expanded)
# ── Nested braces ──
echo a{b,c}d{e,f} # abde abdf acde acdf — cartesian product
echo {{a,b},{c,d}} # a b c d — nested lists flatten
# ── Brace expansion vs glob — critical distinction ──
echo *.txt # GLOB: only if .txt files exist → "file1.txt file2.txt"; else "*.txt"
echo file{1..3}.txt # BRACE: always → "file1.txt file2.txt file3.txt" (even if they don't exist)
# Glob depends on filesystem; brace expansion is pure string generation.
Globbing: Filename Expansion Mechanics
bash
# ── Glob patterns ──
* # matches any string (including empty)
? # matches any single char
[abc] # matches a, b, or c
[a-z] # matches a through z (locale-dependent!)
[!abc] # matches anything NOT a, b, c (POSIX) / [^abc] in some shells
** # recursive (needs shopt -s globstar)
# ── Globbing happens AFTER word splitting and variable expansion ──
pattern="*.txt"
echo "$pattern" # *.txt — quoted: no glob, literal string
echo $pattern # file1.txt file2.txt — unquoted: glob expands (if matches exist)
# ── nullglob: empty result if no match (instead of literal pattern) ──
shopt -s nullglob
echo *.nonexistent # (empty) — no match → empty, not "*.nonexistent"
shopt -u nullglob
echo *.nonexistent # *.nonexistent — no match → literal string passed through
# ── failglob: error if no match (Bash 3+, strongest safety) ──
shopt -s failglob
echo *.nonexistent # bash: no match: *.nonexistent — script aborts (catches typos!)
# Best for scripts: use failglob to catch glob typs early.
# ── globstar: recursive ** (Bash 4+) ──
shopt -s globstar
echo **/*.py # all .py files in current dir and all subdirs (recursively)
# ⚠️ ** only matches recursively if it's a path component by itself:
echo **.py # matches: dir.py (NOT recursive — ** is not a standalone component)
echo **/*.py # matches: subdir/file.py (recursive — ** is a standalone component)
# ── extglob: extended pattern matching (Bash 2.02+, needs shopt) ──
shopt -s extglob
# ?(pattern) — zero or one match
# *(pattern) — zero or more
# +(pattern) — one or more
# @(pattern) — exactly one
# !(pattern) — anything that doesn't match
echo !(*.txt) # all files NOT ending in .txt
echo @(*.jpg|*.png) # all .jpg or .png files
echo *(.bak|.tmp) # all files ending in .bak or .tmp (zero or more — includes files with no extension)
# ── dotglob: include hidden files (dotfiles) in globs ──
shopt -s dotglob
echo * # includes .hidden_file (default: * excludes dotfiles)
Anti-Pattern: Locale-Dependent Range
bash
# ❌ NAIVE — [a-z] depends on locale (case-insensitive in some locales, includes accented chars)
for file in [a-z]*.txt; do # In C locale: a-z. In en_US.UTF-8: might include A-Z!
echo "$file"
done
# ✅ CORRECT — force C locale for deterministic byte-order sorting
LC_ALL=C
for file in [a-z]*.txt; do # guaranteed a-z only
echo "$file"
done
# ✅ BEST — use extglob for explicit control
shopt -s extglob
for file in @([a-z]*.txt); do # @() is exact match, no locale ambiguity
echo "$file"
done
# ── The "includes uppercase" trap ──
# In en_US.UTF-8 locale, [a-z] matches: aAbBcC...zZ (because collation is case-insensitive + accent-aware)
# In C locale, [a-z] matches: a b c ... z (ASCII byte order)
# Always use LC_ALL=C for glob ranges in scripts.
💡 Tips & Tricks
bash
# ── Arithmetic with arrays: index arithmetic ──
arr=(10 20 30 40 50)
i=2
echo "${arr[i + 1]}" # 40 — index is an arithmetic expression (no $ needed inside [])
echo "${arr[$((i + 1))]}" # 40 — also works (explicit $((...)))
# ── Bitwise operations for flags ──
readonly READ=1 WRITE=2 EXEC=4
perms=0
((perms |= READ | WRITE)) # 3 — set read and write bits
((perms & READ)) && echo "readable" # readable — check if bit is set
((perms & EXEC)) || echo "not exec" # not exec — bit not set
((perms & ~READ)) # 2 — mask off read bit (doesn't modify perms)
((perms &= ~WRITE)) # clear write bit from perms → perms is now 1
# ── Random numbers ──
echo "$RANDOM" # 0-32767 (pseudo-random, seeded from $$ and time)
echo "$((RANDOM % 100))" # 0-99 — dice roll
echo "$((RANDOM % 6 + 1))" # 1-6 — die
# For better randomness (crypto): read /dev/urandom
od -An -tu4 -N4 /dev/urandom | tr -d ' ' # 32-bit random integer
# ── Sequence generation without seq (Bash 4+) ──
echo {1..100} # 1 2 3 ... 100 — no subprocess
# ❌ seq 1 100 — forks a subprocess
# ✅ {1..100} — in-process brace expansion
# ── Conditional assignment (ternary-like) ──
x=5
# Bash has no ternary, but you can use:
((x > 0 ? y = 1 : y = 0)) # y is 1 if x > 0, else 0 — works in ((...))
# Or: y=0; ((x > 0)) && y=1 — more readable
⚠️ Edge Cases & Gotchas
bash
# ── Integer division truncates toward ZERO, not floor ──
echo "$((-7 / 2))" # -3 (truncates toward zero: -3.5 → -3)
echo "$((-7 % 2))" # -1 (remainder has sign of dividend)
# For floor division of negatives: echo "$((-(7) / 2))" → -3 (same, but be aware)
# ── `((x = 0))` has exit status 1 (result is 0 = false) ──
set -e
x=1
((x = 0)) # ✗ script EXITS — the assignment evaluates to 0, exit status 1!
# Fix: ((x = 0)) || true — or use: x=0 (plain assignment, no arithmetic context)
# ── `[[ =~ ]]` regex is reset by each match ──
[[ "abc" =~ a ]]; echo "${BASH_REMATCH[0]}" # a
[[ "xyz" =~ x ]]; echo "${BASH_REMATCH[0]}" # x — previous match "a" is gone
# Capture immediately after the match, don't reuse stale BASH_REMATCH.
# ── `[[ =~ ]]` regex doesn't support \d, \w, \s ──
# These are PCRE shortcuts. Bash uses ERE (extended regex), which doesn't have them.
[[ "123" =~ \d+ ]] # ✗ false — \d is not ERE
[[ "123" =~ [0-9]+ ]] # ✓ true — use character classes
# ── Glob in case patterns is not regex ──
case "$file" in
*.txt) echo "text" ;; # glob — matches anything ending in .txt
*) echo "other" ;;
esac
# case patterns are globs (*, ?, [...]), NOT regex. For regex, use [[ =~ ]].
# ── Process substitution is Bash-only ──
# <(...) doesn't work in POSIX sh. Use temp files for portable scripts.
# ── `[[ ]]` can't be used in POSIX sh ──
# [[ ]] is a Bash extension. Use [ ] with careful quoting for POSIX scripts.
🧠 Quick Quiz
What does this print?
bash
x=5
((x--))
echo "x=$x status=$?"
((x--))
echo "x=$x status=$?"
Answer
x=4 status=0
x=3 status=0
Wait — let's trace carefully:
x=5.((x--)): postfix decrement. The expression evaluates to the old value (5), then x is decremented to 4. Since the expression result is 5 (non-zero), exit status is 0. x=4, status=0.((x--)): expression evaluates to old value (4, non-zero), x decremented to 3. Exit status 0. x=3, status=0.
But if x had been 1 before a ((x--)): expression evaluates to 1 (old value, non-zero) → exit 0, x becomes 0. Then another ((x--)): expression evaluates to 0 (old value) → exit 1 (result is zero = false). With set -e, the script would exit here.
The lesson: ((x--)) returns exit status based on the expression value (the old x), not the new x. When x decrements to 0, the next ((x--)) returns exit 1 because the old value is 0. This silently triggers set -e.