9

I know that it is possible to reverse "$@" using an array:

arr=( "$@" )

And using this answer, reverse the array.

But that requires a shell that has arrays.

It is also possible using tac:

set -- $( printf '%s\n' "$@" | tac )

But that breaks if the parameters have spaces, tabs or newlines (assuming the default value of $IFS) or contain wildcard characters (unless globbing is disabled beforehand) and removes empty elements, and requires the GNU tac command (using tail -r is slightly more portable outside of GNU systems but with some implementations fails on large input).

Is there a way to reverse shell positional arguments portably, without using an array, and that works even if arguments contain whitespaces or newlines or wildcards or are possibly empty?

14

Portably, no arrays required (only positional parameters) and works with spaces and newlines:

flag=''; for a in "$@"; do set -- "$a" ${flag-"$@"}; unset flag; done

Example:

$ set -- one "two 22" "three
> 333" four

$ printf '<%s>' "$@"; echo
<one><two 22><three
333><four>

$ flag=''; for a in "$@"; do set -- "$a" ${flag-"$@"}; unset flag; done

$ printf '<%s>' "$@"; echo
<four><three
333><two 22><one>

The value of flag controls the expansion of ${flag-"$@"}. When flag is set, it expands to the value of flag (even if it is empty). So, when flag is flag='', ${flag....} expands to an empty value and it gets removed by the shell as it is unquoted. When the flag gets unset, the value of ${flag-"$@"} gets expanded to the value at the right side of the -, that's the expansion of "$@", so it becomes all the positional arguments (quoted, no empty value will get erased). Additionally, the variable flag ends up erased (unset) not affecting following code.

| improve this answer | |
10

When wanting to use no array for temporary storage, we can use the fact that a for loop always iterates over an unchanging static set of elements. In a sense, we can use the loop itself as a temporary storage of the positional parameters while rebuilding the list in reverse order.

To be able to do this, we also need to empty the list on the first iteration. The code below uses a simple flag to detect whether this has to be done or not. When the list is emptied, the flag is toggled.

flag=true
for value do
    if "$flag"; then
        set --
        flag=false
    fi

    set -- "$value" "$@"
done

This is unfortunately quite slow, as the list of positional parameters is effectively rebuilt in each iteration (set -- some-list sets all positional parameters). The bash shell takes about 50 seconds to reverse the integers between 1 and 10000, while zsh takes just over 15 seconds.

Using Isaac's trick with ${flag-"$@"} (which expands to "$@" only if flag is unset) actually makes the whole thing run slower; 1 minute 50 seconds (!) in bash and 25 seconds in zsh.

I'm assuming this is due to some implementation particularities in how the shells perform the test on $flag and/or expand "$@" for the ${flag-"$@"} expansion (the shell might possibly expand "$@" twice internally?).


If allowing ourselves to use an array as temporary storage (this would not be standard, but still fairly portable since we often know what shell we're writing our scripts for), we can use the value $# (the number of positional parameters) as an index into which to store the current value while looping over the positional parameters. Decreasing this value using shift in each iteration gives the effect of inserting values from the end of the array towards the start.

In bash, arrays start at index 0, and since the shift comes after the assignment, the last positional parameter will be stored at index 1 rather than 0. This has no consequence for how the code works in bash, it will still generate the correct result, but it makes it also work in zsh (which uses 1-based array indexes by default).

Code:

tmp=()
for value do
    tmp[$#]=$value
    shift
done

set -- "${tmp[@]}"

With bash or zsh, this uses about 0.6 seconds to reverse the integers between 1 and 10000.

| improve this answer | |
  • A guess about Isaac's way being slower: checking if the variable is set is naively slower than expanding an always-set-in-the-shell variable, because the latter finishes after just consulting the shell's variables, while the former has to then also check environment variables after the shell variable lookup fails. (Checking for not-set environment variables is probably O(n) where n is the number of set environment variables due to how environment variables are typically implemented, while in-shell variables can be implemented as a hash map or something else with faster lookups.) – mtraceur Jan 7 at 21:47
10

Copied from this answer of mine to Bash - print reversed file list using glob, to reverse the list of positional parameters POSIXly:

eval "set -- $(awk 'BEGIN {for (i = ARGV[1]; i; i--) printf " \"${"i"}\""}' "$#")"

Or slightly more legible on several lines:

eval "set -- $(
  awk '
    BEGIN {
      for (i = ARGV[1]; i; i--)
        printf " \"${" i "}\""
    }' "$#"
)"

The idea being to use awk to help generate the set -- "${3}" "${2}" "${1}" shell code for eval to interpret when "$@" has 3 elements for instance.

For large lists, it is likely to be significantly faster than using a shell loop especially one that rebuilds a list at each iteration. The awk code could be replaced by a shell loop that gives the same output (as @mosvy has shown in comments), but in my tests with bash5+gawk4.1, it's still twice as slow except for very short lists.

In zsh, you'd use the Oa parameter flag which is explicitly designed to reverse an array:

set -- "${(Oa)@}"

On my system (slightly slower than @Kusalananda's), and on a list of positional parameters obtained with set $(seq 10000), with bash5 + gawk4.2.1, that eval approach takes 0.4s while @Kusalananda's takes 1 minute and @Isaac's takes 2 minutes (zsh's Oa approach takes about 2 milliseconds).

With the sh and awk from busybox 1.30.1, those timings become: 0.06s, 11s, 11s respectively.

| improve this answer | |
  • +1 I think this would be a better answer if you also showed an unrolled variant of that one-liner - currently it's a rather painful mental process to unwrap all the quotes and which strings become what at which level. – mtraceur Jan 7 at 22:13
  • 2
    @mtraceur you can do the same trick in plain shell, and it won't be much slower: eval "set -- $(i=$#; for a do printf ' "${%d}"' "$i"; i=$((i-1)); done)". I've used this trick a lot, especially when constructing --foo arg1 --foo arg2 ... cmd lists, but I didn't get much traction with it when when answering Qs here (maybe because the common cargo cult dogma is that eval = bad ;-)) – mosvy Jan 8 at 8:58
  • @mosvy Nice! And yes, there is a dogma against eval. Which has very justified roots, but is somewhat misplaced to generalize so much for sh, because sh is so limited that some things just cannot be reasonably done without an eval - I even wrote a command-line tool and equivalent portable shell polyfill years ago called esceval whose sole purpose is to portably quote strings so that any sh will eval them back to the same exact string, precisely because there's just some things you cannot do otherwise. – mtraceur Jan 9 at 1:42
  • @mosvy There is a notable parallel where eval is genuinely not necessary in almost every language out there, and almost everything could be achieved otherwise, so the rare times when it is a good fit in sh gets caught in the cross-fire. It's very similar to the dogma against goto and preprocessor macro "abuse" in C: in C++ and many other languages, you genuinely don't need those things, because there are better ways, so all occurrences of them are suspect - but C, like sh, is limited in a way that sometimes makes it the genuinely best solution available. – mtraceur Jan 9 at 1:46
  • @mtraceur, I'm not sure how I can unroll it more. "\"" is how you specify a string containing a double quote in awk like in many other languages. That makes the code hard to read, but there's not much one can do about it. Adding a -v double_quote='"' seems a bit overkill here and I'm not sure it will help much wrt legibility. – Stéphane Chazelas Jan 9 at 7:08

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.