Skip to content

Add struct methods - #845

Closed
Ryan-D-Gast wants to merge 7 commits into
sharkdp:mainfrom
Ryan-D-Gast:feature/struct-methods
Closed

Add struct methods#845
Ryan-D-Gast wants to merge 7 commits into
sharkdp:mainfrom
Ryan-D-Gast:feature/struct-methods

Conversation

@Ryan-D-Gast

@Ryan-D-Gast Ryan-D-Gast commented Feb 28, 2026

Copy link
Copy Markdown

Add Inline Struct Methods With Struct-Scoped Dispatch

This PR adds inline struct methods (definition and calling) while keeping method resolution scoped to the struct.

Example Syntax

struct Vec<X: Dim> {
    x: X,
    y: X,

    fn scale(self, factor: Scalar) -> Self =
        Vec { x: self.x * factor, y: self.y * factor }

    fn dot<Y: Dim>(self, other: Vec<Y>) -> X * Y =
        self.x * other.x + self.y * other.y

    fn new(x: X, y: X) -> Self =
        Vec { x: x, y: y }
}

let v1 = Vec::new(1 m, 2 m)          # constructor-style call
let v2 = Vec { x: 3 m, y: 4 m }
let v2_cm = Vec { x: 300 cm, y: 400 cm }

let v3 = v1.scale(2)          # Vec { x: 2 m, y: 4 m }
let dp_m = v1.dot(v2)         # 11 m²
let dp_cm = v1.dot(v2_cm)     # 110_000 cm²

Calling Modes

  • Constructor-style calls: Type::method(...)
  • Instance-style calls: value.method(...)

Type checking enforces that constructor methods are called with :: and instance methods are called with ..

Why This Design

I decided against a separate impl block approach (like Rust/Swift) because I wanted methods defined alongside the struct body and to avoid introducing additional language surface and constraints.

Method Metadata + Dispatch (Simple Overview)

Struct methods are represented and executed through a struct-scoped path from typechecking to runtime:

  • Each struct records method metadata (method name, definition span, and whether it is constructor-style or instance-style).
  • Typed statements separate global functions from methods:
    • DefineFunction for global functions
    • DefineMethod for struct methods
  • Typed method-call expressions carry an explicit method reference:
    • owner struct
    • method name
    • call kind (constructor vs instance)
  • At runtime, the VM keeps a method callable table keyed by (struct_name, method_name).
    • entries can point to either compiled method bytecode or foreign/native method callables.

This gives predictable struct-local lookup and avoids name collisions with global/prelude/imported functions.

MISC

This PR relates with #795 and the broader direction discussed with @sharkdp around stronger struct features, which are needed for larger library work (including differential-equation-oriented code). I recognize this might not be a desired feature for the language, but I think it's a natural extension of the current struct syntax and semantics, and it unlocks a lot of expressiveness and code organization benefits.

@sharkdp

sharkdp commented Mar 2, 2026

Copy link
Copy Markdown
Owner

Thank you!

Before I do a deeper review, did you see my draft in #800? That PR is mostly LLM-generated (I may have written some tests myself), which is why I haven't merged it yet. I mostly wanted to explore the design space here.

I'm fine abandoning the impl block idea in favor of "inline" methods. But there are some other (potentially more interesting questions), I think. For example, the question of instance method vs static/constructor methods. Are they distinguished syntactically (e.g. like Python's @staticmethod)? Or do we introduce self as a keyword with a special meaning? How do I type-annotate a method like fn add that takes self and other of the same type (using a Self type)?

I haven't checked this PR in detail, but there are some things that we should definitely be able to handle. For example, we need to distinguish between methods on generic structs and generic methods on (potentially generic) structs. Both of these should work as expected.

There are also some implementation-related questions. For example, it would be good to share code between free functions and methods as much as possible.

All that is to say: I would appreciate if we could discuss the design more before going ahead with one of these two implementations.

@Ryan-D-Gast

Copy link
Copy Markdown
Author

Hey mate,

Before I do a deeper review, did you see my draft in #800? That PR is mostly LLM-generated (I may have written some tests myself), which is why I haven't merged it yet. I mostly wanted to explore the design space here.

I wasn’t aware of #800 — I’ve just gone through it. It looks like it’s aiming to achieve essentially the same goal as this PR, but instead of using impl blocks it defines methods inline with the struct. So conceptually we’re aligned; the main difference is structural.


