Python 2系と3系のsys.stdinの違い

2018-08-28

早速ですが、次のコードを見てください

stdin_loop.py
1
2
3
4
5
import sys

for x in sys.stdin:
    sys.stdout.write('stdin: {}'.format(x))
    sys.stdout.flush()

これをPython 2.7と3.6でそれぞれ実行してみると、それぞれ次のような結果になります。

Python 2.7での実行結果
hoge
fuga
^D
stdin: hoge
stdin: fuga

Python 3.6での実行結果
hoge
stdin: hoge
fuga
stdin: fuga
^D

hogefugaは入力で、^DはCtrl+Dを入力してEOFを送っていることを意味しています。

結果を見ると2系と3系の違いは明らかで、3系は1行ずつ処理されているにも関わらず、2系ではEOFが送られるまで(正確には一定の大きさのバッファがいっぱいになるまで)処理が始まりません。 インタラクティブなプログラムを作ろうと思って、2系で上記のコードを書いてしまうと、処理が進まずに、大変困ったことになります。

解決策

先に解決策を書いておくと、Python 2系においては以下のようにして標準入力を受け取りましょう。

for x in iter(sys.stdin.readline, ''):
    ...

なぜこの問題が発生するのか

ここから先はcpythonのコードを徐々に深くまで見ていきます。

Python 2.7にはcpythonの2.7のブランチの現時点でのHEADを、Python 3.6には3.6のブランチの現時点でのHEADをそれぞれ見ていきます。興味のある人だけお付き合いいただければ。

まずはsys.stdinの定義を見てみる

1
sysin = PyFile_FromFile(stdin, "<stdin>", "r", NULL);
1
2
std = create_stdio(iomod, fd, 0, "<stdin>", encoding, errors);

Python 2.7で呼び出しているPyFile_FromFile関数は、後で少しだけ内部実装を読んでいきますがfileオブジェクトを作るための関数です。

一方、Python 3.6では、create_stdio関数を呼び出しています。これはどこに定義されているかというと、すぐその上です。

さて、この関数の中身を見てみると、至る所で_PyObject_系の関数が呼び出されていますね。 この関数の戻り値streamは、pylifecycle.c#L1151-L1153で作られている、io.TextIOWrapperです。 つまり、Python 2.7と3.6ではsys.stdinのクラスからして、そもそも違うことがわかります。

さて、この関数の中身はだいたい_PyObject_を呼び出しているだけなので、Pythonコードでも、それなりに再現することができます。 sys.stdinを作るときのcreate_stdio関数は次のようなコードで表すことができます。

Python 3.6のstdinをPythonコードで再現してみる
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import io
import sys

def create_stdin():
    buf = io.open(sys.stdin.fileno(), 'rb', -1, None, None, None, 0)
    raw = buf.raw
    raw.name = '<stdin>'
    stream = io.TextIOWrapper(
        buf, sys.stdin.encoding, sys.stdin.errors, sys.stdin.newlines,
        sys.stdin.isatty())
    stream.mode = 'r'
    return stream

stdin = create_stdin()
for x in stdin:
    sys.stdout.write('stdin: {}'.format(x))
    sys.stdout.flush()

実行結果は次のようになります。

Python 2.7での実行結果
hoge
stdin: hoge
fuga
stdin: fuga
^D

Python 3.6での実行結果
hoge
stdin: hoge
fuga
stdin: fuga
^D

ご覧の通り、Python 2.7でも意図した通り1行ずつ処理されるようになりました。

ここまでをまとめると次の通りです。

余談: 実はopenも仕様が異なる

普段何気なく使っているopen関数(組み込み関数の1つです)、実は2系と3系で実体が違うということをご存知ですか?

私はつい最近まで知りませんでした。

具体的には、Python 2系ではfileオブジェクトを作って返しているのに対して、Python 3系ではioモジュールのオブジェクトを返すようになっています。

手軽にそれを実感するには、以下のコードを2系と3系両方で実行してみると良いでしょう。

