Unengineered Weblog

PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND

君はPythonでcatが実装できるか?

これは技術ブログでない。ただの日記だ。

皆様はPythonUnixのコマンドcatを実装できますでしょうか?それっぽいのではなく、ちゃんと標準の/bin/catと同じ動作するものを作ろうとすると意外と引っかかる部分が多いと思います。

まず最も単純な実装(cat1.py)を見てみましょう。

#!/usr/bin/env python3
import sys

BUFSIZE=1024

def cat(f):
    while True:
        b = f.read(BUFSIZE)
        if len(b) == 0:
            return
        print(b, end='')

def main():
    if len(sys.argv) == 1:
        cat(sys.stdin)
    else:
        for fname in sys.argv[1:]:
            with open(fname) as f:
                cat(f)

main()

これは一応catっぽいことをします。引数にテキストファイルがあればそれらを、なければ標準入力をを標準出力に書き出します(標準的なcatは引数に-を指定すると標準入力から読み取るという仕様がありますが、今回それは無視します。そもそも現代のUnixでは/dev/fd/1を指定すればいいし)。 BUFSIZEの大きさは適当に決めてしまいました。『詳細UNIXプログラミング』という書籍が参考になるかも。

これには問題点があって、Python面倒なことにバイナリファイルとテキストファイルを区別します。 だからバイナリファイルを入力すると例外を起こしてしまいます。

$ ./cat1.py /bin/sh
Traceback (most recent call last):
  File "./cat1.py", line 21, in <module>
    main()
  File "./cat1.py", line 19, in main
    cat(f)
  File "./cat1.py", line 8, in cat
    b = f.read(BUFSIZE)
  File "/usr/lib/python3.10/codecs.py", line 322, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf0 in position 24: invalid continuation byte

ファイルをバイナリモードでオープンしないといけませんね。次のプログラム(cat2.py)に書き換えます。

#!/usr/bin/env python3
import sys

BUFSIZE=1024

def cat(f):
    while True:
        b = f.read(BUFSIZE)
        if len(b) == 0:
            return
        sys.stdout.buffer.write(b)

def main():
    if len(sys.argv) == 1:
        cat(sys.stdin.buffer)
    else:
        for fname in sys.argv[1:]:
            with open(fname, "rb") as f:
                cat(f)

main()

バイナリはsys.std{in,out}.bufferを通して標準出入力します。また関数open"rb"と引数を追加してバイナリモードでopenすることを指示しなければなりません。

これでバイナリファイルも扱えるようになりました。

$ ./cat2.py /bin/sh | sha256sum
4f291296e89b784cd35479fca606f228126e3641f5bcaee68dee36583d7c9483  -
$ ./cat2.py < /bin/sh | sha256sum
4f291296e89b784cd35479fca606f228126e3641f5bcaee68dee36583d7c9483  -
$ sha256sum /bin/sh
4f291296e89b784cd35479fca606f228126e3641f5bcaee68dee36583d7c9483  /bin/sh

catが実装できた。めでたしめでたし。

と、思いきやこれで終わりではありません。 Pythonはデフォルトで標準出入力やファイルをバッファリングします。このプログラムもバッファリングしているでしょう。しかしながらGNUやらPlan 9やらのcatは出入力をバッファリングしません。Python版でもバッファリングしないように実装したいですね。

バッファリングについて解説するのは省いて、バッファリングによって/bin/catとcat2.pyが異なる動作をする例を確認してみましょう。つぎのGoのプログラム(coproc)を使います。

package main

import (
    "fmt"
    "log"
    "os"
    "os/exec"
)

