Ultimate Bash

This is part of Underappreciated Bash idioms, which see.

ultimate   ​/ˈʌltɪmət​/
  adj. 1: The last item of a series.
       2: The utmost, the highest.

Highly enjoyable

To an outsider, it looks like a hack, at best. At worst, it's unintelligible syntax. Neither impression does much to rescue Bash from its reputation of not being a "real language", and might even contribute to a sudden outburst of "why am I even wasting my time with shell scripts no really this thing doesn't even have libraries seriously that's it I'm done with Bash I'll rewrite this in Python".

Not quite how I see it.

On its face, Ultimate Bash is just an idiom, or a small combination thereof.

Then you start using it, and it grows on you.
Then it becomes more like a style.
Then soon enough its gravitational force starts arranging other Bash things around it.

Ultimate Bash is a highly enjoyable central piece of my Bash experience.

The basics

You're in a terminal. You want a new directory.

mkdir -p ~/Documents/research/functional-programming/pdf
cd ~/Documents/research/functional-programming/pdf

You're not gonna type that twice, are you?

Recapturing the last argument

Since you never got around to adding this to your ~/.bash_aliases:

mcd() { mkdir -p "$1" && cd "$1" ;}

in which case this common situation could always be solved without repetition:

mcd ~/Documents/research/functional-programming/pdf

you'll need some other way out: how are you going to avoid typing all that again?

You have a few options.

Option 1: tab completion

It helps: ~/D<TAB>re<TAB>… — but not very good, and not general enough: many things don't tab-complete.

Option 2: history expansion

Those exclamation things you never got around to memorizing.

mkdir -p ~/Documents/research/functional-programming/pdf
cd !$

Press RET and the last argument of the previous command replaces the !$ and the line is immediately executed.

(or soon enough)

If you don't like the risk of these things immediately executing without you being sure that it got the intended string right, you can either:

  • press Alt-^ to manually expand it each time, or
  • add this to your ~/.bashrc:

    shopt -s histverify
    

    so that whenever you press RET on history expansions it'll only expand it;

In either case, if the expansion looks ok, you press RET again to execute it.

Yet I never use !$, because...

Option 3: yank-last-arg

It can get better.

mkdir -p ~/Documents/research/functional-programming/pdf
cd

After entering the cd and a space, press Alt-. — and voilà, the last argument of the previous command is inserted at point. Just press RET to run it.

Go ahead. Open a Bash terminal right now.
Type echo then SPC then Alt-. repeatedly to see the last argument of each of your previous commands.

Enormously useful.

So to recap, you can save yourself from retyping by:

  • tab-completing — when that's at all an option;
  • using !$, from history expansion;
  • using Alt-., which is yank-last-arg.

Unfortunately, all of these are for interactive use.

Option 4: dollar-low-line

What if you want to do it in a script?

Then $_ is your friend:

mkdir -p ~/Documents/research/functional-programming/pdf
cd "$_"

Unlike the others, which would save the expanded directory string itself in history, this one saves exactly what it shows, "$_".

Unlike the others, which are shortcuts for expansion, this is "just" a shell variable — one that, to quote man bash,

expands to the last argument to the previous simple command executed in the foreground, after expansion.

Low line is the official Unicode name of the character.
Underscore, underline, low line, low dash — same thing.

"So what?", you ask, "and why do you keep italicizing last argument as if it's important?"

Because it is important. The last argument (or "the ultimate argument") is highly recyclable, threadable, and enjoyable.

And you're gonna get familiar with the dollar-low-line guy — so you might as well start to affectionately call it the ultimate.

Its sidekick, the colon

Bash has a : command:

help :
:: :
    Null command.

    No effect; the command does nothing.

    Exit Status:
    Always succeeds.

which is morally equivalent to true:

help true
true: true
    Return a successful result.

    Exit Status:
    Always succeeds.

with the only con that :'s meaning is less obvious. Yet this is compensated by two pros:

  • it's shorter and unobtrusive
  • it's semantically "less about declaring truth" and "more about doing nothing"

although that last part is not exactly true, or I wouldn't be bothering to write about it.

See, its declared doing-nothingness refers to the fact that it never outputs anything.
But the command is perfectly capable of enabling side effects — an observation that you can engineer to your advantage.

Behold:

x=                 #⇒ (no output)
printf "$x"        #⇒ (no output)
:      "${x:=foo}" #⇒ (no output)
printf "${x^^}"    #⇒ FOO

So it did something, didn't it? It allowed the assignment of the parameter expansion to happen. Because this:

x=                 #⇒ (no output)
printf "$x"        #⇒ (no output)
"${x:=foo}"        #→ error

would give you a line 3: foo: command not found, because that assigment outputs the quoted string "foo", which Bash tries to execute, whereas this:

: "foo"

is fine, because : always outputs nothing and succeeds.

Now look at this:

echo This is foo   #⇒ This is foo
echo "$_"          #⇒ foo
:    This is bar   #⇒ (no output)
echo "$_"          #⇒ bar

What was that again?

No effect; the Command does nothing.

Balderdash!
The Command does many things
when in Ultimate Bash.

The examples

Shall we?

Simplify conditionals

Let's start with conditionals.

Common Bash 1

foo() {
    local fruit_selection
    case "$foo" in
        a) fruit_selection=apples   ;;
        b) fruit_selection=bananas  ;;
        c) fruit_selection=cherries ;;
        d) fruit_selection=dates    ;;
        *) echo "Wrong!" >&2
           return 1
    esac
    echo "$fruit_selection"
}

Common Bash 2

foo() {
    case "$foo" in
        a) echo apples   ;;
        b) echo bananas  ;;
        c) echo cherries ;;
        d) echo dates    ;;
        *) echo "Wrong!" >&2
           return 1
    esac
}

Ultimate Bash

foo() { case "$foo" in
            a) : apples   ;;
            b) : bananas  ;;
            c) : cherries ;;
            d) : dates    ;;
            *) echo "Wrong!" >&2; return 1
        esac; echo "$_" ;}

Compose a string to build a message

You have a long error message that doesn't fit the line.

Common Bash

foo() { local number="$1"
        if ((number==42))
        then echo "Good choice!"
        else echo "Unfortunately, the number you've chosen, " >&2
             echo "$number, is not a really good one, " >&2
             echo "so we'll have to leave this slightly " >&2
             echo "contrived error message" >&2
             FOO="Bad number"; return 1
        fi ;}

Less common Bash

foo() { local number="$1" err=""
        if ((number==42))
        then echo "Good choice!"
        else err="Unfortunately, the number you've chosen, "
             err+="$number, is not a really good one, "
             err+="so we'll have to leave this slightly "
             err+="contrived error message"
             echo "$err" >&2; FOO="Bad number"; return 1
        fi ;}

Ultimate Bash

foo() { local number="$1"
        if ((number==42))
        then echo "Good choice!"
        else : "Unfortunately, the number you've chosen,"
             : "$_ $number, is not a really good one,"
             : "$_ so we'll have to leave this slightly"
             : "$_ contrived error message"
             echo "$_" >&2; FOO="Bad number"; return 1
        fi ;}

Compose a string to build command's expression

You have several sed substitutions to make, some of which big.

They wouldn't fit in one line.

Common Bash

foo() {
    sed -e 's/something-from-1/something-to-1/g' \
        -e 's/something-from-2/something-to-2/g' \
        -e 's/something-from-3/something-to-3/g' \
        -e 's/something-from-4/something-to-4/g' \
        -e 's/something-from-5/something-to-5/g' \
        -e 's/something-from-6/something-to-6/g' "$1" | \
        grep -oP '(?<=before)foo.*(?=after)'
}

Pro: most standard format; aggressively aligns in Emacs.
Con: repetition of -e; can't add comments.

Less common Bash 1a

foo() {
    sed 's/something-from-1/something-to-1/g
         s/something-from-2/something-to-2/g
         s/something-from-3/something-to-3/g
         s/something-from-4/something-to-4/g
         s/something-from-5/something-to-5/g
         s/something-from-6/something-to-6/g' "$1" |
        grep -oP '(?<=before)foo.*(?=after)'
}

Less common Bash 1b

# Same, but with comments:
foo() {
    sed 's/something-from-1/something-to-1/g
         # This will change something-2:
         s/something-from-2/something-to-2/g
         s/something-from-3/something-to-3/g   # <-- solves stg3
         s/something-from-4/something-to-4/g
         # Important because of something-5:
         s/something-from-5/something-to-5/g
         s/something-from-6/something-to-6/g' "$1" |
        grep -oP '(?<=before)foo.*(?=after)'
}

Pro: less noise: gets rid of quotes, of -e, and of line continuations; comments allowed
Con: multiline string won't autoalign in Emacs; comments don't syntax-highlight properly

I like this style better than Common Bash's.

Yes, you can add comments to the middle of sed strings if at end of line:

<<<foo sed '# Replace the f; s/f/g/; # Now the first o; s/o/e/; s/g/G/' #⇒ foo
<<<foo sed '# Replace the f
            s/f/g/  # Now the first o
            s/o/e/; s/g/G/'   #⇒ Geo

Ultimate Bash 1a

foo() {
    : 's/something-from-1/something-to-1/g
       s/something-from-2/something-to-2/g
       s/something-from-3/something-to-3/g
       s/something-from-4/something-to-4/g
       s/something-from-5/something-to-5/g
       s/something-from-6/something-to-6/g'
    sed "$_" "$1" | grep -oP '(?<=before)foo.*(?=after)'
}

Ultimate Bash 1b

# Same, but with comments:
foo() {
    : 's/something-from-1/something-to-1/g
       # This will change something-2:
       s/something-from-2/something-to-2/g
       s/something-from-3/something-to-3/g   # <-- solves stg3
       s/something-from-4/something-to-4/g
       # Important because of something-5:
       s/something-from-5/something-to-5/g
       s/something-from-6/something-to-6/g'
    sed "$_" "$1" | grep -oP '(?<=before)foo.*(?=after)'
}

Pro: same as Less common; logic immediately clear since command now fits one line.
Con: same as Less common

Ultimate Bash 2

foo() {
    :     "s/something-from-1/something-to-1/g"
    # This will change something-2
    : "$_; s/something-from-2/something-to-2/g"
    : "$_; s/something-from-3/something-to-3/g"  # <-- solves stg3
    : "$_; s/something-from-4/something-to-4/g"
    # Important because of something-5
    : "$_; s/something-from-5/something-to-5/g"
    : "$_; s/something-from-6/something-to-6/g"
    sed "$_" "$1" | grep -oP '(?<=before)foo.*(?=after)'
}

Pro: logic in one line; comments highlight as comments; aggressively aligns.
Con: noisier than the previous.

Ultimate Bash 3

foo() {
    local r; declare -a r
    r=("something-from-1" "something-to-1"
       # This will change something-2
       "something-from-2" "something-to-2"
       "something-from-3" "something-to-3"  # <-- solves stg3
       "something-from-4" "something-to-4"
       # Important because of something-5
       "something-from-5" "something-to-5"
       "something-from-6" "something-to-6")
    : "$(printf 's/%s/%s/g;' "${r[@]}")"
    sed "$_" "$1" | grep -oP '(?<=before)foo.*(?=after)'
}

Pro: same, but also gets rid of many s///g and $_;.
Con: using array adds other noises with declare and printf, plus the extra quotes.

Ultimate Bash 4

foo() {
    local r; declare -a r
    r=("something-from-1/something-to-1"
       # This will change something-2
       "something-from-2/something-to-2"
       "something-from-3/something-to-3"  # <-- solves stg3
       "something-from-4/something-to-4"
       # Important because of something-5
       "something-from-5/something-to-5"
       "something-from-6/something-to-6")
    : "$(printf 's/%s/g;' "${r[@]}")"
    sed "$_" "$1" | grep -oP '(?<=before)foo.*(?=after)'
}

Same, but merges each pair into from/to, so less noise from quotes.

I like Ultimate Bash #1a better if no comments are needed; but #2 when heavy commenting is preferable.

Test a function's arguments

You have a function.

  • If it receives three or more arguments, it's wrong.
  • If 2nd arg is null or unset, make it 42.
  • Assign 2nd arg to local variable bar.
  • Unless 1st arg is null or unset:
    • assume it's a file, and
    • store its contents in the local variable foo.
  • Output to stdout: Bar: $bar\nContents of foo:\n$foo

Common Bash

quux() {
    local foo bar
    if [ "$#" -ge 3 ]; then
        echo "Error: we need two or less args" >&2
        return 1
    fi
    if [ -z "$2" ]; then
        bar=42
    else
        bar="$2"
    fi
    if [ -n "$1" ]; then
        foo=$(cat "$1")
    else
        foo=""
    fi
    echo "Bar: $bar"
    echo "Contents of foo:"
    echo "$foo"
}

First impression: Very common and readable Bash, nothing wrong with it!
Second impression: Argh! Can't. Avoid. Tweaking it.

Less common Bash

quux() {
    local foo bar
    (("$#">2)) && echo >&2 "Error: we need two or less args" && return 1
    [[ -z "$1" ]] && foo=""   || foo=$(<"$1")
    [[ -n "$2" ]] && bar="$2" || bar=42
    echo -e "Bar: $bar\nContents of foo:\n$foo"
}

Much better. Sigh of relief.

Those first foo and bar assignments (after &&) always return true.

On the other hand, this wouldn't be nice:

[[ -n "$1" ]] && foo=$(<"$1") || foo=""

because if $1 isn't a file, it fails, then foo="" runs.

We could fix that with:

local foo=""
[[ -n "$1" ]] && foo=$(<"$1")

or:

[[ -f "$1" ]] && foo=$(<"$1") || foo=""

but rather than analyzing the possibility of failure case by case, there's an alternative that makes short-circuit evaluations robust.

Ultimate Bash 1

quux() { : "Error: we need two or less args"
         (("$#">2)) && echo >&2 "$_" && return 1
         [[ "$1" ]] && : "$(<"$1")" || : "" ; local foo="$_"
         [[ "$2" ]] && : "$2"       || : 42 ; local bar="$_"
         echo -e "Bar: $bar\nContents of foo:\n$foo" ;}

Shorter maxwidth by detaching the error message.
Less repetitions of foo and bar. Count them.
Two lines fewer by not leaving braces in their own lines.

ShellCheck will complain that:

A && B || C is not if-then-else. C may run when A is true.

Wrong. No, it won't. Not here.

  • When A is true, it'll run B.
  • B is always true, because : always succeeds.
  • Therefore, C will never run.

With Ultimate Bash's colons, A && B || C is if-then-else.

Not convinced?

Try this:

true && : "$(<NonexistingFile)" || : Oops
printf "\$_ is ->%s<-  and  return code is ->%s<-\n" "$_" "$?"

and you get:

bash: NonexistingFile: No such file or directory
$_ is -><-  and  return code is ->0<-

The error turns "$(<NonexistingFile)" into the empty string.
But despite the error, it succeeds, so it doesn't reach Oops.

If anyone has a counter-example, I'm all ears.

Caveat: typing mistakes will make B fail — but then it's no longer the same thing:

true  && :"foo bar" || : Oops
echo "$_"
bash: :foo bar: command not found
Oops

Yeah, sure. You didn't add a space after :, then it became the :foo bar command — which doesn't exist, so we reached Oops.

So just don't do that.

Ultimate Bash 2

quux() { : "Error: we need two or less args"
         (("$#">2)) && echo >&2 "$_" && return 1
         local foo="${1:+$(<"$1")}" bar="${2:-42}"
         echo -e "Bar: $bar\nContents of foo:\n$foo" ;}

Here we test the arguments with parameter expansions.

Ultimate Bash 3

quux() { : "Error: we need two or less args"
         (("$#">2)) && echo >&2 "$_" && return 1
         echo -e "Bar: ${2:-42}\nContents of foo:\n${1:+$(<"$1")}" ;}

Same, but ditching the local variables altogether.

Ultimate Bash 4

quux() { : "Error: we need two or less args"
         (("$#">2)) && echo >&2 "$_" && return 1
         : "Contents of foo:\n${1:+$(<"$1")}"
         echo -e "Bar: ${2:-42}\n$_" ;}

Same, but you happen to prefer to see them separately.

Sequentially modify a string without sed

You want to:

  • get only the characters 5 to 7
  • downcase the first of these
  • replace all "o" with "0"
  • mark it as hex
  • show conversion from hex to decimal

Like this:

foohex "Our Foe is finally defeated."
foohex "The Foo won't fool you."
foohex "The Doc has arrived."
0xf0e = 3854
0xf00 = 3840
0xd0c = 3340

Common Bash 1

foohex() {
    local hex
    hex=$(echo "$1" \
              | sed -E \
                    -e "s/^....(...).*/\1/" \
                    -e "s/^./\L&/" \
                    -e "y/o/0/")
    echo -n "0x$hex = "
    echo "$hex" | sed "s/.*/\U&/; s/^/ibase=16; /" | bc -l
}

I'd actually be surprised to see sed's seemingly little known \L instead of more complicated maneuvers to downcase a single letter.

The sed "s/.*/\U&/" would be simpler to replace — tr 'a-z' 'A-Z' would do it, and more commonly seen.

My point is that \L and \U are not that common — but the rest is.

Common Bash 2

foohex() {
    local hex
    hex=$(echo "$1" \
              | sed -E \
                    -e "s/^....(...).*/\1/" \
                    -e "s/^./\L&/" \
                    -e "y/o/0/")
    echo "0x$hex = $((0x$hex))"
}

Gets rid of bc, which cuts the size of the last line by 80%.

Less common Bash

