Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

36. Value::NativeObject: one opaque object type per module, dispatched through the existing method-lookup path

  • Status: Accepted
  • Date: 2026-09-05

Context

Korrin has no mechanism today for "a native object with private Rust state and its own methods." The closest existing thing, BoundNative (ADR 0016), binds a receiver to a &'static Rust function for exactly three Value variants — Str, List, Map — via a compile-time-closed 3-arm match in methods::lookup (crates/korrin/src/interpreter/methods.rs). Every stdlib function today (math.sqrt, io.read_file, ...) is a flat, stateless Value::Native — nothing in the codebase constructs an object carrying opaque Rust-side state (a file handle, a buffer, an open window) that Korrin methods then operate on.

Milestone 7's Window needs exactly that: a minifb::Window plus a pixel buffer, held behind methods like poll_events/draw_rect/present. A third-party type cannot simply become a new Value enum variant — Value lives in the published crates/korrin library, and a module author's own type is defined outside it.

Decision

One new Value variant, NativeObject, carrying opaque state plus a method table the module itself supplies — not a fully general, multi-type, inheritance-capable object-type registry.

pub struct NativeObject {
    pub type_name: &'static str,       // what type() reports — the module's own choice
    state: RefCell<Box<dyn Any>>,      // opaque to Korrin and to the interpreter alike
    pub methods: &'static [NativeMethod],
}
impl NativeObject {
    pub fn downcast_ref<T: 'static>(&self) -> Option<Ref<'_, T>>;
    pub fn downcast_mut<T: 'static>(&self) -> Option<RefMut<'_, T>>;
}
impl Value {
    pub fn native_object(type_name: &'static str, state: impl Any, methods: &'static [NativeMethod]) -> Value;
}

methods::lookup gains exactly one new arm:

Value::NativeObject(obj) => obj.methods,

Everything downstream — BoundNative construction in get_attribute, the arity check, the call dispatch in both engines — is unchanged. A module's method table reuses the existing NativeMethod/NativeMethodFn/Arity types verbatim, built with the same const fn method(...) helper methods.rs already uses for str/list/map. A NativeMethodFn downcasts its receiver internally (recv.as_native_object().unwrap().downcast_mut::<WindowState>().unwrap()) — the same "dispatch already guarantees the variant; a mismatch would be a bug in lookup, not a Korrin program's fault" invariant recv_str/ recv_list/recv_map already rely on.

type_name(), repr()/Debug, and values_equal each gain the forced new arm: type_name() returns obj.type_name (like Instance returning its class name, not a fixed string); repr()/Debug render <native {type_name}>, never the opaque state; equality is Rc::ptr_eq only — the same identity-only stance already taken for Instance/Class/Function.

Consequences

  • Ships the smallest real thing that actually unblocks GameKorr, matching this project's own established discipline (M1's tree-walker before M3's VM; M5's leaf-only packages before a real dependency graph).
  • One object type per module, not several. A module wanting two distinct native object kinds (say, a Window and a Font) gets two separate Value::native_object(...) call sites with two separate &'static [NativeMethod] tables — there is no registry keyed by type name, no way for Korrin code to ask "what native types exist," and no inheritance between native object types. This is a real, deliberate limitation, not an oversight — a fully general registry is named and rejected below.
  • downcast_mut/downcast_ref returning None on a type mismatch is only ever a bug in the native module itself (its own method table pointing a method at the wrong constructor's state) — never a condition a Korrin program can trigger, since lookup only ever returns a method from the table already attached to the receiver. Native code is expected to .expect() this, not propagate it as a catchable Korrin exception.
  • get_attribute, BoundNative, and both engines' call-dispatch code need zero changes — the entire cost of this decision is one new Value variant, its forced exhaustive-match arms, and one new line in methods::lookup.

Alternatives considered

  • A fully general type registry — multiple native types per module, real extensibility hooks (a name-to-table HashMap populated at runtime, method resolution across a type hierarchy). Closer to a real object system for native types, and more future-proof. Rejected for v1: real upfront interpreter-surface design and risk (how do two modules' types interact? what does type() report for a registry-dispatched value? does it need its own diagnostics?) that nothing in this milestone — or in GameKorr's own stated shape — actually needs yet. Revisit once a second native module with its own object type exists and the one-type- per-module limit is felt for real, not hypothetically.
  • Representing native state as an Instance (Korrin's existing class/object mechanism) with a special "native" class flag. Rejected: Instance's fields are a HashMap<String, Value> — Korrin-level values set by self.x = ... — with no slot for opaque, non-Value Rust data (a minifb::Window cannot be a Value). Retrofitting Instance to carry an optional native payload would complicate a type every existing class-related code path already handles, for a case that needs none of Instance's actual class machinery (inheritance, super, user-defined init).