zshでパイプ後の標準入力を利用したコマンド置換でハマった。

コマンド置換(echo $(command)など)がbashではうまくいくけどzshでうまくいかないことがあって、なんでだろうなーとしばらく思っていたのが今日解決したので書いておきます。

ハマったところ

zshでDocument以下の適当なtxtファイルについてディレクトリ名を表示させようと以下のコマンドを書いたところ、

$ find ~/Documents -name "*.txt" | tail -n 1 | dirname "$(cat)"
cat: -: 入力/出力エラーです
.

となって上手く行かない。bashだとちゃんと上手くいく。

$ bash
$ find ~/Documents -name "*.txt" | tail -n 1 | dirname "$(cat)"
/home/atsushi/Documents/thesis/fig
$ exit
exit

簡単にした例として以下のコマンドを試してみる。

$ echo test | echo $(cat)
cat: -: 入力/出力エラーです

やっぱり上手く行かない。どうもパイプ後の標準入力を利用したコマンド置換が上手くいっていないようだということが分かった。

原因

zshとbashだとパイプ越しのコマンド置換での/dev/stdinの指してる先が違うということを知った。これは @systemctl_ryoto さんに教えてもらいました(ありがとうございました…!)。

以下の画像は左がzsh、右がbashで試したもの。readlink -f シンボリックリンク はシンボリックリンクを再帰的に遡って元のファイル名を表示してくれる。

対策

$ echo test | xargs echo
test
$ echo 2 | xargs -I{} echo 1 {} 3
1 2 3

これも @systemctl_ryoto さんにアイデアをいただきました。ありがとうございました。

追記1

zshのプロセス置換についても同様のことが起きることが分かった。

~ $ # bash
~ $ readlink -f /dev/stdin
/dev/pts/0
~ $ echo | cat <(readlink -f /dev/stdin)
/proc/10914/fd/pipe:[64248]
~ # zsh
~ readlink -f /dev/stdin
/dev/pts/0
~ echo |  cat <(readlink -f /dev/stdin)
/dev/pts/0

また、パイプ先が複合コマンドの場合はうまくいくらしい。@angel_p_57さんが色々調査してくださりました。(勉強になりました…!)

以下の回避策も可能だということが分かった。

$ echo 2 | ( echo 1 $(cat) 3 )
1 2 3
$ echo 1 | { cat <(cat) }
1

追記2

この問題について言及している質問を見つけた。

質問についていた回答を鵜呑みにするとzshはなるべく(Preferablyの訳)コマンド置換をしてから入出力の供給がされるようなので、echo | echo $(cat)のcatで使う標準入力はパイプ元ではなくターミナルの入力を向いてしまうらしい。

回答ではcatで入出力エラーが出る理由についても言及しているが、これは正直私自身の勉強不足で理解できていない。

またmultiosオプションについても言及している。で、このmultiosが何なのかというとzshのリダイレクションのマニュアル7.2 Multiosの項に書いてあって、複数リダイレクションを記述するとそれぞれに内容をコピーしてくれるらしい(パイプも暗黙的なリダイレクションだと言っている)。

zshは下のようなことができるようだ(知らなかった)。

~/workspace echo a >b.txt >c.txt
~/workspace cat b.txt c.txt # bashではb.txtは空になっている
a
a
~/workspace cat <b.txt <c.txt # bashではc.txtしか表示されない
a
a
~/workspace echo a >a.txt | cat # bashではa.txtには出力されるがターミナルには何も表示されない
a
~/workspace cat a.txt
a
~/workspace echo d >d.txt
~/workspace cat c.txt | cat <d.txt # bashではd.txtの内容しか表示されない
a
d

追記3

コマンド置換はbashでは子プロセスで、zshでは自身のプロセスで行われるらしい。プロセス置換も同様。

~ $ # bash
~ $ $(ps f >&2)
  PID TTY      STAT   TIME COMMAND
11661 pts/0    Ss+    0:00 -bash
11943 pts/0    S+     0:00  \_ -bash
11944 pts/0    R+     0:00      \_ ps f
~ $ cat <(ps f)
  PID TTY      STAT   TIME COMMAND
11661 pts/0    Ss     0:00 -bash
11945 pts/0    S      0:00  \_ -bash
11947 pts/0    R      0:00  |   \_ ps f
11946 pts/0    S+     0:00  \_ cat /dev/fd/63
~ # zsh
~ $(ps f >&2)
  PID TTY      STAT   TIME COMMAND
 2989 pts/0    Ss+    0:00 -zsh
 4935 pts/0    R+     0:00  \_ ps f
~ cat <(ps f)
  PID TTY      STAT   TIME COMMAND
 2989 pts/0    Ss     0:00 -zsh
 4949 pts/0    R      0:00  \_ ps f
 4950 pts/0    S+     0:00  \_ cat /proc/self/fd/11

おや、と思ったのはサブシェル(( list ))の挙動。zshは子プロセスでは起動していない。それでも想定通りの結果が得られているので特に問題はない。

~ $ # bash
~ $ unset hoge
~ $ ( hoge=1; cd ~/workspace; ps f; )
  PID TTY      STAT   TIME COMMAND
12635 pts/0    Ss     0:00 -bash
12677 pts/0    S+     0:00  \_ -bash
12678 pts/0    R+     0:00      \_ ps f
~ $ echo $hoge

~ $ { hoge=1; cd ~/workspace; ps f; }
  PID TTY      STAT   TIME COMMAND
12635 pts/0    Ss     0:00 -bash
12679 pts/0    R+     0:00  \_ ps f
~/workspace $ echo $hoge
1
~/workspace $ echo $$
12635
~ # zsh
~ unset hoge
~ ( hoge=1; cd ~/workspace; ps f; )
  PID TTY      STAT   TIME COMMAND
 7762 pts/0    Ss     0:00 -zsh
 9555 pts/0    R+     0:00  \_ ps f
~ echo $hoge

~ { hoge=1; cd ~/workspace; ps f; }
  PID TTY      STAT   TIME COMMAND
 7762 pts/0    Ss     0:00 -zsh
 9579 pts/0    R+     0:00  \_ ps f
~/workspace echo $hoge
1
~/workspace echo $$
7762

また、zshではパイプラインの最後がサブシェルになっていないようだ。これも知らなかった。

~ $ # bash
~ $ unset HOGE
~ $ echo | HOGE=1
~ $ echo $HOGE

~ # zsh
~ unset HOGE
~ echo | HOGE=1
~ echo $HOGE
1