プログラミングなどに関する、ひらう子のブログです

PythonのコードをPythonで生成して実行してみる

Pythonでも特に有名なHTML用テンプレートエンジンと言えば、Jinja2だと思います。そのJinja2ですが、どうやらJinja2の処理系は、受け取ったテンプレートをPythonコードに変換して、コンパイルして実行しているということです。そして、その処理方法がJinja2の高速さの大きな要因だとか…。

テンプレートエンジンの処理をふつうに素直に実装するなら、「テンプレートをパースして各種オブジェクトの集合体を生成し、レンダリングの際はオブジェクトのメソッドを呼び出して、各オブジェクトのメソッドが呼び合いつつ結果を出力する」という方式が普通でしょう。
恐らく、Jinja2のようにPythonコードを生成してコンパイルしたものを呼び出してレンダリングする方式なら、テンプレート読み込み時は時間が掛かるにしても、レンダリングはかなり高速に出来そうな気がします。

そんな訳で、物は試しにそのような処理を書いてみて、処理速度を簡単に計測してみました。ちゃんとやるならJinja2のソースを読み解くべきところですが、まあ軽い実験なのでさくっとそれらしいものを…。


書いたのが次のコードです。

import io

class Node:
    def __init__(self,tagName):
        self.tagName = tagName
        self.children = []
    def appendChild(self,node):
        self.children.append(node)

    def getCodeFuncName(self):
        return "f{}".format(id(self))
    def writeFuncCode(self,codeIO):
        codeIO.write("def {}():\n".format(self.getCodeFuncName()))
        codeIO.write("  output.write('<{}>')\n".format(self.tagName))
        for child in self.children:
            codeIO.write("  {}()\n".format(child.getCodeFuncName()))
        codeIO.write("  output.write('</{}>')\n".format(self.tagName))
        
        for child in self.children:
            child.writeFuncCode(codeIO)
    def getWholeCode(self):
        codeIO = io.StringIO()
        self.writeFuncCode(codeIO)
        codeIO.write("{}()\n".format(self.getCodeFuncName()))
        return codeIO.getvalue()
    def getCompiledCode(self):
        return compile(self.getWholeCode(),"<string>","exec")

    def writeString(self,output):
        output.write('<{}>'.format(self.tagName))
        for child in self.children:
            child.writeString(output)
        output.write('</{}>'.format(self.tagName))

import time
class Timer:
    # 生成された時間を記録して、そこからの経過時間を取得できるクラス
    def __init__(self):
        self.start = time.time()
    def elapsed(self):
        return time.time()-self.start

def createNodeTree():
    root = Node("root")
    head = Node("head")
    root.appendChild(head)
    middle = Node("middle")
    root.appendChild(middle)
    foot = Node("foot")
    root.appendChild(foot)
    return root

def main():

    # まず木構造を作って、
    # Pythonコードにした文字列を取得
    # コンパイルしたバイトコードも取得
    root = createNodeTree()
    sCode = root.getWholeCode()
    bCode = root.getCompiledCode()

    # Pythonコードを表示
    print("---------code---------")
    print(sCode)
    print("---------code---------")
    print()
    
    # 計測時の繰り返し回数を定数にする
    COUNT = 100000

    output = None

    # まずはNodeクラスのメソッドで、結果をoutput書き出すプロセス
    # COUNT分、繰り返して、経過時間を表示
    t = Timer()
    for i in range(COUNT):
        output = io.StringIO()
        root.writeString(output)
    print("did node method.   time:",t.elapsed())
    # 最後の繰り返しでの結果を標準出力
    print(output.getvalue())

    # 次は、コンパイルされたバイトコードを実行し、結果を書き出すプロセス
    # COUNT分、繰り返して、経過時間を表示
    t = Timer()
    for i in range(COUNT):
        output = io.StringIO()
        exec(bCode,{"output":output})
    print("did compiled code. time:",t.elapsed())
    # 最後の繰り返しでの結果を標準出力
    print(output.getvalue())

    # 最後に、Pythonに変換した文字列をexecで実行し、結果を書き出すプロセス
    # COUNT分、繰り返して、経過時間を表示
    t = Timer()
    for i in range(COUNT):
        output = io.StringIO()
        exec(sCode,{"output":output})
    print("did string code.   time:",t.elapsed())
    # 最後の繰り返しでの結果を標準出力
    print(output.getvalue())

