9
0

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【スコープ】

はじめに

「Pythonでインタプリタ作るの?それPythonで実行するんでしょ?矛盾してない?」

言語処理系を学ぶのに最適な言語がPythonだから。

理由:

  • コードが読みやすい
  • dataclass が神
  • 正規表現なしでも字句解析書ける
  • 動的型付けだからAST操作が楽

ということで、Pythonを使ってPythonっぽい(でもちょっと違う)言語を作っていく。

目標

シリーズ完走時に、こんなコードが動くインタプリタを作る:

let x = 10;
let y = 20;

fn add(a, b) {
    return a + b;
}

if x > 5 {
    print(add(x, y));
} else {
    print(0);
}

今回はその第一歩、**字句解析(Lexer)**を作る!

字句解析とは

字句解析(Lexical Analysis) は、ソースコードの文字列を「トークン」という単位に分解する処理。

let x = 42;

これを字句解析すると:

"let"  → LET(キーワード)
"x"    → IDENT("x")(識別子)
"="    → EQ(等号)
"42"   → NUMBER(42)(数値)
";"    → SEMICOLON(セミコロン)

人間が「これは変数名」「これは数字」と認識するのと同じことを、プログラムでやってるわけ。

正規表現いらない説

字句解析って正規表現使うイメージあるかもしれないけど、手書きの方がわかりやすいことが多い。

  • 文字が数字 → 数字を読み続ける → 数値トークン
  • 文字が英字 → 英数字を読み続ける → 識別子 or キーワード
  • 文字が "" が来るまで読む → 文字列トークン

これだけ。正規表現より直感的でしょ?

トークンを設計する

まず、どんなトークンが必要か考える。

今回作る言語の文法:

  • 変数宣言: let x = 10;
  • 関数定義: fn add(a, b) { return a + b; }
  • if文: if x > 0 { ... } else { ... }
  • while文: while x > 0 { ... }
  • print文: print(x);

トークンの種類

from dataclasses import dataclass
from enum import Enum, auto
from typing import List, Optional

class TokenType(Enum):
    """トークンの種類"""
    # リテラル
    NUMBER = auto()      # 数値: 42, 3.14
    STRING = auto()      # 文字列: "hello"
    IDENT = auto()       # 識別子: foo, bar
    
    # キーワード
    LET = auto()         # let
    IF = auto()          # if
    ELSE = auto()        # else
    WHILE = auto()       # while
    FN = auto()          # fn
    RETURN = auto()      # return
    TRUE = auto()        # true
    FALSE = auto()       # false
    PRINT = auto()       # print
    
    # 演算子
    PLUS = auto()        # +
    MINUS = auto()       # -
    STAR = auto()        # *
    SLASH = auto()       # /
    PERCENT = auto()     # %
    EQ = auto()          # =
    EQEQ = auto()        # ==
    NOTEQ = auto()       # !=
    LT = auto()          # <
    LTEQ = auto()        # <=
    GT = auto()          # >
    GTEQ = auto()        # >=
    AND = auto()         # &&
    OR = auto()          # ||
    NOT = auto()         # !
    
    # 区切り記号
    LPAREN = auto()      # (
    RPAREN = auto()      # )
    LBRACE = auto()      # {
    RBRACE = auto()      # }
    COMMA = auto()       # ,
    SEMICOLON = auto()   # ;
    
    # 特殊
    EOF = auto()         # ファイル終端

auto() を使うと、値を自動で割り振ってくれる。便利。

Tokenクラス

@dataclass
class Token:
    """トークン"""
    type: TokenType
    value: any
    line: int
    column: int
    
    def __repr__(self):
        if self.value is not None:
            return f"Token({self.type.name}, {self.value!r})"
        return f"Token({self.type.name})"

@dataclass 使うと、__init__ とか __repr__ を自動生成してくれる。神。

linecolumn はエラーメッセージ用。「3行目でエラー」って言えると親切でしょ?

Lexerを実装する

基本構造

