構造体とクラスを実装する——値型のstructと参照型のclassを、コンパイラに型情報を持たせずに使い分ける

状態を書き換える手段がまだ無かった

letで束縛した変数は、これまで一度書いたら書き換えられませんでした。構造体やクラスにフィールドを持たせても、それを更新する手段が無ければ、次のような自然な「カウンタを1つ増やす」処理すら書けません。

public fn increment() {
    count = count + 1; // 再代入。これがまだ無かった
}

構造体・クラスの実装に先立って、まず変数・フィールドへの再代入文(x = 2;)を導入する必要がありました。

代入文——ローカルかupvalueか、フィールドか

代入文の左辺(代入先)は、パーサの時点で3種類に分かれます。

pub enum AssignTarget {
    Ident(String),
    Field { object: Box<Expr>, field: String },
}

Identはただの識別子への代入で、これがローカル変数なのかupvalue(クロージャが捕まえた外側の変数)なのか、あるいはクラスのメソッド内でフィールドを指しているのかは、コンパイラが実際に名前を解決してみるまで分かりません。

fn compile_assign(&mut self, target: &AssignTarget, value: &Expr, line: usize, column: usize) -> Result<(), CompileError> {
    match target {
        AssignTarget::Ident(name) => {
            let frame_index = self.frames.len() - 1;
            if let Some(slot) = self.resolve_local_in(frame_index, name) {
                self.compile_expr(value, line, column)?;
                self.frame().chunk.write_op(OpCode::SetLocal, line);
                self.frame().chunk.write_u8(slot as u8, line);
            } else if let Some(index) = self.resolve_upvalue(frame_index, name) {
                self.compile_expr(value, line, column)?;
                self.frame().chunk.write_op(OpCode::SetUpvalue, line);
                self.frame().chunk.write_u8(index, line);
            } else if self.frame().is_method {
                self.emit_get_local(0, line);
                self.compile_expr(value, line, column)?;
                self.emit_set_field(name, line);
            } else {
                return Err(self.error(line, column, format!("undefined variable '{name}'")));
            }
        }
        AssignTarget::Field { object, field } => {
            self.compile_expr(object, line, column)?;
            self.compile_expr(value, line, column)?;
            self.emit_set_field(field, line);
        }
    }
    self.frame().chunk.write_op(OpCode::Pop, line);
    Ok(())
}

resolve_local_inresolve_upvalueは式を読むとき(Expr::Ident)に使っているものとまったく同じ解決ロジックで、クロージャの実装で作った仕組みをそのまま再利用しています。どちらでも見つからずクラスのメソッド内なら、thisのフィールドとして解決を試みます(thisを明示するかどうかを自由に選べる仕組みは後述します)。

面白いのは、グローバル変数への代入用オペコードが要らなかったことです。トップレベルのletはグローバル変数テーブルではなく、フレーム0のローカルスロットとして解決される設計を最初から採っているため、resolve_local_inはトップレベルの変数も等しく見つけてくれます。グローバル変数テーブル(OP_DEFINE_GLOBAL/OP_GET_GLOBAL)は今もfnclass宣言専用のままです。

OP_SET_LOCALは既存の命令をそのまま使い、新しく増やしたのはOP_SET_UPVALUEOP_SET_FIELDの2つです。OP_SET_UPVALUEはOpen/Closedのどちらの状態でも書き込めるようにしています。

OpCode::SetUpvalue => {
    let index = self.read_u8() as usize;
    let value = self.peek(0).clone();
    let upvalue = self.current_closure().upvalues[index];
    unsafe {
        match (*upvalue).as_upvalue_mut() {
            UpvalueData::Open(stack_index) => {
                let stack_index = *stack_index;
                self.stack[stack_index] = value;
            }
            UpvalueData::Closed(slot) => *slot = value,
        }
    }
}