foohex() { local hex
           hex=$(<<<"$1" \
                    sed -E "\
                    s/^....(...).*/\1/
                    s/^./0x\L&/; y/o/0/")
           echo "$hex = $((hex))" ;}

Closer to my taste: compact, prepended Here String, no -e for sed.

Ultimate Bash 1

foohex() { : "$(<<<"$1" \
                   sed -E "\
                   s/^....(...).*/\1/
                   s/^./0x\L&/; y/o/0/")"
           echo "$_ = $((_))" ;}

Local variable is gone. Hello : and $_ — we're in Ultimate Bash territory.

But this forces the huge command substitution to be quoted, which in turn messes up both with syntax highlighting and aggressive indentation. We can do better.

Ultimate Bash 2

foohex() { <<<"$1" \
              sed -E "s/^....(...).*/\1/; s/^./0x\L&/ ; y/o/0/" |
               (: "$(cat)"; echo "$_ = $((_))") ;}

The huge quoted command substitution gets out of the subshell and becomes a regular command at the current shell. That's then piped to a subshell block and captured by the cat inside a tiny and pleasant-to-look-at command substitution, whose evaluation is then passed as the last argument of the : command, which "does nothing". This is, of course, recorded by $_, which is then used twice.

Better!

Ultimate Bash 3

foohex() { : "s/^....(...).*/\1/ ; s/^./0x\L&/ ; y/o/0/"
           <<<"$1"  sed -E "$_" | (: "$(cat)"
                                   echo "$_ = $((_))") ;}

Oh, yes. By all means let's use a whole line for that large expression and pass that to next line's sed. That cleans up the sed line, whose logic you can now see more clearly, unencumbered by the details of the actual substitutions.

But wait a minute: do we even need that extra parenthesized subshell?

The $(cat) is being used exactly as captured. So we could assign-and-capture at once using a throwaway variable x (local not needed, as it'll disappear with the pipe's subshell):

Ultimate Bash 4

foohex() { : "s/^....(...).*/\1/ ; s/^./0x\L&/ ; y/o/0/"
           <<<"$1"  sed -E "$_" | echo "${x=$(cat)} = $((x))" ;}

But wait a minute: now that the thing is so short, do we need that pipe? Do we need that cat?

Ultimate Bash 5

foohex() { : "s/^....(...).*/\1/ ; s/^./0x\L&/ ; y/o/0/"
           : "$(sed -E "$_" <<< "$1")"
           echo "$_ = $((_))" ;}

A huge thing in the first line is abstracted to $_.
This is used in the second line, all of which is abstracted to $_.
This is used twice (one of which modified) in the third line.
This is sent to stdout.
No pipe. Unless you squint.

Ultimate Bash 6

foohex() { : "${1:4:3}" ;: "0x${_,}" ;: "${_//o/0}"
           echo "$_ = $((_))" ;}

Shortest. Same idea, but no sed. Perfect.

Parameter expansions of ultimate variable chained through null commands:

  • Faster. Tiny.
  • At first, illegible.
  • Then, you study it.
  • Then, you get used to it.
  • Then, you love it.

The formal definitions

We're ready for formal definitions.

ultimate   ​/ˈʌltɪmət​/
  noun 1: (programming) In a shell, refers generically to the ultimate (i.e. last)
                        argument of a previous command.
       2: (programming) In Bash, the ultimate (i.e. last) argument of the previous
                        simple foreground command of the current shell environment.
       3: (programming) Metonymically, the variable "$_" that dynamically stores
                        the value of the ultimate argument.

  verb 1: (programming) To practice Ultimate Bash.

and:

Ultimate Bash   ​/​ˌʌltɪmət​ ˈbæʃ​/
  noun 1: (programming) In Bash programming, the art and practice of employing
                        the ultimate (sense 3) in lieu of explicit repetitions
                        or new variable assignments, often with the support of
                        the null command and parameter expansions.
       2: (programming) Code written in that style.

The laundry lists

But isn't that... unstable?

The idea of using in "serious code" something that changes all the time and you can't assign directly makes you uneasy.

I hear you. You don't want the $_ thing chameleonizing into something unexpected. Using hardcoded "stable" "normal" variables feels safer.

I once had this concern myself, and was likewise reluctant. All I can say is that I've been Ultimate Bashing for years now, and unpredictability has quickly fallen to about zero. I almost never see myself going:

Oh. This was not what I expected this thing to consider as previous command's ultimate argument.

Tell you what: let me try to exhaust the situations involving the last argument. This should assuage your concerns.

Where can you use the ultimate argument?

Will we get the $_'s value we expect?

You can use it in simple commands

: This is foo
echo "This? Why, it's a $_!"
This? Why, it's a foo!

You can use it more than once

: This is foo
echo "This? It's a $_! A $_! ${_^}, nothing else."
This? It's a foo! A foo! Foo, nothing else.

You can sneak it into pipes

: This is foo
echo "This?" | sed "s/$/ Why, it's a $_! ${_^}./"
This? Why, it's a foo! Foo.

You can sneak it into Here Strings

: What is this?
sed "s/$/ Why, it's a foo!/" <<< "${_^}"
This? Why, it's a foo!

You can sneak it into Here Docs

: What is this?
sed "s/$/ Why, it's a foo!/" <<EOF
${_^}
EOF
This? Why, it's a foo!

You can sneak it into command substitutions

: This is foo
echo "$(sed "s/$/ Why, it's a $_! ${_^}./" <<< "This?")"
This? Why, it's a foo! Foo.

You can sneak it into process substitutions

: This is foo
cat < <(sed "s/$/ Why, it's a $_! ${_^}./" <<< "This?")
This? Why, it's a foo! Foo.

You can sneak it into arithmetic expansions

: The answer is 42
echo "The answer? $((_))."
The answer? 42.
: The answer is NOT 21
echo "The answer? $((_*2))."
The answer? 42.

You can sneak it into compound commands

Namely:

You can sneak it into braces (current shell)
: This is foo
{ x="A $_."; echo "This? $x Just ${x,}" ;}
This? A foo. Just a foo.
You can sneak it into parentheses (subshell)
: This is foo
(x="A $_."; echo "This? $x Just ${x,}")
This? A foo. Just a foo.
You can sneak it anywhere inside tests and conditionals
short-circuit
: This is foo
[[ "$_" == foo ]] && : "Ok, a $_." || : "That's a $_, not a foo!"
echo "$_"
Ok, a foo.
: This is bar
[[ "$_" == foo ]] && : "Ok, a $_." || : "That's a $_, not a foo!"
echo "$_"
That's a bar, not a foo!
if … then … fi
: This is foo
if [[ "$_" == foo ]]
then : "Ok, a $_."
else : "That's a $_, not a foo!"
fi
echo "$_"
Ok, a foo.
: This is bar
if [[ "$_" == foo ]]
then : "Ok, a $_."
else : "That's a $_, not a foo!"
fi
echo "$_"
That's a bar, not a foo!
case … esac
: This is foo
case "$_" in
    foo) : "Ok, a $_." ;;
    *  ) : "That's a $_, not a foo!"