class Lexer:
    """字句解析器"""
    
    def __init__(self, source: str):
        self.source = source
        self.pos = 0        # 現在位置
        self.line = 1       # 現在行
        self.column = 1     # 現在列
    
    def current_char(self) -> Optional[str]:
        """現在の文字を返す"""
        if self.pos >= len(self.source):
            return None
        return self.source[self.pos]
    
    def peek_char(self) -> Optional[str]:
        """次の文字を覗き見る(進まない)"""
        if self.pos + 1 >= len(self.source):
            return None
        return self.source[self.pos + 1]
    
    def advance(self) -> str:
        """1文字進む"""
        char = self.current_char()
        self.pos += 1
        if char == '\n':
            self.line += 1
            self.column = 1
        else:
            self.column += 1
        return char

シンプルでしょ?

  • current_char(): 今見てる文字
  • peek_char(): 次の文字を覗き見(=== の区別に使う)
  • advance(): 1文字進んで、進む前の文字を返す

空白とコメントをスキップ

def skip_whitespace(self):
    """空白をスキップ"""
    while self.current_char() and self.current_char().isspace():
        self.advance()

def skip_comment(self):
    """コメントをスキップ(#から行末まで)"""
    if self.current_char() == '#':
        while self.current_char() and self.current_char() != '\n':
            self.advance()

Pythonスタイルの # コメントに対応。

数値を読む

def read_number(self) -> Token:
    """数値を読み取る"""
    start_line, start_col = self.line, self.column
    num_str = ""
    
    while self.current_char() and (self.current_char().isdigit() or self.current_char() == '.'):
        num_str += self.advance()
    
    # 整数か浮動小数点かを判定
    if '.' in num_str:
        value = float(num_str)
    else:
        value = int(num_str)
    
    return Token(TokenType.NUMBER, value, start_line, start_col)

数字が続く限り読んで、. があったら float、なければ int

文字列を読む

def read_string(self) -> Token:
    """文字列を読み取る"""
    start_line, start_col = self.line, self.column
    quote = self.advance()  # 開始のクォートをスキップ
    string = ""
    
    while self.current_char() and self.current_char() != quote:
        if self.current_char() == '\\':
            self.advance()
            # エスケープシーケンス
            escape_char = self.advance()
            if escape_char == 'n':
                string += '\n'
            elif escape_char == 't':
                string += '\t'
            elif escape_char == '\\':
                string += '\\'
            elif escape_char == quote:
                string += quote
            else:
                string += escape_char
        else:
            string += self.advance()
    
    if self.current_char() == quote:
        self.advance()  # 終了のクォートをスキップ
    else:
        raise SyntaxError(f"Unterminated string at line {start_line}")
    
    return Token(TokenType.STRING, string, start_line, start_col)

エスケープシーケンス(\n とか \t)も対応。

識別子とキーワード

# キーワードマップ
KEYWORDS = {
    "let": TokenType.LET,
    "if": TokenType.IF,
    "else": TokenType.ELSE,
    "while": TokenType.WHILE,
    "fn": TokenType.FN,
    "return": TokenType.RETURN,
    "true": TokenType.TRUE,
    "false": TokenType.FALSE,
    "print": TokenType.PRINT,
}

def read_identifier(self) -> Token:
    """識別子またはキーワードを読み取る"""
    start_line, start_col = self.line, self.column
    ident = ""
    
    while self.current_char() and (self.current_char().isalnum() or self.current_char() == '_'):
        ident += self.advance()
    
    # キーワードかどうかチェック
    token_type = KEYWORDS.get(ident, TokenType.IDENT)
    value = ident if token_type == TokenType.IDENT else None
    
    # true/false は値を持つ
    if token_type == TokenType.TRUE:
        value = True
    elif token_type == TokenType.FALSE:
        value = False
    
    return Token(token_type, value, start_line, start_col)

読み取った識別子がキーワードかどうかを辞書で判定。シンプル。

メインの処理

def next_token(self) -> Token:
    """次のトークンを取得"""
    self.skip_whitespace()
    self.skip_comment()
    self.skip_whitespace()
    
    if self.current_char() is None:
        return Token(TokenType.EOF, None, self.line, self.column)
    
    start_line, start_col = self.line, self.column
    char = self.current_char()
    
    # 数値
    if char.isdigit():
        return self.read_number()
    
    # 文字列
    if char in ('"', "'"):
        return self.read_string()
    
    # 識別子/キーワード
    if char.isalpha() or char == '_':
        return self.read_identifier()
    
    # 2文字演算子をチェック
    two_char = char + (self.peek_char() or '')
    two_char_ops = {
        '==': TokenType.EQEQ,
        '!=': TokenType.NOTEQ,
        '<=': TokenType.LTEQ,
        '>=': TokenType.GTEQ,
        '&&': TokenType.AND,
        '||': TokenType.OR,
    }
    
    if two_char in two_char_ops:
        self.advance()
        self.advance()
        return Token(two_char_ops[two_char], None, start_line, start_col)
    
    # 1文字演算子/記号
    one_char_ops = {
        '+': TokenType.PLUS,
        '-': TokenType.MINUS,
        '*': TokenType.STAR,
        '/': TokenType.SLASH,
        '%': TokenType.PERCENT,
        '=': TokenType.EQ,
        '<': TokenType.LT,
        '>': TokenType.GT,
        '!': TokenType.NOT,
        '(': TokenType.LPAREN,
        ')': TokenType.RPAREN,
        '{': TokenType.LBRACE,
        '}': TokenType.RBRACE,
        ',': TokenType.COMMA,
        ';': TokenType.SEMICOLON,
    }
    
    if char in one_char_ops:
        self.advance()
        return Token(one_char_ops[char], None, start_line, start_col)
    
    raise SyntaxError(f"Unexpected character '{char}' at line {start_line}, column {start_col}")

def tokenize(self) -> List[Token]:
    """全トークンを取得"""
    tokens = []
    while True:
        token = self.next_token()
        tokens.append(token)
        if token.type == TokenType.EOF:
            break
    return tokens

ポイントは 2文字演算子を先にチェックすること。

== を先にチェックしないと、= が2つとして認識されちゃう。

動かしてみる

テストコード:

if __name__ == "__main__":
    source = '''
    let x = 42;
    let y = 3.14;
    let name = "hello";
    
    fn add(a, b) {
        return a + b;
    }
    
    if x > 10 {
        print(x);
    } else {
        print(y);
    }
    '''
    
    lexer = Lexer(source)
    tokens = lexer.tokenize()
    
    for token in tokens:
        print(token)

実行結果

Token(LET)
Token(IDENT, 'x')
Token(EQ)
Token(NUMBER, 42)
Token(SEMICOLON)
Token(LET)
Token(IDENT, 'y')
Token(EQ)
Token(NUMBER, 3.14)
Token(SEMICOLON)
Token(LET)
Token(IDENT, 'name')
Token(EQ)
Token(STRING, 'hello')
Token(SEMICOLON)
Token(FN)
Token(IDENT, 'add')
Token(LPAREN)
Token(IDENT, 'a')
Token(COMMA)
Token(IDENT, 'b')
Token(RPAREN)
Token(LBRACE)
Token(RETURN)
Token(IDENT, 'a')
Token(PLUS)
Token(IDENT, 'b')
Token(SEMICOLON)
Token(RBRACE)
Token(IF)
Token(IDENT, 'x')
Token(GT)
Token(NUMBER, 10)
Token(LBRACE)
Token(PRINT)
Token(LPAREN)
Token(IDENT, 'x')
Token(RPAREN)
Token(SEMICOLON)
Token(RBRACE)
Token(ELSE)
Token(LBRACE)
Token(PRINT)
Token(LPAREN)
Token(IDENT, 'y')
Token(RPAREN)
Token(SEMICOLON)
Token(RBRACE)
Token(EOF)

完璧!ソースコードがトークン列に変換された!

まとめ

今回作ったもの:

  • TokenType: トークンの種類を定義するEnum
  • Token: トークンを表すdataclass
  • Lexer: ソースコードをトークン列に変換するクラス

字句解析は正規表現なしでも書けるということがわかったと思う。

むしろ手書きの方が:

  • デバッグしやすい
  • エラーメッセージを細かく出せる
  • 拡張しやすい

次回はこのトークン列を構文木(AST)に変換するパーサーを作る!

次回予告

Part2: 再帰下降パーサー、Pythonだと楽すぎる

トークン列をASTに変換!演算子の優先順位をどう扱うかがポイント。

9
0
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
9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?