リダイレクトと代入と展開だけのBashコマンド

この記事はシェル芸 Advent Calendar 2019の10日目の記事です。

問題のコマンド

{x}<"$x" _=${x=<(echo moo)} <&0$(cat <&"$x" >&2)

これはどういうコマンドでしょうか?

ちなみにこれはwiki-dev.bash-hackers.orgに記載されているもので、Simple Command(単純なコマンド)の一つとして挙げられています。

All of the following are simple commands.

Bash Hackers Wiki – Basic Grammar Rules of Bash

お、おう…

まぁ確かにCompound Command(複合コマンド: if ...; then ..; else ..; fi など改行やセミコロンによって構成されるコマンド群)ではないのでSimple Commandではあるんですが、名前が名前だけに「Simple..うん?」と違和感を覚えてしまうものです。

コマンドの挙動

これはBash4.1以上で動作するコマンドです。標準エラー出力に "moo" が出力されます。目に見える挙動としてはそれだけです。

謎を紐解く

まず3つの単語列に分解して整理しましょう。

{x}<“$x”

{varname}<word はBash4.1から誕生したリダイレクト記法です。
このリダイレクションでは "$x" というファイルを開き、そのファイルディスクリプタを x という変数に割り当てる、という動作をしています。
"$x" はどこから来た、という話は後ほど。

_=${x=<(echo moo)}

ただの代入に見せかけて、解説することは結構あります。

<(command)

まず内側の <(echo moo) から。
これはプロセス置換(process substitution)と呼ばれるもので、カッコ内に記述されたコマンドの実行結果をファイル(/dev/fd/[number] あるいは名前付きパイプ:named pipe / FIFO)から読み取れる状態にし、そのファイル名をコマンドに渡すというものです。
したがって _=${x=<(echo moo)} は プロセス置換を展開すると、(例えば) _=${x=/dev/fd/63} のようになります。この場合 /dev/fd/63cat すると "moo" が出力されます。

${name=value}

通常シェルでは name=[value] の形式で変数宣言及び代入をします。
そしてこれはundocumented(多分)な挙動なのですが、 ${name=value} と表記することで、変数宣言・代入をすると同時に value を返します。

echo ${name=value}
echo $name

こうすると "value" が2回出力されるというわけです。

(追記:2019/12/10)投稿後に以下の情報をいただきました。

POSIXに示された表を見ると、 ${name=value}

  • $name にnull以外が代入されているとき: $name に置換する(代入は発生しない)
  • $name にnullが代入されているとき:nullに置換する(上記と変わらない)
  • $name が未定義の時: "value" を代入する( $name 置換も発生する)

となることがわかります。(追記終わり)

(追記:2019/12/11)再び情報をいただきました。

undocumentedだと思ってたら、実はちゃんと書かれているって!?
改めてマニュアルをよくみると

When not performing substring expansion, using the form described below (e.g., ‘:-’), Bash tests for a parameter that is unset or null. Omitting the colon results in a test only for a parameter that is unset. Put another way, if the colon is included, the operator tests for both parameter’s existence and that its value is not null; if the colon is omitted, the operator tests only for existence.

Bash 5.0 Manual – Shell Parameter Expansion

本当だ…!(追記終わり)

_=$var

続いてパラメータ _ (アンダースコア) についてです。
こいつはシェルの開始時かそうでないかによって意味が変わり、

  • シェル起動時:実行しているシェル(ここではBash)の絶対パス
  • 1回でもコマンドを実行した後:最後に実行したコマンドの最後の引数

と、それぞれ返す値が違います。
ちなみに _ は上書き可能ですが、コマンド実行後に仕様通りの値が上書きされます。

$ _=a eval 'echo $_'
a
$ _=a; echo $_
# 何も出ない("_=a"コマンドに引数はないから)

ここまでのまとめ

これらの仕様から、 _=${x=<(echo moo)}

  • <(echo moo) のコマンド置換で(例えば) "/dev/fd/63" が返る。
  • 置換後は _=${x=/dev/fd/63} となり、 _x の両方に "/dev/fd/63" が代入される。

という挙動をします。

<&0$(cat <&”$x” >&2)

[n]<&word[n] は数値が現れたり現れなかったりする意)の形式を取ると、リダイレクトとして認識されます。
今回は word0$(cat <&"$x" >&2) となっています。
コマンド展開が含まれているので、まずは展開の結果から見ていきましょう。

$(cat <&”$x” >&2)

ここにもリダイレクトが含まれています。
<&"$x" の挙動は $x の値によって変わり、

  • $x が数値だった場合:その番号のファイルディスクリプタを標準入力として受け取る
  • $x"-" だった場合:標準出力を閉じる(が、実際には標準出力のコピーなので何も起きない)

ようになります。

