Python とマクロ、インポートフックと抽象構文木

どちらがきっかけだったのか忘れてしまいましたが、wikipedia:メタプログラミングwikipedia:抽象構文木 について調べているうちに マクロ が出てきました。

私の中では、マクロと聞くと、C 言語の、プリプロセッサ (コンパイルの前処理) でコードに置き換えるものを漠然とイメージします。改めてマクロって何だったっけ?何が嬉しいのだっけ?と考えてみると、基本的なことが分かっていないことに気付いたのでマクロについて調べ直してみました。

マクロとは

wikipedia からマクロの定義を引用します。

A macro (short for "macroinstruction", from Greek μακρο- 'long') in computer science is a rule or pattern that specifies how a certain input sequence (often a sequence of characters) should be mapped to a replacement output sequence (also often a sequence of characters) according to a defined procedure. The mapping process that instantiates (transforms) a macro use into a specific sequence is known as macro expansion. A facility for writing macros may be provided as part of a software application or as a part of a programming language. In the former case, macros are used to make tasks using the application less repetitive. In the latter case, they are a tool that allows a programmer to enable code reuse or even to design domain-specific languages.

wikipedia:en:Macro_(computer_science)

ざっくり意訳すると、

コンピューターサイエンスで言うところのマクロ ("マクロ命令" の省略形) とは、定義された手続きに従い、特定の入力シーケンス (文字列のシーケンス) が出力シーケンス (文字列のシーケンス) に置き換えられる方法を指定するルールやパターンのことです。特定のシーケンス内にインスタンス化する (変換する) 対応付け処理は、マクロ展開として知られています。マクロを書くための機構 (facility) は、ソフトウェアアプリケーションの一部、もしくはプログラミング言語の一部として提供されるかもしれません。前者は簡潔な表現でそのアプリケーションを使うタスクを作るために使われます。後者はプログラマーにとってコードを再利用させたり、ドメイン特化言語を設計することさえ可能にするといったツールになります。

大雑把に要約すると、既定のコードを置き換えるルールやパターンを作ることで簡潔な表現やコードの再利用性をもたらすといったことが嬉しそうですね。

また、マクロと言えば Lisp 系の言語の特徴的な機能のように私はよく見聞きしていました。

S式は言ってみれば言語の構文木そのものです。普通の言語では、処理系のフロントエンドにある構文解析器が、「人間に優しい」文法を「機械が理解しやすい」 構文木に変換します。

Lisp:S式の理由

wikipedia:S式 という表現方法、プログラムのコードそのものをデータとして扱えるという特性により、普通の言語 *1 で必要な構文解析や抽象構文木を操作するといった処理が簡単になり、その結果としてマクロがより身近で強力なものになるのではないかと推測します。

参考までに Clojure でのマクロを使う動機付けについて書かれた記事を紹介します。


閑話休題。前置きが長くなってしまいました。先の wikipedia の続きの説明によると、Python も立派にマクロをサポートしています。

While syntactic macros are often found in Lisp-like languages, they are also available in other languages such as Prolog, Dylan, Scala, Nemerle, Rust, Haxe,[5] and Python.[6]

Syntactic macros

あれ!?そうだったっけ?と思う方もいるかもしれません。普通に Python でマクロを書いたりすることはないのでイメージできないかもしれません。

その根拠として Python でマクロを提供するライブラリとして MacroPy が紹介されています。このライブラリは様々な機能がマクロとして実装されていて、Python でマクロをどう実装するかの参照実装の1つとして良いと思います。念のため、初学者向けに断っておくと、Python における一般的なアプリ開発の用途でマクロを使う必要性は全くありません。本稿ではマクロという概念そのものを学ぶことが目的です。またマクロはその特性上、その言語におけるメタプログラミングを提供する仕組みとも密接な関係があります。そのため、マクロを学ぶことは Python におけるメタプログラミングを学ぶ上で良い題材とも言えるでしょう。

Python におけるマクロの概説

MacroPy の概要説明に分かりやすい図があるのでそこから引用します。


MacroPy は大まかに次のように動作します。

1. モジュールのインポートをフックする (インターセプトする)
2. モジュールのソースコード構文解析して AST (抽象構文木) に変換する
3. AST をトラバースして、見つけたマクロを展開する
4. 改変した AST をコンパイルして、モジュールの読み込みを再開する

(注) あるモジュール内でマクロが使われているとき、そのモジュールを直接実行することはできない (マクロが有効にならない) 。

MacroPy 30,000ft Overview

横文字がたくさん出てきました。まず用語が分からないとそれだけで嫌になってきます。それぞれの用語を1つずつ整理しながら意図している内容を噛み砕いていきましょう。

Python のモジュール

通常 モジュール は、Python のプログラム (ソースコード) を記述した xxx.py というファイルです。ここでは Python でインポートできる最小単位がモジュールであることを覚えておいてください。モジュールも Python の世界の中で扱えるオブジェクトの1つです。

歴史的に Python のインポートの API が貧弱だったことからいまの状況はやや混沌としています。私はよく知らないので簡単な紹介しかできませんが、いまは3つの方法があります。

1. __import__() 関数

import 文で呼ばれる組み込み API といったものでしょうか。インポートを制御する低レベルのインターフェースです。昔からのライブラリなどで動的にモジュールをインポートするプログラムでしばしば見かけたりします。昔は __import__ を使うしかなかったのですが、いまは importlib.import_module() を使うようにとドキュメントで推奨されています。

2. imp ライブラリ

このライブラリがいつからあるのか、どういった変遷を経たのか私はよく知りませんが、PEP 302 の仕組みを提供するライブラリの1つです。但し、ドキュメントによると 3.4 で撤廃とあるので今後は importlib へ移行されていくようです。

PEP 302 の Python バージョンが 2.3 (2002年) と明記されています。それなりに昔からある仕組みのようです。この PEP には後述するインポートフックの仕様についても記述されています。

3. importlib ライブラリ

Python 3.1 から導入されたインポートを扱う新たなライブラリです。一部 2.7 にもバックポートされています。

What’s New In Python 3.1 を眺めていて1つ気付くのは、importlib はインポート文の pure Python な参照実装だとあります。imp の C 拡張 (CPython) なところを取り除いていって、インタープリター間の移植性を高めたいといったところも狙いのようです。

と、考察した後になってから importlib の冒頭にその目的が書いてあることに気付きました。

The purpose of the importlib package is two-fold.
One is to provide the implementation of the import statement (and thus, by extension, the __import__() function) in Python source code.
(...snip...)
Two, the components to implement import are exposed in this package, making it easier for users to create their own custom objects (known generically as an importer) to participate in the import process.

importlib 31.5.1 はじめに

また後述するインポートフックのところでも出てきますが、以下の内容も頭の片隅に入れておいてください。

モジュールには、関数定義に加えて実行文を入れることができます。これらの実行文はモジュールを初期化するためのものです。これらの実行文は、インポート文の中で 最初に モジュール名が見つかったときにだけ実行されます。

6.1. モジュールについてもうすこし
インポートフック

Python のモジュールをインポートするときの処理に割り込んでごにょごにょするといったことをインポートフックと呼びます。

先ほどの PEP 302 で導入された仕組みによると、sys.meta_pathfinder オブジェクトを登録することにより、デフォルトの sys.path よりも先にその finder.find_module() が呼ばれます。そして、finder.find_module()loader オブジェクトを返し、loader.load_module() がモジュールオブジェクトを返します。

実際に試してみましょう。

$ vi run.py 
# -*- coding: utf-8 -*-
def main():
    from test import t1
    from test import t2
    from test import t3

if __name__ == '__main__':
    main()

適当なパッケージとモジュールを作り、

$ tree .
.
├── run.py
└── test
    ├── __init__.py
    ├── t1.py
    ├── t2.py
    ├── t3.py