esac
echo "$_"
Ok, a foo.
: This is bar
case "$_" in
    foo) : "Ok, a $_." ;;
    *  ) : "That's a $_, not a foo!"
esac
echo "$_"
That's a bar, not a foo!
You can sneak it into arithmetic evaluations
: The answer is 42
((_==6*7)) && : "The answer? $_." || : "$_ is not the answer."
echo "$_"
The answer? 42.
: The answer is NOT 21
((_==6*7)) && : "The answer? $_." || : "$_ is not the answer."
echo "$_"
21 is not the answer.
You can even directly modify it inside arithmetic evaluations...
: The answer is NOT 21
((_=42))   # assignment in arithmetic evaluation
echo "The answer? $_."
The answer? 42.
...but NOT outside them, mind you
: The answer is NOT 21
_=42       # assignment in current shell
echo "The answer? Who knows?$_"
#                            ^empty!
The answer? Who knows?
: The answer is NOT 21
(_=42)     # assignment in subshell
echo "The answer? Certainly not $_."
The answer? Certainly not 21.

More on that later.

And you can sneak it into redirects

: Try urandom
(tr -cd 'foobar' | head -c6) < /dev/"$_"
brafob     # (this will vary of course — it's random!)
: Try null
echo Gone! > /dev/"$_"
(no output)

What can provide the ultimate argument?

Ok, good. But we also need to look from the opposite direction.

Remember: $_ is assigned to "the last argument to the previous simple command executed in the foreground, after expansion".

Simple command

: "This is foo"
echo "$_"
This is foo

Unquoting changes the last argument

: This is foo
echo "$_"
foo

Expansions happen first

Braces:

# This is bee boo gee goo fee foo
: This is {b,g,f}{ee,oo}
echo "$_"
foo

Global variable:

: This is "$BASH"
echo "$_"
/usr/bin/bash

If no last command, last argument is the pathname of the executable

echo "$_"
/usr/bin/bash

If no argument is passed, last argument is the command

:
echo "$_"
:
false
echo "$_"
false
ls >/dev/null
echo "$_"
ls

So if you want it empty, pass the empty string

: ""
echo "$_"
(empty)
: ''
echo "$_"
(empty)

Pipes

: foo
echo b | sed "s/$/ar/"
echo "$_"
bar
foo

Surprised?
In multi-command pipelines, the commands execute in subshells.
The last simple foreground command is : foo.

Process substitution

: foo
sed "s/$/ar/" < <(echo b)
echo "$_"
bar
s/$/ar/

But here that sed is running in the current shell environment — a simple command in the foreground.

Here String

sed "s/$/ar/" <<< b
echo "$_"
bar
s/$/ar/

and:

cat <<< foo
echo "$_"
foo
cat

and:

: a <<< b
echo "$_"
a

You got the idea: the last argument is before the Here String.

Changing the order preserves that:

: <<<b a
echo "$_"
a

Here Document

cat <<EOF
foo
EOF
echo "$_"
foo
cat

and:

: <<EOF
foo
EOF
echo "$_"
:

Same idea: the last argument is before the Here Document.

Changing the order preserves that:

<<EOF cat
foo
EOF
echo "$_"
foo
cat

Command substitution

It's expanded, and then processed normally. Therefore, quoting matters:

: "$(seq -s', ' 3)"   # : "1, 2, 3"
echo "$_"
1, 2, 3

but:

: $(seq -s', ' 3)     # : 1, 2, 3
echo "$_"
3

Conditional — bracket test

: foo
[[ -n "bar" ]] && : is ok || : empty
echo "$_"

: foo
[ -n "bar" ]   && : is ok || : empty
echo "$_"

: foo
test -n "bar"  && : is ok || : empty
echo "$_"
ok
ok
ok

Make sense?

If you don't use the test, it gets weird — avoid:

: foo
[[ -n "bar" ]]
echo "$_"

: foo
[ -n "bar" ]
echo "$_"
foo
]

But when do you run a test that you don't use? So this shouldn't be an issue.

Conditional — if…then…else…fi

: foo
if [[ -n "bar" ]]; then : is ok; else : empty; fi
echo "$_"

: foo
if [ -n "bar" ]  ; then : is ok; else : empty; fi
echo "$_"
ok
ok

As expected, and just like the short-circuits.

Even though if…then…else…fi is a compound command, it evaluates : is ok, which is a simple command executed in the foreground, so we get ok (is ok was unquoted).

You'd "capture the foo" if the : is ok were in a compound command subshell:

: foo
if [[ -n "bar" ]]; then (: is ok); else : empty; fi
echo "$_"
foo

Arithmetic evaluation is also compound, so if you put that there...

: foo
if [[ -n "bar" ]]; then ((6*7)); else : empty; fi
echo "$_"

: foo
if [ -n "bar" ]  ; then ((6*7)); else : empty; fi
echo "$_"
foo
]

But arithmetic expansion is another story:

: foo
if [[ -n "bar" ]]; then : "$((6*7))"; else : empty; fi
echo "$_"

