これは技術ブログでない。ただの日記だ。
皆様はPythonでUnixのコマンドcatを実装できますでしょうか?それっぽいのではなく、ちゃんと標準の/bin/catと同じ動作するものを作ろうとすると意外と引っかかる部分が多いと思います。
まず最も単純な実装(cat1.py)を見てみましょう。
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)に書き換えます。
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システムコールを呼んでいたらプログラムは遅くなってしまいます。
だからPythonのsys.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)は次のようになります。
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も難しいですね。