また >&2 は比較的有名とは思いますが、標準出力を標準エラー出力にリダイレクトします。
したがって、このコマンド置換部分の挙動は「 "$x" を入力として受け取り、標準エラー出力に出力する」となります。

そして重要なのは、ここで展開されるコマンドはどうやっても標準出力に一切の出力がされないということです。

ここまでのまとめ

<&0$(cat <&"$x" >&2) を全体で見ると <&0 となり、標準入力を標準入力にリダイレクトします。
つまり何も変わりません。

しかし先ほど述べたように "$x" の中身が標準エラー出力に出力されます。結果的にこちらの方がメインになりました。

リダイレクトの予備知識

The following redirection operators may precede or appear anywhere within a simple command or may follow a command.

Bash 5.0 Manual – Redirection

つまりSimple commandである限りはどこに現れようとリダイレクトの構文をなしている限りリダイレクトとして扱われます。

$ echo hello >/dev/null world
# 何も出ない
$ echo hello ">/dev/null" world
hello >/dev/null world

コマンド評価順序

ところでまだ解明されていない謎が残されています。
$x がたくさん登場しているが、どの $x に何が代入されているのか?」

左から順に読んでいくと、 {x}<"$x" の時点で詰んでしまいます。
というわけで、徹底的に(?)調べました。

処理順

そもそもBashがSimple Commandをどのように処理しているのか。
マニュアルには以下のように記されています。

When a simple command is executed, the shell performs the following expansions, assignments, and redirections, from left to right.

1. The words that the parser has marked as variable assignments (those preceding the command name) and redirections are saved for later processing.

2. The words that are not variable assignments or redirections are expanded (see Shell Expansions). If any words remain after expansion, the first word is taken to be the name of the command and the remaining words are the arguments.

3. Redirections are performed as described above (see Redirections).

4. The text after the ‘=’ in each variable assignment undergoes tilde expansion, parameter expansion, command substitution, arithmetic expansion, and quote removal before being assigned to the variable.

Bash 5.0 Manual – Simple Command Expansion

簡単に訳すと

  1. 変数代入とリダイレクトは一旦無視される。
  2. それ以外に単語が残っていれば、一連の展開(ブレース展開など)を行う。最初の単語をコマンド、残りを引数として認識。
  3. リダイレクトの処理がされる。
  4. 変数代入は、代入前に右辺にある文字列の各種展開を済ませる。

ところがどうやら、私がBashのソースコード解析をした限りでは変数代入の後にリダイレクト処理がされているようです。
というのも、リダイレクトの文字列展開処理実装は、変数宣言の影響を受けるように作られているためです。

ここで改めて1文目を見ると

When a simple command is executed, the shell performs the following expansions, assignments, and redirections, from left to right.

あ、 “from left to right” ってそういうことかぁ…!
(1. ~ 4. の同率順位の話をしているのかと思ったら、expansions, assignments, and redirectionsの話だった)

コマンド実行の流れ

  • {x}<"$x" _=${x=<(echo moo)} <&0$(cat <&"$x" >&2) を以下の3つに分ける
    • {x}<"$x"
    • _=${x=<(echo moo)}
    • <&0$(cat <&"$x" >&2)
  • この中で {x}<"$x"<&0$(cat <&"$x" >&2) はリダイレクトと認識されるので、コマンド単語列から除外される
  • _=${x=<(echo moo)} を展開し、(例えば) /dev/fd/63_x に代入される
  • リダイレクトの {x}<"$x" が評価される
    • {x}<"/dev/fd/63" となり、 x には新しく(例えば)10 が代入される
    • このとき、 /dev/fd/10/dev/fd/63 → ("moo" を出力)といったチェーンになっている
  • リダイレクトの <&0$(cat <&"$x" >&2) が評価される
    • $(cat <&"$x" >&2) のコマンド置換が展開され、 cat <&10 >&2 となる
    • "moo"/dev/fd/10 から標準エラー出力に流れる
    • このコマンド自体は標準出力に何も出力しないため、リダイレクト全体は <&0 だけになる
  • 最終的なコマンドは {x}</dev/fd/63 <&0 となり、これ自体は特に何も起こらない
  • 総合的には、 "moo" が標準エラー出力に現れるだけ

したがって、3つの文字列の配置によって正しく動くかどうかが以下のように変わります。

$ {x}<"$x" _=${x=<(echo moo)} <&0$(cat <&"$x" >&2) #動く(当初のやつ)
$ _=${x=<(echo moo)} {x}<"$x" <&0$(cat <&"$x" >&2) #動く(ナチュラルに読める)
$ {x}<"$x" <&0$(cat <&"$x" >&2) _=${x=<(echo moo)} #動く
$ _=${x=<(echo moo)} <&0$(cat <&"$x" >&2) {x}<"$x" #動かない(リダイレクトの順序が逆転している)

おわりに

Bash奥が深い…たのしい!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です