But there are some other (potentially more interesting questions), I think. For example, the question of instance method vs static/constructor methods. Are they distinguished syntactically (e.g. like Python's @staticmethod)? Or do we introduce self as a keyword with a special meaning? How do I type-annotate a method like fn add that takes self and other of the same type (using a Self type)?

In my version, instance and static/constructor methods are distinguished syntactically:

  • A static method does not take self.
  • If a static method returns the struct type, it acts as a constructor.
  • Instance methods take self as the first parameter.

The call syntax mirrors Rust:

  • Foo::new() for static/constructor methods
  • foo.bar() for instance methods

Fields remain immutable (consistent with the rest of the language), so instance methods cannot mutate fields.

self behaves like Rust’s self. It doesn’t require annotation because it is implicitly the parent struct type. For example:

struct Foo {
    x: Scalar

    fn add(self, other: Scalar) -> Scalar =
        self.x + other
}

So overall, this part intentionally mirrors Rust’s model.


I'm fine abandoning the impl block idea in favor of "inline" methods.

Here’s my view on the impl vs inline question.

I think impl goes against the language’s goal (as I understand it) of keeping the syntax small and simple. Introducing impl also requires enforcing something like an orphan rule to prevent attaching methods to types defined elsewhere, which adds interpreter complexity.

With inline methods:

  • Methods must be defined where the struct is defined.
  • No orphan rule enforcement is required.
  • The interpreter doesn’t need additional structural checks.
  • The implementation is simpler.

The main argument in favor of impl would be future support for traits (impl Trait for Type), essentially following Rust’s model. However, I’m not convinced traits align with numbat’s design philosophy — they significantly increase language complexity. If traits are not a goal, the benefits of impl seem limited compared to the added complexity.


We need to distinguish between methods on generic structs and generic methods on (potentially generic) structs.

If I understand correctly, my current implementation already handles this. The comprehensive example I posted in my original comment demonstrates generic structs and generic methods working together. Let me know if I’m misunderstanding the edge case you're referring to.


It would be good to share code between free functions and methods as much as possible.

That was my initial approach. However, I didn’t like how much additional branching it introduced into the free-function handling code.

Specifically, in Statement::DefineFunction within the bytecode interpreter, I had to add multiple if branches to distinguish method cases from normal functions. This branching started spreading throughout the codebase, which made things harder to reason about.

To keep things cleaner, I separated method handling from free functions to avoid excessive branching. That said, I’m open to revisiting this — there may be a cleaner unification approach that I missed.


Related to this, I’ve also been thinking about decorator-style annotations for methods, e.g.:

@index
fn index(self, i: Int) -> Scalar = ...

This would allow something like:

foo = Foo { x, y }
z = foo[0]
assert_eq(x, z)

Users could define their own indexing behavior, which would let us implement Matrix and Tensor types as library-level abstractions (like in Rust), instead of introducing new primitive types.

Keeping method handling separate from free functions would likely make future extensions like this cleaner and reduce branching complexity in the interpreter.


I would appreciate if we could discuss the design more before going ahead with one of these two implementations.

Absolutely — I agree that design alignment should come first.

One additional thought:

If the language is meant to be purely functional, we should consider the memory implications of returning Self from instance methods. For example, in a builder-style pattern with 10 chained calls, we don’t want to allocate 9 intermediate struct instances that are immediately discarded.

We may want to think about how Self is treated internally to avoid unnecessary reallocations in these cases.


Let me know where you’d like to steer this — I’m happy to adjust the implementation once we settle on the design direction.

Pre-register struct methods before compiling their bodies so forward and mutual method recursion resolves correctly at bytecode compile time.\n\nAdd VM support for compiling into reserved method chunks.\n\nExpand struct-method edge-case tests in parser, typechecker, and interpreter suites.
Allow 'Self { ... }' in struct-construction expressions when typechecking inside struct methods.

Add parser/typechecker/interpreter coverage for 'Self { ... }' usage, including constructor and instance method paths.
Previously, struct instances stored fields as Vec<Value> and VM field access used a mutable/remove-style path. Non-generic struct instantiation in the typechecker also rebuilt equivalent instantiated StructInfo values repeatedly.

This change:
- stores Value::StructInstance fields as Arc<[Value]>;
- builds struct instances directly as Arc<[Value]> in the VM;
- accesses struct fields via borrowed slice + clone of selected field (no swap_remove path);
- updates FFI struct construction/unpacking callsites for the shared immutable field storage;
- caches non-generic struct instantiations in the typechecker and invalidates on struct redefinition.

Example of prior inefficiency in Numbat:

struct Builder {
  a: Scalar,
  b: Scalar,
  c: Scalar,
  fn new() = Builder { a: 0, b: 0, c: 0 }
  fn with_a(self, a: Scalar) = Self { a: a, b: self.b, c: self.c }
  fn with_b(self, b: Scalar) = Self { a: self.a, b: b, c: self.c }
  fn with_c(self, c: Scalar) = Self { a: self.a, b: self.b, c: c }
}
Builder::new().with_a(1).with_b(2).with_c(3)

In this chained builder pattern, cloning intermediate struct values is now much cheaper because struct payload clones are Arc refcount bumps rather than Vec payload clones.
Reduce overlapping struct-method coverage while preserving unique parser/typechecker/runtime behavior checks.

Test suite changes:
- Removed duplicated interpreter struct-method scenarios that were already covered by focused namespace/recursion tests.
- Removed duplicated typechecker success cases for simple Self-return updates now covered by broader nested/generic/where/Self scenarios.
- Kept distinct negative-path checks (constructor-vs-instance misuse, wrong arity, dimension mismatch, missing method, Self outside methods).

Docs/examples changes:
- Clarified struct method semantics in the book (Self { ... } support and immutable return semantics).
- Updated syntax example source to use Self { ... } for constructor/update methods.
- Regenerated the autogenerated syntax reference page from examples/numbat_syntax.nbt.

Validation:
- cargo test -p numbat
@Ryan-D-Gast

Copy link
Copy Markdown
Author

Closing this in favor of #847. The struct-method work from this branch has been consolidated into the draft PR, so this branch is now superseded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants