139

I'm writing a bash script which has set -u, and I have a problem with empty array expansion: bash appears to treat an empty array as an unset variable during expansion:

$ set -u
$ arr=()
$ echo "foo: '${arr[@]}'"
bash: arr[@]: unbound variable

(declare -a arr doesn't help either.)

A common solution to this is to use ${arr[@]-} instead, thus substituting an empty string instead of the ("undefined") empty array. However this is not a good solution, since now you can't discern between an array with a single empty string in it and an empty array. (@-expansion is special in bash, it expands "${arr[@]}" into "${arr[0]}" "${arr[1]}" …, which makes it a perfect tool for building command lines.)

$ countArgs() { echo $#; }
$ countArgs a b c
3
$ countArgs
0
$ countArgs ""
1
$ brr=("")
$ countArgs "${brr[@]}"
1
$ countArgs "${arr[@]-}"
1
$ countArgs "${arr[@]}"
bash: arr[@]: unbound variable
$ set +u
$ countArgs "${arr[@]}"
0

So is there a way around that problem, other than checking the length of an array in an if (see code sample below), or turning off -u setting for that short piece?

if [ "${#arr[@]}" = 0 ]; then
   veryLongCommandLine
else
   veryLongCommandLine "${arr[@]}"
fi

Update: Removed bugs tag due to explanation by ikegami.

Ivan Tarasov
  • 7,038
  • 5
  • 27
  • 23
  • Things may be even more nasty when using a construction like `cmd "${A[@]}" "${B[@]}" "${C[@]}"` where any of the array could be empty – U. Windl Nov 18 '22 at 10:44

12 Answers12

97

According to the documentation,

An array variable is considered set if a subscript has been assigned a value. The null string is a valid value.

No subscript has been assigned a value, so the array isn't set.

But while the documentation suggests an error is appropriate here, this is no longer the case since 4.4.

$ bash --version | head -n 1
GNU bash, version 4.4.19(1)-release (x86_64-pc-linux-gnu)

$ set -u

$ arr=()

$ echo "foo: '${arr[@]}'"
foo: ''

There is a conditional you can use inline to achieve what you want in older versions: Use ${arr[@]+"${arr[@]}"} instead of "${arr[@]}".

$ function args { perl -E'say 0+@ARGV; say "$_: $ARGV[$_]" for 0..$#ARGV' -- "$@" ; }

$ set -u

$ arr=()

$ args "${arr[@]}"
-bash: arr[@]: unbound variable

$ args ${arr[@]+"${arr[@]}"}
0

$ arr=("")

$ args ${arr[@]+"${arr[@]}"}
1
0: 

$ arr=(a b c)

$ args ${arr[@]+"${arr[@]}"}
3
0: a
1: b
2: c

Tested with bash 4.2.25 and 4.3.11.

ikegami
  • 367,544
  • 15
  • 269
  • 518
  • 5
    Can anyone explain how and why this works? I'm confused about what `[@]+` actually does and why the second `${arr[@]}` won't cause an unbound error. – Martin von Wittich Jul 21 '16 at 09:16
  • 5
    `${parameter+word}` only expands `word` if `parameter` is not unset. – ikegami Nov 22 '16 at 17:51
  • 2
    `${arr+"${arr[@]}"}` is shorter and seems to work just as well. – Per Cederberg Jan 14 '17 at 18:01
  • 3
    @Per Cerderberg, Doesn't work. `unset arr`, `arr[1]=a`, `args ${arr+"${arr[@]}"}` vs `args ${arr[@]+"${arr[@]}"}` – ikegami Jan 15 '17 at 06:15
  • @ikegami: Well, if you use 0-based indexes it does work :-) `unset arr; arr[0]=a; echo ${arr+"${arr[@]}"}` Maybe this is a bit nasty though, as it accesses the 0-indexed value without providing the index part. – Per Cederberg Jan 18 '17 at 09:04
  • Without surrounding double quotes array elements with spaces will explode. – Stanislav German-Evtushenko May 13 '19 at 14:30
  • @Stanislav German-Evtushenko, I'm not sure what you mean or how it's relevant. Please clarify. – ikegami May 13 '19 at 19:18
  • Checked again. My bad. I haven't realized that with `${arr+"${arr[@]}"}` we basically always substitute. So just a note, if we use a similar expression to set default values it should be surrounded by quotes also. It doesn't harm though to quote it always just not to remember when we should and when we shouldn't. Compare `arr=('arg 1' 'arg 2'); default_arr=(a b c); for a in ${arr[@]-"${default_arr[@]}"}; do echo "$a"; done` and `arr=('arg 1' 'arg 2'); default_arr=(a b c); for a in "${arr[@]-"${default_arr[@]}"}"; do echo "$a"; done` – Stanislav German-Evtushenko May 14 '19 at 03:40
  • 1
    To be precise, in cases where the `+` expansion doesn't occur (namely, an empty array) the expansion is replaced with *nothing*, which is exactly what an empty array expands to. `:+` is unsafe because it also treats a single-element `('')` array as unset and similarly expands to nothing, losing the value. – dimo414 May 01 '20 at 23:00
  • @dimo414, I'm confused. Are you explaining why code removed from the question more than 4 years ago doesn't work? – ikegami May 02 '20 at 04:22
  • No, I was replying to the preceding comment's "we basically always substitute" assertion. There are specific cases where substitution *doesn't* happen, and using `:+` vs. `+` affects which cases. – dimo414 May 02 '20 at 06:42
  • @dimo414 Where is that difference of using`+` instead of `:+` documented? I could not find it; I only found "*${parameter:+word} Use Alternate Value. If parameter is null or unset, nothing is substituted, otherwise the expansion of word is substituted.*" – U. Windl Nov 18 '22 at 10:52
  • @U. Windl, from ijs's answer, `${arr[@]:+"${arr[@]}"}` prints `0` for `arr=('')`. – ikegami Nov 18 '22 at 13:41
  • OK, it seems `:+` triggers on the empty string, too, while just `+` only triggers for unset. Nitpicking: The expression you gave prints nothing (but an error message); I used `X=(${arr[@]:+"${arr[@]}"})` to find out. – U. Windl Nov 18 '22 at 13:48
  • @U. Windl My answer provides a harness for testing (`args ...`) – ikegami Nov 18 '22 at 14:15
  • Exactly, ["omitting the colon results in a test only for a parameter that is unset."](https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html), so you don't want `:+` because it matches an array with an empty string, which we don't want to transform. – dimo414 Nov 20 '22 at 17:06
  • @dimo414 Thanks, I suggested to add that information to the BASH manual page, too. – U. Windl Nov 21 '22 at 07:37
74

The only safe idiom is ${arr[@]+"${arr[@]}"}

Unless you only care about Bash 4.4+, but you wouldn't be looking at this question if that were the case :)

This is already the recommendation in ikegami's answer, but there's a lot of misinformation and guesswork in this thread. Other patterns, such as ${arr[@]-} or ${arr[@]:0}, are not safe across all major versions of Bash.

As the table below shows, the only expansion that is reliable across all modern-ish Bash versions is ${arr[@]+"${arr[@]}"} (column +"). Of note, several other expansions fail in Bash 4.2, including (unfortunately) the shorter ${arr[@]:0} idiom, which doesn't just produce an incorrect result but actually fails. If you need to support versions prior to 4.4, and in particular 4.2, this is the only working idiom.

Screenshot of different idioms across versions

Unfortunately other + expansions that, at a glance, look the same do indeed emit different behavior. Using :+ instead of + (:+" in the table), for example, does not work because :-expansion treats an array with a single empty element (('')) as "null" and thus doesn't (consistently) expand to the same result.

Quoting the full expansion instead of the nested array ("${arr[@]+${arr[@]}}", "+ in the table), which I would have expected to be roughly equivalent, is similarly unsafe in 4.2.

You can see the code that generated this data along with results for several additional version of bash in this gist.

dimo414
  • 47,227
  • 18
  • 148
  • 244
  • 2
    I don't see you testing `"${arr[@]}"`. Am I missing something? From what I can see it works at least in `5.x`. – x-yuri May 09 '20 at 17:49
  • 2
    @x-yuri yes, Bash 4.4 fixed the situation; you don't need to use this pattern if you know your script will only run on 4.4+, but many systems are still on earlier versions. – dimo414 May 09 '20 at 22:28
  • Absolutely. Despite looking nice (e.g. formatting), extra-spaces are great evil of bash, causing lots of troubles – agg3l Aug 04 '20 at 02:11
  • On bash 4.4.20(1) this doesn't work as intended. the variable expansion quote in this answer _does not count the number of items in an array_. Worse, it will leave the variable unquoted. – inetknght Oct 16 '20 at 15:06
  • @inetknght can you share a [MCVE](https://stackoverflow.com/help/minimal-reproducible-example) of what you're observing? Despite the odd syntax this _is_ properly quoted. The outer (unqouted) expansion expands to the internal (quoted) expansion when the array is non-empty. – dimo414 Oct 16 '20 at 22:24
  • A quick test on 4.4.23 suggest this is working - `docker run bash:4.4.23 -c 'arr=(1 "2 3" "" 4); printf "%s\n" ${arr[@]+"${arr[@]}"}'` - if you can share an MCVE I'll compile 4.4.20 and verify. – dimo414 Oct 16 '20 at 22:26
  • Your example expands the array into parameters. How do you count the number of items in the array? Does this work with `${#arr[@]}` ? – inetknght Oct 19 '20 at 04:57
  • @inetknght It's an easy way to demonstrate that the expansion is properly quoted. For `${#` array-length expansion you don't need this workaround e.g. `docker run bash:3.1 -c 'set -u; arr=(); echo "${#arr[@]}"'` prints `0` (removing the `#` fails with `arr[@]: unbound variable`). – dimo414 Oct 19 '20 at 21:27
  • How did you generate this graphic? Something custom or a bash testing lib? – Jonah Jun 09 '21 at 14:14
  • The code's in the [linked gist](https://gist.github.com/dimo414/2fb052d230654cc0c25e9e41a9651ebe); it's pretty sloppy, just colored output in the terminal. – dimo414 Jun 09 '21 at 20:45
  • Wouldn't the deprecated `"${arr[@]+${arr[@]}}"` insert an empty argument if the array is empty anyway? So that would be wrong in every version of BASH. – U. Windl Nov 18 '22 at 10:55
25

@ikegami's accepted answer is subtly wrong! The correct incantation is ${arr[@]+"${arr[@]}"}:

$ countArgs () { echo "$#"; }
$ arr=('')
$ countArgs "${arr[@]:+${arr[@]}}"
0   # WRONG
$ countArgs ${arr[@]+"${arr[@]}"}
1   # RIGHT
$ arr=()
$ countArgs ${arr[@]+"${arr[@]}"}
0   # Let's make sure it still works for the other case...
ijs
  • 251
  • 3
  • 2
  • No longer makes a difference. `bash-4.4.23`: `arr=('') && countArgs "${arr[@]:+${arr[@]}}"` produces `1`. But `${arr[@]+"${arr[@]}"}` form allows to differentiate between empty/non-empty value by adding/not adding colon. – x-yuri Jan 25 '19 at 18:36
  • `arr=('') && countArgs ${arr[@]:+"${arr[@]}"}` -> `0`, `arr=('') && countArgs ${arr[@]+"${arr[@]}"}` -> `1`. – x-yuri Jan 25 '19 at 18:48
  • 2
    This has been fixed in my answer long ago. (In fact, I'm sure I've previously left a comment on this answer to that effect?!) – ikegami Sep 19 '19 at 11:07
17

Turns out array handling has been changed in recently released (2016/09/16) bash 4.4 (available in Debian stretch, for example).

$ bash --version | head -n1
bash --version | head -n1
GNU bash, version 4.4.0(1)-release (x86_64-pc-linux-gnu)

Now empty arrays expansion does not emits warning

$ set -u
$ arr=()
$ echo "${arr[@]}"

$ # everything is fine
John Kugelman
  • 349,597
  • 67
  • 533
  • 578
agg3l
  • 1,444
  • 15
  • 21
16

this may be another option for those who prefer not to duplicate arr[@] and are okay to have an empty string

echo "foo: '${arr[@]:-}'"

to test:

set -u
arr=()
echo a "${arr[@]:-}" b # note two spaces between a and b
for f in a "${arr[@]:-}" b; do echo $f; done # note blank line between a and b
arr=(1 2)
echo a "${arr[@]:-}" b
for f in a "${arr[@]:-}" b; do echo $f; done
Jayen
  • 5,653
  • 2
  • 44
  • 65
  • 11
    This will work if you are just interpolating the variable, but if you want to use the array in a `for` this would end up with a single empty string when the array is undefined/defined-as-empty, where as you might want the loop body to not run if the array is not defined. – Ash Berlin-Taylor Sep 06 '16 at 09:14
  • 1
    thanks @AshBerlin, I added a for loop to my answer so readers are aware – Jayen Sep 06 '16 at 22:59
  • -1 to this approach, it's simply incorrect. This replaces an empty array with a single empty-string, which is not the same. The pattern suggested in the accepted answer, `${arr[@]+"${arr[@]}"}`, correctly preserves the empty-array state. – dimo414 May 01 '20 at 19:43
  • See also [my answer](https://stackoverflow.com/a/61551944/113632) showing the situations where this expansion breaks down. – dimo414 May 01 '20 at 22:57
  • it's not incorrect. it explicitly says it'll give an empty string, and there's even two examples where you can see the empty string. – Jayen May 02 '20 at 05:31
7

@ikegami's answer is correct, but I consider the syntax ${arr[@]+"${arr[@]}"} dreadful. If you use long array variable names, it starts to looks spaghetti-ish quicker than usual.

Try this instead:

$ set -u

$ count() { echo $# ; } ; count x y z
3

$ count() { echo $# ; } ; arr=() ; count "${arr[@]}"
-bash: abc[@]: unbound variable

$ count() { echo $# ; } ; arr=() ; count "${arr[@]:0}"
0

$ count() { echo $# ; } ; arr=(x y z) ; count "${arr[@]:0}"
3

It looks like the Bash array slice operator is very forgiving.

So why did Bash make handling the edge case of arrays so difficult? Sigh. I cannot guarantee you version will allow such abuse of the array slice operator, but it works dandy for me.

Caveat: I am using GNU bash, version 3.2.25(1)-release (x86_64-redhat-linux-gnu) Your mileage may vary.

dimo414
  • 47,227
  • 18
  • 148
  • 244
kevinarpe
  • 20,319
  • 26
  • 127
  • 154
  • 9
    ikegami originally had this, but removed it because it is unreliable, both in theory (there is no reason why this should work) and in practice (the OP's version of bash didn't accept it). –  Jun 02 '14 at 13:57
  • @hvd: Thanks for the update. Readers: Please add a comment if you find versions of bash where the above code does not work. – kevinarpe Apr 03 '15 at 13:47
  • hvp already did, and I'll tell you too: `"${arr[@]:0}"` gives `-bash: arr[@]: unbound variable`. – ikegami May 27 '15 at 14:31
  • One thing that should work across versions is to set a default array value to `arr=("_dummy_")`, and use the expansion `${arr[@]:1}` everywhere. This is mentioned in other answers, referring to sentinel values. – init_js Mar 07 '18 at 02:00
  • 1
    @init_js: Your edit was sadly rejected. I suggest you add as a separate answer. (Ref: https://stackoverflow.com/review/suggested-edits/19027379) – kevinarpe Mar 12 '18 at 11:54
  • The `:0` expansion, though nice and concise, is [broken in Bash 4.2](https://stackoverflow.com/a/61551944/113632). – dimo414 May 01 '20 at 22:54
6

"Interesting" inconsistency indeed.

Furthermore,

$ set -u
$ echo $#
0
$ echo "$1"
bash: $1: unbound variable   # makes sense (I didn't set any)
$ echo "$@" | cat -e
$                            # blank line, no error

While I agree that the current behavior may not be a bug in the sense that @ikegami explains, IMO we could say the bug is in the definition (of "set") itself, and/or the fact that it's inconsistently applied. The preceding paragraph in the man page says

... ${name[@]} expands each element of name to a separate word. When there are no array members, ${name[@]} expands to nothing.

which is entirely consistent with what it says about the expansion of positional parameters in "$@". Not that there aren't other inconsistencies in the behaviors of arrays and positional parameters... but to me there's no hint that this detail should be inconsistent between the two.

Continuing,

$ arr=()
$ echo "${arr[@]}"
bash: arr[@]: unbound variable   # as we've observed.  BUT...
$ echo "${#arr[@]}"
0                                # no error
$ echo "${!arr[@]}" | cat -e
$                                # no error

So arr[] isn't so unbound that we can't get a count of its elements (0), or a (empty) list of its keys? To me these are sensible, and useful -- the only outlier seems to be the ${arr[@]} (and ${arr[*]}) expansion.

don311
  • 61
  • 1
  • 2
2

I am complementing on @ikegami's (accepted) and @kevinarpe's (also good) answers.

You can do "${arr[@]:+${arr[@]}}" to workaround the problem. The right-hand-side (i.e., after :+) provides an expression that will be used in case the left-hand-side is not defined/null.

The syntax is arcane. Note that the right hand side of the expression will undergo parameter expansion, so extra attention should be paid to having consistent quoting.

: example copy arr into arr_copy
arr=( "1 2" "3" )
arr_copy=( "${arr[@]:+${arr[@]}}" ) # good. same quoting. 
                                    # preserves spaces

arr_copy=( ${arr[@]:+"${arr[@]}"} ) # bad. quoting only on RHS.
                                    # copy will have ["1","2","3"],
                                    # instead of ["1 2", "3"]

Like @kevinarpe mentions, a less arcane syntax is to use the array slice notation ${arr[@]:0} (on Bash versions >= 4.4), which expands to all the parameters, starting from index 0. It also doesn't require as much repetition. This expansion works regardless of set -u, so you can use this at all times. The man page says (under Parameter Expansion):

  • ${parameter:offset}

  • ${parameter:offset:length}

    ... If parameter is an indexed array name subscripted by @ or *, the result is the length members of the array beginning with ${parameter[offset]}. A negative offset is taken relative to one greater than the maximum index of the specified array. It is an expansion error if length evaluates to a number less than zero.

This is the example provided by @kevinarpe, with alternate formatting to place the output in evidence:

set -u
function count() { echo $# ; };
(
    count x y z
)
: prints "3"

(
    arr=()
    count "${arr[@]}"
)
: prints "-bash: arr[@]: unbound variable"

(
    arr=()
    count "${arr[@]:0}"
)
: prints "0"

(
    arr=(x y z)
    count "${arr[@]:0}"
)
: prints "3"

This behaviour varies with versions of Bash. You may also have noticed that the length operator ${#arr[@]} will always evaluate to 0 for empty arrays, regardless of set -u, without causing an 'unbound variable error'.

init_js
  • 4,143
  • 2
  • 23
  • 53
  • Unfortunately the `:0` idiom fails in Bash 4.2, so this isn't a safe approach. See [my answer](https://stackoverflow.com/a/61551944/113632). – dimo414 May 01 '20 at 22:52
1

Now, as technically right the "${arr[@]+"${arr[@]}"}" version is, you never want to use this syntax for appending to an array, ever!

This is, as this syntax actually expands the array and then appends. And that means that there is a lot going on computational- and memory-wise!

To show this, I made a simple comparison:

# cat array_perftest_expansion.sh
#! /usr/bin/bash

set -e
set -u

loops=$1

arr=()
i=0

while [ $i -lt $loops ] ; do
        arr=( ${arr[@]+"${arr[@]}"} "${i}" )
        #arr=arr[${#arr[@]}]="${i}"

        i=$(( i + 1 ))
done

exit 0

And then:

# timex ./array_perftest_expansion.sh 1000

real           1.86
user           1.84
sys            0.01

But with the second line enabled instead, just setting the last entry directly:

arr=arr[${#arr[@]}]="${i}"



# timex ./array_perftest_last.sh 1000

real           0.03
user           0.02
sys            0.00

If that is not enough, things get much worse, when you try to add more entries!

When using 4000 instead of 1000 loops:

# timex ./array_perftest_expansion.sh 4000

real          33.13
user          32.90
sys            0.22

Just setting the last entry:

# timex ./array_perftest_last.sh 4000

real           0.10
user           0.09
sys            0.00

And this gets worse and worse ... I could not wait for the expansion version to finish a loop of 10000!

With the last element instead:

# timex ./array_perftest_last.sh 10000

real           0.26
user           0.25
sys            0.01

Never use such an array expansion for any reason.

U. Windl
  • 3,480
  • 26
  • 54
Thomas
  • 43
  • 4
  • Appending to an empty array never was a problem, and appending an empty array just works (and it's not more complicated). `set -u; a=(); a+=()` – U. Windl Nov 18 '22 at 11:04
1

Here are a couple of ways to do something like this, one using sentinels and another using conditional appends:

#!/bin/bash
set -o nounset -o errexit -o pipefail
countArgs () { echo "$#"; }

arrA=( sentinel )
arrB=( sentinel "{1..5}" "./*" "with spaces" )
arrC=( sentinel '$PWD' )
cmnd=( countArgs "${arrA[@]:1}" "${arrB[@]:1}" "${arrC[@]:1}" )
echo "${cmnd[@]}"
"${cmnd[@]}"

arrA=( )
arrB=( "{1..5}" "./*"  "with spaces" )
arrC=( '$PWD' )
cmnd=( countArgs )
# Checks expansion of indices.
[[ ! ${!arrA[@]} ]] || cmnd+=( "${arrA[@]}" )
[[ ! ${!arrB[@]} ]] || cmnd+=( "${arrB[@]}" )
[[ ! ${!arrC[@]} ]] || cmnd+=( "${arrC[@]}" )
echo "${cmnd[@]}"
"${cmnd[@]}"
solidsnack
  • 1,631
  • 16
  • 26
0

Interesting inconsistency; this lets you define something which is "not considered set" yet shows up in the output of declare -p

arr=()
set -o nounset
echo ${arr[@]}
 =>  -bash: arr[@]: unbound variable
declare -p arr
 =>  declare -a arr='()'

UPDATE: as others mentioned, fixed in 4.4 released after this answer was posted.

MarcH
  • 18,738
  • 1
  • 30
  • 25
  • That's just incorrect array syntax; you need `echo ${arr[@]}` (but prior to Bash 4.4 you'll still see an error). – dimo414 May 01 '20 at 22:50
  • Thanks @dimo414, next time suggest an edit instead of downvoting. BTW if you had tried `echo $arr[@]` yourself you would have seen that the error message is different. – MarcH May 02 '20 at 05:51
  • And this "answer" just repeats the question? It should be deleted IMHO. – U. Windl Nov 18 '22 at 11:08
-2

The most simple and compatible way seems to be:

$ set -u
$ arr=()
$ echo "foo: '${arr[@]-}'"
nikolay
  • 2,565
  • 1
  • 21
  • 13
  • Right, so it's OK for string interpolation but not looping. – Craig Ringer Jan 18 '19 at 06:01
  • Maybe refer to the question "*What's the difference between an empty parameter and no parameter?*" ;-) When expanding into a string it does not make a difference. – U. Windl Nov 18 '22 at 11:11
  • @ikegami You should pay more attention to my corrections, like the `-` I added instead of downvoting! – nikolay Aug 29 '23 at 02:27
  • No, it's *you* that should pay more attention. You should have paid more attention before posting your incorrect answer, and you should have paid more attention before posting your incorrect comment. The OP showed `${arr[@]-}` (with the `-`) doesn't work from the beginning. `"${arr[@]-}"` always expands to exactly one argument, which isn't the desired behaviour. You were told this. Twice. Think a little before attacking others next time. – ikegami Aug 29 '23 at 03:08