8

Suppose we have an if statement as follows:

if [ $(false) ]; then
    echo "?"
fi

Then "?" is not printed (the condition is false). However, in the following case, "?!" is printed, why?

if [ $(false) -o $(false) ]; then
    echo "?!"
fi
  • 1
    Note that [ is not a POSIX sh operator, it's a separate utility aka test, here used in the if part of a if/then/elif/else/fi statement in the sh language syntax. – Stéphane Chazelas Nov 25 '22 at 13:08
  • 1
    By the way, Shellcheck points out issues, e.g. by raising SC2046 ("Quote this to prevent word splitting"). This might raise alarm bells if you thought that $(false) was going to give you its exit code, because why would word splitting happen on an exit code? – Patrick Stevens Nov 28 '22 at 08:51

2 Answers2

32

$(false) doesn’t evaluate to false, it produces an empty string. Because it isn’t quoted,

if [ $(false) ]; then

evaluates to

if [ ]; then

which is false, because [ with an empty expression is false.

if [ $(false) -o $(false) ]; then

evaluates to

if [ -o ]; then

This doesn’t use the -o operator, it evaluates -o as an expression with a single string; [ with such an expression is true, so the then part of the if statement runs.

See the POSIX specification for test, in particular:

The algorithm for determining the precedence of the operators and the return value that shall be generated is based on the number of arguments presented to test. (However, when using the "[...]" form, the <right-square-bracket> final argument shall not be counted in this algorithm.)

In the following list, $1, $2, $3, and $4 represent the arguments presented to test:

0 arguments:
Exit false (1).
1 argument:
Exit true (0) if $1 is not null; otherwise, exit false.

test only considers operators if it is given at least two arguments.

If you want to use a command’s exit status as a condition, don’t put it either in a command substitution or in [ ]:

if false; then

and

if false || false; then

Note too that test’s -a and -o operators are deprecated and unreliable; you should use the shell’s && and || operators instead, e.g.

if [ "$a" = b ] || [ "$a" = c ]; then
Stephen Kitt
  • 434,908
  • 3
    Possibly of note, this exact type of issue is a major part of why the -o and -a options for test and [ are deprecated and their use is heavily discouraged by sane developers. There are technically other cases as well where they cause problems, but the specific case of one or both sides evaluating to an empty string unexpectedly and changing how the argument list is parsed is probably the biggest issue with their usage. – Austin Hemmelgarn Nov 26 '22 at 16:00
  • 1
    Note also that this only happens because the command substitution is unquoted. If quoted, it would evaluate to "" (i.e. an empty string, which is passed as a separate argument), but when unquoted it gets split+globbed, causing it to evaluate to nothing at all (i.e. no separate argument). As a result, [ "$(false)" -o "$(false)" ] evaluates to false (empty string or empty string). – Kevin Nov 26 '22 at 23:07
11

I won't repeat the points made in the fine answers by @StephenKitt and @ilkkachu here, just focus on what it means to write if [ $(cmd) ]; then....

First note that [ is not a POSIX sh operator, it's a separate utility also known as test, here used in the if part of a if/then/elif/else/fi statement in the syntax of the sh language.

Its job is to evaluate its arguments as a conditional expression. For instance, if it receives [, a, =, b and ] as arguments or test, a, = and b, it will return a false/failure exit status as it has interpreted it as the "is 'a' the same string as 'b'?" conditional expression.

[ is often used as the one and only command in the if part of a if/then/elif/else/fi statement or in while/do/done or until/do/done statement, but it doesn't have to be nor do those statement have to invoke [ nor a single command.

Doing [ $(cmd) ] means calling the [ command with [, the result of the $(cmd) expansion and ] as separate arguments. Then [ interprets those arguments as a conditional expression and returns true or false depending on the result (also false if it can't understand the expression).

In POSIX sh, $(cmd) expands to the standard output of cmd stripped of all trailing newline characters, and as it's not quoted and in list context, subject to IFS-splitting, and then globbing (with the behaviour unspecified if there are NUL characters in that output).

So if [ $(cmd) ] or test $(cmd) doesn't really make any sense.

It's something like asking the question: "does the output of cmd, once split+globbed make up a valid conditional expression that results to true?"

For instance, if cmd outputs a,* and $IFS happens to contain , and the current working directory contains two files, one called =, one called b, [ $(cmd) ] will call [ with [, a, =, b, ] as arguments. Quite luckily, that happens to be a valid conditional expression, but one that returns false as a is not b.

$ cd "$(mktemp -d)"
$ touch = b
$ cmd() { echo 'a,*'; }
$ IFS=,
$ set -o xtrace
$ if [ $(cmd) ]; then echo yes; else echo no; fi
++ cmd
++ echo 'a,*'
+ '[' a = b ']'
+ echo no
no

Now, [ "$(cmd)" ] makes quite a bit more sense. Quoting a command substitution doesn't prevent the stripping of trailing newline characters, still is unspecified when cmd outputs NULs, but it prevents split+glob and empty removal. So [ will always be passed 3 arguments: [, the output of cmd stripped of trailing newline characters and ]. With one argument beside [ and ], [ returns true if and only if that argument is not an empty string, it's the same as [ -n "$(cmd)" ].

So it's like saying: "does cmd output at least one character that is not a newline character (or NUL)". For commands that output text (text being guaranteed to be made of lines and to not contain NULs), that's "does cmd output at least one non-empty line?"

Except for short output, it does that in an inefficient way, as we end up reading the whole output and storing it in memory, and then pass is to [.

if cmd | grep -q .; then...

Would have been more efficient as grep would exit with true as soon as it finds a line that contains at least one character.

If cmd was yes, if [ "$(cmd)" ] would eventually crash the shell with a not enough memory error, while if cmd | grep -q . would output yes straight away (and yes would be terminated via a SIGPIPE signal).

To test whether cmd succeeds regardless of what it may or may not output, you just do:

if cmd; then
  echo cmd succeeded
else
  echo cmd failed
fi

if $(cmd); then is another example of code that makes little sense. But there's a bit of a twist in the example of cmd being false.

Again, $(cmd) is stripped of the trailing newline characters, subject to split+glob. But this time, the resulting words are not passed to a [ / test utility, but the first of those words if any is treated as the command to execute, and all the words passed as arguments to it.

So if cmd outputs echo:hello:world and $IFS contains :, that ends up running echo with echo, hello and world as arguments and assuming echo manages to successfully write "hello world\n" on its stdout, it will return true/success, so the then part will be run.

Now, if cmd produces no output or only newline characters and IFS-whitespace characters, then $(cmd) will result in no word at all, so another command won't be run, and then at that point, it's the exit status of cmd itself that will matter to the if statement.

In:

if $(false); then
  echo yes
else
  echo no
fi

no is output because since false produces no output, no command was run and it's the exit status of false that decides that the else part be run.

In:

if $(echo true; false); then
  echo yes
else
  echo no
fi

and assuming $IFS contains no t, r, u nor e character; true ends up being run and it's its exit status that determines the branching in the if statement.