パスワードを忘れた? アカウント作成
41410 journal

okkyの日記: リダイレクションの説明が難しい例 21

日記 by okky

リダイレクションの説明が難しい… で教えてもらったURL:
http://x68000.q-e-d.net/~68user/unix/pickup?%A5%EA%A5%C0%A5%A4%A5%EC%A5%AF%A5%C8
ですが。

ここにある説明だと次のような例について説明がつきません。詳解してくれたstehanさんは、このサイトの説明とは「無関係に」動作原理はわかっているようなのですが、逆にそのせいで初心者が何で引っかかるのか判らないようなので。そこらへんを強調する例を:

.

次の2つの例を考えてください。

例1) command1 2>&1 > file
例2) command1 2>&1 | command2

このとき、command1 の stdout と stderr はそれぞれどこに繋がっているのかを考えます。

例1) command1 2>&1 > file

stdout: file
stderr: /dev/tty

例2) command1 2>&1 | command2

stdout: comamnd2 の stdin
stderr: command2 の stdin

質問: 例1の場合、stdout とstderr は「異なる対象」に接続しました。しかし、例2 の場合 stdout と stderr は「同じ対象」に接続しました。例1と例2の違いは >file か |command2 かの違いだけです。なのにこのような違いが発生した、その理由を説明しなさい。ただし:

条件1) 「そういうもんだから」は無し。
条件2) 内部構造を説明するのも、なし。
条件3) 本当の動作を厳密に説明する必要は無い。
条件4) 初心者が納得するような説明にしなさい。例などを使うのは当然、あり。多少の嘘も許そう。

