13

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.

kjo
  • 15,339
  • 25
  • 73
  • 114
  • http://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 00: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 01: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 04: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 08:04

11 Answers11

4

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

AsymLabs
  • 2,697
  • 1
    The first one fail if last argument contain space. – cuonglm Jan 23 '16 at 07: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
  • @StéphaneChazelas I considered IFS - as far as I can tell the first character will be a space by default in Posix shells - is this correct? That is why I did not include it. – AsymLabs Jan 23 '16 at 14:14
  • 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 09:32
4

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 ¯\_(ツ)_/¯

  • 1
    This should be the accepted answer.

    Even better would be: echo "${*: -1}" which shellcheck won't complain about.

    – Tom Hale Oct 29 '18 at 09:52
  • 6
    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
  • @TomHale Shellcheck now complains about this answer: SC3057: In POSIX sh, string indexing is undefined. – arcanemachine May 07 '23 at 00:33
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)

cuonglm
  • 153,898
Jeff Schaller
  • 67,283
  • 35
  • 116
  • 255
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)

cuonglm
  • 153,898
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 ;-)

jlliagre
  • 61,204
  • 3
    You forgot the curly braces around the function body ;-). –  Jan 26 '16 at 01:02
  • @BinaryZebra I warned you ;-) I didn't forget them. The braces are surprisingly optional here. – jlliagre Jan 26 '16 at 01:06
  • 1
    @jlliagre I, indeed, was warned ;-) :P ...... And: certainly ! –  Jan 26 '16 at 01:33
  • Which part of the syntax spec allows this? – Tom Hale Oct 29 '18 at 09: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 http://pubs.opengroup.org/onlinepubs/9699919799/. – jlliagre Oct 29 '18 at 09:48
  • The do do also had me puzzled, but I guess the first do could be replaced with anything. – Tom Hale Dec 10 '21 at 06:51
  • @TomHale It can indeed. I could also have written for for do :; done;printf "%s\n" "$for" ;-) – jlliagre Dec 10 '21 at 09:19
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.

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
mikeserv
  • 58,310
  • 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 02: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 03:42
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? –  Jan 24 '16 at 23:24
  • That doesn't mean it doesn't do them. – mikeserv Jan 25 '16 at 03:46
  • 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. –  Jan 26 '16 at 03:59
  • you needlessly copy an entire array just to destroy it to enable you to do what otherwise might be done with a simple index. You describe this method choice as simple and without loops, when, in fact, in order to do it, the interpreter for your language must loop unnecessarily several times. It's misleading, and bad form. And why bother arguing with me about it? If I say nothing of substance you ought merely to ignore it, but if a comment on your answer offers valid criticism don't fight it - fix it. – mikeserv Jan 26 '16 at 04:06
  • Why should I ignore you? The only people I have been taught to ignore is crazy people. Are you crazy? If so, then yes, I should ignore you. I am only answering to your (clearly) incorrect statements. Statements that only mislead unsuspecting users. For them, I try to clear your mistakes. Yours is not valid criticism. And note that there have been five other points that I have already proven correct. This is just one more. You keep pushing for a meaning that is not inside what is written. –  Jan 26 '16 at 05:17
  • The function, as requested by the OP, must receive "$@" (the arguments). As I have already said, and you keep forgetting (because it fits your goals), the answers from jlliagre, Jeff Schaller, and @terdon also copy the arguments to a function. And the answer from cuonglm destroy the argument array. Why do you pick my answer as your only target? Solve the problems in all the other answers as well. And frankly: Why don't you go fix the whole internet? It is full of mistakes and flaws. And leave alone this correct answer. It is you who is wrong, not this answer. –  Jan 26 '16 at 05:17
  • I have already said this: none of the other answers which copy the array claim to do anything but. Your answer is misleading. That is all I am saying. – mikeserv Jan 26 '16 at 05:20
  • My answer does not have added a for, while, or until loop. That's a fact. Your interpretation is the one misleading. –  Jan 26 '16 at 05:25
  • If so, then ignore me. But I contend that it is not - and only care because it could be a good answer and only isnt for sake of your pride as near as i can tell. Whether or not your answer adds shell loops is immaterial to the *fact* that its primary method is a loop and that it opens with a statement which is directly contradictory to the *fact* of the matter. – mikeserv Jan 26 '16 at 05:28
  • 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. –  Jan 26 '16 at 05:35
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.

terdon
  • 242,166
  • @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 03: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
}
brm
  • 1,021
0

This comment suggested to use the very elegant:

echo "${@:$#}"
Tom Hale
  • 30,455