Bash - variables, arrays & eval
Notes on the parts of bash that trip people up: word splitting, why eval
exists, and why arrays are almost always the better answer.
How the shell processes a command
Before running anything, bash goes through these steps in order:
1. Parse the line
2. Expand variables ($VAR becomes its value)
3. Word split (break into arguments by whitespace)
4. Quote removal (strip quotes used for grouping)
5. Execute the command with the resulting arguments
This order is why strings, arrays and eval behave the way they do.
Variables (strings)
NAME="hello world"
echo $NAME # echo gets 2 args: "hello" and "world"
echo "$NAME" # echo gets 1 arg: "hello world"
Rule: always double-quote your variables ("$VAR") unless you
specifically want word splitting.
MY_VAR=$(whoami) # command substitution - captures output
unset MY_VAR # removes the variable entirely
echo "${MY_VAR}_suffix" # braces stop the var name running into the suffix
echo "${#MY_VAR}" # length
echo "${MY_VAR^^}" # uppercase (,, = lowercase)
echo "${MY_VAR/hello/hi}" # replace first match
echo "${MY_VAR:0:5}" # substring (offset:length)
The word splitting problem
CMD="ls -la /tmp"
$CMD # works - bash splits into: ls, -la, /tmp
CMD="grep 'hello world' file.txt"
$CMD
# bash splits into: grep, 'hello, world', file.txt
# the quotes inside the string are NOT processed - they're just characters
Why? Variable expansion (step 2) happens before quote removal (step 4).
By the time bash sees the quotes inside $CMD they are already plain
characters, not grouping markers.
eval - the nuclear option
eval takes a string and runs it through the entire parsing pipeline
again, from step 1:
CMD="grep 'hello world' file.txt"
$CMD # WRONG: grep sees 'hello as the pattern
eval "$CMD" # RIGHT: eval re-parses, quotes are honoured
It executes anything in the string, which is exactly why it’s dangerous:
user_input="hello; rm -rf /"
eval "echo $user_input" # runs BOTH commands
Rule: never eval untrusted input. Reserve it for the rare cases of dynamic variable names or re-parsing quotes you control.
Arrays - the proper solution
Arrays keep each element as a separate word, so there is no word-splitting issue to work around.
FRUITS=(apple banana cherry) # create
EMPTY=() # empty
COLORS+=(red) # append
echo "${FRUITS[0]}" # single element (0-indexed)
echo "${FRUITS[@]}" # ALL elements, each a separate word
echo "${#FRUITS[@]}" # number of elements
echo "${!FRUITS[@]}" # all indices
[@] vs [*] - the critical difference
ITEMS=("hello world" "foo bar")
for x in "${ITEMS[@]}"; do echo "[$x]"; done # [hello world] / [foo bar]
for x in "${ITEMS[*]}"; do echo "[$x]"; done # [hello world foo bar] (one string)
for x in ${ITEMS[@]}; do echo "[$x]"; done # [hello]/[world]/[foo]/[bar] (split!)
Rule: always use "${ARRAY[@]}" with double quotes and @.
Arrays as command arguments
This is the key use case - building a command with optional arguments:
# The eval way (avoid)
EXTRA_ARGS=""
[ "$verbose" = "true" ] && EXTRA_ARGS="--debug -vvvv"
eval "tmt run $EXTRA_ARGS --all"
# The array way (correct)
EXTRA_ARGS=()
[ "$verbose" = "true" ] && EXTRA_ARGS=(--debug -vvvv)
tmt run "${EXTRA_ARGS[@]}" --all
# empty: tmt run --all set: tmt run --debug -vvvv --all
"${EXTRA_ARGS[@]}" on an empty array expands to nothing - not an empty
string - so there’s no stray gap, and it’s safe under set -u.
Quick reference
What |
Syntax |
|---|---|
Create array |
|
Empty array |
|
Read element |
|
All elements |
|
Length |
|
Append |
|
Indices |
|
When to use what
Scenario |
Use |
|---|---|
Simple value |
|
Optional command arguments |
|
Building commands from strings |
avoid - use arrays |
Dynamic variable names |
|
Must re-parse quotes in a string |
|