はじめに
この記事は DENSO Advent Calendar 2024 の14日目の記事です。
これまでアプリやインフラの開発・運用をしてきて、普段当たり前に使っているツールのその裏側について、どう作られているか良く知らないなとふと感じることがありました。そんな折に、Go のコンパイラづくりに関する以下の動画を見て、言語の動く仕組みに興味が湧いてきました。
Goのプログラム処理の一端を垣間見るため、まずは言語処理系の基礎である AST (Abstract Syntax Tree) について探検してみることにしました。
コンパイラの仕組み
コンパイラは、私たちが書いた高水準言語のソースコードを、コンピュータが直接実行可能な機械語(バイナリ)へと変換するプログラムです。その過程は大まかに次のステップで構成されます。
-
字句解析 (Lexical Analysis)
ソースコードを「トークン」という最小単位(キーワード、識別子、演算子、リテラルなど)に分解します。
例: if x > 0 { return x } → if, x, >, 0, {, return, x, } -
構文解析 (Syntax Analysis)
トークン列をもとに文法規則に従って階層構造化し、AST(抽象構文木)を構築します。
ここでソースコードは「if文の構造」「式(x > 0)の構造」といった論理的なツリーとして表されます。 -
意味解析 (Semantic Analysis)
ASTを走査し、変数や関数の存在、型が正しいか、定義と参照が整合しているかなどをチェックします。 -
最適化 (Optimization)
不要な計算を取り除いたり、計算順序を変えるなど、より高速な実行やコードサイズ縮小のための最適化を行います。 -
コード生成 (Code Generation)
最終的にターゲットとなるアーキテクチャ(x86、ARMなど)の機械語や中間言語(IR)を生成します。
これらの中でも、構文解析で得られるASTはコンパイラにおける中核的なデータ構造です。ASTを理解すれば、コンパイラがコードをどう「理解」しているかを知る入り口になるわけです。
以下は、AST(Abstract Syntax Tree)がなぜ言語の本質を表し、なぜASTを通して機械可読性が向上するのかに焦点を当てた説明です。
AST (Abstract Syntax Tree) の重要性
プログラミング言語は、人間が理解しやすいようにテキストで記述されます。たとえば、以下のような式を考えてみましょう。
x + y * z
人間の目には「x
にy * z
を足す」式として直感的に理解できますが、コンピュータにとっては、このままでは単なる文字の並び('x'
, ' '
, '+'
, ' '
, 'y'
, ' '
, '*'
, ' '
, 'z'
)であり、そこには数値、演算子、優先順位といった意味情報が直接埋め込まれていません。
人間にとって読みやすいテキストと機械処理のギャップ
テキストとしてのソースコードは、人間が読むには便利です。変数名はわかりやすく、空白や改行、コメントを使って意図を表現できます。しかし、機械にとっては「テキストのまま」では以下のような問題があります。
-
意味の境界が曖昧: 機械は「
x + y * z
」という文字列を、そのままでは「何が変数で、何が演算子か?」、「どこからどこまでが一つの式か?」を理解できません。 -
優先順位や文法規則が隠れている: 人間は算術規則を知っているので「
y * z
は+
より優先して計算する」と瞬時に判断しますが、コンピュータはそのような文法・意味規則をテキストから直接推定できません。
ASTで機械可読性を向上させる
ASTを構築すると、上記のテキスト情報は明確な「構造化データ」として表現されます。先ほどの式x + y * z
は、ASTでは次のようなツリー構造に変換されます。
(+)
/ \
(x) (*)
/ \
(y) (z)
このASTにおいて、
-
BinaryExpr(+)
という「加算演算子を表すノード」が根にある - 左の子ノードが
Ident(x)
、右の子ノードがBinaryExpr(*)
という「乗算演算子を表すノード」 - 乗算ノードの下には
Ident(y)
とIdent(z)
がぶら下がる
という明確な「意味的、構造的」な階層が定義されます。
このような構造があることで、機械は次のようなことを容易に行えます。
-
ノード種別による処理分岐:
BinaryExpr(+)
といったノード型を見れば「ここは加算演算を行う部分」と判断でき、Ident(x)
なら「変数x」を表すと理解できます -
優先順位の自然な表現:
+
ノードの右側には*
ノードが存在し、*
ノードはy
とz
を子として持っています。これにより、y * z
がひとまとまりの式としてx + ...
よりも先に計算されるべきことが、構造的に明示されます - 機械処理の容易さ:単なる文字列ではなく、型付きの構造化データとしてコードを扱えるため、後続の最適化、型チェック、コード生成といった処理がシンプルかつ堅牢になります
この様に、ASTはテキストベースの人間可読なコードを機械が理解できる様に言語特有の「ルールと意味」を含めて変換された結果です。このため、ASTは「言語の本質」を内包するデータ構造と言えます。
GolangのASTを覗いてみる――実装編
ではここからは、実際にGo のプログラムのASTをいくつか見てみましょう。
Goにはgo/ast
パッケージやgo/parser
パッケージが標準で用意されており、Goのソースコードを簡単にパースし、ASTとして扱うことができます。
Go プログラムファイルパスをコマンドライン引数で渡すと AST を標準出力するサンプルコードを以下に示します。
package main
import (
"flag"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
)
// 本プログラムは、指定したGoファイルをパースしてASTを表示します。
// Usage: go run main.go -file "./sample.go"
func main() {
filePath := flag.String("file", "", "Path to the Go source file")
flag.Parse()
fmt.Println(*filePath)
if *filePath == "" {
fmt.Println("Please specify a file path with -file option.")
os.Exit(1)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, *filePath, nil, parser.ParseComments)
if err != nil {
fmt.Printf("Error parsing file: %v\n", err)
os.Exit(1)
}
ast.Print(fset, f)
}
処理のフローは以下の通りです。
-
Goファイルのパスを受け取る
flag.String("file", "", "Path to the Go source file")で-fileフラグを定義し、コマンドライン引数から解析します。 -
token.FileSetの生成
token.NewFileSet()でソースコード内の位置情報(行番号やファイル内オフセット)を管理するためのFileSetを生成します。ASTを扱う際、ソースコード中の各要素がどの行・列に対応するか、このFileSetによって追跡可能になります。 -
parser.ParseFileによるAST生成
parser.ParseFile(fset, *filePath, nil, parser.ParseComments)を呼び出すと、指定したファイルパスのGoソースコードを構文解析し、*ast.File構造体としてASTが返されます。 -
ast.PrintでASTを出力
ast.Print(fset, f)を呼ぶと、AST内の構造が標準出力にツリー形式で表示されます。ノードの型や、Ident(変数名・関数名などの識別子)、FuncDecl(関数宣言)、IfStmt(if文)、ForStmt(for文)などの構成要素が確認できます。
この処理に以下のGoプログラムを渡してみます。
package main
import "fmt"
func sample() {
a := 1 + 1
fmt.Println(a)
}
すると、ASTのデータ構造を含む以下の結果が出力されてきます。
./sample.go
0 *ast.File {
1 . Package: ./sample.go:1:1
2 . Name: *ast.Ident {
3 . . NamePos: ./sample.go:1:9
4 . . Name: "main"
5 . }
6 . Decls: []ast.Decl (len = 2) {
7 . . 0: *ast.GenDecl {
8 . . . TokPos: ./sample.go:3:1
9 . . . Tok: import
10 . . . Lparen: -
11 . . . Specs: []ast.Spec (len = 1) {
12 . . . . 0: *ast.ImportSpec {
13 . . . . . Path: *ast.BasicLit {
14 . . . . . . ValuePos: ./sample.go:3:8
15 . . . . . . Kind: STRING
16 . . . . . . Value: "\"fmt\""
17 . . . . . }
18 . . . . . EndPos: -
19 . . . . }
20 . . . }
21 . . . Rparen: -
22 . . }
23 . . 1: *ast.FuncDecl {
24 . . . Name: *ast.Ident {
25 . . . . NamePos: ./sample.go:5:6
26 . . . . Name: "sample"
27 . . . . Obj: *ast.Object {
28 . . . . . Kind: func
29 . . . . . Name: "sample"
30 . . . . . Decl: *(obj @ 23)
31 . . . . }
32 . . . }
33 . . . Type: *ast.FuncType {
34 . . . . Func: ./sample.go:5:1
35 . . . . Params: *ast.FieldList {
36 . . . . . Opening: ./sample.go:5:12
37 . . . . . Closing: ./sample.go:5:13
38 . . . . }
39 . . . }
40 . . . Body: *ast.BlockStmt {
41 . . . . Lbrace: ./sample.go:5:15
42 . . . . List: []ast.Stmt (len = 2) {
43 . . . . . 0: *ast.AssignStmt {
44 . . . . . . Lhs: []ast.Expr (len = 1) {
45 . . . . . . . 0: *ast.Ident {
46 . . . . . . . . NamePos: ./sample.go:6:2
47 . . . . . . . . Name: "a"
48 . . . . . . . . Obj: *ast.Object {
49 . . . . . . . . . Kind: var
50 . . . . . . . . . Name: "a"
51 . . . . . . . . . Decl: *(obj @ 43)
52 . . . . . . . . }
53 . . . . . . . }
54 . . . . . . }
55 . . . . . . TokPos: ./sample.go:6:4
56 . . . . . . Tok: :=
57 . . . . . . Rhs: []ast.Expr (len = 1) {
58 . . . . . . . 0: *ast.BinaryExpr {
59 . . . . . . . . X: *ast.BasicLit {
60 . . . . . . . . . ValuePos: ./sample.go:6:7
61 . . . . . . . . . Kind: INT
62 . . . . . . . . . Value: "1"
63 . . . . . . . . }
64 . . . . . . . . OpPos: ./sample.go:6:9
65 . . . . . . . . Op: +
66 . . . . . . . . Y: *ast.BasicLit {
67 . . . . . . . . . ValuePos: ./sample.go:6:11
68 . . . . . . . . . Kind: INT
69 . . . . . . . . . Value: "1"
70 . . . . . . . . }
71 . . . . . . . }
72 . . . . . . }
73 . . . . . }
74 . . . . . 1: *ast.ExprStmt {
75 . . . . . . X: *ast.CallExpr {
76 . . . . . . . Fun: *ast.SelectorExpr {
77 . . . . . . . . X: *ast.Ident {
78 . . . . . . . . . NamePos: ./sample.go:7:2
79 . . . . . . . . . Name: "fmt"
80 . . . . . . . . }
81 . . . . . . . . Sel: *ast.Ident {
82 . . . . . . . . . NamePos: ./sample.go:7:6
83 . . . . . . . . . Name: "Println"
84 . . . . . . . . }
85 . . . . . . . }
86 . . . . . . . Lparen: ./sample.go:7:13
87 . . . . . . . Args: []ast.Expr (len = 1) {
88 . . . . . . . . 0: *ast.Ident {
89 . . . . . . . . . NamePos: ./sample.go:7:14
90 . . . . . . . . . Name: "a"
91 . . . . . . . . . Obj: *(obj @ 48)
92 . . . . . . . . }
93 . . . . . . . }
94 . . . . . . . Ellipsis: -
95 . . . . . . . Rparen: ./sample.go:7:15
96 . . . . . . }
97 . . . . . }
98 . . . . }
99 . . . . Rbrace: ./sample.go:8:1
100 . . . }
101 . . }
102 . }
103 . FileStart: ./sample.go:1:1
104 . FileEnd: ./sample.go:8:3
105 . Scope: *ast.Scope {
106 . . Objects: map[string]*ast.Object (len = 1) {
107 . . . "sample": *(obj @ 27)
108 . . }
109 . }
110 . Imports: []*ast.ImportSpec (len = 1) {
111 . . 0: *(obj @ 12)
112 . }
113 . Unresolved: []*ast.Ident (len = 1) {
114 . . 0: *(obj @ 77)
115 . }
116 . GoVersion: ""
117 }
たった6行のソースコードから変換されたAST、読み解いていきましょう。
-
*ast.File
がASTの入り口
一番外側が*ast.File
ノードで、ファイル全体を表します。-
Package: ./sample.go:1:1
でpackage main
が定義されている位置情報が示されています。 -
Name: "main"
から、パッケージ名がmain
であることがわかります。
-
-
インポート宣言 (
*ast.GenDecl
)
Decls
フィールドはファイル直下の宣言をリストで持っています。- 行7あたりから始まる
*ast.GenDecl
がimport "fmt"
を表します。
Specs
配下に*ast.ImportSpec
があり、Path
が"fmt"
を示しています。
- 行7あたりから始まる
-
関数宣言 (
*ast.FuncDecl
)
Decls
の2番目(行23あたり)にある*ast.FuncDecl
がfunc sample() {...}
を表します。-
Name: "sample"
から、この関数がsample
という名前であることがわかります。 -
Type: *ast.FuncType
内にParams
フィールドが空(()
のみ)で、引数なし関数であることが確認できます。
-
-
関数ボディ (
*ast.BlockStmt
)
関数ボディはBody: *ast.BlockStmt
で表現され、その中にList: []ast.Stmt
として関数内の文が列挙されています。List
には2つのステートメントがあります。-
割り当て文 (
*ast.AssignStmt
) (行43以降)
Tok: :=
から、短変数宣言が行われていることが読み取れます。
左辺(Lhs
)に*ast.Ident{Name: "a"}
, 右辺(Rhs
)に*ast.BinaryExpr
が含まれており、a := 1 + 1
という構造がそのままツリーになっています。*ast.BinaryExpr
に注目すると、X: *ast.BasicLit {Value: "1"}
Op: +
-
Y: *ast.BasicLit {Value: "1"}
となっており、1 + 1
がBinaryExpr
ノードで表現されているのがわかります。
これにより、a
は1 + 1
の計算結果を保持する変数としてスコープ内に登録されます。
また
Obj
フィールドに着目すると、"a"
がvar
としてast.Object
に登録されており、シンボルテーブル的な側面も持っていることが見て取れます。 -
関数呼び出し文 (
*ast.ExprStmt
) (行74以降)
2つ目の文はfmt.Println(a)
を表すExprStmt
です。
X: *ast.CallExpr
が関数呼び出しを表し、Fun: *ast.SelectorExpr
によってfmt.Println
という識別子が階層的に表現されています。*ast.SelectorExpr
:X: *ast.Ident{Name: "fmt"}
Sel: *ast.Ident{Name: "Println"}
このように、
fmt.Println
はX
(レシーバ側)としてfmt
というパッケージ識別子、Sel
としてPrintln
というメソッドまたは関数名で分解され、ASTがパッケージ識別と、その中の関数参照を明確に表しています。Args: []ast.Expr
内にはa
が含まれ、先ほど定義した変数a
がここで使用されていることがわかります。Obj
フィールドが同じa
を指しており、AST内でシンボルを解決できるようになっています。
-
-
Unresolved識別子 (
Unresolved: []*ast.Ident
)
出力末尾近くにUnresolved: []*ast.Ident (len = 1) { "fmt" }
とあります。これはコンパイラ構築中の段階のAST情報で、fmt
がまだ解決されていない識別子として記録されていることを示します。
Goコンパイラはこの後のフェーズ(意味解析)で、fmt
がimport "fmt"
で定義された外部パッケージであると認識し、参照を解決します。このステップまで行けばfmt
は未解決リストから外れ、完全な意味解析が完了します。
実際、インタプリタ風にソースコードを実行する評価器を実装してみましたが、その場合にも、上記の様に AST をデータ構造の浅いところから1つ1つ評価していき、動作を実行していくことで Go の簡単なプログラムであれば動作を再現できることが分かってきました。
様々なAST
ここからは、様々なGoの構文におけるASTに登場する要素を見ていきながら、実際機械に処理させる方法を考えてみましょう。
if文のAST
package main
import "fmt"
func sample() {
a := 1
if a == 1 {
a = 2
} else {
a = 3
}
fmt.Println(a)
}
*ast.IfStmt
の該当箇所のみ記載します。
Cond
に渡された不等式を評価し、true なら Body
の処理を、false なら Else
の処理を実行する様に動作させると良さそうです。
*ast.IfStmt {
If: ./sample.go:7:2
Cond: *ast.BinaryExpr {
. X: *ast.Ident {
. . NamePos: ./sample.go:7:5
. . Name: "a"
. . Obj: *(obj @ 48)
. }
. OpPos: ./sample.go:7:7
. Op: ==
. Y: *ast.BasicLit {
. . ValuePos: ./sample.go:7:10
. . Kind: INT
. . Value: "1"
. }
}
Body: *ast.BlockStmt {
. Lbrace: ./sample.go:7:12
. List: []ast.Stmt (len = 1) {
. . 0: *ast.AssignStmt {
. . . Lhs: []ast.Expr (len = 1) {
. . . . 0: *ast.Ident {
. . . . . NamePos: ./sample.go:8:3
. . . . . Name: "a"
. . . . . Obj: *(obj @ 48)
. . . . }
. . . }
. . . TokPos: ./sample.go:8:5
. . . Tok: =
. . . Rhs: []ast.Expr (len = 1) {
. . . . 0: *ast.BasicLit {
. . . . . ValuePos: ./sample.go:8:7
. . . . . Kind: INT
. . . . . Value: "2"
. . . . }
. . . }
. . }
. }
. Rbrace: ./sample.go:9:2
}
Else: *ast.BlockStmt {
. Lbrace: ./sample.go:9:9
. List: []ast.Stmt (len = 1) {
. . 0: *ast.AssignStmt {
. . . Lhs: []ast.Expr (len = 1) {
. . . . 0: *ast.Ident {
. . . . . NamePos: ./sample.go:10:3
. . . . . Name: "a"
. . . . . Obj: *(obj @ 48)
. . . . }
. . . }
. . . TokPos: ./sample.go:10:5
. . . Tok: =
. . . Rhs: []ast.Expr (len = 1) {
. . . . 0: *ast.BasicLit {
. . . . . ValuePos: ./sample.go:10:7
. . . . . Kind: INT
. . . . . Value: "3"
. . . . }
. . . }
. . }
. }
. Rbrace: ./sample.go:11:2
}
switch文のAST
package main
import "fmt"
func sample() {
a := 1
switch a {
case 1:
a = 10
case 2:
a = 20
default:
a = 0
}
fmt.Println(a)
}
*ast.SwitchStmt
の該当箇所のみ記載します。
Tag
に指定された a の変数を使って、*ast.CaseClause
で case や default 節を評価して該当する処理を実行させれば良さそうです。
*ast.SwitchStmt {
Switch: ./sample.go:7:2
Tag: *ast.Ident {
. NamePos: ./sample.go:7:9
. Name: "a"
. Obj: *(obj @ 48)
}
Body: *ast.BlockStmt {
. Lbrace: ./sample.go:7:11
. List: []ast.Stmt (len = 3) {
. . 0: *ast.CaseClause {
. . . Case: ./sample.go:8:2
. . . List: []ast.Expr (len = 1) {
. . . . 0: *ast.BasicLit {
. . . . . ValuePos: ./sample.go:8:7
. . . . . Kind: INT
. . . . . Value: "1"
. . . . }
. . . }
. . . Colon: ./sample.go:8:8
. . . Body: []ast.Stmt (len = 1) {
. . . . 0: *ast.AssignStmt {
. . . . . Lhs: []ast.Expr (len = 1) {
. . . . . . 0: *ast.Ident {
. . . . . . . NamePos: ./sample.go:9:3
. . . . . . . Name: "a"
. . . . . . . Obj: *(obj @ 48)
. . . . . . }
. . . . . }
. . . . . TokPos: ./sample.go:9:5
. . . . . Tok: =
. . . . . Rhs: []ast.Expr (len = 1) {
. . . . . . 0: *ast.BasicLit {
. . . . . . . ValuePos: ./sample.go:9:7
. . . . . . . Kind: INT
. . . . . . . Value: "10"
. . . . . . }
. . . . . }
. . . . }
. . . }
. . }
. . 1: *ast.CaseClause {
. . . Case: ./sample.go:10:2
. . . List: []ast.Expr (len = 1) {
. . . . 0: *ast.BasicLit {
. . . . . ValuePos: ./sample.go:10:7
. . . . . Kind: INT
. . . . . Value: "2"
. . . . }
. . . }
. . . Colon: ./sample.go:10:8
. . . Body: []ast.Stmt (len = 1) {
. . . . 0: *ast.AssignStmt {
. . . . . Lhs: []ast.Expr (len = 1) {
. . . . . . 0: *ast.Ident {
. . . . . . . NamePos: ./sample.go:11:3
. . . . . . . Name: "a"
. . . . . . . Obj: *(obj @ 48)
. . . . . . }
. . . . . }
. . . . . TokPos: ./sample.go:11:5
. . . . . Tok: =
. . . . . Rhs: []ast.Expr (len = 1) {
. . . . . . 0: *ast.BasicLit {
. . . . . . . ValuePos: ./sample.go:11:7
. . . . . . . Kind: INT
. . . . . . . Value: "20"
. . . . . . }
. . . . . }
. . . . }
. . . }
. . }
. . 2: *ast.CaseClause {
. . . Case: ./sample.go:12:2
. . . Colon: ./sample.go:12:9
. . . Body: []ast.Stmt (len = 1) {
. . . . 0: *ast.AssignStmt {
. . . . . Lhs: []ast.Expr (len = 1) {
. . . . . . 0: *ast.Ident {
. . . . . . . NamePos: ./sample.go:13:3
. . . . . . . Name: "a"
. . . . . . . Obj: *(obj @ 48)
. . . . . . }
. . . . . }
. . . . . TokPos: ./sample.go:13:5
. . . . . Tok: =
. . . . . Rhs: []ast.Expr (len = 1) {
. . . . . . 0: *ast.BasicLit {
. . . . . . . ValuePos: ./sample.go:13:7
. . . . . . . Kind: INT
. . . . . . . Value: "0"
. . . . . . }
. . . . . }
. . . . }
. . . }
. . }
. }
. Rbrace: ./sample.go:14:2
}
for文のAST
package main
import "fmt"
func sample() {
a := 1
for i := 0; i < 10; i++ {
a++
}
fmt.Println(a)
}
*ast.ForStmt
の該当箇所のみ記載します。
Init
でインクリメントの変数を初期化し、Cond
が true なら Body
の処理を実行します。Body
の処理が終わると Post
のインクリメント処理を実施し、以降Cond
評価が falseになるまで "Cond
評価" -> "Body
実施" -> "Post
実施" を繰り返せば良さそうです。
*ast.ForStmt {
For: ./sample.go:7:2
Init: *ast.AssignStmt {
. Lhs: []ast.Expr (len = 1) {
. . 0: *ast.Ident {
. . . NamePos: ./sample.go:7:6
. . . Name: "i"
. . . Obj: *ast.Object {
. . . . Kind: var
. . . . Name: "i"
. . . . Decl: *(obj @ 67)
. . . }
. . }
. }
. TokPos: ./sample.go:7:8
. Tok: :=
. Rhs: []ast.Expr (len = 1) {
. . 0: *ast.BasicLit {
. . . ValuePos: ./sample.go:7:11
. . . Kind: INT
. . . Value: "0"
. . }
. }
}
Cond: *ast.BinaryExpr {
. X: *ast.Ident {
. . NamePos: ./sample.go:7:14
. . Name: "i"
. . Obj: *(obj @ 72)
. }
. OpPos: ./sample.go:7:16
. Op: <
. Y: *ast.BasicLit {
. . ValuePos: ./sample.go:7:18
. . Kind: INT
. . Value: "10"
. }
}
Post: *ast.IncDecStmt {
. X: *ast.Ident {
. . NamePos: ./sample.go:7:22
. . Name: "i"
. . Obj: *(obj @ 72)
. }
. TokPos: ./sample.go:7:23
. Tok: ++
}
Body: *ast.BlockStmt {
. Lbrace: ./sample.go:7:26
. List: []ast.Stmt (len = 1) {
. . 0: *ast.IncDecStmt {
. . . X: *ast.Ident {
. . . . NamePos: ./sample.go:8:3
. . . . Name: "a"
. . . . Obj: *(obj @ 48)
. . . }
. . . TokPos: ./sample.go:8:4
. . . Tok: ++
. . }
. }
. Rbrace: ./sample.go:9:2
}
GolangのASTを覗いてみる――簡単お試し編
ここまで読んで、自分も手元で見てみたい!と思われた方におすすめの簡易的な手法として、以下のサイトを紹介します。
Go AST Viewer
というオンラインのASTビューワーであり、任意のコード片をペーストすれば即座にASTツリーが可視化されるため、手軽に内部構造を学べます。
今後の展望:ASTから広がる世界
ASTを理解できれば、インタプリタ・コンパイラだけでなく、コード解析ツールや静的解析器、コード自動変換ツール(codemod)の作成に挑戦できます。
また、他の言語処理系(Python, Rust, C++など)の構文解析にもASTの概念は共通すると考えられるので、横展開してみるのも良さそうです。ASTは言語を跨いだ共通言語のようなもので、言語設計者やツール開発者の視点を得るきっかけにもなります。
まとめ
こうした「ASTを通して言語内部を理解する」経験は、普段なかなか意識しないコンパイラやインタプリタの世界を身近なものにしてくれるはずです。ぜひ一度、自分の書いたコードをASTに落とし込み、その構造を覗いてみてはいかがでしょうか。