func main() {
    if len(os.Args) < 2 {
        log.Fatal("no args")
    }

    c := exec.Command(os.Args[1], os.Args[2:]...)
    w, _ := c.StdinPipe()
    r, _ := c.StdoutPipe()
    if err := c.Start(); err != nil {
        log.Fatal(err)
    }

    nbuf := 100
    buf := make([]byte, nbuf)

    greeting := []byte("hello\n")
    w.Write(greeting)
    fmt.Printf("input: %q (%d bytes)\n", greeting, len(greeting))

    n, _ := r.Read(buf)
    fmt.Printf("output: %q (%d bytes)\n", buf[:n], n)

    greeting = []byte("bye\n")
    w.Write(greeting)
    fmt.Printf("input: %q (%d bytes)\n", greeting, len(greeting))

    n, _ = r.Read(buf)
    fmt.Printf("output: %q (%d bytes)\n", buf[:n], n)

    w.Close()
    if err := c.Wait(); err != nil {
        log.Fatal(err)
    }
}

このプログラムは引数に与えられたプログラムをfork/execで起動します。 coprocは起動したプロセスへパイプを通してhello\nをwriteしたら100bytesぶんreadし、またbye\nをwriteしたら100bytesぶんreadするといった単純なものです。 /bin/catと./cat2.pyで試してみましょう。

$ ./coproc /bin/cat
input: "hello\n" (6 bytes)
output: "hello\n" (6 bytes)
input: "bye\n" (4 bytes)
output: "bye\n" (4 bytes)
$ ./coproc ./cat2.py 
input: "hello\n" (6 bytes)

/bin/catでは予想通りの結果が出力されプログラムが終了しました。 しかしながらcat2.pyは出力がされずプログラムが固まってしまいました。 なぜでしょう?

writeシステムコールは重いシステムコールと呼ばれ、実行が遅いです。 ちょっとの文字列しか出力しないprintfのような関数で毎回writeシステムコールを呼んでいたらプログラムは遅くなってしまいます。 だからPythonsys.std{in,out}.bufferやCのstdio.hは出力するデータをバッファに一時的に貯め、まとまった量を一気にwriteします。 これをバッファリングといいます。 ちなみにCのstdio.hではデータを貯める量(バッファーサイズ)をBUFSIZとして定義してあります。 readシステムコールも同様です。

すると上記の./coproc ./cat2.pyが固まってしまった理由がわかるでしょう。 coprocからパイプを通して書き込まれた6bytesのhello\nはcat2.pyのバッファーサイズよりも小さいですから、cat2.pyはwriteせずにバッファに貯めます。 その状態でcoprocがパイプをreadしても読み込むものはありません。 通常この状態になるとreadシステムコールはプログラムをブロックします。 これによってcoprocは固まってしまったのです。

一方通常の/bin/catはバッファリングしていないですから、readしたらすぐにwriteします。 catはデータの加工をしませんから、まとまった量を一気にreadして、そのままwriteしちゃえばいいんです。 この点においてcatは一行ずつreadして加工をして一行ずつwriteする他のプログラム(grepやらsedやら)と異なります。

話をもとに戻しましょう。バッファリングしない完成版Python版cat(cat3.py)は次のようになります。

#!/usr/bin/env python3
import sys

BUFSIZE=1024

def cat(f):
    while True:
        b = f.read(BUFSIZE)
        if len(b) == 0:
            return
        sys.stdout.buffer.raw.write(b)

def main():
    if len(sys.argv) == 1:
        cat(sys.stdin.buffer.raw)
    else:
        for fname in sys.argv[1:]:
            with open(fname, "rb", buffering=0) as f:
                cat(f)

main()

sys.std{in,out}.bufferの代わりにsys.std{in,out}.buffer.rawを通して入出力します。 またopenの引数にbuffering=0が追加しました。 これをcoprocで起動してみましょう。

$ ./coproc ./cat3.py 
input: "hello\n" (6 bytes)
output: "hello\n" (6 bytes)
input: "bye\n" (4 bytes)
output: "bye\n" (4 bytes)

ようやく/bin/catと同じ動作になりました。 Pythonでcatを実装するのも簡単ではないですね。 バッファリングやらread/writeシステムコールの知識も必要だし、これまでsys.std{in,out}.buffer.rawなんか知らなかったし、Pythonも難しいですね。