1
open.__module__

next(FileObject)next(io.TextIOWrapper)の違いを見てみる

では、なぜこのような差が生まれるのか、詳しく実装の差を見ていきましょう。

Python 2.7: next(FileObject)の概要

2.7の実装はだいぶ単純です。

まずPyFile_Typeの定義を見ればわかる通り、next(FileObject)が呼び出されると、file_iternext関数が呼び出されます(fileobject.c#L2344)。

そのなかで、readahead_get_line_skip関数を呼び出し(fileobject.c#L2299)、さらにそのなかでreadahead関数(fileobject.c#L2262)を呼び出し、さらにそこからPy_UniversalNewlineFread関数を呼び出すことで、内部に持っているFILE *から読み進めようとします。(fileobject.c#L2860)

さて、ここまで来ればお分かりかと思いますが、結局内部的にはbufferedなfread関数を呼び出しています(fileobject.c#L2875)。 これなら戻ってこないのも納得です。

ちなみに、nの値はREADAHEAD_BUFSIZEとして定義されている8192です。

これがなぜ未だに直っていないかというと、これは個人的な印象ですが、sys.stdinの仕様(というか実体)を変えずに、既存のコードへの影響が少ない対策を取る方法が難しいのではないのかなという気がしています。

というのも、FileObjectstdin以外でも多くの場所で使われており、今回の問題を解決しようとすると、iterの挙動が変わってしまう(もしくはstdinの時だけiterの挙動を変えるという微妙なコードを入れる必要が出てしまう)ためです。

ところで、Python 2系のsys.stdin.readlineは改行で正しく動作しますよね。 こちらはどのような実装になっているかというと、内部的にはget_line関数(fileobject.c#L1426)を呼び出していて、その中では実質getc関数を連続的に読んで、改行までループを繰り返しています(ちょっとかわいいですね)。

Python 3.6: next(io.TextIOWrapper)の概要

2.7とは異なり、3.6はちょっと追うのが大変です。まず、ioモジュール以下のクラス階層をざっくりと把握しておく必要があります。

ここからは入力処理を行っている部分までを説明するのは少々大変なので、この関数へのコールスタックだけ張っておきます。

ようやく_Py_readまでたどり着けました。

この関数のなかで入力を受け取っているのは、実質fileutils.c#L1386にあるread関数です。 ちなみに、1行の入力サイズがバッファーを超えてしまうような場合、_textiowrapper_readlineのなかにあるループまで戻って、再度1行の続きを読むことになります。

おわりに

ちょっと長くなりましたが、内部の実装的にこんな感じでPython 2系と3系のnext(sys.stdin)の挙動は違っている感じです(どこか読み間違えているかもしれません)。 当たり前ですが、Python 2系と3系は内部のコードも大幅に違いますね。

最後に、Python 2系と3系の違いを感じられるiterator関係のコードを2つ貼って終わりにしたいと思います(どちらも最近私がやらかしそうになったやつです)。

その1

yarakashi1.py
1
2
3
4
5
6
7
8
import sys

def pseudo_readline():
    print('called')
    return 'hoge'

for i, x in zip(range(3), iter(pseudo_readline, '')):
    print('{} {}'.format(i, x))
Python 2.7での実行結果
called
called
called
0 hoge
1 hoge
2 hoge

Python 3.6での実行結果
called
0 hoge
called
1 hoge
called
2 hoge

その2

yarakashi2.py
1
2
3
4
5
6
7
8
import sys

def pseudo_readline():
    print('called')
    return '10'

for a in map(int, iter(pseudo_readline, '')):
    break
Python 2.7での実行結果
called
called
called
called
called
called
called
...(以下無限ループ)
Python 3.6での実行結果
called
このエントリーをはてなブックマークに追加
« 今年は”ポエム”を出さない代わりに、ここにポエムを書きます SECCON 2019 Online CTF に参加した (Beeeeeeeeeer, Sandstorm, repair, PPKeyboard) »