Bash: Indirect Expansion Exploration

A common case that comes up when programming is accessing a variable that is received as a parameter. Bash scripting accomplishes this using something called indirect expansion, which will be our topic in this post.

Setup

Let’s make a shell script. In your favourite editor type

#!/bin/bash

x=2
letters=(a b c d)

And save it somewhere as indirect.sh. Now we need to make it executable as follows:

[ahmed@amayem ~]$ chmod +x ./indirect.sh 
[ahmed@amayem ~]$ ./indirect.sh 

Looks good so far.

Indirect Expansion

Let’s take a look at the man pages:

${parameter}
            The value of parameter is substituted. The braces are required when parameter is a positional parameter with more than one digit, or when parameter is followed by a character which is not to be interpreted as part of its name.

If the first character of parameter is an exclamation point, a level of variable indirection is introduced. Bash uses the value of the variable formed from the rest of parameter as the name of the variable; this variable is then expanded and that value is used in the rest of the substitution, rather than the value of parameter itself. This is known as indirect expansion.

Now to apply it.

Simple Variable

Accessing the Variable

First let’s try to specify a simple variable in this case x:

fn()
{
    echo ${!1}
}

fn x

Running it gives us the following:

[ahmed@amayem ~]$ ./indirect.sh
2

Excellent. What does, ${1} mean? It indicates the first parameter passed to the function. It is another way of writing $1. When we put the exclamation mark in the beginning, the !1 is replaced with the value of $1, so it becomes ${x}. So in effect ${!1} is ${x}.

Changing the Variable

I would like to get the name of the variable as a parameter and then change the value of that variable in the function. Let’s try the following:

fn()
{
    ${!1}=5
    echo $x
}

fn x

It gives us the following:

[ahmed@amayem ~]$ ./indirect.sh
./../arrays.sh: line 12: 2=5: command not found
2

So it seems that indirect expansion can’t be used as a pointer. When ${!1} is reached, the value of the variable is replaced, so in our case it was like saying 2=5, which doesn’t make sense. Instead let’s try using ${1}, which would translate to x=5.

fn()
{
    ${1}=5
    echo $x
}

fn x

However, we still get this:

[ahmed@amayem ~]$ ./indirect.sh
./../arrays.sh: line 12: x=5: command not found
2

So it seems that ${1} is substituted with x but the resulting string x=5 is treated as a command name. We need the string x=5 to be evaluated. This needs the eval command:

Eval

Let’s see what the bash man pages have to say about eval:

eval [arg ...]
    The args are read and concatenated together into a single command. This command is then read and executed by the shell, and its exit status is returned as the value of eval. If there are no args, or only null arguments, eval returns 0.

Let’s try to apply it:

fn()
{
    eval ${1}=5
    echo $x
}

fn x

This give us the following:

[ahmed@amayem ~]$ ./indirect.sh
5

Success. However, care should be taken when using eval that the final string should not be something malicious.

Other Methods for assignment

There are other commands that deal specifically with assigning values to variables, such as the read and printf commands.

read

Let’s take a look at the read command entry in the bash man pages:

read [-ers] [-u fd] [-t timeout] [-a aname] [-p prompt] [-n nchars] [-d delim] [name ...]
    One line is read from the standard input, or from the file descriptor fd supplied as an argument to the -u option, and the first word is assigned to the first name, the second word to the second name, and so on, with leftover words and their intervening separators assigned to the last name. If there are fewer words read from the input stream than names, the remaining names are assigned empty values. The characters in IFS are used to split the line into words. The backslash character () may be used to remove any special meaning for the next character read and for line continuation.

Let’s apply it:

fn()
{
    read ${1} <<< 5
    echo $x
}

fn x

The <<< 5 part is basically giving 5 as the input from standard input. When we run it we get:

[ahmed@amayem ~]$ ./indirect.sh
5

Success.

printf

As usual let’s see what the bash man pages have to say:

