Python
xonsh
XonshDay 7

Xonshでメタプログラミング

More than 1 year has passed since last update.

はじめの前に

この記事はもともと20日以降の記事として書いたものです.
今日の投稿の間に合わなかったので、しかたなく前倒ししました.すいません.
後半のつもりで書いたので、あんまりみんなが嬉しい内容じゃないかもしれません.

はじめに

いよいよXonsh Advent Calenderも後半戦ですね。
日本語ドキュメントになればと思って始めましたが、後半戦なので万人受けしない記事の一つくらい書こうと思います。
Xonshが万人受けしないのに、そのさらにニッチに行くのかという点に関しては何も言わないで下さい。



さて、言っておくと自分はpythonが嫌いです。毎日仕事ではpythonでnumpyとtensorflowをいじり続けて、この糞言語めと思いながら生活しています。
というか大抵の言語が嫌いなのですが、その原因の大部分をまともなマクロが提供されていないことが占めています。
大抵の言語に正気を保ったまま読んだり書いたりできるコードで構文木をコンパイル前にいじる機能が無いのです。
HaskellでいうTemplate Haskellとかですね。あれは正気を失いかけますが。
じゃあなんの言語ならいいんだといえば、それはあれですよ。括弧がかわいいですよね。

hylang( https://github.com/hylang/hy )というAI時代最後の希望がありますが、現状コミュニティが小さすぎて絶望感があります。
そこで、ただのpythonが使えるシェルという存在感の無さから気にしていなかったXonshです。
Xosnhスクリプト(.xsh)というと、それpythonじゃんというツッコミ待ちの単語に聞こえますが、今日紹介する機能でまるでpythonとは別言語っぽくなります。

では、マクロの世界を覗いてみましょう。後半戦なので自重しません!

マクロってなに

理論的には色々あるんでしょうが、とりあえずコンパイル前にコードでコードを生成する機能だと思って下さい。
メタプログラミングの一つに分類されるジャンルの技術です。
えー黒魔術きもーい、と思うかもしれませんが、黒魔術でもなんでもありません。
関数が手続きの再利用だとすれば、マクロは記述スタイルの再利用です。関数では簡単にはできない、書き方という抽象的なレイヤーを再利用できます。
用途としては言語内DSLを作ったりする時にとても重宝します。欲しいシステムを記述するDSLを作ることがソフトウェアを作るということなのだという宗教もあります。(好きです)
というわけで、マクロはみんながしあわせになる白魔術です。

Xonshのマクロ(python-mode)

まずはtutorialの翻訳っぽい内容から入ろうと思います。まずはマクロの定義からです。

def macro(x):
    return x

見た目はただの関数です。使い方が違います。

$ macro!(x = 10)
x = 10

こんなふうに呼び出すことでマクロ展開として処理されます。
さて、やばさが伝わりはじめてますかね。ぱっと見でもpythonにおける禁忌、statementとexpressionの共存です。

先ほどの定義を省略なしの正式な書き方で書くと以下になります。

def macro(x : str):
    return x

型付きの関数みたいに見えますが、ちょっと違います。受け取った値のマクロ定義内における解釈です。
受け取った値はこのアノテーションで指定したオブジェクトとしてキャストされる感じです。
- str: 与えられた文字列。ソースコードを表す文字列
- eval: 与えられた文字列をpython式として評価した値
- exec: 与えられた文字列を複数行を含むpythonコードとして評価した値
- type: 与えられた文字列をpython式として評価した値のtype
- ast: 与えられた文字列をpython式としてパースしたAST(抽象構文木)を表すオブジェクト
- compile: 与えられた文字列をpython式としてパースしたASTをコンパイルしたコードオブジェクト

コードの評価と言っても一段階ではありません。コードはreadされて文字列に、parseされてASTに、compileされて実行オブジェクト、実行されて値になります。
Xonshマクロでは、アノテーションによってどの時点の評価値を使うかということを指定できるわけです。
アノテーションが省略された場合はstrになります。

一つ一つ例を見てみましょう。

>> def f(x : str):
       return x
>> f!([1, 2, 3] + [4])
'[1, 2, 3] + [4]'

>> def f(x : eval):
       return x
>> f!([1, 2, 3] + [4])
[1, 2, 3, 4]

>> def f(x : exec):
       return x
>> f!([1, 2, 3] + [4]; print('spam'))
spam

>> def f(x : type):
       return x
>> f!([1, 2, 3] + [4]) 
list

>> def f(x : 'ast'):
       return x
>> ast = f!([1, 2, 3] + [4]) 
>> ast
<_ast.Expression at 0x7f3ddf47b4a8>
>> ast.body
<_ast.BinOp at 0x7f3ddf547208>
>> ast.body.left
<_ast.List at 0x7f3ddf531b00>
>> [ num.n for num in ast.body.left.elts ]
[1, 2, 3]
>> [ num.n for num in ast.body.right.elts ]
[4]

>> def f(x : compile):
       return x
>> f!([1, 2, 3] + [4]) 
<code object <module> at 0x7f3ddf8c2390, file "f(x)", line 1>

潜在能力がひしひしと伝わってきますね。これはテンションが上がってきます。
受け取った文字列をごちゃごちゃいじって新しいコードを作ってもいいし、ASTの上をウォークしまくって変換してもいいんです。

実例はあとで存分に見せるので、まずは文法だけ眺めて下さい。

Xonshのマクロ(subprocess-mode)

これはおまけみたいなものですが、とりあえず言及しておきます。
Xonshのsubprocessマクロはさっきと比べてシンプルです。引数は一つでstrしかとれません。
一例としてechoをマクロ実行してみます。

$ echo spam1                             spam2
spam1 spam2
$ echo ! spam1                             spam2
spam1                             spam2

この場合、通常のechoが入力をパースしてその一つ一つを空白を入れて返してくるのに対して、マクロ版ではパーサーを介さずそのまま出力されています。
これだと物足りないので、tutorialに良い例としてtimeitコマンドが挙げられています。

$ timeit "spam" + "spam"
xonsh.__amalgam__.XonshError: xonsh: subprocess mode: command not found: spam
$ timeit ! "spam" + "spam"
100000000 loops, best of 3: 9.91 ns per loop

通常の実行であるとシェルのパーサーが頑張ってしまうので、適切でないところをコマンドだと思って探しに行って失敗します。
それに対してマクロ実行した場合、後ろの行が一つの文字列としてすべて突っ込まれるのでちゃんと動きます。

ここに用途別のいいワンライナーDSLを実装してやるととても便利なものが作れる予感がします。もらったstrをどう料理するかはあなた次第です。

Context Manager Macro

あとちょっと、これを説明すればやっと本番に入れますので頑張って下さい。
とある世界ではwith系のマクロというのは伝統的に一分野をなしているといっても過言ではなく、マクロのデザインパターンの代表的な一つです。
そのため、pythonには最初から拡張可能なwith構文が提供されていますし、みなさん便利に使ってるんじゃないかと思います。
ただ、閉じたpythonの世界ではwithがどんなに便利でも中で書けるのはpythonの文法に縛られたままです。
Xonshのwith!(with-bang)マクロはそれを解決します。

使う前の準備

コンテキストを管理し、最終的なお料理をする入れ物を定義します。

class Body:
    __xonsh_block__ = str

    def __enter__(self):
        # 料理の結果を返却(今回は素材そのまま)
        return self.macro_block

    def __exit__(self, *exc):
        # お掃除
        del self.macro_block, self.macro_globals, self.macro_locals

self.macro_blockの中に入力が全部入っています。入力が何かは次で。

使ってみる

>> with! Body() as body:
...    def func():
...        for i in range(10):
...            print(i)

>> body
'def func():\n    for i in range(10):\n        print(i)\n\n'

うわああ、文字列じゃなくてpythonで書いたら書いた部分が文字列に!完璧です!
まともなマクロのない貧弱な言語でよく起きる、コード生成をしようとすると文字列の中にコードの断片を入れて繋げるみたいなエディタ殺しのひどいワークアラウンドを強いられる問題点をこれで解決できます。

今何をやっているかわかりやすく言うと、with!の中におけるpython parserを停止したわけです。
つまり、そこはpythonである必要すらないのです。(とても重要)

>> with! Body() as body:
...    <html>
...       <body></body>
...    </html>
>>> body
'<html>\n   <body></body>\n</html>\n'

>>> with! Body() as body:
...     あーたのしいー(^q^)
>>> body
'あーたのしいー(^q^)\n'

最終的に正しいpythonのコードや値になればいいんです。これでいくらでもpythonを改造できます。

本番1(テンプレートマクロ)

やっと本番です。楽しいマクロプログラミングですよ。
小さい例を改善するのが何をしているのかわかるようになると思うので、2日目くらいに書いたモジュールの遅延ロードするコードをマクロ化してみます。
元のコード例が以下です。

@lazyobject
def plt():
    plt = importlib.import_module('matplotlib.pyplot')
    plt.style.use("dark_background")
    return plt

大して長くないですが、これをいっぱい書きたくないなーと思ったとします。

辛かった頃

def lazy_import(mod_var : str, mod_name : str,
                body : str):
    code = '''          
@lazyobject 
def {mod_var}():
    {mod_var} = importlib.import_module('{mod_name}')
    {body}
    return {mod_var}'''.format(**{'mod_var' : mod_var, 'mod_name' : mod_name, 'body' : body})
    exec(body, globals(), locals()) 

formatの活躍でまだ見ていられますが、泣けるコードです。
コードの部分が文字列として囲われているせいでエディタが助けてくれません。大変でした。

使ってみましょう。

>> lazy_import!(plt, matplotlib.pyplot, plt.style.use("dark_background"))
>> plt
<module 'matplotlib.pyplot' from '/usr/lib/python3.6/site-packages/matplotlib/pyplot.py'>

ちゃんとできてますね。引数の評価も停止しているので、文字列にする必要もなくて良い感じです。
ただ、実はbodyの埋込みに問題があります。これだと複数行の埋め込みができません。
もっというと、インデントを埋め込まれる位置で調整しないといけないですし、bodyが複数埋め込まれうる場合なども考慮する必要があります。

しあわせになる

マクロというのは大抵引数から色々と計算して、それを適宜生成したテンプレートに埋め込んでいくというパターンの実装が多いです。
そういう機能が現状Xonshでは提供されていないので、今回作りました。

早速ですが、実装が以下です。

class MacroTemplate:
    __xonsh_block__ = str

    def __init__(self, vars, body=None):
        self.vars = vars # 埋め込む変数たちです
        self.body = body # 埋め込むコード部分です

    def __enter__(self):
        if self.body:
            # __body__というキーだけ特別に扱います。そこにbodyを突っ込みます
            def replace(mobj):
                return self.body
            block = re.sub('[{]__body__[}]', replace, self.macro_block)
        else:
            block = self.macro_block

        return block.format(**self.vars)

    def __exit__(self, *exc):
        del self.macro_block


# 作ったコードをしかるべき環境で実行してあげます
def realize(macro, body):
    glbs = macro.macro_globals
    locs = macro.macro_locals
    exec(body, glbs, locs)

なんとこれで、こんなふうに書けるようになります。

def lazy_import(mod_var : str, mod_name : str,
                body : str):
    with! MacroTemplate({'mod_var': mod_var, 'mod_name': mod_name}, body) as code:
        @lazyobject
        def {mod_var}():
            {mod_var} = importlib.import_module('{mod_name}')
            {__body__}
            return {mod_var}
    # debug print
    print(code)

    realize(lazy_import, code)

どうですか。結構わかりやすいんじゃないでしょうか。
実行してみます。

>> lazy_import!(np, numpy, print('test'))
@lazyobject
def np():
    np = importlib.import_module('numpy')
    print('test')
    return np

お、動いたっぽいです。次に複数行のbodyの埋込みをやってみましょう。

>> lazy_import!(np, numpy,
print('test')
print('test')
)
@lazyobject
def np():
    np = importlib.import_module('numpy')
    print('test')
print('test')
    return np

xonsh: To log full traceback to a file set: $XONSH_TRACEBACK_LOGFILE = <filename>
Traceback (most recent call last):
  File "/usr/lib/python3.6/site-packages/xonsh/__amalgam__.py", line 15472, in default
    run_compiled_code(code, self.ctx, None, 'single')
  File "/usr/lib/python3.6/site-packages/xonsh/__amalgam__.py", line 3634, in run_compiled_code
    func(code, glb, loc)
  File "<xonsh-code>", line 1, in <module>
  File "/usr/lib/python3.6/site-packages/xonsh/__amalgam__.py", line 19395, in call_macro
    rtn = f(*args, **kwargs)
  File "<xonsh-code>", line 10, in lazy_import
  File "/home/riktor/.xonsh.d/macro_utils.py", line 73, in realize
    exec(body, glbs, locs)
  File "<string>", line 6
    return np
    ^
IndentationError: unexpected indent

動きません!これだからインデントブロックはいやなんだ!括弧を最初と最期につけていれば!
実は最初から完成品のMacroTemplateの実装を見せるとちょっと難しいので、ノリだけ理解できるように重要な機能を省略しました。そのためバグっています。
デバッグプリントを見て下さい。コードが崩れてしまっています。
それで、完成品がこちら

def unwrap(var_str):
    return var_str.strip("'").strip('"')


class MacroTemplate:
    __xonsh_block__ = str

    def __init__(self, vars, body=None):
        # 文字列を受けたいこともあるので、クォートははずします
        self.vars = { k : unwrap(v) for k, v in vars.items()}

        # bodyはあとで埋め込むときのために予め行毎にしておきます
        # クォート付きの文字列リテラルを入れたいこともあるので、ここでそれはとってあげます
        self.body = unwrap(body).split('\n')

    def __enter__(self):
        if self.body:
            # __body__というキーだけ特別に扱います。そこにbodyを突っ込みます
            # {__body__}を見つけたら、そこの行頭のspaceを数えて埋め込むときの補正インデントとします
            def replace(mobj):
                indent = ' ' * mobj.group(0).find('{')
                body = '\n'.join([ indent + l for l in self.body ]) + '\n'
                return body
            block = re.sub('.*[{]__body__[}]\n', replace, self.macro_block)
        else:
            block = self.macro_block
            self.vars['__body__'] = '' 

        return block.format(**self.vars)

    def __exit__(self, *exc):
        del self.macro_block

やってみます。

>> lazy_import!(np, numpy,
for i in range(10):
    print('test')
)
@lazyobject
def np():
    np = importlib.import_module('numpy')
    for i in range(10)
        print('test')
    return np

完璧です。動きました。

Before

@lazyobject
def plt():
    plt = importlib.import_module('matplotlib.pyplot')
    plt.style.use("dark_background")    
    return plt

@lazyobject
def sns():
    sns = importlib.import_module('seaborn')
    sns.set(style="ticks", context="talk")
    return sns

@lazyobject
def np():
    return importlib.import_module('numpy')

@lazyobject
def pd():
    return importlib.import_module('pandas')

@lazyobject
def tf():
    return importlib.import_module('tensorflow')

After

lazy_import!(plt, matplotlib.pyplot,plt.style.use("dark_background"))
lazy_import!(sns, seaborn, sns.set(style="ticks", context="talk"))
lazy_import!(np, numpy, '')
lazy_import!(pd, pandas, '')
lazy_import!(tf, tensorflow, '')

確かに短くはなりましたが、ちょっと地味な結果ですね。
まあ、MacroTemplateによるlazy_importマクロの実装の綺麗さが一番重要な結果です。

本番2(lambdaをどうにかする)

lambdaが一つの式しか書けないのは本当にどうかしてる仕様です。こんなもの使い物になりません。
これをちょっと直してみましょう。
MacroTemplateがもうあるので、実装は超かんたんです。我ながら便利なものを作りました。

def xlambda(args : str, procs : str):
    with! MacroTemplate({'args': args}, procs) as code:
        def _(__tp__):
            {args} = __tp__
            {__body__}
    realize(xlambda, code)
    return _

mapなどの使い方を想定しているので、内部で作ってる関数はtupleを受けて、内部で展開するようにしました。
使ってみます。

>> list(map(xlambda!('x, y, z',
...print('spam here.')
...return x+y+z), [(1, 2, 3), (4, 5, 6)]))

spam here.
spam here.
[6, 15]

はい、なんかそれっぽく動いてます。

まとめ

どうでしたか。マクロ、ちょっと便利だと思いませんか?
これはまだ簡単な例ですが、マクロの上にマクロを重ねて高級なマクロができてくると、とてもとても関数ではやりたくないようなことができたりします。
メタプログラミングの強力な枠組みであるマクロ、やったことない人は是非Xonshで入門してみて下さい。

余談ですが、今回のマクロはソースコード文字列を自前で読んでいじってるので、リードマクロに入るんでしょうか。ホスト言語の構造を完全に無視した割と過激なマクロの部類になります。

脱線(AST最適化)

※ここからは、おまけなのでXonshのことだけ知りたい人は全然読む必要ないです。

次は、AST transformationをやってみます。
ASTをいじることについては実はXonshの機能ではありません。純然たるpythonの機能です。
でもXonshマクロがASTを手軽に使えるように提供してくれているし、何に使えるのかというのを示す意味でも、なんとなくやるべきだと思ったのでやります。
コンパイラの仕事を助けて、最適化をプログラマが定義してあげます。

今回の設定を説明します。
今、変数を含む数式をごちゃごちゃ計算しているコードがあったとします。
コンパイラも賢いので色々とやってくれますが、数学的な知識まで入れた最適化はさすがにやってくれないことが多いです。
でも、np.exp(np.log(x))ってなってたらxでいいじゃないですか。誤差もなくなるし高速化するし良いことばかりです。xが0以下だったらどうするんだー!っていう人はそこは今回保証されていると考えて下さい。
そういうのをASTをいじれるなら実装できます。コンパイラをアシストするマクロを書いてみましょう。

ASTについておさらい

真面目に説明する気は無いですが、AST(Abstract Syntax Tree, 抽象構文木)とは、例えば足し算っていうオペレータがノードになって、その右左オペランドがその子ノードとしてくっついているような木です。
情報系の人は大学でやったはずです。思い出して下さい。

これを扱えるpython標準ライブラリastがあります。というか実はcompile関数がastを吐き出す機能を標準でもっています。
当然xonshもこれを利用しているので、扱いは簡単です。

どうせだからXonshでASTを取り出して中身を下調べしてみましょう。

>> def get_ast(x : 'ast'):
...    return x
>> tree = get_ast!(np.exp(np.log(x)))
>> tree.body
<_ast.Call at 0x7f3c25826518>
>> tree.body.func.attr
'exp'
>> tree.body.args[0]
<_ast.Call at 0x7f3c249bb828>
>> tree.body.args[0].func.attr
'log'
>> tree.body.args[0].args[0].id
'x'

こんな感じですね。

無駄な計算を置き換える

np.exp(np.log(~))の部分を探し出して置き換えます。
ast.NodeTransformerはASTのノードにvisitしつつ変換もできるような特殊なVisitorだそうです。
ふーんって感じですが、これを使えば良さそうですね。

import ast
import xonsh.ast.pprint

class ExpLogReplacer(ast.NodeTransformer):
    # visit_Callはすべてのast.Callノードを回ってくれます。
    def visit_Call(self, node):
        if node.func.attr == 'exp':
            if type(node.args[0]) is ast.Call and node.args[0].func.attr == 'log':
                # 見つけたので、下の下のノードで置き換えちゃいます(copy_location(new_node, old_node)です)
                return ast.copy_location(node.args[0].args[0], node)

        # 変更なし
        return node

def optimize(formula : 'ast'):
    print('before:')
    xonsh.ast.pprint.pprint(ast.dump(formula))
    ExpLogReplacer().visit(formula)
    print('after')
    xonsh.ast.pprint.pprint(ast.dump(formula))
    return compile(formula, '', 'eval')

確認します。

>> x = 10000
>> slow_obj = compile('np.exp(np.log(x)) + np.exp(np.log(x)) + np.exp(np.log(x))', '', 'eval')
>> fast_obj = optimize!(np.exp(np.log(x)) + np.exp(np.log(x)) + np.exp(np.log(x)))
before:
("Expression(body=BinOp(left=BinOp(left=Call(func=Attribute(value=Name(id='np', "
 "ctx=Load()), attr='exp', ctx=Load()), "
 "args=[Call(func=Attribute(value=Name(id='np', ctx=Load()), attr='log', "
 "ctx=Load()), args=[Name(id='x', ctx=Load())], keywords=[])], keywords=[]), "
 "op=Add(), right=Call(func=Attribute(value=Name(id='np', ctx=Load()), "
 "attr='exp', ctx=Load()), args=[Call(func=Attribute(value=Name(id='np', "
 "ctx=Load()), attr='log', ctx=Load()), args=[Name(id='x', ctx=Load())], "
 'keywords=[])], keywords=[])), op=Add(), '
 "right=Call(func=Attribute(value=Name(id='np', ctx=Load()), attr='exp', "
 "ctx=Load()), args=[Call(func=Attribute(value=Name(id='np', ctx=Load()), "
 "attr='log', ctx=Load()), args=[Name(id='x', ctx=Load())], keywords=[])], "
 'keywords=[])))')
after
("Expression(body=BinOp(left=BinOp(left=Name(id='x', ctx=Load()), op=Add(), "
 "right=Name(id='x', ctx=Load())), op=Add(), right=Name(id='x', ctx=Load())))")
>> eval(slow_obj)
30000.000000000029
>> eval(fast_obj)
30000
>> timeit ! eval(slow_obj)
The slowest run took 9.10 times longer than the fastest. This could mean that an intermediate result is being cached.
100000 loops, best of 3: 6.15 µs per loop
>> timeit ! eval(fast_obj)
1000000 loops, best of 3: 381 ns per loop

20倍になりました。は、はえー。まあ、足し算だけなんで当然ですね。

まとめ

自重せずに書きたいことを書いたら長くなってしまいました。後半戦は濃くなったらいいなあと思っていたのですが、どうだったでしょうか。

  • Xonshマクロの基本の説明
  • Xonshマクロの実例
  • AST transformatinの簡単な実例

をやってみました。

Xonshのマクロは頑張れば結構強力にすることができそうなのですが、本気でマクロ書いている記事を英語でも探すことができませんでした。(Xonshでググると10500件しかひっかからない。。。)あったら是非教えて下さい。
pythonの分厚いコミュニティの一部の中にいるであろう濃い人たちがこのあたりをガチンコで支えてくれたりしたら、Xonshは唯一無二のシェルになるかもしれません。
というかそうなってきたらpython系の別言語としてXonshは選択肢になりえるんじゃないかとさえ思います。