OP_SET_LOCALと同じく、代入した値をスタックに残したまま次の命令に進みます(peekであってpopではありません)。これはfor文のインデックス更新など、既存のコードが「値を書き込んだ後、呼び出し側が改めてOP_POPする」という規約に統一されていたのに合わせたもので、代入文自体も文として使うときはcompile_assignの最後で必ずOP_POPしています。

struct——ヒープを使わない、名前で引くだけの値

構造体は「フィールドしか持たない、常にpublicな値型」と割り切りました。Valueに足したのはこの1バリアントです。

pub enum Value {
    // ...
    Struct(String, HashMap<String, Value>),
    Instance(*mut Obj),
    Class(*mut Obj),
    Void,
}

構造体リテラルPoint { x: 1, y: 2 }は、フィールド名と値を交互にスタックへ積んでから、まとめてHashMapに組み立てるOP_MAKE_STRUCT一発でコンパイルします。

Expr::StructLiteral { name, fields } => {
    for (field_name, field_expr) in fields {
        self.emit_constant(Value::Str(field_name.clone()), line);
        self.compile_expr(field_expr, line, column)?;
    }
    let name_const = self.frame().chunk.add_constant(Value::Str(name.clone()));
    self.frame().chunk.write_op(OpCode::MakeStruct, line);
    self.frame().chunk.write_u8(name_const, line);
    self.frame().chunk.write_u8(u8::try_from(fields.len()).expect("too many fields in one struct literal (max 255)"), line);
    Ok(())
}

ここで意識したのは、コンパイラが構造体のフィールド順やレイアウトを一切知らなくていい設計にすることです。関数呼び出しが最初から「名前の文字列を定数として埋め込み、実行時にグローバル変数テーブルを引く」というやり方で、コンパイラ自身が関数のシグネチャを覚えておく必要が無かったのと同じ発想を、構造体のフィールドにもそのまま持ち込みました。コンパイル時にレイアウトを決め打ちして数値インデックスでアクセスする方式(実際のC++コンパイラがやるような最適化)より遅くはなりますが、型検査と実行が完全に分離した今のアーキテクチャを崩さずに済みます。

フィールドの読み出しも同様に名前引きです。

fn exec_get_field(&mut self) {
    let Value::Str(field_name) = self.read_constant() else {
        unreachable!("compiler always emits a string constant for OP_GET_FIELD");
    };
    let object = self.pop();
    let value = match object {
        Value::Struct(_, fields) => fields.get(&field_name).cloned().expect("type checker guarantees the field exists"),
        Value::Instance(obj) => unsafe { (*obj).as_instance() }
            .fields
            .get(&field_name)
            .cloned()
            .expect("type checker guarantees the field exists"),
        other => unreachable!("type checker guarantees a struct or an instance, got {other:?}"),
    };
    self.push(value);
}

OP_GET_FIELDは構造体とクラスのインスタンスを同じ命令で扱っています。違いはValue::Structがスタック上の値そのものを直接見るのに対し、Value::Instanceはヒープへのポインタ越しに見る、という一点だけです。

構造体には代入文を許していません。let p = Point { x: 1 }; p.x = 2;は型検査の時点でエラーにします。

Type::Struct(_) => Err(self.error(line, column, "struct fields cannot be reassigned after construction".to_string())),

構造体は値型なのでコピーのたびにHashMapごと複製され(let q = p;とするとqは独立したコピーになります)、フィールドの再代入まで許すとなると「スタック上の値をその場で書き換える」ための別の仕組みが必要になります。今回はスコープを絞り、状態を書き換えたければ次に説明するclassを使う、という役割分担にしました。

class——ガベージコレクタのヒープに載る参照型

クラスのインスタンスは、ガベージコレクタを実装する記事で作ったmark-sweepヒープにそのまま載せます。ヒープのオブジェクト種別を2つ増やしました。

pub enum ObjData {
    Closure(ClosureObj),
    Upvalue(UpvalueData),
    Class(ClassObj),
    Instance(InstanceObj),
}

