It helps a bit if you think the file descriptors as variables that accept a file as a value (or call it an i/o stream) and the order they appear is the order of their evaluation.
What happens in the above example is:
1) The script starts (as per default and unless otherwise inherited) with the following
fd/0 = stdin # that's the keyboard
fd/1 = stdout # that's the screen
fd/2 = stderr # the screen again, but different stream
exec command translates to declaring a new variable and assigning a value
fd/3 = fd/1 # same as stdout
So now, two file descriptors have the value stdout, i.e. both can be used to print to the screen.
ls is executed and inherits all open file descriptors, the following setup happens
ls.fd/1 = grep.fd/0 # pipe gets precedence, ls.fd/1 writes to grep.stdin
ls.fd/2 = ls.fd/1 # ls.fd/2 writes to grep.stdin
ls.fd/1 = ls.fd/3 # ls.fd/1 writes to stdout
ls.fd/3 = closed # fd/3 will not be inherited by `ls`
fd/3 has served the purpose of keeping the stdout value long enough to return it to fd/1. So now everything that
ls sends to fd/1 goes to stdout and not
The order is important, e.g. if we'd run
ls -l >&3 2>&1 3>&-, ls.fd/2 would write to stdout instead of
4) fd/3 for
grep is closed and not inherited. It would be unused anyway.
grep can only filter error messages from
The example provided in ABSG is probably not the most helpful and the comment "Close fd 3 for 'grep' (but not 'ls')" is a bit misleading. You can interpret it as: "for the ls, pass the value of ls.fd/3 to ls.fd/1 before unsetting so it won't get closed".