11

What's a better way to implement print_last_arg?

#!/bin/sh

print_last_arg () {
    eval "echo \${$#}"  # this hurts
}

print_last_arg foo bar baz
# baz

(If this were, say, #!/usr/bin/zsh instead of #!/bin/sh I'd know what to do. My problem is finding a reasonable way to implement this for #!/bin/sh.)

EDIT: The above is only a silly example. My goal is not to print the last argument, but rather to have a way to refer to the last argument within a shell function.


EDIT2: I apologize for such an unclearly worded question. I hope to get it right this time.

If this were /bin/zsh instead of /bin/sh, I could write something like this

#!/bin/zsh

print_last_arg () {
    local last_arg=$argv[$#]
    echo $last_arg
}

The expression $argv[$#] is an example of what I described in my first EDIT as a way to refer to the last argument within a shell function.

Therefore, I really should have written my original example like this:

print_last_arg () {
    local last_arg=$(eval "echo \${$#}")   # but this hurts even more
    echo $last_arg
}

...to make it clear that what I'm after is a less awful thing to put to the right of the assignment.

Note, however, that in all the examples, the last argument is accessed non-destructively. IOW, the accessing of the last argument leaves the positional arguments as a whole unaffected.

  • unix.stackexchange.com/q/145522/117549 points out the various possibilities of #!/bin/sh -- can you narrow it down any? – Jeff Schaller Jan 22 '16 at 19:57
  • i do like the edit, but perhaps you may have also noticed that there is an answer here which offers a non-destructive means of referencing the last argument...? you should not equate var=$( eval echo \${$#}) to eval var=\${$#} - the two are nothing alike. – mikeserv Jan 24 '16 at 0:18
  • 1
    Not sure I get your last note but almost all answers provided so far are non destructive in the sense they do preserve the running script arguments. Only shift and set -- ... based solutions might be destructive unless used in functions where they are harmless too. – jlliagre Jan 24 '16 at 1:53
  • @jlliagre - but they are still destructive in the main - they require creating disposable contexts so they can destroy to discover. but... if you get a second context anyway - why not just get the one allows you to index? is there something wrong with using the tool intended for the job? interpreting shell expansions as expandable input is eval's job. and there's nothing significantly different about doing eval "var=\${$#}" when compared to var=${arr[evaled index]} except that $# is a guaranteed safe value. why copy the whole set then destroy it when you could just index it directly? – mikeserv Jan 25 '16 at 4:42
  • 1
    @mikeserv A for loop done in the main part of the shell is leaving all arguments unchanged. I agree looping all arguments is very un-optimized, especially if thousands of them are passed to the shell and I agree too that directly accessing the last argument with the proper index is the best answer (and I don't understand why it was downvoted) but beyond that, there is nothing really destructive and no extra context created. – jlliagre Jan 25 '16 at 8:04

11 Answers 11

1
eval printf %s${1+"'\n' \"the last arg is \${$#"\}\"}

...will either print the string the last arg is followed by a <space>, the value of the last argument, and a trailing <newline> if there is at least 1 argument, or else, for zero arguments, it will print nothing at all.

If you did:

eval ${1+"lastarg=\${$#"\}}

...then either you would assign the value of the last argument to the shell variable $lastarg if there is at least 1 argument, or you would do nothing at all. Either way, you would do it safely, and it ought to be portable even to ye Olde Bourne shell, I think.

Here's another one that would work similarly, though it does require copying the whole arg array twice (and requires a printf in $PATH for the Bourne shell):

if   [ "${1+:}" ]
then set "$#" "$@" "$@"
     shift    "$1"
     printf %s\\n "$1"
     shift
fi
  • Comments are not for extended discussion; this conversation has been moved to chat. – terdon Jan 23 '16 at 12:04
  • 2
    FYI, your first suggestion fails under the legacy bourne shell with a "bad substitution" error if no arguments are present. Both remaining ones work as expected. – jlliagre Jan 24 '16 at 2:02
  • @jillagre - thank you. i wasn't so sure about that top one - but was pretty sure of the other two. it was just meant to be demonstrative of how arguments might be accessed by reference inline. preferably a function would open with something like ${1+":"} return right away - because who wants it doing anything or risking side effects of any kind if was called for nothing? Math is pretty for the same - if you can be sure you can expand a positive integer you can eval all day. $OPTIND is great for it. – mikeserv Jan 24 '16 at 3:42
3

Here's a simplistic way:

print_last_arg () {
  if [ "$#" -gt 0 ]
  then
    s=$(( $# - 1 ))
  else
    s=0
  fi
  shift "$s"
  echo "$1"
}

(updated based on @cuonglm's point that the original failed when passed no arguments; this now echos a blank line -- change that behavior in the else clause if desired)

  • This required using /usr/xpg4/bin/sh on Solaris; let me know if Solaris #!/bin/sh is a requirement. – Jeff Schaller Jan 22 '16 at 19:42
  • This question is not about a specific script I'm trying to write, but rather a general how-to. I'm looking for a good recipe for this. Of course, the more portable, the better. – kjo Jan 22 '16 at 19:49
  • the $(( )) math fails in Solaris /bin/sh; use something like: shift `expr $# - 1` if that's required. – Jeff Schaller Jan 22 '16 at 19:51
  • alternatively for Solaris /bin/sh, echo "$# - 1" | bc – Jeff Schaller Jan 22 '16 at 19:54
  • 1
    that's exactly what I wanted to write, but it failed on the 2nd shell I tried -- NetBSD, which apparently is an Almquist shell that doesn't support it :( – Jeff Schaller Jan 23 '16 at 17:46
3

Given the example of the opening post (positional arguments without spaces):

print_last_arg foo bar baz

For the default IFS=' \t\n', how about:

args="$*" && printf '%s\n' "${args##* }"

For a safer expansion of "$*", set IFS (per @StéphaneChazelas):

( IFS=' ' args="$*" && printf '%s\n' "${args##* }" )

But the above will fail if your positional arguments can contain spaces. In that case, use this instead:

for a in "$@"; do : ; done && printf '%s\n' "$a"

Note that these techniques avoid the use of eval and do not have side-effects.

Tested at shellcheck.net

  • 1
    The first one fail if last argument contain space. – cuonglm Jan 23 '16 at 7:41
  • 1
    Note that the first one also did not work in pre-POSIX shell – cuonglm Jan 23 '16 at 12:08
  • @cuonglm Well spotted, your correct observation is incorporated now. – AsymLabs Jan 23 '16 at 12:08
  • It also assumes the first character of $IFS is a space. – Stéphane Chazelas Jan 23 '16 at 13:55
  • 1
    It will be if there's no $IFS in the environment, unspecified otherwise. But because you need to set IFS virtually everytime you use the split+glob operator (leave an expansion unquoted), one approach with dealing with IFS is to set it whenever you need it. It wouldn't harm having IFS=' ' here just to make it clear that it's being used for the expansion of "$*". – Stéphane Chazelas Jan 24 '16 at 9:32
2

POSIXly:

while [ "$#" -gt 1 ]; do
  shift
done

printf '%s\n' "$1"

(This approach also works in old Bourne shell)

With other standard tools:

awk 'BEGIN{print ARGV[ARGC-1]}' "$@"

(This won't work with old awk, which did not have ARGV)

  • Where there's still a Bourne shell, awk could also be the old awk that didn't have ARGV. – Stéphane Chazelas Jan 23 '16 at 14:00
  • @StéphaneChazelas: Agree. At least it worked with Brian Kernighan's own version. Do you have any ideal? – cuonglm Jan 23 '16 at 14:11
  • This erase the arguments. How to keep them?. – user79743 Jan 23 '16 at 14:33
  • @BinaryZebra: Use the awk approach, or use other plain for loop like other did, or passing them to a function. – cuonglm Jan 23 '16 at 14:43
2

This should work with any POSIX compliant shell and will work too with the pre POSIX legacy Solaris Bourne shell:

do=;for do do :;done;printf "%s\n" "$do"

and here is a function based on the same approach:

print_last_arg()
  if [ "$*" ]; then
    for do do :;done
    printf "%s\n" "$do"
  else
    echo
  fi

PS: don't tell me I forgot the curly braces around the function body ;-)

  • 2
    You forgot the curly braces around the function body ;-). – user79743 Jan 26 '16 at 1:02
  • @BinaryZebra I warned you ;-) I didn't forget them. The braces are surprisingly optional here. – jlliagre Jan 26 '16 at 1:06
  • 1
    @jlliagre I, indeed, was warned ;-) :P ...... And: certainly ! – user79743 Jan 26 '16 at 1:33
  • Which part of the syntax spec allows this? – Tom Hale Oct 29 '18 at 9:37
  • @TomHale The shell grammar rules allows both a missing in ... in a for loop and an no curly braces around an ifstatement used as function body. See for_clause and compound_command in pubs.opengroup.org/onlinepubs/9699919799. – jlliagre Oct 29 '18 at 9:48
2

From "Unix - Frequently Asked Questions"

(1)

unset last
if    [ $# -gt 0 ]
then  eval last=\${$#}
fi
echo  "$last"

If the number of arguments could be zero, then argument zero $0 (usually the name of the script) will be assigned to $last. That's the reason for the if.

(2)

unset last
for   last
do    :
done
echo  "$last"

(3)

for     i
do
        third_last=$second_last
        second_last=$last
        last=$i
done
echo    "$last"

To avoid printing an empty line when there are no arguments, replace the echo "$last" for:

${last+false} || echo "${last}"

A zero argument count is avoided by if [ $# -gt 0 ].

This is a not an exact copy of what is in the linked in the page, some improvements were added.

2

Although this question is just over 2 years old, I thought I’d share a somewhat compacter option.

print_last_arg () {
    echo "${@:${#@}:${#@}}"
}

Let’s run it

print_last_arg foo bar baz
baz

Bash shell parameter expansion.

Edit

Even more concise: echo "${@: -1}"

(Mind the space)

Source

Tested on macOS 10.12.6 but should also return the last argument on most available *nix flavors...

Hurts much less ¯\_(ツ)_/¯

  • This should be the accepted answer. Even better would be: echo "${*: -1}" which shellcheck won't complain about. – Tom Hale Oct 29 '18 at 9:52
  • 4
    This won't work with a plain POSIX sh, ${array:n:m:} is an extension. (the question did explicitly mention /bin/sh) – ilkkachu Oct 29 '18 at 10:01
0

This should work on all systems with perl installed (so most UNICES):

print_last_arg () {
    printf '%s\0' "$@" | perl -0ne 's/\0//;$l=$_;}{print "$l\n"'
}

The trick is to use printf to add a \0 after each shell argument and then perl's -0 switch which sets its record separator to NULL. Then we iterate over the input, remove the \0 and saving each NULL-terminated line as $l. The END block (that's what the }{ is) will be executed after all input has been read so will print the last "line": the last shell argument.

  • @mikeserv ah, true, I hadn't tested with a newline in any but the last argument. The edited version should work for pretty much anything but is probably too convoluted for such a simple task. – terdon Jan 23 '16 at 18:50
  • yeah, calling up an outside executable (if it's not already part of the logic) is, to me, a little heavy-handed. but if the point is to pass that argument on to sed, awk, or perl then it could be valuable information anyway. still you can do the same with sed -z for up-to-date GNU versions. – mikeserv Jan 23 '16 at 18:59
  • why did you get downvoted? this was good... i thought? – mikeserv Jan 25 '16 at 3:47
0

Here's a version using recursion. No idea how POSIX compliant this is though...

print_last_arg()
{
    if [ $# -gt 1 ] ; then
        shift
        echo $( print_last_arg "$@" )
    else
        echo "$1"
    fi
}
0

A simple concept, no arithmetic, no loop, no eval, just functions.
Remember that Bourne shell had no arithmetic (needed external expr). If wanting to get an arithmetic free, eval free choice, this is an option. Needing functions means SVR3 or above (no overwrite of parameters).
Look below for a more robust version with printf.

printlast(){
    shift "$1"
    echo "$1"
}

printlast "$#" "$@"          ### you may use ${1+"$@"} here to allow
                             ### a Bourne empty list of arguments,
                             ### but wait for a correct solution below.

This structure to call printlast is fixed, the arguments need to be set in the list of shell arguments $1, $2, etc. (the argument stack) and the call done as given.

If the list of arguments needs to be changed, just set them:

set -- o1e t2o t3r f4u
printlast "$#" "$@"

Or create an easier to use function (getlast) that could allow generic arguments (but not as fast, arguments are passed two times).

getlast(){ printlast "$#" "$@"; }
getlast o1e t2o t3r f4u

Please note that arguments (of getlast, or all included in $@ for printlast) could have spaces, newlines, etc. But not NUL.

Better

This version does not print 0 when the list of arguments is empty, and use the more robust printf (fall back to echo if external printf is not available for old shells).

printlast(){ shift  "$1"; printf '%s' "$1"; }
getlast  ()  if     [ $# -gt 0 ]
             then   printlast "$#" "$@"
                    echo    ### optional if a trailing newline is wanted.
             fi
### The {} braces were removed on purpose.

getlast 1 2 3 4    # will print 4

Using EVAL.

If the Bourne shell is even older and there are no functions, or if for some reason using eval is the only option:

To print the value:

if    [ $# -gt 0 ]
then  eval printf "'1%s\n'" \"\$\{$#\}\"
fi

To set the value in a variable:

if    [ $# -gt 0 ]
then  eval 'last_arg='\"\$\{$#\}\"
fi

If that needs to be done in a function, the arguments need to be copied to the function:

print_last_arg () {
    local last_arg                ### local only works on more modern shells.
    eval 'last_arg='\"\$\{$#\}\"
    echo "last_arg3=$last_arg"
}

print_last_arg "$@"               ### Shell arguments are sent to the function.
  • it is better but it says doesn't use a loop - but it does. an array copy is a loop. and with two functions it uses two loops. also - is there some reason iterating the entire array should be preferred to indexing it with eval? – mikeserv Jan 24 '16 at 14:15
  • 1
    Do you see any for loop or while loop? – user79743 Jan 24 '16 at 23:24
  • yes i do – mikeserv Jan 24 '16 at 23:57
  • 1
    By your standard, I guess that all code has loops, even a echo "Hello" has loops as the CPU has to read memory locations in a loop. Again: my code has no added loops. – user79743 Jan 26 '16 at 3:59
  • 1
    I really do not care about your opinion of good or bad, it has been proven wrong several times already. Again: my code has no for, while or until loops. Do you see any for while or until loop written in the code?. No?, then just accept the fact, embrace it and learn. – user79743 Jan 26 '16 at 5:35
0

This comment suggested to use the very elegant:

echo "${@:$#}"

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.