pub struct ClassObj {
    pub name: String,
    pub constructor: Option<*mut Obj>,
    pub methods: HashMap<String, *mut Obj>,
}

pub struct InstanceObj {
    pub class: *mut Obj,
    pub fields: HashMap<String, Value>,
}

ClassObjはクラス宣言が実行されたときに一度だけ作られ、コンストラクタとメソッドの実体(どちらもクロージャ)を持ちます。InstanceObjCounter(0)のように呼び出すたびに新しく作られ、自分がどのクラスに属するかを指すclassフィールドと、自分自身のフィールドを持ちます。ガベージコレクタのmark_objectにはこの2種類のケースを1つずつ足すだけで済みました(コンストラクタ・メソッドの実体をマークし、インスタンスならフィールドの中身も再帰的にマークします)。マーク処理がすでに循環参照に対応しているおかげで、インスタンスが自分自身を指すフィールドを持つような循環を作っても、正しく回収できます。

可視性(public/private)はフィールド・メソッド・コンストラクタのすべてで省略を許さず、常に明示させています。ただしこの区別は型検査でしか使いません。バイトコード自体にはpublicもprivateも一切現れず、コンパイラは可視性を見もしません。クラスの外からprivateなフィールドにアクセスするコードは、型検査を通った時点でもう存在しないので、実行時の防御は不要という判断です。

if *visibility == Visibility::Private && self.current_class.as_deref() != Some(name.as_str()) {
    return Err(self.error(line, column, format!("field '{field}' of class '{name}' is private")));
}

コンストラクタ——クラス自体を呼び出し可能な値にする

コンストラクタは「クラス名と同じ名前を持つ、fnキーワードの無いメソッド」という、C++に寄せた文法にしました。呼び出し方もCounter(0)と、通常の関数呼び出しと同じ見た目です。これを実現するために、クラスそのものをValue::Classという呼び出し可能な値にし、既存のOP_CALLにひとつ分岐を足しました。

fn exec_call(&mut self) {
    let arg_count = self.read_u8() as usize;
    let callee = self.peek(arg_count).clone();
    match callee {
        Value::Closure(closure) => {
            // 通常の関数呼び出し(変更なし)
            let arity = unsafe { (*closure).as_closure() }.function.arity;
            self.frames.push(CallFrame { closure, ip: 0, stack_base: self.stack.len() - arg_count - 1 });
        }
        Value::Class(class_obj) => {
            let constructor = unsafe { (*class_obj).as_class() }.constructor
                .expect("type checker guarantees a class is only called if it has a constructor");
            let instance_obj = self.alloc_instance(InstanceObj { class: class_obj, fields: HashMap::new() });
            let callee_slot = self.stack.len() - arg_count - 1;
            self.stack[callee_slot] = Value::Instance(instance_obj);
            self.frames.push(CallFrame { closure: constructor, ip: 0, stack_base: callee_slot });
        }
        other => unreachable!("type checker guarantees the callee is callable, got {other:?}"),
    }
}

ここでの鍵はcallee_slotです。関数呼び出しはもともと、呼び出し先の値(クロージャ自身)が積まれていたスタックの位置を、そのまま新しいコールフレームのstack_base(=スロット0)として使い回す設計でした。クラスを呼び出すときは、そのスロットの中身を「呼び出されたクラス」から「今作ったばかりのインスタンス」へその場で上書きしてしまいます。すると、コンストラクタの本体からはスロット0=thisとして、何の特別扱いも無く既存のローカル変数解決の仕組みだけで見えるようになります。

コンストラクタの本体が終わったときに何を返すかも同じ仕組みで解決しています。通常の関数は本体の末尾にVoidを積んでからOP_RETURNしますが、コンストラクタだけはthis(スロット0)を積みます。

if is_constructor {
    self.emit_get_local(0, line);
} else {
    self.frame().chunk.write_op(OpCode::Void, line);
}
self.frame().chunk.write_op(OpCode::Return, line);