: foo
if [ -n "bar" ]  ; then : "$((6*7))"; else : empty; fi
echo "$_"
42
42

The arithmetic expansion resolves itself to the string 42, and : 42 is a simple command.

Can you guess what happens if you put that in a subshell?

: foo
if [[ -n "bar" ]]; then (: "$((6*7))"); else : empty; fi
echo "$_"

: foo
if [ -n "bar" ]  ; then (: "$((6*7))"); else : empty; fi
echo "$_"
foo
]

"Hey, ok, stop! This already happened a few times: why does the first outputs foo but the second [?"

Here's a hint:

: "["
cat <<HINT
$(type $_$_ ]] $_ ] 2>&1)

And "help $_" will tell you that:
  $(help $_)

$_ is this: $(type -P $_)
HINT
[[ is a shell keyword
]] is a shell keyword
[ is a shell builtin
bash: line 2: type: ]: not found

And "help [" will tell you that:
  [: [ arg... ]
    Evaluate conditional expression.

    This is a synonym for the "test" builtin, but the last argument must
    be a literal `]', to match the opening `['.

[ is this: /usr/bin/[

Redirects

As with Here Strings and Here Documents, the last argument is the one before the redirect:

: null
: foo > /dev/"$_"
echo "$_"
foo
: null
cat < /dev/"$_"
echo "$_"
cat

Group commands count:

: null
{ echo -n; cat ;} < /dev/"$_"
echo "$_"
cat

But here we have a pipeline, therefore subshells:

: urandom
{ tr -cd @ | head -c3 ;} < /dev/"$_"
echo "$_"
@@@urandom

The last simple foreground command was : urandom.

Changing the order doesn't matter, still a pipeline:

: urandom
tr -cd @ < /dev/"$_" | head -c3
echo "$_"
@@@urandom

No pipes, but also a subshell:

: null
(echo -n; cat) < /dev/"$_"
echo "$_"
null

Assignment and arithmetic

Remember: $_ is assigned to "the last argument to the previous simple command executed in the foreground, after expansion".

Assignment — in simple command

Current shell. Command is the last.

: quux
x="This is foo"
echo "$_"
(empty)

With stuff after it:

: quux
x="This is foo" : Hello bar
echo "$_"
bar
Assignment — in group command

Current shell. Command is the last.

: quux
{ : baz
  x="This is foo" ;}
echo "$_"
(no output)

With stuff after it:

: quux
{ : baz
  x="This is foo" : Hello bar ;}
echo "$_"
bar
Assignment — in subshell

Compound command, subshell. Command is not the previous. Skipped.

: quux
(: baz; x="This is foo")
echo "$_"
quux

With stuff after it:

: quux
(: baz; x="This is foo" : Hello bar)
echo "$_"
quux
Assignment — in Arithmetic evaluation

Compound command, so it's not the previous. Skipped.

: quux
((x=21, y=42))
echo "$_"
quux

Unless!

: quux
((x=21, _=42))
echo "$_"
42
: quux
((_=42, x=21))
echo "$_"
42

Since the assignment is to $_ itself, it changes that variable's value from quux to 42.

Any other arithmetic evaluation
: quux
((x=40, y=2, x+y<30))   # or whatever
echo "$_"
quux
You can't directly assign

The _=42 in ((…)) above is an exception. Elsewhere it won't work:

echo This is foo  #⇒ foo
echo "->$_<-"     #⇒ ->foo<-
_=bar             #⇒ (no output)
echo "->$_<-"     #⇒ -><-

nor this:

# WON'T WORK
foo() { local number="$1"
        if ((number==42))
        then echo "Good choice!"
        else : "Unfortunately, the number you've chosen,"
             _+=" $number, is not a really good one,"
             _+=" so we'll have to leave this slightly"
             _+=" contrived error message"
             echo "$_" >&2; FOO="Bad number"; return 1
        fi ;}

This is because the plain assignment is a simple command composed of:

  • an assignment (which is before the $0)
  • the rest of the command, starting with $0 (which is empty, since it's just the assignment)

So $0, $1, $2 — all empty, so $_ is empty.

Compare:

x=A y=B z=C       #⇒ (no output)
printf "$_"       #⇒ (no output)

x=A y=B z=C true  #⇒ (no output)
printf "$_"       #⇒ true
Arithmetic expansion

Expanded, then processed normally.

: quux
: "$((x=40, y=2, x+y))"
echo "$_"
42

The cheat sheets

A small summary of assignment cases

: foo bar        # The last argument is bar
echo "$_"        #⇒ bar

: foo bar
x=7              # This regular foreground assignment is the last simple command
echo "$_"        #⇒ (empty string...)

: foo bar
_=7              # Since the rest of the simple command is empty, no last argument
echo "$_"        #⇒ (empty string...)

: foo bar
{ x=7 ;}         # The same applies to group commands
echo "$_"        #⇒ (empty string...)

: foo bar
{ _=7 ;}         # So this reassignment will also be ignored
echo "$_"        #⇒ (empty string...)

: foo bar
x=7 &            # But this is a background assignment, therefore skipped
echo "$_"        #⇒ bar

: foo bar
_=7 &            # Same here: background, therefore skipped
echo "$_"        #⇒ bar

: foo bar
(x=7)            # And this assignment is in a subshell compound command — skipped
echo "$_"        #⇒ bar

: foo bar
(_=7)            # As is this. And subshell assignments don't survive outside them.
echo "$_"        #⇒ bar

: foo bar
((x=7))          # Arithmetic evaluation is also compound command, so also skipped
echo "$_"        #⇒ bar

: foo bar
((_=7))          # Skipped too — BUT! Assignment of _ survives the ((…))!
echo "$_"        #⇒ 7

: foo bar
: "$((x=7, x))"  # Here the simple command starts with a colon, ends with $x
echo "$_"        #⇒ 7

This takes some getting used to. But it's not a big deal.

A small summary of non-assignment cases

Since we're here, with non-assignments:

: foo bar          # The last argument is bar
echo "$_"          #⇒ bar

: foo bar
: quux             # The last one overrides it
echo "$_"          #⇒ quux

: foo bar
{ : quux ;}        # The same applies to group commands
echo "$_"          #⇒ quux

: foo bar
: quux &           # But this ends in &, so a background command — skipped
echo "$_"          #⇒ bar

: foo bar
(: quux)           # And this is in a subshell compound command — skipped
echo "$_"          #⇒ bar

: foo bar
echo | true        # Same with pipes
echo "$_"          #⇒ bar

: foo bar
true < <(echo)     # But here, true is in the current shell environment
echo "$_"          #⇒ true

: foo bar
sed 1d <<< quux    # 2nd line has no output; 2nd line's 1d is last
echo "$_"          #⇒ 1d

: foo bar
sed 1d <<EOF       # Same idea.
quux
EOF
echo "$_"          #⇒ 1d

: foo bar
: <<< quux         # 2nd line has no output; 2nd line's colon is the last
echo "$_"          #⇒ :

: foo bar
: <<EOF            # Same idea.
quux
EOF
echo "$_"          #⇒ :

: foo bar
: "$(echo quux)"   # Command substitution: result becomes a regular string
echo "$_"          #⇒ quux

: foo bar
: "$((6*7))"       # Arithmetic expansion: result becomes a regular string
echo "$_"          #⇒ 42

: foo bar
[[ x ]] &&
    : a || : b     # Test is true, and : a is a simple command
echo "$_"          #⇒ a

: foo bar
if [[ x ]]
then : a
else : b
fi                 # Same idea.
echo "$_"          #⇒ a

: foo bar
[[ x ]] &&
    : ((6*7)) ||   # But ((6*7)) is not a simple command — skipped
    : b
echo "$_"          #⇒ bar

: foo bar
if [[ x ]]
then ((6*7))
else : b
fi                 # Same idea.
echo "$_"          #⇒ bar

The asymmetries

At first, you thought that "look from the opposite direction" seemed a bit redundant.

But then you noticed the asymmetries.

You can sneak the last argument into pipes...

: A CAT
echo | sed "s/^/${_,,}/"
cat

...but you can't capture it from pipes:

: A foo
echo -n | cat
echo "$_"
foo

You can sneak it into background commands...

: A CAT
echo "${_,,}" &
cat

...but you can't capture it from background commands:

: A foo
: A cat &
echo "$_"
foo

You can sneak it into parenthesized subshells...

: A CAT
(echo "${_,,}")
cat

...but you can't capture it from parenthesized subshells:

: A foo
(: A cat)
echo "$_"
foo

Backgrounds, pipes, or parentheses can almost always be used to modify a command without any meaningful difference in the outcome:

echo foo
(echo foo)  # same result, delayed by a few microseconds that you won't notice

And this opens up a few possibilities.

Delayed ultimate capture

The concept

In another article, you have contemplated the possibility of delayed pipe captures:

echo foo | { printf "Just a "; cat                         ;}
echo foo | { printf "Just a "; echo "$(</dev/stdin)."      ;}
echo foo | { printf "Just a "; : "$(cat)" ; echo "${_^^}!" ;}
Just a foo
Just a foo.
Just a FOO!

In Ultimate Bash, data flows less from pipes, and more through the dynamic $_ variable.

You may thus find it germane to ponder whether the idea of delayed capture translates to this style.

  • Can we better control the flow?
  • Can we do other things instead of rushing to use what the last command offers us?
  • Can we also delay the capture of the last argument — aka the ultimate argument, aka the ultimate?

Why, but of course! Behold:

Options for delaying

We want to capture the bar.

: A foo
: A bar
echo "$_"  #⇒ bar

We no longer want to capture the bar.

: A foo
: A bar &
echo "$_"  #⇒ foo

: A foo
(: A bar)
echo "$_"  #⇒ foo

: A foo
: A bar | cat
echo "$_"  #⇒ foo

: A foo
: A bar |:
echo "$_"  #⇒ foo

The first one (&) sends it to the background — but parallelizing things might change the order of outputs, and you might not want that. So be careful.

The second and third are your go-to options for doing something while preserving the last argument.

Compare:

: Hello
echo "$_ world!"
echo "$_, once again."
Hello world!
Hello world!, once again.

But:

: Hello
(echo "$_ world!")
echo "$_, once again."
Hello world!
Hello, once again.

Likewise:

: Hello
echo "$_ world!" | cat
echo "$_, once again."
Hello world!
Hello, once again.

Piping into the void

The fourth may look odd. After all, piping anything to : will make it successfully disappear:

echo foo |:   #⇒ (empty, with exit status 0)
false    |:   #⇒ (empty, with exit status 0)

It looks useless, then:

: Hello
echo "$_, once again."
Hello, once again.
: Hello
echo "$_ world!" |:
echo "$_, once again."
Hello, once again.

But here's one use case: error messages.

: A foo
echo quux >&2
echo "$_"
quux  # to stderr
quux  # to stdout
: A foo
echo quux >&2 |:
echo "$_"
quux  # to stderr
foo   # to stdout

The pipe only captures stdin, so the error message is sent intact to stderr, just as it would if |: weren't there.

Yet the pipe makes execution happen in subshells, so the line is skipped by $_.

And either of these two would do the same job as the |: solution above:

: A foo
echo quux >&2 | cat
echo "$_"
: A foo
(echo quux >&2)
echo "$_"

When integers are involved

If what you want to do in the middle involves integers, you can also use arithmetic evaluation to delay the capture.

You won't capture the foo here:

: A foo
x=42
echo "$_"  #⇒ (empty)

because x=42 is a valid foreground simple command with no last argument (prepended assignments don't count, remember?), so $_ becomes empty.

Nor here:

: A foo
: "${x=42}"
echo "$_"  #⇒ 42

because "${x=42}" assigns x, then expands to 42, which becomes the last argument of the simple command : 42.

But this will work:

: A foo
((x=42))
echo "$_"  #⇒ foo

The previous, more general options wouldn't quite work.

I mean, they would work for capturing the foo, but the variable assignment itself would fail.

Look:

: A foo

# Subshell — skipped by $_, and assignments will be gone
v=42 &
(w=42)
: "${x=42}" | cat
: "${y=42}" | :

# Compound — skipped by $_, but assignment will persist
((z=42))

cat <<EOF
_ = $_  (we captured it!)

v = $v     │
w = $w     │> gone with the subshell
x = $x     │
y = $y     │

z = $z   (persisted)
EOF
_ = foo  (we captured it!)

v =      │
w =      │> gone with the subshell
x =      │
y =      │

z = 42   (persisted)

The other shells

What, Bash no good? 😕

Z shell, aka Zsh

Z shell folks, rejoice: it seems that Zsh can potentially fulfill your ultimate needs:

touch foo1.zsh
chmod +x "$_"          # So far so good

cat <<'FOO' >foo1.zsh  # Why not "$_"? We'll see why...
#!/usr/bin/env zsh
: The world
echo "Hello, $_!"
FOO

./foo1.zsh
Hello, world!

Zsh has :, Zsh has $_, and they seem to work in the simplest case.

BUT!

# Bash would write it to ./bar
#  Zsh would write it to ./there

: A foo bar
echo Hello there > "$_"

And:

# Bash would output: bar\n
#  Zsh would output: \n

: A foo bar
(: Hello)
echo "$_"

And:

# Bash would output: foobar\n
#  Zsh would output: foocat\n

: A bar
printf foo | cat
echo "$_"

The art of Ultimate Shell Scripting is a different animal over there.

man zshall has this to say about it:

_ <S>  The last argument of the previous command.
       Also, this parameter is set in the environment of every
       command executed to the full pathname of the command.

Zsh's "The last argument of the previous command."
sounds simpler than
Bash's "The last argument to the previous simple command executed in the foreground, after expansion."

but a quick look at the examples above suggests it's not so simple.

What counts as "previous command" in Zsh?

  • When redirecting, does the first part already count as previous, as the there suggests?
  • Was (: Hello) a previous command with no last argument, then?
  • Does the last command in a pipeline count as the previous, as the cat suggests?

I'll leave these questions for Zsh folks to answer.

Will someone there feel inspired to flesh out the details of Ultimate Zsh?

Dash, aka sh

You're out of luck.

In Bash, when there's no previous command, $_ will output the path to the executable.

Zsh, on the other hand, outputs nothing in this case: $_ is empty when no previous command.

What about sh?

The sh executable is usually actually a symlink to dash, a POSIX-compliant implementation of /bin/sh:

stat -c%N /bin/sh | tr -d \'
/bin/sh -> dash

Over there, : works fine, but $_ seems to return path to the executable regardless of whether there was some previous command.

In other words, it seems you won't be able to practice your ultimate skills when in sh.

Here's the summary:

:; bash -c ': A foo; echo "$_"'   #⇒ foo            # Ultimate Bash! 🎉
:; bash -c '         echo "$_"'   #⇒ /usr/bin/bash

:;  zsh -c ': A foo; echo "$_"'   #⇒ foo            # Ultimate Zsh!  🎊
:;  zsh -c '         echo "$_"'   #⇒ (empty string)

:;   sh -c ': A foo; echo "$_"'   #⇒ /usr/bin/sh    # Not over here. 😕
:;   sh -c '         echo "$_"'   #⇒ /usr/bin/sh

The wild

Most of the previous examples were "crafted for didactical purposes".

That's prefectly fine, but I'd also like to show a few instances of Ultimate Bash used in "actual code".

Function output

From Append2Org.

Uses conditionals to select the next sed expression to use.

Variable input is sequentially redefined with an alternation of parameter expansions (substring expansion plus case modification) and sed.

output()   {
    local input="$1"
    # Let's further fix the input.
    #   We want its first line to always start with "** ".
    #   We also want to be able to batch-add entries.
    if   [[ "${input:0:3}" == "** " ]]; then : ""
    elif [[ "${input:0:3}" == "***" ]]; then : "1 s/^/** (new entry)\n/"
    elif [[ "${input:0:2}" == "* "  ]]; then : "/^[*]+ / s/^/*/"
    else                                     : "1 s/^/** /"
    fi
    input="$(sed -E "$_" <<< "$input")"

    # Then we check if there's a "todo" followed or not by [abc].
    # This is simply so we can feed clipboard with things like:
    #   "todoa Wash cat" and have it sent to inbox as "** TODO [#A] Wash cat".
    : "${input:0:8}"
    : "${_,,}"
    if   [[ "$_" == "** todoa" ]]; then : "1 s/...todoa/** TODO [#A]/I"
    elif [[ "$_" == "** todob" ]]; then : "1 s/...todob/** TODO [#B]/I"
    elif [[ "$_" == "** todoc" ]]; then : "1 s/...todoc/** TODO [#C]/I"
    elif [[ "$_" == "** todo " ]]; then : "1 s/...todo /** TODO /I"
    else                                : ""
    fi
    input="$(sed -E "$_" <<< "$input")"

    # We can be even lazier:
    #   "t a Wash cat".
    : "${input:0:7}"
    : "${_,,}"
    if     [[ "$_" == "** t a " ]]; then : "1 s/...t../** TODO [#A]/I"
    elif   [[ "$_" == "** t b " ]]; then : "1 s/...t../** TODO [#B]/I"
    elif   [[ "$_" == "** t c " ]]; then : "1 s/...t../** TODO [#C]/I"
    else                                 : ""
    fi
    sed -E "$_" <<< "$input"
}

Function loadvars

From Append2Org.

Sometimes the opposite makes sense: conditional clauses may want to repeatedly use the previous command's last argument.

loadvars() { : ~/.config/append2org.rc
             # USER: this file^ must exist
             if [[ -f "$_" ]]
             then . "$_"
             else echo "$_ is missing; create it" >&2
                  exit 1
             fi ;}

Comprehensive enough?

Now you try it.

📆 2025-11-08