$ head test/*.py
==> test/__init__.py <==

==> test/t1.py <==
print('I am t1')

==> test/t2.py <==
print('I am t2')

==> test/t3.py <==
print('I am t3')

インポート時に標準出力するだけのプログラムを用意します。

$ python run.py 
I am t1
I am t2
I am t3

インポートフックを実装するための finder/loader の両方の機能をもつ ImportHook クラスを定義し、インポート前に sys.meta_path に登録します。ImportHookインスタンスfind_module() が loader としての自分自身を返し、load_module() が呼ばれます。

# -*- coding: utf-8 -*-
import imp
import sys

class ImportHook:

    def find_module(self, mod_name, path=None):
        if mod_name == 'test.t2':
            print('find_module:', mod_name, path)
            return self

    def load_module(self, mod_name):
        print('load_module:', mod_name)
        path = mod_name.split('.')
        mod = imp.load_module(mod_name, *imp.find_module(path[-1], path[0:-1]))
        return mod

def main():
    sys.meta_path.insert(0, ImportHook())
    from test import t1
    from test import t2
    from test import t3

if __name__ == '__main__':
    main()

このプログラムを実行すると以下の出力になります。

$ python3.4 run.py  # python2.7 でも実行可
I am t1
find_module: test.t2 ['/Users/t2y/work/external-repo/python/learn/import-hook/test']
load_module: test.t2
I am t2
I am t3

インポートフックが呼ばれてモジュールの検索と読み込みが行われているのが確認できました。

余談ですが、ドキュメントを見ていて sys.path_hooks というのもあるようです。この例では sys.meta_path に finder オブジェクトを登録しましたが、その finder オブジェクトを生成する呼び出し可能オブジェクトのリストを登録するようです。さらにもう1つ前の段階でもフックできるようですね。

リファレンス:

抽象構文木 (Abstract Syntax Tree)

抽象構文木構文解析構文木とデータ構造の中間的なものとして使用される。さらにコンパイラインタプリタなど(プログラミング言語処理系)でのプログラムの中間表現として使われ、コンパイラ最適化やコード生成はその上で行われる。抽象構文木のとりうる構造は抽象構文で記述されている。

wikipedia:抽象構文木

Pythonソースコード構文解析して、抽象構文木 (以下 AST) を扱うために ast モジュールという標準ライブラリがあります。ast モジュールのヘルパー関数を使うと、簡単にソースコードを AST のノードオブジェクトに変換できます。

ソースコードを見た方が分かりやすいので簡単なサンプルを紹介します。

# -*- coding: utf-8 -*-
import ast
import inspect
import sys

def func():
    for i in range(3):
        if i == 2:
            print('Value is {}'.format(i))

source = inspect.getsource(func)
tree = ast.parse(source)
print(ast.dump(tree))

実行すると以下のような AST のノードオブジェクトの dump が出力されます。手で整形するのも難しかったのでちょっと見辛いですが、どういったオブジェクト表現かという雰囲気は掴めます。後ほど、マクロ展開について考察するときのために Python の AST 表現は (知らない人には) 訳が分からない程度に覚えておいてください。

$ python3.4 ast_sample.py 
Module(
body=[FunctionDef(name='func',
           args=arguments(args=[], vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]),
  body=[For(target=Name(id='i', ctx=Store()),
             iter=Call(func=Name(id='range', ctx=Load()),
             args=[Num(n=3)], keywords=[], starargs=None, kwargs=None),
    body=[If(test=Compare(left=Name(id='i', ctx=Load()), ops=[Eq()], comparators=[Num(n=2)]), 
      body=[Expr(value=Call(func=Name(id='print', ctx=Load()),
                 args=[Call(func=Attribute(value=Str(s='Value is {}'), attr='format', ctx=Load()),
                   args=[Name(id='i', ctx=Load())],
                   keywords=[], starargs=None, kwargs=None)],
                 keywords=[], starargs=None, kwargs=None))],
    orelse=[])],
  orelse=[])],
decorator_list=[], returns=None)]
)

実際に ast モジュールを使ったことがなかったのと公式ドキュメントでは使い方がよく分からなかったので以下に簡単な入門記事を書きました。

ast モジュールの使いどころとしては、Python コードを処理するテンプレート、コード解析、マクロ、そういった類のツールなどが一般的です。分かりやすいサンプルとして、以下にデコレートされている関数やメソッドを調べるといったことをやってみました。

ソースコードの AST 変換とモジュール読み込み

ここまででインポートフックと AST について分かりました。

前々節のインポートフックのソースコードを修正して、あるモジュールのソースコードを読み込んで AST に変換・改変した後、その AST をコンパイルして新規にモジュールを作成するのをやってみましょう。

AST のノードオブジェクトに変更を加えるには ast モジュールの NodeTransformer を使います。ast.parse() でファイルのソースコードを AST オブジェクト (Module ノード) に変換し、NodeTransformer を継承したクラスを設けてトラバースするのが簡単です。ここでは文字列のノードオブジェクトの値を書き換えています。

class StringTransformer(ast.NodeTransformer):
    def visit_Str(self, node):
        node.s = 'I am StringTransformer'
        return node

...
    def handle_ast(self, file_, mod_name):
        source = file_.read()
        tree = ast.parse(source)
        transformed_tree = StringTransformer().visit(tree)
        code = compile(transformed_tree, mod_name, 'exec')
        return code

NodeTransformer は Visitor パターンで処理を実装します。Visitor パターンって何だったっけ?という方は以下の記事で復習しましょう。

そして、NodeTransformer で変更を行った AST の Module ノードオブジェクトを compile() することでコードオブジェクトを取得します。

次に置き換え用のモジュールオブジェクトを新規に生成し、先ほど変更を加えてコンパイルしておいたコードオブジェクトをそのモジュールの名前空間exec() します。これはモジュールのところでインポート時に実行して初期化されるという処理に相当します。

    def create_module(self, mod_name, file_name, code):
        mod = imp.new_module(file_name)
        sys.modules[mod_name] = mod
        exec(code, mod.__dict__)
        return mod

Python でインポートしたときに行われる処理、sys.modules への登録やコード実行などを行っています。本来は __file____package__ といった属性にも適切な値を設定すべきですが、単純なサンプルなのでここでは省きます。

前々節のインポートフックのソースコードに修正を施したものが以下になります。

# -*- coding: utf-8 -*-
import ast
import imp
import sys

class StringTransformer(ast.NodeTransformer):
    def visit_Str(self, node):
        node.s = 'I am StringTransformer'
        return node

class ImportHook:
    def find_module(self, mod_name, path=None):
        if mod_name == 'test.t2':
            print('find_module:', mod_name, path)
            return self

    def load_module(self, mod_name):
        print('load_module:', mod_name)
        package_name, file_name = mod_name.split('.')
        file_, pathname, desc = imp.find_module(file_name, [package_name])

        # read source and transform ast
        code = self.handle_ast(file_, mod_name)

        # create new module and exec it
        mod = self.create_module(mod_name, file_name, code)
        return mod

    def handle_ast(self, file_, mod_name):
        source = file_.read()
        tree = ast.parse(source)
        print('AST:', tree)
        transformed_tree = StringTransformer().visit(tree)
        code = compile(transformed_tree, mod_name, 'exec')
        print('compiled:', code)
        return code

    def create_module(self, mod_name, file_name, code):
        mod = imp.new_module(file_name)
        sys.modules[mod_name] = mod
        exec(code, mod.__dict__)
        return mod

def main():
    sys.meta_path.insert(0, ImportHook())
    from test import t1
    from test import t2
    from test import t3

if __name__ == '__main__':
    main()

実行結果です。

$ python3.4 run.py 
I am t1
find_module: test.t2 ['/Users/t2y/work/external-repo/python/learn/import-hook/test']
load_module: test.t2
AST: <_ast.Module object at 0x1053c0518>
compiled: <code object <module> at 0x105313ae0, file "test.t2", line 1>
I am StringTransformer
I am t3

"I am t2" の文字列出力を AST のレイヤーで書き換えて実行することができました。

ここまでのサンプルコードではマクロ展開以外の動作、つまり MacroPy の概要説明にあるモジュール読み込みのワークフローの流れを確認しました。

マクロ展開について少し

前節のサンプルコードでは、実際にマクロを定義したわけではありませんが、StringTransformer で AST のノードオブジェクトの文字列の値を直接変更しました。冒頭で紹介した、マクロが既定コードの置き換えを目的としているといった内容を思い出してください。マクロ展開というのは、マクロというルールやパターンから AST のレイヤーで変更を行うことに相当します。つまり、マクロで実現したいことは AST で実現できるということであり、AST で実現できないことはマクロで実現できないということでもあります。

ここで Python の構文とその AST のオブジェクト表現を見比べてみます。

>>> import ast
>>> ast.dump(ast.parse("2 + 2"))
'Module(body=[Expr(value=BinOp(left=Num(n=2), op=Add(), right=Num(n=2)))])'

ある記事の言葉を引用すると、

この結果にはかなりがっかりした。Lisp なら、これと同等の構文木は (+ 2 2) である。この構文木なら誰にでも使えるが、 Python のこんな構文木をいじれるのは本当のエキスパートだけだろう。

Python にはマクロがない。

Python の構文と AST の表現が全く違うことから、マクロを書く・展開する・適用する = AST を操作するということが誰にでも実装できない、ひいては普通にマクロを書くことはないということにつながるように思います。

Karnickel

いきなり MacroPy を読み進めようとすると、複雑過ぎて挫折してしまうかもしれません。私は挫折しました。

学習向けにもっと簡単なものとして Karnickel という小さいライブラリがあります。これは Sphinx の作者としても有名な Georg Brandl 氏 *2ast モジュールとインポートフックのデモとして作ったライブラリのようです。

MacroPy の概要説明の図を使いたかったために紹介する順序があべこべになってしまいましたが、KarnickelMacroPy 同様のモジュール読み込みフローをもちます。インポートフック、NodeTransformer によるマクロ展開といった一通りのサンプルを含み、1ファイル300行ちょっとのコード量です。私は最初に Karnickel のライブラリを読み進めました。

Karnickel のマクロ

マクロ実装のデモ向けライブラリのため、マクロとして提供されている機能そのものにあまり意味はありません。

また使い方の説明が分かりにくいため (インポートフックを知っている前提)、マクロ展開のフローが分かっていないと、試しに実行するところから戸惑うかもしれません。簡単に使い方を紹介します。ちなみに KarnickelPython 3 では動きません (Python 2.6+) 。

example.macros にサンプルのマクロが定義されています。マクロは @macro デレコーターで定義するようです。

from karnickel import macro

@macro
def add(i, j):
    i + j

@macro
def assign(n, v):
    n = v

@macro
def custom_loop(i):
    for __x in range(i):
        print __x
        if __x < i-1:
            __body__

実行するときはこのマクロ定義モジュール以外に、インポートフックがある実行用ファイル (run.py) とマクロをインポートして使うファイル (example/test.py) の2ファイルが必要です (たぶん) *3

# -*- coding: utf-8 -*-
import karnickel
karnickel.install_hook()

from example.test import usage_expr, usage_block, usage_3

print 'usage_expr():', usage_expr()
print 'usage_block():', usage_block()
print 'usage_3():'
usage_3()

実行結果。

$ python run.py 
usage_expr(): 22
usage_block(): 1
usage_3():
0
loop continues...
1
loop continues...
2
loop continues...
3
loop continues...
4
loop continues...
5
loop continues...
6
loop continues...
7
loop continues...
8
loop continues...
9

詳細は追いませんが、NodeTransformer を使ってマクロ展開を実装しているのが karnickel.py で確認できます。

class Expander(ast.NodeTransformer):
    """
    AST visitor that expands macros.
    """

    def __init__(self, module, macro_definitions=None, debug=False):
        ...

まとめ

Python におけるマクロを提供する仕組み、言わばメタプログラミングについて紹介しました。

  • Python におけるマクロ
    • インポートフック
    • 抽象構文木
    • AST 変換 (マクロ展開)

インポートフックを実装してみると、インポート周りの API が分かりにくいと思うかもしれません。良く言えば癖がある、悪く言えば使いにくいといった感じでしょうか。歴史的経緯や互換性もあるでしょうし、メタプログラミングが本質的に難しいということなのかもしれません。以前からインポート周りは Python の良くないところの1つに挙げられている発表を私はいくつか見たことがあります。importlib の冒頭にも書かれているよう、もっと使いやすくなるように今後も改善されていくと思います。

次回は MacroPy で提供されているマクロの機能についても見ていきましょう。

*1:この文脈では一般的な手続き型プログラミング言語を指していると思われます

*2:基調講演者 - PyCon APAC 2013

*3:これらを1ファイルにして実行するとエラーになったのでそういう作りなんだと思います