この議論は、okky (2487)によって ログインユーザだけとして作成されたが、今となっては 新たにコメントを付けることはできません。
  • 例1) command1 2>&1 > file

    2>&1
    stderr の出力を stdout が示す出力へ切り替えた
      stderr: /dev/tty
      stdout: /dev/tty

    > file
    stdout の出力を file へ切り替えた
      stderr: /dev/tty
      stdout: ./file

    例2) command1 2>&1 | command2
    stderr の出力を stdout が示す出力へ切り替えた
      stderr: /dev/tty
      stdout: /dev/tty

    | command2
    command2 の stdin を command1 の stdout に切り替えた

    • それだと「|command2 の結果得られるのは
          stdout: command1 のstdin
          stderr: /dev/tty
      にならないのはなぜ??!」
      となりますよ?だって、stderr はあくまでも「stdout と同じ…って事は /dev/tty に」切り替わったんだよね??

      例2) command1 2>&1 | command2
      stdout: comamnd2 の stdin
      stderr: command2 の stdin


      と合致しません。

      .

      この問題を解くには、事象を「追う」のでは駄目です。パイプやリダイレクションを判りやすいモデルにマッピングして説明する必要があります。

      「事象を追いかけられる」って事は頭の中にモデルがあるって事ですが、初心者にはそのモデルが無いのです。モデルを「作ってあげなくてはいけない」。ここを理解しないと「初心者がなぜはまるのか判らない」という状態に陥ります。そのモデルの説明からスタートしましょう。
      --
      fjの教祖様
      親コメント
    • 例2の場合、時系列順では、先ず

      | command2
      (command1とcommand2とを繋ぐpipeを生成し、command2のstdinをpipeの出口から読むsub shellと、command1のstdoutをpipeの入口に書くsub shellと、を起動。)

      で、次いで

      2>&1
      (command1のsub shellで、stderrの出力をstdoutへ切り替える。)

      では?
      親コメント
      • 時系列順では、先ず

        まぁ、その辺はうそが入っていても構わない(判りやすければ)というルールを適用するとして。

        おもわず実験してしまいましたよ。

        % cat test.sh
        #!/bin/bash
        cat 2>&1 | tee afo
         
        %strace -f -o strace.log -i -ttt -T ./test.sh
        ^D
        % nl strace.log > strace-num.log
        で、strace-num.log を見てみました。
        # 行番号が無いと、前後関係がわからなくなるので nl で番号を打ってから読むことにした。

        おー。

        ややこしいところを全部除去すると

        1) まず、pipe(3, 4)
        2) 次に fork (Linux上だったので、実際には clone ) 2連発。片方を Process 1 (P1)、もう一方を Process 2 (P2) としよう。

        3) P1: dup2 (4, 1), close(4) これで上記 pipe の一方が 1 に
        4) P1: dup2 (1, 2)
        5) P1: cat になれ~~

        6) P2: dup2 (3, 0), close(3) これで上記 pipe のもう一方が0に
        7) P2: tee になれ~~~

        と言うわけで、先に | してから 2>&1 ですね。
        --
        fjの教祖様
        親コメント
        • bashでは、bash.1

          Pipelines

          A pipeline is a sequence of one or more commands separated by the character |. The format for a pipeline is:

          [time [-p]] [ ! ] command [ | command2 … ]
          The standard output of command is connected via a pipe to the standard input of command2. This connection is performed before any redirections specified by the command (see REDIRECTION below).

          と書いてあるので、「多少の嘘」もつかずに「先ずパイプ、次いで左側からリダイレクト」と叩き込むのが無難と思います。

          # 未公開でレビュー中ならば「パイプ及びリダイレクトは右側から」仕様に直すのが良いのでせうが、今更は無理でせう。
          親コメント
        • それは仕様ですか? 実装ですか?

          という部分がくだらない、と感じた理由なんですよね、失礼を承知でいうと。
          そのような順序で解釈される、と規定されているのなら「そういうもの」とする以外になく、その上で
          そういうものとなるよう設計された理由は何かという話から説明するべきでしょうし、
          そうでなければシェルの一実装におけるローカルルールをそこまで追求する価値って何なんでしょうと。
          もっとも、仮に本当にローカルルールだったとしても、
          もはや今さら避けては通れないスタンダードとなってしまっているのかもしれませんが。

          もうひとつ、先週は分かったつもりになっていたけど、
          いろいろ試すとやはり怪しい理解しかできていなかったことに気がつきました。
          ごく簡単なことだ、などと生意気なことを書いてすみませんでした。
          親コメント
          • それは仕様ですか? 実装ですか?

            という部分がくだらない、と感じた理由なんですよね、失礼を承知でいうと。

            若い証拠ですな。いい意味で、ですが。

            全てのソフトウェアは、まず「仕様」があり、ついでそれに矛盾しない範囲での「実装」があります。
            この逆の順序はありえませんし、あってはいけません。

            なぜなら、使う人は「どうやって実装されているのか」ではなく「どういう仕様になっているのか」に従って使っているからです。逆にその仕様の範囲内であれば、どのように実装しても構わない。

            しかし sh のように、あるコミュニティの黎明期に作られたものは「実装」が先にあってそれに合わせて「仕様」を作っていきます。これはもう defacto standard の悪い一面としか言いようが無い。

            問題は「実装から仕様を作る」時に、正確に仕様化できなかった時に生じます。仕様に実装を合わせられればいいんですが、POSIXのように「全体を見ると自己矛盾している」場合はどうやっても実装は仕様に合致しません。その場合は、『このソフトではこういう仕様にします』と言うのを先に作らなくてはいけない。仕様の段階で自己矛盾がない事を確認しなくてはいけない。

            .

            でないと何がバグで何はバグではないのかを知る術がなくなります。利用者も、作る側も。

            利用者のその場その場の要求に従っていると、ソフトウェアは見る見るうちにカオスになっていきます。
            「今日は右から左にパースして欲しい。」
            「明日は左から右にパースして欲しい。」
            「午前中は & を無視して欲しい。」
            という要求は(若いうちは信じられないかもしれませんが)当たり前のように発生します。なにしろ Unix の重鎮とその道のプロが集まって作ったはずの POSIX が『80%以上準拠する事は絶対不可能な自己矛盾の塊』になるぐらいですから。

            なので、「なにが仕様なのか」、「何は実装に過ぎないのか」、「何はバグなのか」、「何はバグではないのか」は慎重に見極める癖をつけなくてはいけません。また、人に教えるときもこれらをきちんと踏まえなくてはいけません。その上で、「仕様だけで自己矛盾を起こしていない」事を確認する能力を養わなくてはいけない。それが出来ない人はプログラマではなく、ただのコーダーです。

            .

            ちなみに。この辺を深く考えなかった有名人に、「z80を開発した 嶋正利さん」がいます。
            彼はプロセッサのデザインもしましたが、その上で動くプログラムも一杯書いています。
            が、彼のコードはその殆どが z80の高速版では動きませんでした。

            彼は z80 の仕様書をちゃんと書いていたのですが、彼のソフトはその仕様書を無視して「電気回路的に考えて何が起こるのか」を元にビットパターンを並べたものだったからです。z80を高速化した際、回路が何箇所も最適化されたのですが、当然その結果として「仕様外」だった彼のコードはことごとく動作しなかった。ザイログ社はこの書き直しにえらい手間を食ったといいます。当人も何がどうなっているのかよく判らない部分がたくさんあったそうですし。

            .

            歳を取ると、若かったときに「3年ぐらいでなくなるんじゃないか」と思い込んでいたコードやスクリプトがいまだに生きている事を知らされて愕然とする事がよくあります。『やべっ、あの辺りコンパイラ依存にしてるよ』的な心当たりが秘かに増えるわけです (^^;)。

            なので、仕様と実装は分けるとか、可能な限り「仕様に準拠した形で」説明をするとか、そういうことに口うるさくなるのです。逆にここが判っているかどうか、がプログラムデザイナーとしての能力を測る際の、私が重きを置いている基準の1つです(つーか最重要視しています)。
            --
            fjの教祖様
            親コメント
  • by k3m (12461) on 2008年11月27日 17時11分 (#1462944) 日記
    説明難しいですね。

    ---------------------------------------
    ○前提
    1. コマンドのデータ出口(であるstdoutとstderr)は"管"で出力装置とつながっており、stdout/stderr -> 出力装置の方向にデータが流れます。
    2. コマンドのデータ出力は1つの管しかつなげることができません。
    3. "管"は入口の数を増やして、複数のものをつなげることができます。

    ○初期状態
    1. command の1番出口(stdout) は "stdout管"という管を使用して
      /dev/tty につながっています。
    2. command の2番出口(stderr) は "stderr管"という管を使用して
      /dev/tty につながっています。

    ○例1) command1 2>&1 > file の場合
    1. "2>&1" を実行すると、command1 の 2番出口(stderr)は、1番出口(stdout)
    が使用している"stdout管"を使用します。
    具体的には、"stdout管"の入口を1つ増やして
    2つにして、2番出口(stderr)を"stdout管"の入口につなぎます。

    2. "> file"を実行すると、command1 の 1番出口(stdout)から file に向かって、
    新しく管("file管"と命名)が作られます。
    "stdout管"の入口はcommand1 の2番出口(stderr)がつながっているのみとなります。

    3. したがって、command1 の 1番出口(stdout)は"file管"を通して、 file につながり、
    2番出口(stderr)は"stdout管"を通して、 /dev/tty につながることになります。

    ○例2) command1 2>&1 | command2 の場合
    1. "2>&1" を実行すると、command1 の 2番出口(stderr)は、1番出口(stdout)
    が使用している"stdout管"を使用します。
    具体的には、"stdout管"の入口を1つ増やして
    2つにして、2番出口(stderr)を"stdout管"の入口につなぎます。

    2. "| command2"を実行すると、command1 の "stdout管"の出口をcommand2 の0番入口(stdin)につなぎます。

    3. したがって、command1 の1番出口(stdout)と2番出口(stderr)は"stdout管"を通して、
    command2の0番入口(stdin) につながることになります。
    • 前提の訂正です。


      2. コマンドのデータ出力は1つの管しかつなげることができません。


      2. コマンドのそれぞれのデータの出口は1つの管しかつなげることができません。
      親コメント
      • ついでにもう一つの例を追加します。

        ○例3) command1 > file 2>&1 の場合
        1. "> file"を実行すると、command1 の 1番出口(stdout)から file に向かって、
        新しく管("file管"と命名)が作られます。

        2. "2>&1" を実行すると、command1 の 2番出口(stderr)は、1番出口(stdout)
        が使用している"file管"を使用します。
        具体的には、"file管"の入口を1つ増やして
        2つにして、2番出口(stderr)を"file管"の入口につなぎます。

        3. したがって、command1 の 1番出口(stdout)と2番出口(stderr)は"file管"を通して、 file につながることになります。
        親コメント
      • 多分、もう一つ条件として
        • 管は1つしか出口はありません

        を追加したほうがよいかと(今回の例では不要でもどうにかなりますが)。

        2. "| command2"を実行すると、command1 の "stdout管"の出口をcommand2 の0番入口(stdin)につなぎます。

        やはりこれに収斂するんでしょうね。

        『> は管の「入口」を、 | は管の「出口」を操作しているのだ』

        と「管」に対する操作を中心とした説明にしないと、説明が難しい。
        --
        fjの教祖様
        親コメント
        • 補足感謝です。

          書いていて思ったのですが、この説明を聞いた初心者が、
          エラー出力だけをパイプで別プロセスに流そうとして、

          command1 2| command2
          と書いて失敗してなぜなんだろうと思いそうです。

          #実際には command1 2>&1 > $SSH_TTY | command2 のように書かないといけない?

          親コメント
  • 仮に自分がホワイトボードの前に居て、目の前にいるのが弊社の1~3年坊主だとすると、こういう説明をすると思います (要求されているものとは違うかもしれませんが)。

    ルール

    • stdout, stderr は出力する先のファイルを内部に持っている (初期値は端末。 但し、各々の「端末」は論理的に別物)
      イメージ
      stdout => stdout の出力先 (初期値は端末)
      stderr => stderr の出力先 (初期値は別の端末)

    • | (パイプ) は左側のコマンドの stdout と右側のコマンドの stdin をパイプで連結する
      イメージ
      左 stdout => 元々の出力先 (初期値は端末(出力))
      右 stdin  <= 元々の入力元 (初期値は端末(入力))
       ↓
      左 stdout => パイプ
      右 stdin  <= パイプ

    • > file (リダイレクション) は、stdout が内部に持っている出力先を file に置き換える。 元々 stdout が持っていた出力先は放棄される
      イメージ
      stdout => 元々の出力先
       ↓
      stdout => 元々の出力先 新しい出力先

    • 2>&1stdoutstderr にコピーする
      イメージ
      stdout => 元々の stdout の出力先 (初期値は端末)
      stderr => 元々の stderr の出力先 (初期値は別の端末)
       ↓
      stdout => 元々の stdout の出力先
      stderr => 元々の stdout の出力先 (複製)

    • 1>&2stderrstdout にコピーする
      イメージ
      stdout => 元々の stdout の出力先 (初期値は端末)
      stderr => 元々の stderr の出力先 (初期値は別の端末)
       ↓
      stdout => 元々の stderr の出力先 (複製)
      stderr => 元々の stderr の出力先

    まずはこれ↑を踏まえて戴いて、

    例1) command1 2>&1 > file

    この例では、

    1. command1 2>&1
      • stdout: 変わらず。 出力先は端末のまま
      • stderr: stdout のコピーになる。 出力先は (stdout が指していた) 端末に変わる
    2. > file
      • stdout: 元々の出力先だった端末は放棄され、出力先が file に変わる
      • stderr: 出力先は (stdout の出力先だった) 端末のまま変わらない

    よって、command1 の標準出力は file へ、command1 のエラーメッセージは端末へ出力される。

    一方、

    例2) command1 2>&1 | command2

    この例では、

    1. command1 2>&1 | command2
      • command1 2>&1 全体の stdout: command2 へのパイプにつながる
      • command1 2>&1 全体の stderr: 変わらず。 出力先は端末のまま
    2. command1 2>&1
      • command1 の stdout: 変わらず。 出力先は command2 へのパイプのまま
      • command1 の stderr: stdout のコピーになる。 出力先は (stdout の出力先である) command2 へのパイプに変わる

    よって、command1 の標準出力、エラーメッセージ共に command2 へ送られる。

    パイプをリダイレクションよりも先に評価するのがポイントですね。

    後は上述のルールを元に、

    • rm -f a; ls a . >file 2>&1
    • rm -f a; ls a . 2>&1 >file

    の動作がどう違うか自力で説明せよ、という課題を出すと思います。 結局、口やら図やらでどう説明しようが、自力で演習させないと分かってくれないことが多いので。

    • パイプをリダイレクションよりも先に評価するのがポイントですね。

      これは現象としては正しいのですが、説明としては良くない、と私は思っています。
      というのは、シェルに関するものの本はほぼ全て「行は全て左から右に評価される」とあるからです。

      もちろん、「評価する順序」と「その結果を実装するために処理が行われる順序」は違って構わないのですが、少なくとも評価する順序として与えられた、その通りの順序で評価して、最終結果がどうなるのかを説明できないのでは、教わる側は混乱すると思います。
      --
      fjの教祖様
      親コメント
  • こんな感じであってますかね?
    ファイルディスクリプションを箱に、
    openを、箱を作り、パイプをつなぎかえる動作に、
    dup2を、(箱を作らずに)パイプを別のパイプと同じ箱につなぎかえる動作に対応させているつもりです。
    closeまわりは適当です。

    command1 > file 2>&1
    最初、0〜2のパイプは、このように箱につながっています
    0 - [ /dev/tty ]
    1 - [ /dev/tty ]
    2 - [ /dev/tty ]


    > file で、fileの箱を新しく作り、パイプを新しく作った箱につなぎ変えます
    0 - [ /dev/tty ]
      [ /dev/tty ]
    2 - [ /dev/tty ]
    1 - [  file  ]


    2>&1 は、2を1と同じ箱につなぎ変えます。
    リダイレクトがm>&nの場合、mのパイプはnのパイプと全く同じ箱と接続されます。
    (このとき、新しい箱は作られません)
    0 - [ /dev/tty ]
      [ /dev/tty ]
      [ /dev/tty ]
      ___________
    1 - |  file  |
    2 - |__________|


    誰もつないでいない箱を消して整理すると
    0 - [ /dev/tty ]
      ___________
    1 - |  file  |
    2 - |__________|

    のようになります。

    command1 2>&1 > file
    の場合だと、
    2>&1 で2を1と同じ箱につなぎ
    0 - [ /dev/tty ]
      ___________
    1 - | /dev/tty |
    2 - |__________|

      [ /dev/tty ]


    > file で、新しくfileの箱を作り、1をそちらにつなぎ替え、
    0 - [ /dev/tty ]
    2 - [ /dev/tty ]
      [ /dev/tty ]
    1 - [  file  ]

    整理して
    0 - [ /dev/tty ]
    1 - [  file  ]
    2 - [ /dev/tty ]

    となります。

    command1 | command2
    は、command1 の 1 を、command2 の 2 に接続します。
    command1の0 - [ /dev/tty ]
    command1の1 - [ /dev/tty ] - command2の0
    command1の2 - [ /dev/tty ]

    command1 2>&1 | command2
    の場合、command1は
    command1の0 - [ /dev/tty ]
            ___________
    command1の1 - | /dev/tty |
    command1の2 - |__________|

           [ /dev/tty ]

    のように接続されていることになります。
    1をcommand2の0につなぎ、整理すると
    command1の0 - [ /dev/tty ]
            ___________
    command1の1 - | /dev/tty | - command2の0
    command1の2 - |__________|

    となります。
    --
    1を聞いて0を知れ!
    • 自己レスです。ファイルディスクリプションなどという意味不明な単語を使っていました。
      単語そのものすら間違っているのに、概念を理解できているとは到底思えませんので、勉強しなおしてきます。
      --
      1を聞いて0を知れ!
      親コメント
    • おー、面白いです。

      1をcommand2の0につなぎ、整理すると

      command1の0 - [ /dev/tty ]
              ___________
      command1の1 - | /dev/tty | - command2の0
      command1の2 - |__________|
      となります。

      一つ問題が。

      この最後の例の最後。/dev/tty には command1 の 1 と 2 の出力内容が「見える」様な気がする…。

      全体として「箱」の中身が同じなのに「1つ」でつながったり「2つ」で繋がったりできるのはなぜ??しかも同じ内容を持った「箱」が何個もあるように見えるんだけれど…という辺りについて、前提説明をしっかりする必要がありますね。

      一つのファイルを複数のプログラムが同時に操作する、と言うことはありえる状態ですので、そちらのほうの説明からパイプ/名前付きパイプへと話を展開していく、という順序を取ると説明しやすい気がします。

      --
      fjの教祖様
      親コメント
      • たしかに見えちゃいそうですね。

        どうすりゃいいか考えてみたら、

        command1 の 1 を、command2 の 2 に接続します。
        がそもそも間違っていることに気づきました。
        command1 2>&1 >/dev/null | command2
        の説明がおかしくなりますので。

        eiさんが言っているように、|を先につないでしまった方がよさそうですね。

        その場合、
        command1 | command2
        は、
        command1の0 - [ /dev/tty ]
        command1の1 - command2の0
        command1の2 - [ /dev/tty ]
        となります。

        これだと、見えちゃうこともないかと。
        さらに、command2の0と箱に入ったファイルは同列に扱えると気づいてくれたりすると、話の展開もしやすそうです。
        同じ箱が複数個あることや、つなぐ数が変えられちゃうことに疑問を持ってくれる人なら、きっと気づいてくれるでしょう。
        --
        1を聞いて0を知れ!
        親コメント
  • リダイレクトが後付けなのは、以下でもわかりますが、これはどう説明しましょうか?

    $ 1> file command

    具体的には

    $ 1> a.txt ls -l

    みたいな。
    • export LANG=C make

      とか、この辺の記述の柔軟さは勘弁して欲しいときがありますね。

      $ 1> a.txt ls -l

      実際に処理する順番としては fork をまずやって、close 1, 1=open("a.txt") をしてから exec("ls -l") なのはいいんですが、どうして
      「1> という名前のコマンドじゃない、と判るのか(ファイルとしてはそういう名前のファイルは作れるし、chmod +x もできるのに)」
      とか、いっぱいザワザワしますよね。

      この辺のザワザワ感が、csh, sh 論争が宗教論争になる一端だったりしますし、私が最後には csh/tcsh 派である理由だったりします。
      # いえ、cshにだってザワザワ感は一杯あるんですが…

      .....

      とりあえず、最初は教えない。

      多分、named pipe を教える辺りで、「名前のあるパイプ」「名前の無いパイプ」ってどういう意味? 辺りから入りなおすときに、| が実際は何をしているのか、という辺りの一環として教えると思います。
      --
      fjの教祖様
      親コメント
typodupeerror

※ただしPHPを除く -- あるAdmin

読み込み中...