10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonで作る自作インタプリタ入門シリーズ
Part1【字句解析】 | Part2【構文解析】 | Part3【AST構築】 | Part4【評価器】 | Part5【スコープ・完結】

はじめに

シリーズ最終回!

今回は変数スコープを深掘りする。

  • シャドーイング
  • クロージャ
  • スコープチェーン

これらがなぜ動くのかを実際のコードで検証していく。

スコープとは

**スコープ(Scope)**は、変数が有効な範囲のこと。

let x = 10;        # グローバルスコープ

if true {
    let x = 20;    # ローカルスコープ(シャドーイング)
    print(x);      # 20
}

print(x);          # 10

同じ名前の変数でも、スコープが違えば別物。

Environmentクラスの復習

前回作った Environment クラス:

class Environment:
    def __init__(self, parent: Optional['Environment'] = None):
        self.variables: Dict[str, Any] = {}
        self.parent = parent
    
    def define(self, name: str, value: Any):
        """変数を定義"""
        self.variables[name] = value
    
    def get(self, name: str) -> Any:
        """変数を取得"""
        if name in self.variables:
            return self.variables[name]
        if self.parent:
            return self.parent.get(name)
        raise NameError(f"Undefined variable: {name}")
    
    def set(self, name: str, value: Any):
        """変数に代入"""
        if name in self.variables:
            self.variables[name] = value
            return
        if self.parent:
            self.parent.set(name, value)
            return
        raise NameError(f"Undefined variable: {name}")

ポイント:

  • 親環境へのポインタを持つ
  • 変数が見つからなければ親を辿る
  • これがスコープチェーン

テスト1: 基本的なスコープ

let x = 10;

if true {
    let x = 20;
    print(x);
}

print(x);

実行結果:

20
10

なぜこうなる?

グローバル環境 { x: 10 }
    │
    └── if ブロック環境 { x: 20 }  ← 親はグローバル
  1. ifブロック内の print(x) → ローカルの x = 20 を見つける
  2. ifブロック外の print(x) → グローバルの x = 10 を見つける

let現在のスコープに新しい変数を作るから、シャドーイングが起きる。

テスト2: クロージャ

let x = 10;

fn addX(n) {
    return n + x;
}

print(addX(5));
x = 20;
print(addX(5));

実行結果:

15
25

クロージャが動いてる!

なぜ?

グローバル環境 { x: 10, addX: <Function> }
    │
    └── addX呼び出し環境 { n: 5 }
        └── 親 → グローバル環境(クロージャ)

addX は定義時のグローバル環境をクロージャとして保持している。

だから関数内から x にアクセスできる。

そして x = 20 でグローバルの x を変更すると、次の呼び出しでは 25 になる。

テスト3: 再帰とフィボナッチ

fn fib(n) {
    if n <= 1 {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

print(fib(0));
print(fib(1));
print(fib(2));
print(fib(3));
print(fib(4));
print(fib(5));
print(fib(10));

実行結果:

0
1
1
2
3
5
55

再帰が完璧に動いてる!

再帰が動く仕組み:

グローバル { fib: <Function> }
    │
    ├── fib(5)の環境 { n: 5 }
    │   └── fib(4)の環境 { n: 4 }
    │       └── fib(3)の環境 { n: 3 }
    │           └── ...
    │
    └── 各環境の親 → グローバル(fibを含む)

各呼び出しで新しい環境が作られるから、n の値が混ざらない。

そして全ての環境の親(クロージャ)がグローバルを指しているから、fib 自身を呼べる。

テスト4: ネストした関数呼び出し

fn double(x) {
    return x * 2;
}

fn quadruple(x) {
    return double(double(x));
}

print(quadruple(5));

実行結果:

20

quadruple(5) の実行過程:

  1. quadruple(5) 呼び出し
  2. double(5) 呼び出し → 10 を返す
  3. double(10) 呼び出し → 20 を返す
  4. 20 を返す

ネストした関数呼び出しも問題なく動く。

テスト5: シャドーイング地獄

let x = 1;
print(x);

if true {
    let x = 2;
    print(x);
    
    if true {
        let x = 3;
        print(x);
    }
    
    print(x);
}

print(x);

実行結果:

1
2
3
2
1

スコープのネスト:

グローバル { x: 1 }
    │
    └── ifブロック1 { x: 2 }
        │
        └── ifブロック2 { x: 3 }

各ブロックを抜けると、そのスコープの変数は消える(環境が捨てられる)。

だから x の値が 1 → 2 → 3 → 2 → 1 と変化する。

発狂しかけたポイント

1. let と代入の違い

最初、これでハマった:

let x = 10;

if true {
    x = 20;  # これは代入(グローバルのxを変更)
}

print(x);  # 20
let x = 10;

if true {
    let x = 20;  # これは新しい変数の定義(シャドーイング)
}

print(x);  # 10

let = 新規定義、= = 既存変数への代入。

これを混同すると地獄。

2. クロージャのタイミング

# 間違った実装
func_env = Environment(self.global_env)  # グローバルを親に

# 正しい実装
func_env = Environment(func.closure)  # 定義時の環境を親に

クロージャは定義時の環境を保持する。呼び出し時じゃない。

3. 代入のスコープ探索

代入文は、変数が定義されているスコープまで辿る必要がある:

def set(self, name: str, value: Any):
    if name in self.variables:
        self.variables[name] = value
        return
    if self.parent:
        self.parent.set(name, value)  # 親を辿る!
        return
    raise NameError(f"Undefined variable: {name}")

これを忘れると、グローバル変数への代入が動かない。

完成!

これで自作インタプリタが完成!

動くもの:

  • 変数宣言と代入
  • 算術演算、比較演算、論理演算
  • if文、while文
  • 関数定義と呼び出し
  • 再帰関数
  • クロージャ
  • シャドーイング

動かないもの:

  • 配列、オブジェクト
  • 第一級関数(関数を値として扱う)
  • 文字列操作

でも、FizzBuzzは書ける

fn fizzbuzz(n) {
    let i = 1;
    while i <= n {
        if i % 15 == 0 {
            print("FizzBuzz");
        } else {
            if i % 3 == 0 {
                print("Fizz");
            } else {
                if i % 5 == 0 {
                    print("Buzz");
                } else {
                    print(i);
                }
            }
        }
        i = i + 1;
    }
}

fizzbuzz(20);

シリーズまとめ

Part 内容 学んだこと
Part1 字句解析 トークン、正規表現なしでLexer書ける
Part2 構文解析 再帰下降パーサー、演算子の優先順位
Part3 AST構築 dataclass神、Union型
Part4 評価器 return文は例外で実装、クロージャ
Part5 スコープ スコープチェーン、シャドーイング

全部合わせても500行くらい

Pythonだと本当に楽。C++で書いたら3倍はかかる。

次のステップ

もっと発展させるなら:

  1. 配列サポート: let arr = [1, 2, 3];
  2. オブジェクト: let obj = { name: "foo" };
  3. 第一級関数: 関数を変数に代入、引数に渡す
  4. エラー処理: try-catch
  5. 型システム: 静的型付け
  6. バイトコードコンパイル: VM方式に

でも、まずは動くものを作るのが大事。

言語処理系、思ったより簡単でしょ?

おわりに

「Pythonでインタプリタを作る」というプロジェクト、完走できた!

学べたこと:

  • 言語処理系の構造(Lexer → Parser → Interpreter)
  • 再帰下降パーサーの書き方
  • スコープとクロージャの実装
  • dataclass の便利さ

自分で言語を作ると、プログラミング言語への理解が深まる

ぜひ皆さんも作ってみてください!

10
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?