pythonで学ぶ抽象構文木 〜astライブラリの紹介と導入〜
はじめに
こんにちは、DAAEテクノロジーGの近藤司です!
pythonのコード解析に四苦八苦しているあなたへ。この記事では、pythonでライブラリを使って抽象構文木(AST)を超入門的に紹介します!
ASTは、ソースコードに対して型などの様々な抽象的な情報が付加されたものです。ASTを使えば、今まで手作業で行っていたソース解析作業を効率的にできるかもしれません。
さあ、私と一緒にASTの奥深い世界を覗いてみましょう!
前提
python3が利用可能な環境ができていること
python3の基本的な文法が理解できていること(変数、関数定義、クラスなど)
また、ASTを扱う上で、木構造についての理解は大変役に立ちます。
もし本記事を読もうとしている方の中で 木構造そのものや巡回(traverse)といった用語に馴染みのない方は、 一度こちらの記事を読んでみると理解の助けになるかもしれません。
うさぎでもわかる2分探索木 後編 2分探索木における4つの走査方法
抽象構文木とは
抽象構文木(AbstractSyntaxTree、略してAST)とは、簡単に言うとソースコードの構文解析に意味付けを行ったものです。
例えば、
def hoge(arg):
print(1+2+3)
このソースをASTに変換して表示するスクリプトを組みます。
import ast
src = """
def hoge(arg):
print(1+2+3)
"""
# srcをASTに変換
parsed_src = ast.parse(src)
# ターミナル出力
print(ast.dump(parsed_src, indent=4))
結果はこのようになります。
Module(
body=[
FunctionDef(
name='hoge',
args=arguments(
posonlyargs=[],
args=[
arg(arg='arg')],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Expr(
value=Call(
func=Name(id='print', ctx=Load()),
args=[
BinOp(
left=BinOp(
left=Constant(value=1),
op=Add(),
right=Constant(value=2)),
op=Add(),
right=Constant(value=3))],
keywords=[]))],
decorator_list=[])],
type_ignores=[])
たった2行30文字程度のソースが、26行になって返ってきました。
内容を見てみると、FunctionDefという関数定義の情報があって、その内に先ほど定義した関数の名称name、引数arg、関数の処理内容bodyがあり、bodyの中には更に...と入れ子になって入っていることがわかるかと思います。
また、出力結果でソースの1+2+3に対応する部分のASTに注目してみましょう。
BinOp(
left=BinOp(
left=Constant(value=1),
op=Add(),
right=Constant(value=2)),
op=Add(),
right=Constant(value=3))
ここで重要なのは、1と2と3がどれも同じようにConstant(value=XXX)として出力されていることです。
つまりASTは、ソースに対して単純な構文解析がされただけのものではなく、言語仕様で評価して型などの抽象的な情報が付与されているわけです。
これらがASTを用いてソース解析を行う動機の一つであり、抽象化された情報をうまく扱うことでソース解析で楽ができるかもしれません。
少し話がそれますが、pythonのASTにはwalkメソッドが用意されており、構造化されたASTを容易に巡回できます。 (返り値がgeneratorであり、forやnextで値を取り出す必要がある点に注意)
# 巡回用のgeneratorを作成
gen = ast.walk(ast.parse(src))
# 先頭の一個だけ表示する
next(gen)
実装の流れ
試しに、以下のソース内のtarget関数に渡している引数を、ASTを使ってすべてターミナルに表示させることを考えます。
def target(arg):
print(arg)
def hoge():
pass
target(0)
hoge()
今回は、target関数を呼び出している場合にだけ着目しているので、 試しに関数呼び出しのノードの中身がどうなっているのか出力してみます。
walkメソッドでASTを巡回して、Callの場合だけ出力します。
import ast
src = """
def target(arg):
print(arg)
def hoge():
pass
target(0)
hoge()
"""
parsed_src = ast.parse(src)
for node in ast.walk(parsed_src):
if type(node) is not ast.Call:
continue
print(ast.dump(node,indent=4))
Call(
func=Name(id='target', ctx=Load()),
args=[
Constant(value=0)],
keywords=[])
Call(
func=Name(id='hoge', ctx=Load()),
args=[],
keywords=[])
Call(
func=Name(id='print', ctx=Load()),
args=[
Name(id='arg', ctx=Load())],
keywords=[])
リファレンスを見てみると、Callインスタンスにはfunc属性に変数名を扱うNameクラスのインスタンスが入っていて、idが変数名(関数名)となっているようです。 したがって、Callインスタンスをidで絞り込む必要があります。
for node in ast.walk(parsed_src):
if type(node) is not ast.Call:
continue
if not hasattr(node, "func"):
continue
if type(node.func) is not ast.Name:
continue
if node.func.id != "target":
continue
print(ast.dump(node,indent=4))
Call(
func=Name(id='target', ctx=Load()),
args=[
Constant(value=0)],
keywords=[])
今度はtarget関数を呼び出しているノードだけを取得することができました。
今回の目的はtarget関数に使われている引数を出力することなので、出力内容をCallインスタンスのargsから抽出します。 先ほどの出力結果からargsがConstant型のリストでvalueに値が入っているあることがわかるので、Constant型のvalueを出力します。(今回は引数が明らかに1つなので先頭の引数のみ抽出)
for node in ast.walk(parsed_src):
# フィルター
if type(node) is not ast.Call:
continue
if not hasattr(node, "func"):
continue
if type(node.func) is not ast.Name:
continue
if node.func.id != "target":
continue
# 引数を取得
arg = node.args[0]
if type(arg) is ast.Constant:
print(arg.value)
else:
print(ast.dump(node,indent=4))
0
無事に引数をターミナルに表示させることができました!
同様の処理をNodeVisitorをカスタマイズして実装することもできます。
import ast
class MyVisitor(ast.NodeVisitor):
def visit_Call(self, node):
# フィルター
if not hasattr(node, "func"):
self.generic_visit(node)
return
if type(node.func) is not ast.Name:
self.generic_visit(node)
return
if node.func.id != "target":
self.generic_visit(node)
return
# 引数を取得
arg = node.args[0]
if type(arg) is ast.Constant:
print(arg.value)
else:
print(ast.dump(node,indent=4))
src = """
def target(arg):
print(arg)
def hoge():
pass
target(0)
hoge()
"""
parsed_src = ast.parse(src)
visitor = MyVisitor()
visitor.visit(parsed_src)
0
NodeVisitorの例では、visit_Callを定義することでCallに対してのみ実行する処理を実装しています。 NodeVisitorではノードの型ごとに処理を定義でき、visit_XXXが定義されていない型に対してはgeneric_visitの処理が行われます。
NodeVisitorを利用する利点としては、ノードの型ごとに巡回時の処理を実装するつくりになっているため型ごとで処理を変えたいような場合に扱いやすくなり、スクリプトの可読性も上がる点にあると感じます。
今回の例では非常に単純な例を対象にしましたが、複数種のノードが条件分岐に関わる場合など複雑な場合はNodeVisitorを使わないと解析結果が読みづらくなってしまいます。
また、型ごとのvisit_XXXを定義する際に、generic_visitなど次のノードを走査するメソッドの前後に処理を記述することで容易に行きがけ順・帰りがけ順の処理を実装できるのも、NodeVisitorの利点です。
NodeVisitorについては、以下の記事が参考になります。
Python の ast モジュール入門 (NodeVisitor を使う)
おわりに
ASTを扱ってみて、いかがだったでしょうか。
実はASTはpythonを実行するときに使われている技術でもあり、ASTを見ていると少しだけ機械の気持ちが分かるような気がして楽しいです。
他にもASTのライブラリがある言語はありますので、ぜひ好きな言語で調べて作業を効率化しましょう!
参考文献
\もっと身近にもっとリアルに!DAAE公式Twitter/
お問合せはお気軽に
https://service.shiftinc.jp/contact/
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら