LoginSignup
2
1

PythonでHello World

Last updated at Posted at 2023-12-22

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

  1. プログラムのソースコードを読み込む
  2. 字句解析: ソースコードをトークン列に変換する
  3. 構文解析: トークン列を構文解析し、抽象構文木(ast)に変換する
  4. 意味解析: 抽象構文木をバイトコードに変換する
  5. バイトコードを(仮想マシン上で)実行する

つまりこれらの処理を順番に追っていくことで、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 メソッドを呼び出すことで、バイトコードの実行の様子を観察することができます。簡単のため、スタックマシンのインスタンスとして stackoutput のみを持っており、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の処理系は、ソースコードを字句解析・構文解析して抽象構文木に変換し、意味解析してバイトコードに変換し、バイトコードを実行することで、プログラムを実行していることが確認できました。
今回は文字を表示するだけの簡単なプログラムでしたが、より複雑なプログラムを実行する場合でも、同じような手続きで実行されていることを確認できると思います。

参考資料

  1. 厳密には最適化が入ることがあります。また、意味解析のスコープは実際と若干異なる気がします。。

  2. このコードは構造的パターンマッチを用いているので、Python3.10以降のみで動作します。3.9以前のもので動作させたい場合は、if-elifなどを用いて適宜書き換えてください。

  3. pythonのprint文は任意のオブジェクトに対して実行でき、pythonでは全てがオブジェクトとして表現されているので、組み込み関数でさえもprintできます。便利ですね。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1