はじめに
Pythonのソースコードは、実行前に一度「構文木(AST:Abstract Syntax Tree)」という形に変換されます。 このASTを扱えるようになると、コードを安全に解析・変換・自動操作できる強力なツールを手に入れることができます。
今回は、Pythonの ast モジュールを使って...
- コードを木構造として捉える感覚
- NodeVisitor / NodeTransformer の使い方
- AST → ソースコードへ戻す方法(
ast.unparse) - 安全な
evalの作り方(式だけを評価するミニ実装) - ログ自動挿入、演算子カウント、型ヒント自動付与などの応用
までを、実行可能なコードとともに整理しました。
この記事で得られること
-
ast.parseでコードを"木"として扱うイメージがつかめる -
NodeVisitor/NodeTransformerを使った解析・変換の基本 -
ast.unparseによるコード再生成(AST → ソースへのラウンドトリップ) - ASTを使った「安全な式評価(安全なeval)」の仕組み
- 応用例:ログ自動挿入、演算子カウント、型ヒント自動付与
こんな人におすすめ
-
evalは危険と聞いたけど、安全な使い方を知りたい - ASTって聞いたことあるけど、実例付きで理解したい
- リファクタリングやコード自動修正をASTでやってみたい
- NodeVisitor / NodeTransformer を実務で使えるレベルで学びたい
サンプルコード(Colabで実行可)
ノートブック形式のサンプルコードは GitHub にあります。
Google Colab で開けばそのまま実行可能です。
📄 https://github.com/sakai-path/python-ast-guide/tree/main/notebooks
(ファイル:python_ast_guide.ipynb)
1. ASTって何?なぜ使う?
AST(Abstract Syntax Tree)=コードの構造を表現する木 です。
Pythonのソースコードは実行される前に、まず「抽象構文木」という中間表現に変換されます。この木構造では、コードの各要素(変数、演算子、関数呼び出しなど)がノードとして表現され、親子関係で構造化されています。
なぜASTを使うのか?
メリットは大きく3つ
- 安全性:文字列置換ではなく"構造"に基づく変換で壊しにくい
- 精密さ:関数だけ、演算子だけ、など"狙い撃ち"の解析ができる
- 再利用性:解析(Visitor)と変換(Transformer)を分離できる
例えば、コード内のすべての print 文を logging.info に置き換えたい場合、正規表現では複雑な条件分岐が必要ですが、ASTなら確実に「関数呼び出しノード」だけを対象にできます。
2. 最小の例:ast.parse と ast.dump
まずは最もシンプルな例から始めましょう。ast.parse() はPythonコードを構文木に変換し、ast.dump() はその構造を見やすく表示します。
import ast
code = """
x = 10
y = x * 2 + 5
def add(a, b):
return a + b
print(add(y, 3))
"""
tree = ast.parse(code, mode="exec")
print(type(tree)) # -> <class '_ast.Module'>
print(ast.dump(tree, indent=4)) # 構造を階層表示
modeパラメータの使い分け
ast.parse() の mode パラメータは、解析するコードの種類を指定します。
-
mode="exec":複数の文を含むコード(通常のPythonファイル) -
mode="eval":単一の式(eval()で評価可能なもの) -
mode="single":対話モードの1行(REPLのような動作)
実行すると、コードが木構造として解析され、各要素(代入、関数定義、演算など)がノードとして表現されているのが確認できます。
3. ノードを歩く:ast.walk / NodeVisitor
ASTの木構造を探索する方法は2つあります。ast.walk() は全ノードを順に訪問し、NodeVisitor は特定のノードタイプに対して処理を定義できます。
from collections import Counter
# 全ノードの種類を数える
counts = Counter(type(n).__name__ for n in ast.walk(tree))
print(counts)
# 関数名だけ拾う Visitor
class FuncCollector(ast.NodeVisitor):
def __init__(self):
self.func_names = []
def visit_FunctionDef(self, node):
self.func_names.append(node.name)
self.generic_visit(node) # 子ノードも訪問
collector = FuncCollector()
collector.visit(tree)
print(collector.func_names) # -> ['add']
NodeVisitorの仕組み
NodeVisitor クラスを継承すると、visit_ノード名 というメソッドで特定のノードタイプだけを処理できます。上の例では visit_FunctionDef で関数定義ノードだけを処理しています。
self.generic_visit(node) を呼ぶと、そのノードの子要素も再帰的に訪問します。これを忘れると、ネストした構造(関数内の関数など)が処理されません。
4. コードを"安全に"書き換える:NodeTransformer
NodeTransformer を使うと、ASTを変更できます。例として、「2倍の掛け算」を「左シフト演算」に置き換えてみましょう(x * 2 → x << 1)。
class TimesTwoToShift(ast.NodeTransformer):
def visit_BinOp(self, node):
# まず子ノードを処理
self.generic_visit(node)
# 条件: 掛け算で、右辺が定数2の場合
if (isinstance(node.op, ast.Mult) and
isinstance(node.right, ast.Constant) and
node.right.value == 2):
# 左シフト演算に置き換え
return ast.BinOp(
left=node.left,
op=ast.LShift(),
right=ast.Constant(1)
)
return node
tree2 = ast.parse(code)
new_tree = TimesTwoToShift().visit(tree2)
ast.fix_missing_locations(new_tree) # 位置情報を補完
重要なポイント
-
self.generic_visit(node)を最初に呼んで、子ノードも変換対象にする - 新しいノードを返すことで、元のノードを置き換える
-
ast.fix_missing_locations()で行番号などの位置情報を補完(これを忘れるとコンパイル時にエラーになることがある)
5. ast.unparse でソースへ戻す(3.9+)
Python 3.9以降では、変更したASTを再びPythonコードに戻すことができます。
print(ast.unparse(new_tree))
これにより、変換後のコードを実際に確認できます。
例えば、先ほどの例では y = x * 2 + 5 が (x << 1) + 5 に変わります
(演算子の優先順位を保つため、必要な場合は括弧が自動的に付与されます)。
補足:
x << 1 + 5としてしまうと、再パース時にx << (1 + 5)と解釈されてしまい意味が変わります。ASTでは常に(x << 1) + 5の構造になっています。
Python 3.8以下の場合
3.8以下では標準ライブラリに unparse がないため、サードパーティの astor ライブラリを使います。
# pip install astor
import astor
print(astor.to_source(new_tree))
6. ミニ実装:ASTで作る「安全な式評価器」
eval() は便利ですが、ユーザーが入力した文字列をそのまま実行すると、ファイル削除など任意コード実行の危険があります。
ASTを使えば、「数式として安全なものだけ」 を評価できます。
import ast, operator, math
# 許可する演算子と関数のみ
BIN_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}
ALLOWED_FUNCS = {"sqrt": math.sqrt, "sin": math.sin}
ALLOWED_CONSTS = {"pi": math.pi, "e": math.e}
def safe_eval(expr: str):
"""eval の安全な代替:四則演算+一部の関数だけ許可"""
node = ast.parse(expr, mode="eval").body
def _ev(n):
if isinstance(n, ast.Constant):
return n.value
if isinstance(n, ast.BinOp) and type(n.op) in BIN_OPS:
return BIN_OPS[type(n.op)](_ev(n.left), _ev(n.right))
if isinstance(n, ast.UnaryOp): # +x, -x
return +_ev(n.operand) if isinstance(n.op, ast.UAdd) else -_ev(n.operand)
if isinstance(n, ast.Name) and n.id in ALLOWED_CONSTS:
return ALLOWED_CONSTS[n.id]
if isinstance(n, ast.Call) and n.func.id in ALLOWED_FUNCS:
return ALLOWED_FUNCS[n.func.id](*[_ev(a) for a in n.args])
raise ValueError("未許可の構文です")
return _ev(node)
# 使用例
print(safe_eval("1 + 2 * 3")) # 7
print(safe_eval("sqrt(2) * pi")) # OK
# safe_eval("__import__('os').system('rm -rf /')") # → エラー
なぜ安全なのか?
| 防げるもの | 理由 |
|---|---|
__import__ / exec
|
それらのノードをASTで許可していないため |
os.system(...) |
属性アクセス(.)を禁止しているため |
| 代入・関数定義など |
mode="eval" なので式以外は構文エラー |
| 辞書やリストアクセス |
x[0] のような Subscript も許可していない |
7. 応用①:関数へ自動ログを差し込む
デバッグ時に、すべての関数の実行をログに記録したい場合があります。ASTを使えば、既存のコードを変更せずに自動でログを挿入できます。
class InjectLogger(ast.NodeTransformer):
def visit_FunctionDef(self, node):
# 子ノードを処理
self.generic_visit(node)
# ログ出力文を作成
log_stmt = ast.parse(f'print("[LOG] {node.name} called with args:", locals())').body[0]
# 関数本体の先頭に挿入
node.body.insert(0, log_stmt)
return node
# 使用例
sample_code = """
def multiply(x, y):
return x * y
def calculate(a, b):
result = multiply(a, b)
return result + 10
"""
tree3 = ast.parse(sample_code)
logged = InjectLogger().visit(tree3)
ast.fix_missing_locations(logged)
# 変換後のコードを実行
exec(compile(logged, "<ast>", "exec"))
print(calculate(3, 4)) # [LOG] が出力される
実用的な拡張
実際の開発では、print の代わりに logging モジュールを使ったり、関数の引数や戻り値も記録したりすることができます。また、デコレータと組み合わせて、特定の関数だけを対象にすることも可能です。
8. 応用②:演算子の出現回数を集計する
コードの複雑さを測る指標として、演算子の使用頻度を調べることができます。
sample = """
def calc(a, b):
x = a + b
y = x * 2 - a ** 2
return (y / 3) + (a - b)
def distance(x1, y1, x2, y2):
dx = x2 - x1
dy = y2 - y1
return (dx ** 2 + dy ** 2) ** 0.5
"""
# 演算子名のマッピング
op_names = {
ast.Add: '+',
ast.Sub: '-',
ast.Mult: '*',
ast.Div: '/',
ast.Pow: '**',
ast.Mod: '%',
ast.FloorDiv: '//'
}
from collections import Counter
def count_operators(code_str):
"""コード内の演算子使用回数を集計"""
counts = Counter()
tree = ast.parse(code_str)
for node in ast.walk(tree):
if isinstance(node, ast.BinOp) and type(node.op) in op_names:
counts[op_names[type(node.op)]] += 1
return counts
result = count_operators(sample)
print("演算子の使用頻度:")
for op, count in result.most_common():
print(f" {op:3s}: {count}回")
活用例
この手法は以下のような場面で役立ちます。
- コードメトリクスの計算:複雑度の指標として使用
- 最適化の候補探し:頻繁に使われる演算を特定
- コーディング規約のチェック:特定の演算子の使用を制限
9. 応用③:関数に型ヒントを自動付与(デモ)
既存のコードに型ヒントを追加する作業を自動化できます。
class AddTypeHints(ast.NodeTransformer):
def __init__(self, default_type="Any"):
self.default_type = default_type
def visit_FunctionDef(self, node):
# 子ノードを処理
self.generic_visit(node)
# 戻り値の型ヒントがない場合は追加
if node.returns is None:
# 簡単な推論: return文を探す
for stmt in ast.walk(node):
if isinstance(stmt, ast.Return) and stmt.value:
if isinstance(stmt.value, ast.Constant):
if isinstance(stmt.value.value, int):
node.returns = ast.Name(id="int")
break
elif isinstance(stmt.value.value, float):
node.returns = ast.Name(id="float")
break
else:
node.returns = ast.Name(id=self.default_type)
# 引数の型ヒントを追加
for arg in node.args.args:
if arg.annotation is None:
# 引数名から型を推測(簡易版)
if 'id' in arg.arg or 'count' in arg.arg:
arg.annotation = ast.Name(id="int")
elif 'name' in arg.arg or 'text' in arg.arg:
arg.annotation = ast.Name(id="str")
else:
arg.annotation = ast.Name(id=self.default_type)
return node
# 使用例
code_without_hints = """
def calculate_area(width, height):
return width * height
def get_user_name(user_id):
# 実際の実装は省略
return "John Doe"
"""
tree = ast.parse(code_without_hints)
typed = AddTypeHints(default_type="Any").visit(tree)
ast.fix_missing_locations(typed)
print("型ヒント付きコード:")
print(ast.unparse(typed))
より高度な型推論
実用的なツールにするには、以下のような改善が考えられます。
- 変数の使用パターンから型を推論
- 外部ライブラリの型情報を参照
- テストコードから実際の型を収集
mypyのスタブファイルと連携
10. まとめ・実運用の勘所
ベストプラクティス
- Visitorで解析、Transformerで変換:責務を分離すると拡張しやすい
- 置換後は
ast.fix_missing_locations()を忘れずに - バージョン差:Python 3.9+ なら
ast.unparse()、それ未満はastorなどで代替 - 安全評価や自動変換など、"文字列置換の限界"を超えるときにASTが効く
実運用での注意点
- パフォーマンス:ASTの解析・変換は文字列操作より遅いため、大量のコードを処理する場合は注意
-
Python バージョンの互換性:新しい構文(
match文など)は古いPythonでは解析できない - コメントの消失:ASTはコメントを保持しないため、ソース変換時は要注意
- デバッグ情報:行番号などの情報が失われやすいので、エラー時の追跡が困難になることがある
実際のユースケース
-
静的解析ツール:
pylint、flake8などのリンターツール -
コード変換ツール:
2to3(Python 2→3変換)、black(フォーマッター) - メタプログラミング:デコレータの実装、マクロ的な機能
- セキュリティ:ユーザー入力の安全な評価、サンドボックス実行
ASTは強力なツールですが、適切な場面で使うことが重要です。単純な文字列置換で済む場合は無理に使う必要はありませんが、コードの構造を理解した上での変換が必要な場合は、ASTが最適な選択肢となります。
※ASTはコードを「構造」として扱うため、コメントや空行などの情報は保持しません。
コード整形や再構築をするツール(black, autopep8)などと組み合わせて使うと実用的になります。
参考資料
Happy Coding! 🐍✨