途中でreturn;と書いて早期リターンしたときも同じ分岐が働くので、コンストラクタの中でどこで抜けても必ずインスタンス自身が返ります。型検査側は、コンストラクタの戻り値の型をVoidとして扱うことで、returnに値を書くことを禁止しつつ、値無しのreturn;だけを許可しています(既存の「戻り値の無い関数」のチェックがそのまま使えます)。

thisの解決——裸の名前は「ローカル→upvalue→フィールド」の順で探す

メソッド・コンストラクタの本体では、this.countと書いてもcountとだけ書いても同じフィールドを指します。仕組みは単純で、関数がすでに持っていた「スロット0を予約しておく」という設計をそのまま流用しています。

Expr::Ident(name) => {
    let frame_index = self.frames.len() - 1;
    if let Some(slot) = self.resolve_local_in(frame_index, name) {
        self.emit_get_local(slot as u8, line);
        return Ok(());
    }
    if let Some(index) = self.resolve_upvalue(frame_index, name) {
        self.emit_get_upvalue(index, line);
        return Ok(());
    }
    if self.frame().is_method {
        self.emit_get_local(0, line);
        self.emit_get_field(name, line);
        return Ok(());
    }
    Err(self.error(line, column, format!("undefined variable '{name}'")))
}

Expr::This => {
    self.emit_get_local(0, line);
    Ok(())
}

関数のコンパイル時、スロット0には昔から名前の無い予約枠(declare_local(String::new()))が置かれていました。これまでは誰も参照できない空きスロットでしたが、メソッド・コンストラクタとしてコンパイルするときは、ここに実際のレシーバ(this)が乗るようにしただけです。thisという式は単にスロット0を読むだけ、裸の識別子はローカル・upvalueとして見つからなければ「スロット0のフィールドかもしれない」と最後に試す、という2つの経路がここで合流しています。

メソッド呼び出し——実行時にクラスの名前表を引く

obj.method(args)をどう解決するかは、この処理系の既存の制約と関わってきます。関数呼び出しがずっとそうしてきたように、Compilerは最初から意味論的な型情報を一切持たず、純粋に構文を見てバイトコードを生成するだけの設計です。関数呼び出しが型検査済み前提で「名前をグローバル変数テーブルに引く」だけで済んでいたのと同じ理由で、メソッド呼び出しも「レシーバの実際のクラスが持つメソッド表を、実行時に名前で引く」方式にしました。Crafting Interpretersのbytecode実装(clox)が採る標準的なやり方で、継承や仮想メソッドを将来追加する余地も自然に残ります。

Expr::FieldAccess { object, field } => {
    self.compile_expr(object, line, column)?;
    for arg in args {
        self.compile_expr(arg, line, column)?;
    }
    let name_const = self.frame().chunk.add_constant(Value::Str(field.clone()));
    self.frame().chunk.write_op(OpCode::Invoke, line);
    self.frame().chunk.write_u8(name_const, line);
    let arg_count = u8::try_from(args.len()).expect("too many arguments in one call (max 255)");
    self.frame().chunk.write_u8(arg_count, line);
    Ok(())
}

「レシーバのフィールドとしてメソッドを取り出す」→「取り出した値を呼び出す」を素直に2命令に分けることもできますが、それだと取り出した「メソッド」を一時的な値として表現する型(cloxでいう ObjBoundMethod)が余分に必要になります。この処理系はメソッドを値として持ち回ること自体をサポートしないと決めていたので、「取り出して即呼ぶ」を1命令にまとめたOP_INVOKEだけで十分でした。

fn exec_invoke(&mut self) {
    let Value::Str(method_name) = self.read_constant() else {
        unreachable!("compiler always emits a string constant for OP_INVOKE");
    };
    let arg_count = self.read_u8() as usize;
    let receiver_slot = self.stack.len() - arg_count - 1;
    let Value::Instance(instance_obj) = self.stack[receiver_slot].clone() else {
        unreachable!("type checker guarantees method calls target a class instance")
    };
    let class_obj = unsafe { (*instance_obj).as_instance() }.class;
    let method = unsafe { (*class_obj).as_class() }
        .methods
        .get(&method_name)
        .copied()
        .expect("type checker guarantees the method exists");
    self.frames.push(CallFrame { closure: method, ip: 0, stack_base: receiver_slot });
}