printf [-v var] format [arguments]
    Write the formatted arguments to the standard output under the control of the format. The format is a character string which contains three types of objects: plain characters, which are simply copied to standard output, character escape sequences, which are converted and copied to the standard output, and format specifications, each of which causes printing of the next successive argument. In addition to the standard printf(1) formats, %b causes printf to expand backslash escape sequences in the corresponding argument (except that c terminates output, backslashes in ', ", and ? are not removed, and octal escapes beginning with  may contain up to four digits), and %q causes printf to output the corresponding argument in a format that can be reused as shell input.

The most important part is the following:

The -v option causes the output to be assigned to the variable var rather than being printed to the standard output.

Let’s apply it and use printf‘s formatting potential:

fn()
{
    printf -v ${1} "%.10i" 5
    echo $x
}

fn x

The format string "%.10i" is saying print the argument as an integer which is padded with zeroes and is 10 characters long. These are the results:

[ahmed@amayem ~]$ ./indirect.sh
0000000005

Success.

Specifying an array

Accessing one element

Let’s use letters instead of x, and try accessing as we did with the simple elements before.

fn()
{
    echo ${!1}
}

fn letters

We get the following:

[ahmed@amayem ~]$ ./indirect.sh
a

This is because when the name of an array is called it gives the first element as mentioned here. How can we access other elements that we may want?

fn()
{
    elem2=$1"[2]"
    echo ${!elem2}
}

fn letters

Give us:

[ahmed@amayem ~]$ ./indirect.sh
c

Basically ${!elem2} becomes ${letters[2]}.

Accessing all elements

fn()
{
    all=$1"[*]"
    echo ${!all}
}

fn letters

Gives us:

[ahmed@amayem ~]$ ./indirect.sh
a b c d

Accessing array length and indices

As mentioned here we find the length of an array with the ${#arrayname[*]} syntax and the array indices with the ${!arrayname[*]} syntax. Let’s try to get the length using indirections:

fn()
{
    all="#"$1"[*]"
    echo ${!all}
}

fn letters

When we try it we get an empty line. It seems that bash looked for a variable called #letters[*] and couldn’t find one so nothing was printed except the new line added by echo. The same thing happens with !letters[*].

Bash-hackers had this to say:

It was an unfortunate design decision to use the ! prefix for indirection, as it introduces parsing ambiguity with other parameter expansions that begin with !. Indirection is not possible in combination with any parameter expansion whose modifier requires a prefix to the parameter name. Specifically, indirection isn’t possible on the ${!var@}, ${!var}, ${!var[@]}, ${!var[]}, and ${#var} forms. This means the ! prefix can’t be used to retrieve the indices of an array, the length of a string, or number of elements in an array indirectly (see indirection for workarounds). Additionally, the !-prefixed parameter expansion conflicts with ksh-like shells which have the more powerful “name-reference” form of indirection, where the exact same syntax is used to expand to the name of the variable being referenced.

So there we have it. Luckily they also gave us a workaround:

Array length and indices workaround

Luckily another Bash-hackers wiki gives us a workaround. I have used the relative part here.

fn()
{
    local -a 'arraykeys=("${!'"$1"'[@]}")'
    echo ${arraykeys[*]}
    echo ${#arraykeys[*]}
}

fn letters

Gives us the following:

[ahmed@amayem ~]$ ./indirect.sh
0 1 2 3
4

There it is. Success. Why does local -a 'arraykeys=("${!'"$1"'[@]}")' work? I am still looking into it. If I find out why I plan to make a post about it.


UPDATE: 8th December 2017 Thanks to Rodrigo Ribeiro Gomes for supplying the following explanation in his comment:

The solution in local -a ‘arraykeys=(“${!'”$1″‘[@]}”)’ works due to a simple string concatenation:

1) The main string is delimited by single quotes. So we have this string: arraykeys=(“${!

2) Next, we concatenate the value of $1. This comes from “$1” expression. At this point we have arrayKeys=(“${!letters

3) Finally, we append the rest of the string, that start next single quote: [@]}”).

4) This result in arrayKeys=(“${!letters[@]}”)

It is the same as use declare ‘a=someValue’; echo $a;


Modifying array elements

First attempts:

At first we may be tempted to try the following:

fn()
{
    ${1}=5
    echo ${letters[*]}
}

fn letters

But that would give us the following:

[ahmed@amayem ~]$ ./indirect.sh
./../arrays.sh: line 27: letters=5: command not found
a b c d

It seems we have to resort to eval again.

eval

fn()
{
    eval ${1}=5
    echo ${letters[*]}
}

fn letters

Gives us:

[ahmed@amayem ~]$ ./indirect.sh
5 b c d

Specifying an index to modify

fn()
{
    eval ${1}"[2]"=5
    echo ${letters[*]}
}

fn letters

Gives us the following:

[ahmed@amayem ~]$ ./indirect.sh
a b 5 d

read

fn()
{
    read ${1}"[2]" <<< 5
    echo ${letters[*]}
}

fn letters

Gives us the following:

[ahmed@amayem ~]$ ./indirect.sh
a b 5 d

printf

First element

fn()
{
    printf -v ${1} "%.10i" 5
    echo ${letters[*]}
}

fn letters

Gives us the following:

[ahmed@amayem ~]$ ./indirect.sh
0000000005 b c d

Specifying an index

Let’s try the same thing as before:

fn()
{
    printf -v ${1}"[2]" "%.10i" 5
    echo ${letters[*]}
}

fn letters

But we get the following:

[ahmed@amayem ~]$ ./../arrays.sh
./../arrays.sh: line 15: printf: `letters[2]': not a valid identifier
a b c d

Looks like it doesn’t like it.

Workaround

We will have to use a workaround of saving the output into a temp variable and then putting the value into the array at the desired index using one of the previous methods.

fn()
{
    printf -v temp "%.10i" 5
    read ${1}"[2]" <<< $temp
    echo ${letters[*]}
}

fn letters

Gives us the following:

[ahmed@amayem ~]$ ./indirect.sh
a b 0000000005 d

Success.

References

  1. Bash-Hackers parameter expansion wiki
  2. Bash-Hackers arrays wiki
  3. This Wooledge wiki
  4. My posts on arrays

Ahmed Amayem has written 90 articles

A Web Application Developer Entrepreneur.

  • Rodrigo Ribeiro Gomes

    Nice article.
    The solution in local -a ‘arraykeys=(“${!'”$1″‘[@]}”)’ works due to a simple string concatenation:

    1) The main string is delimited by single quotes. So we have this string: arraykeys=(“${!
    2) Next, we concatenate the value of $1. This comes from “$1” expression. At this point we have arrayKeys=(“${!letters
    3) Finally, we append the rest of the string, that start next single quote: [@]}”).
    4) This result in arrayKeys=(“${!letters[@]}”)

    It is the same as use declare ‘a=someValue’; echo $a;

    • http://ahmed.amayem.com ahmedamayem

      Thank you. I think I missed the second single quote and it threw me off. I have updated the article and referenced you and your explanation.
      Thank you.