Linux & Bash · Part I · Module 19 · Arrays and Lists of Values
"${array[@]}" vs "${array[*]}"
This is the chapter in the module to get right. An array holds your items correctly; whether they arrive at a command correctly depends on four characters, and three of the four combinations lose or invent items. All four are measured here by argument count, because the obvious way to look at them does not show the difference.
Intermediate19 min readLinuxPart IBashArraysQuotingIFSScripting
Part I · Module 19 · Chapter 19.3 · "${array[@]}" vs "${array[*]}"
Chapter 19.1 ended on a warning: an array holds the right thing, and the wrong expansion throws it away again. This chapter is that warning, measured.
1. Measure It With Argument Count, Not Output
First, the instrument. The temptation is to look at the four forms with printf:
$ a=(smoke "two words" burst)
$ printf '[%s]\n' "${a[@]}"
[smoke]
[two words]
[burst]That works, but printf reuses its format once per argument (Chapter 15.4), so it is easy to misread — and with zero arguments it still prints one line, which actively misleads. A function that reports $# cannot:
count() { echo " $# args:"; printf ' [%s]\n' "$@"; }Argument count is the thing that matters, because "how many arguments did the command receive" is the entire question.
2. The Four Forms
$ a=(smoke "two words" burst)
$ count "${a[@]}"
3 args:
[smoke]
[two words]
[burst]
$ count "${a[*]}"
1 args:
[smoke two words burst]
$ count ${a[@]}
4 args:
[smoke]
[two]
[words]
[burst]
$ count ${a[*]}
4 args:
[smoke]
[two]
[words]
[burst]Three items, and the answers are 3, 1, 4 and 4.
Four forms, four argument counts
Read the four as two independent choices:
[@] or [*] decides whether you get many words or one. [@] produces one word per element; [*] concatenates every element into a single word.
The quotes decide whether that result is then re-split. Unquoted, whatever came out goes through Chapter 9.2's word-splitting, which collapses the distinction — which is why the two unquoted forms give the same answer and why that answer is 4 either way.
So only "${a[@]}" both produces separate words and protects them. Write "${a[@]}". Always. It is the form, and the other three are for the specific jobs below.
3. $@ and $* Are the Same Rules
Chapter 15.4 taught "$@" versus "$*" for script arguments and Chapter 18.2 repeated it for functions. Those are not an analogy — the positional parameters are an array, and @ and * mean exactly what they mean here:
| Array form | Positional equivalent | Result |
|---|---|---|
"${a[@]}" | "$@" | one word per item |
"${a[*]}" | "$*" | one word, items joined by the first IFS character |
${a[@]} | $@ | re-split on whitespace |
${a[*]} | $* | re-split on whitespace |
That is one rule to remember rather than two. And it is why for x; do — Chapter 17.1's shorthand for for x in "$@"; do — is safe, and why set -- "${a[@]}" is the standard way to turn an array into the positional parameters.
4. What [*] Is Actually For
[*] is not a mistake looking for a victim. It has one job: producing a human-readable string from a list.
$ tests=(smoke "two words" burst)
$ echo "running: ${tests[*]}"
running: smoke two words burstThat is a message, not a list, and joining is exactly right. Chapter 18.2's note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; } is the same choice for the same reason.
And [*] joins with the first character of IFS, which is occasionally very useful:
$ a=(x y z)
$ echo "default: [${a[*]}]"
default: [x y z]
$ IFS=,; echo "IFS=,: [${a[*]}]"
IFS=,: [x,y,z]
$ IFS=; echo "IFS=: [${a[*]}]"
IFS=: [xyz]A comma-separated list in one expansion, with no loop. The idiomatic way to do that without disturbing the rest of the script is a subshell, so IFS is restored for free:
$ a=(x y z)
$ csv=$(IFS=,; echo "${a[*]}")
$ echo "$csv"
x,y,z[@] is unaffected by IFS when quoted:
$ a=(x y z); IFS=,
$ printf '[%s]' "${a[@]}"; echo
[x][y][z]Which is another way of saying that "${a[@]}" is the form that does not depend on the environment. "${a[*]}" produces different text depending on a global variable someone else may have set — fine for a message you are about to print, not fine for data.
5. Transforming Every Element at Once
Chapter 8.3's pattern-removal and substitution forms work on an array, and when you use [@] they apply to every element, returning a list of the same length:
$ a=(run_1.log run_2.log "my run.log")
$ printf ' [%s]\n' "${a[@]%.log}"
[run_1]
[run_2]
[my run]One expansion, three results, and my run.log kept its space. The same works for every form from Chapter 8.3:
$ printf ' [%s]\n' "${a[@]#run_}"
[1.log]
[2.log]
[my run.log]
$ b=(a-b a-c); printf ' [%s]\n' "${b[@]/-/_}"
[a_b]
[a_c]
$ c=(aXbXc); printf ' [%s]\n' "${c[@]//X/-}"
[a-b-c]Note the second result: my run.log is unchanged by #run_ because it does not start with that prefix. The transformation is applied per element and an element that does not match is passed through — there is no error and no gap.
This is the idiomatic way to derive one list from another, and the count survives if you quote:
$ a=(run_1.log "my run.log")
$ t=("${a[@]%.log}")
$ echo "count=${#t[@]}"
count=2
$ printf ' [%s]\n' "${t[@]}"
[run_1]
[my run]$ t=(${a[@]%.log})
$ echo "count=${#t[@]}"
count=3Section 2's rule, one more time: the transformation was correct and the unquoted expansion undid it on the way into the new array.
set -- is the other way to move a list, when what you want is the positional parameters:
$ a=(p "q r" s)
$ set -- "${a[@]}"
$ echo "$#"
3
$ printf ' [%s]\n' "$@"
[p]
[q r]
[s]
$ set -- ${a[@]}; echo "unquoted: $#"
unquoted: 4Useful when you want to hand an array to something that reads $1, $2, or to re-use shift — and it obeys the identical rule, because §3 established that the positional parameters are an array.
6. Lengths and Slices
${#a[@]} and ${#a[*]} are both the element count — the @/* distinction does not apply to #:
$ a=(hello world)
$ echo "${#a[@]} ${#a[*]} ${#a[0]}"
2 2 5Only ${#a[0]} is different, and it is a string length. Chapter 19.2 §2 covered that.
Slices follow the same quoting rule as the whole array:
$ a=(p "q r" s t)
$ count "${a[@]:1:2}"
2 args:
[q r]
[s]
$ count ${a[@]:1:2}
3 args:
[q]
[r]
[s]Quoted slice, two elements with the space intact. Unquoted, three. Quote the slice too — the rule is about the expansion, not about whether you took all of it.
7. A Complete Script
A wrapper that has to do all three jobs — pass a list as arguments, join it for a message, and count it:
#!/usr/bin/env bash
# run-tests.sh — run a list of tests, reporting with the right expansion
# usage: run-tests.sh <test>...
#
# exit: 0 all passed · 1 at least one failed · 2 bad usage
set -o nounset
note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
# stand-in for the simulator: receives each test as a separate argument
simulate() {
printf ' simulate received %d argument(s):\n' "$#"
local t
for t in "$@"; do
printf ' [%s]\n' "$t"
done
# a test whose name contains "bad" fails
for t in "$@"; do
case $t in *bad*) return 1 ;; esac
done
return 0
}
[ $# -ge 1 ] || { note "usage: ${0##*/} <test>..."; exit 2; }
# "$@" into an array — boundaries preserved all the way in
declare -a tests=("$@")
# [*] for the human-readable announcement
note "about to run ${#tests[@]} test(s): ${tests[*]}"
# [*] with a different separator, for a one-line record
summary=$(IFS=,; echo "${tests[*]}")
note "list=$summary"
# [@] quoted for the actual call — the only correct form
if simulate "${tests[@]}"; then
printf 'PASS %d test(s)\n' "${#tests[@]}"
exit 0
fi
printf 'FAIL one of %d test(s)\n' "${#tests[@]}"
exit 1$ ./run-tests.sh smoke "cache coherency" burst
run-tests.sh: about to run 3 test(s): smoke cache coherency burst
run-tests.sh: list=smoke,cache coherency,burst
simulate received 3 argument(s):
[smoke]
[cache coherency]
[burst]
PASS 3 test(s)
$ echo $?
0
$ ./run-tests.sh smoke bad_reset
run-tests.sh: about to run 2 test(s): smoke bad_reset
run-tests.sh: list=smoke,bad_reset
simulate received 2 argument(s):
[smoke]
[bad_reset]
FAIL one of 2 test(s)
$ echo $?
1
$ ./run-tests.sh
run-tests.sh: usage: run-tests.sh <test>...
$ echo $?
2Now break exactly one character — simulate ${tests[@]} without the quotes — and run the identical command:
$ ./broken.sh smoke "cache coherency" burst
broken.sh: about to run 3 test(s): smoke cache coherency burst
broken.sh: list=smoke,cache coherency,burst
simulate received 4 argument(s):
[smoke]
[cache]
[coherency]
[burst]
PASS 3 test(s)
$ echo $?
0simulate received four tests and the summary still says three. It would have run a test called cache and a test called coherency, neither of which exists, and reported a pass. Both announcement lines are still correct, because they use [*] and ${#tests[@]} — which both work fine. The only thing that changed is what the tool was actually asked to do.
Three decisions:
declare -a tests=("$@") copies the positional parameters into an array with boundaries intact. Drop those quotes and the damage happens a line earlier, which is worse in an interesting way:
$ ./b2.sh smoke "cache coherency" burst
b2.sh: about to run 4 test(s): smoke cache coherency burst
b2.sh: list=smoke,cache,coherency,burst
simulate received 4 argument(s):
[smoke]
[cache]
[coherency]
[burst]
PASS 4 test(s)Here the array itself is wrong, so every message is consistently wrong — the count says 4, the comma-separated record says 4, and the tool got 4. Nothing disagrees with anything, which removes the one clue the previous version left behind. A list damaged at the point of construction is harder to spot than one damaged at the point of use.
${tests[*]} for the two messages, "${tests[@]}" for the call. Different jobs, different forms, and the script is a two-line demonstration of which is which.
summary=$(IFS=,; echo "${tests[*]}") sets IFS inside a subshell, so the script's own IFS is untouched afterwards — the §4 idiom.
Practice
- Write the
counthelper and run all four forms ona=(smoke "two words" burst). Record the four argument counts. Then do the same on an array with no spaces in any item and explain why the bug hides. - State in one sentence what
[@]versus[*]decides, and in another what the quotes decide. - Build a comma-separated string from
a=(x y z)two ways: by settingIFSdirectly, and inside$( ). PrintIFSafterwards in both cases and say which you would ship. - Set
IFS=,and then print"${a[@]}"and"${a[*]}". Which one changed, and what does that tell you about which form depends on the environment? - Print
${#a[@]},${#a[*]}and${#a[0]}fora=(hello world). Which is the odd one out? - Take a quoted slice
"${a[@]:1:2}"ofa=(p "q r" s t)throughcount, then the unquoted version. Explain the difference without using the word "array". - Fill in the §3 table from your own measurements, using
set -- smoke "two words" burstand a script that reports$#. - Take
run-tests.sh, remove the quotes fromsimulate "${tests[@]}", and run it with a two-word test name. Report whatsimulatereceived, what the summary said, and the exit status. Why is this the worst possible failure mode? - In
run-tests.sh, changedeclare -a tests=("$@")totests=($@)and re-run with a two-word test name. Where does it break now, and which messages are still correct? - Take
a=(run_1.log run_2.log "my run.log")and strip.logfrom every element in one expansion. Then assign the result to a new array twice — quoted and unquoted — and compare the counts. - Run
a=(ab cd); echo "${a[@]^^}"on your machine. If the output is lowercase, say what that means for a script that normalises a tool name, and write the portable replacement. - Use
set -- "${a[@]}"to turn an array into the positional parameters, thenshiftonce and print"$@". Why does this work at all?
Check Your Understanding
- Three items, one containing a space. What argument counts do the four forms give?
- Why do the two unquoted forms give the same answer?
- Which single form should you write for a list?
- What is
[*]legitimately for? - What character does
[*]join with? - Is
"${a[@]}"affected byIFS? ${#a[@]}and${#a[0]}— which is the count?
Chapter 27.2 takes these forms to a real tool invocation and measures what each one makes the simulator receive, including the expansion that passes one empty argument instead of none.
8. Remember
"${a[@]}"is the form. One word per element, protected from re-splitting. Write it every time you mean a list.- Three items can arrive as 3, 1 or 4 depending on two characters, measured — and all four forms run without error.
[@]or[*]decides many words or one; the quotes decide whether that is re-split. Unquoted collapses the distinction, which is why both bare forms give 4.- The bug hides on test data. Every form agrees until an item contains a space.
$@and$*follow identical rules, because the positional parameters are an array.[*]is for a message, not for data — an announcement, a log line, a summary.[*]joins with the first character ofIFS.$(IFS=,; echo "${a[*]}")builds a comma-separated string without disturbing the script.- Quoted
[@]ignoresIFS. That is why it is the one form that does not depend on the environment. ${#a[@]}is the count;${#a[0]}is a string length.- Quote slices too.
"${a[@]:1:2}"— the rule is about the expansion, not about taking all of it. - Pattern forms apply to every element:
"${a[@]%.log}"strips the suffix from all of them and preserves the count. An element that does not match passes through unchanged. set -- "${a[@]}"turns an array into the positional parameters, same rule.${a[@]^^}is Bash 4 and a silent no-op on 3.2. Usetrto change case portably.
Chapter 19.4 fills an array from somewhere other than a literal. The obvious one-liner — assigning a command's output directly — is the Chapter 19.1 bug in a new costume, and the measured result is five elements for three files.
Where this fits
Part of the Linux & Bash curriculum.