ここでもreceiver_slotをそのまま新しいコールフレームのstack_baseにしているので、コンストラクタのときとまったく同じ理屈で、メソッドの中からもthis(スロット0)としてレシーバが見えます。呼び出しの種類(関数・コンストラクタ・メソッド)が3通りに増えても、「呼び出し先の直前に積まれていたスタック位置を、そのままスロット0として引き継ぐ」という1つの規約だけで全部説明できるのは、当初の関数呼び出しの設計がそのまま生きている証拠でもあります。

動作確認

構造体のフィールド読み出しと、classの参照セマンティクス(同じインスタンスを複数の変数が指し、片方から書き換えるともう片方にも見える)を1つのプログラムで確認します。

struct Point {
    x: int;
    y: int;
}

let p = Point { x: 1, y: 2 };
let px = p.x;

class Counter {
    private count: int;

    public Counter(start: int) {
        this.count = start;
    }

    public fn increment() {
        count = count + 1;
    }

    public fn get_count(): int {
        return count;
    }
}

let c1 = Counter(0);
let c2 = c1;
c2.increment();
c2.increment();
let shared = c1.get_count();
$ cargo run -- examples/classes.na
stack after execution: [Struct("Point", {"y": Int(2), "x": Int(1)}), Int(1), Instance(0x55e589014ba0), Instance(0x55e589014ba0), Int(2)]

px1で構造体のフィールドが正しく読めています。c1c2は同じアドレス(0x55e589014ba0)を指しており、c2経由で2回increment()したにもかかわらずsharedc1.get_count()の結果)は2です。参照型として同一のインスタンスを共有できていることが確認できます。コンパイラ・型検査器・VM合わせて新規に約35件のテストを追加し、既存分と合わせて129件が全てパスすることも確認済みです。

備考

structのフィールドはなぜ再代入不可にしたか

C++やRustの構造体は本来、値型であってもフィールドは自由に書き換えられます。今回あえて「構築後は読み取り専用」にしたのは、構造体がスタック上の値としてどこにあるか(ローカル変数のスロットなのか、配列の要素なのか、他の構造体のフィールドなのか)を追跡してその場で書き換える一般的な代入先(lvalue)の仕組みを、この段階では作り込みたくなかったためです。状態を持たせて書き換えたいならclassを使う、という役割分担をひとまず明確にしています。structのフィールドを可変にする場合は、代入先の一般化(obj[i].field = vのような式も含めて)を別途設計する必要があります。

継承・仮想メソッド・ジェネリクスはまだ無い

classは単一のクラスとして完結しており、他のクラスを継承すること、メソッドをオーバーライドすることはできません。OP_INVOKEが実行時にメソッド名を引く設計にしてあるのは、将来こうした機能を足すときに、少なくともメソッド呼び出しの命令自体は作り直さずに済むようにという見込みからです。

メソッドを値として持ち回ることはできない

c1.incrementのように、呼び出さずにメソッドだけを取り出して変数に入れたり、コールバックとして渡したりすることはサポートしていません。obj.method(args)という直接呼び出しの形でしか使えず、これは関数がまだ第一級の値になっていないのと同じ制約です。

可視性チェックはコンパイル時だけで、バイトコードには一切残らない

privateかどうかの判定は型検査の段階で完結し、コンパイラもOP_GET_FIELD/OP_INVOKEを生成する際に可視性を一切見ません。型検査を通過したバイトコードは「アクセスして良いことが既に確認済み」という前提で実行されるため、実行時にもう一度可視性を検証するコードは意図的に書いていません(引数の個数チェックを実行時はdebug_assert_eq!だけで済ませているのと同じ考え方です)。