main()

メインの処理はmain()関数に書いて呼び出しています。

Nodeクラス

Nodeクラスは、木構造を作るノードです。子オブジェクトのリストを持っていて、writeString()メソッドを呼び出すと、引数で指定した出力ストリームに、HTMLライクな入れ子構造を書き込みます。
writeString()メソッドとは別に、getWholeCode()メソッドと、getCompiledCode()メソッドがあります。getWholeCode()は、writeString()と同等の処理を行うコード文字列を、指定した出力ストリームに書き込むメソッドです。getCompiledCode()メソッドは、writeString()メソッドによって出力されたコードをコンパイルして、バイナリコードを返すメソッドです。

compile()とexec()、あとeval()

さて、このコンパイル作業ですが、PythonにはPythonコードをやってくれる関数が組み込みで存在します。それが、compile()です。この関数でコンパイルされたバイナリコードを実際に実行できるのは、同じく組み込み関数のexec()とeval()です。
exec()もeval()も、基本的には文字列をコードとして実行する関数ですが、compile()によって生成されたバイトコードを実行することも出来ます。

exec()とeval()の違いは、eval()は式として文字列を実行して結果を返す関数で、exec()は一つのPythonスクリプトのコードを実行出来る関数です。今回はexec()の方を使います。

exec()関数は、第二引数に辞書オブジェクトを渡せば、その辞書オブジェクトをグローバル変数のスコープとして利用します。例えば、{"a":1,"b":2}という辞書を渡せば、グローバル変数にa(値は1)とb(値は2)とが存在する状態でコードを実行します。今回のコードの中では、outputというグローバル変数として、結果を出力する為のストリームをコードに渡す形にしています。

コードの根幹、main関数

main関数は、まずNodeインスタンス木構造を組み上げ、そのあと結果出力用のコード文字列と、コンパイル済みのバイトコードを生成させます。コード文字列はprintで表示します。
そして、「Nodeインスタンス群に直接結果を出力させる方式」、「生成させたバイトコードをexecで実行する方式」、「生成させたコード文字列をexecで実行する方式」の3パターンで、100000回分の時間を計測します。ちなみに、100000回のそれぞれの繰り返しの最後、出力された文字列を表示しています。


結果は以下のようになります。

---------code---------
def f5164112():
  output.write('<root>')
  f5164208()
  f9014480()
  f9078576()
  output.write('</root>')
def f5164208():
  output.write('<head>')
  output.write('</head>')
def f9014480():
  output.write('<middle>')
  output.write('</middle>')
def f9078576():
  output.write('<foot>')
  output.write('</foot>')
f5164112()

---------code---------

did node method.   time: 0.6110348701477051
<root><head></head><middle></middle><foot></foot></root>
did compiled code. time: 0.36802101135253906
<root><head></head><middle></middle><foot></foot></root>
did string code.   time: 9.005515098571777
<root><head></head><middle></middle><foot></foot></root>

先に述べたとおり、Nodeによって生成されたPythonコードが先に表示されています。execで実行されるのがこれですね。

100000回の繰り返しに掛かった時間は、以下の通りです。

  • Nodeインスタンス群による直接出力方式では0.611秒
  • 生成したコードをcompileしたものをexecする方式が0.368秒
  • コード文字列をそのままexecに渡す方式は、9.006秒

流石に、文字列をexec実行する方式のものは遅いですが、バイトコードをexec実行する方式のものは、インスタンス群に直接出力させる方式より中々速いです。やはり、インスタンス群の処理に比べて、生成したコードで行う処理はかなり単純なので、これだけの差が出るのでしょう。


割と適当に書いた実験でも、結構な速度差が確認できました。これなら、jinja2がPythonコードへの変換方式を使うのも、納得ですね。

コードを生成して実行すると聞くとかなり大変そうなイメージがありますが、実際に書いてみるとそれほど大変でもなさそうです。
私が現在制作しているHTMLSeaもHTMLを生成する為の言語ですし、特にテンプレートエンジンのように変数を使う場合スコープの管理を自前のPythonスクリプトで行うのはかなり効率が悪いので、コード生成方式を使うのはかなり有効な感じがします。高速化にあたっては、是非採用してみたいと思いました!