Python で Hello World
Python Advent Calendar 2023 の23日目の記事です。
まえおき
Python入門記事ではないです。ご了承ください。
Hello World
Pythonでは、以下のように書くことでHello Worldを出力することができます。
print("Hello, World")
このコードを main.py
という名前で保存し、ターミナルで以下のように実行します。
$ cat << EOF > main.py
print("Hello, World")
EOF
$ python main.py
Hello, World
Pythonのインタプリタを起動して、以下のように入力しても同じ結果が得られます。
>>> print("Hello, World")
Hello, World
もっとも、この記事を読んでいる方であれば、既にご存知だと思います。
Hello World の裏側で何が起こっているのか説明できますか??
これは少し難しい質問です。「Pythonはインタプリタ型の言語なのでコードを逐次的に解釈して結果を表示している」のようなそれっぽい説明はできそうですが、あまり納得できる説明ではなさそうです。
ということで今回は、Pythonのインタプリタで何が起こっているのか、少し深掘ってみたいと思います。
おことわり
この記事内では、Python処理系としてCPythonを想定しています。断言はできませんが、例えばpypyのようなJITコンパイラを持つ処理系では、異なる挙動をとる可能性があります。また、Pythonのバージョンによっても挙動が異なる可能性があります。
大まかなPythonの実行の流れ
Pythonに限った話ではないですが、インタプリタ型と呼ばれる言語の処理系は、大まかに以下のような流れで動作します。1
- プログラムのソースコードを読み込む
- 字句解析: ソースコードをトークン列に変換する
- 構文解析: トークン列を構文解析し、抽象構文木(ast)に変換する
- 意味解析: 抽象構文木をバイトコードに変換する
- バイトコードを(仮想マシン上で)実行する
つまりこれらの処理を順番に追っていくことで、Pythonの実行の流れを大まかに理解することができるのではないでしょうか。
字句解析・構文解析の様子を観察する
Pythonでは、標準ライブラリの ast
モジュールを使うことで、字句解析・構文解析の様子を観察することができます。
実際にHello Worldを解析してみましょう。
import ast
source = """
print("Hello, World")
"""
tree = ast.parse(source)
print(ast.dump(tree))
実行すると以下のような結果が得られます。
Module(body=[Expr(value=Call(func=Name(id='print', ctx=Load()), args=[Constant(value='Hello, World')], keywords=[]))], type_ignores=[])
少しわかりづらいですが、この結果を ast --- 抽象構文木#抽象文法 (Abstract Grammar) と照らし合わせると、以下のように解釈できます。
- トップレベルのノードは
Module
で、これはPythonのモジュールまたはスクリプトであることを表している -
Module
の中にはbody
という属性があり、ここには実際のコードの実行内容が含まれている - 今回は、
body
としてExpr
というノードが1つだけあり、これは式を表している -
Expr
ノードの中にはCall
ノードがあり、これは関数呼び出しを表している - この
Call
ノードはprint
関数を呼び出している -
args
は関数の引数を表し、今回はConstant
ノードが1つだけあり、これは定数を表している - この
Constant
ノードとして"Hello, World"
という文字列を渡している - keywords はキーワード引数を表しているが、今回は空
たったの一行のスクリプトですが、このように様々なノードに分解されていることがわかります。
バイトコードがどのように生成されるのか観察する
Pythonでは、標準ライブラリの dis
モジュールを使うことで、バイトコードの生成の様子を観察することができます。先ほど構文解析を行ったコードからバイトコードを生成してみます。
compiled_code = compile(tree, filename="<ast>", mode="exec")
bytecode = dis.dis(compiled_code)
これを実行すると以下のような結果が得られます。
0 0 RESUME 0
2 2 PUSH_NULL
4 LOAD_NAME 0 (print)
6 LOAD_CONST 0 ('Hello, World')
8 PRECALL 1
12 CALL 1
22 POP_TOP
24 LOAD_CONST 1 (None)
26 RETURN_VALUE
dis --- Python バイトコードの逆アセンブラ#Python バイトコード命令 によると、このバイトコードは以下のように解釈できます。
-
LOAD_NAME
:print
という名前をスタックにプッシュする -
LOAD_CONST
:'Hello, World'
という定数をスタックにプッシュする -
CALL
: スタックのトップから呼び出し可能な関数を、n 個の引数とともに取り出して呼び出す。ここではprint
関数を呼び出している -
POP_TOP
: スタックのトップを取り出して破棄する。ここではprint
関数の戻り値を破棄している -
LOAD_CONST
,RETURN_VALUE
:None
をスタックにプッシュして、関数から戻る
Hello, World
という文字列を表示するために、print
関数を呼び出していることが確かに確認できました。
バイトコードを実行する
先ほどのバイトコードの命令からも推測できるように、これらのバイトコードはスタックマシン上で実行されてます。
スタックマシンについての詳しい説明は割愛しますが、スタックと呼ばれるデータ構造に対してPushやPopといった操作を行うことで、プログラムを実行の実行を行うものです。
スタックマシンに関するより詳しい説明は 低レイヤを知りたい人のためのCコンパイラ作成入門#スタックマシン の項などを参照してください。
さて、話を戻します。Python Developer’s Guideの The bytecode interpreter (3.11) によると、バイトコードの実行を行うのは ceval.c というファイルだそうです。つまり、例えばgdbのようなデバッガを使ってこのファイル内で起こっていることを追っていくことで、バイトコードの実行の様子を観察することができます。
とはいえ、cpythonのソースコードは大変に複雑なので、実際にバイトコードの実行の様子を追うのはかなり大変です。少しずるいやり方ですが、バイトコードを解釈して実行する簡易的なシミュレータを実装して観察することにします。
簡単なスタックマシンの実装は以下のようになります。2 バイトコードの各命令はメソッドとして実装してあり、simulate
メソッドを呼び出すことで、バイトコードの実行の様子を観察することができます。簡単のため、スタックマシンのインスタンスとして stack
と output
のみを持っており、output
には各命令の実行後のスタックの状態が保存されていきます。
class PVM:
def __init__(self):
self.stack = []
self.output = []
def LOAD_NAME(self, name):
self.stack.append(name)
def LOAD_CONST(self, const):
self.stack.append(const)
def CALL(self, arg_count):
args = [self.stack.pop() for _ in range(arg_count)]
func = self.stack.pop()
result = func(*args)
if result is not None:
self.stack.append(result)
def POP_TOP(self):
if self.stack:
self.stack.pop()
def simulate(self, instructions):
for instr, arg in instructions:
match instr:
case "LOAD_NAME":
self.LOAD_NAME(arg)
case "LOAD_CONST":
self.LOAD_CONST(arg)
case "CALL":
self.CALL(arg)
case "POP_TOP":
self.POP_TOP()
self.output.append(list(self.stack))
return self.output
さて、このスタックマシンを使って、先ほどのバイトコードを実行してみましょう。
instructions = [
("LOAD_NAME", print),
("LOAD_CONST", "Hello, World"),
("CALL", 1),
("POP_TOP", None),
]
pvm = PVM()
stack_states = pvm.simulate(instructions)
実行すると、以下のような結果が得られます。
Hello, World
無事に Hello, World
という文字列が表示されました。めでたいですね。また、各命令を実行した後のスタックの状態は、以下のようにして確認できます。
for i, state in enumerate(stack_states):
print(f"{i}: {state}")
これを実行すると以下のような結果が得られます。3
0: [<built-in function print>]
1: [<built-in function print>, 'Hello, World']
2: []
3: []
確かにスタックの状態が、バイトコードの実行の様子を反映していることが確認できます。
まとめ
ということで、Pythonの処理系がどのように動いているのか少し深掘ってみました。
Pythonの処理系は、ソースコードを字句解析・構文解析して抽象構文木に変換し、意味解析してバイトコードに変換し、バイトコードを実行することで、プログラムを実行していることが確認できました。
今回は文字を表示するだけの簡単なプログラムでしたが、より複雑なプログラムを実行する場合でも、同じような手続きで実行されていることを確認できると思います。
参考資料
- 言語処理系とは
- スタックマシン
- プログラミング言語処理系の基礎
- ast --- 抽象構文木#抽象文法 (Abstract Grammar)
- dis --- Python バイトコードの逆アセンブラ#Python バイトコード命令
- Pythonの処理系はどのように実装され,どのように動いているのか?我々はその実態を調査すべくアマゾンへと飛んだ.
- Read Inside The Python Virtual Machine