Skip to content

Add struct defaults, methods, and operator overloading decorators - #847

Draft
Ryan-D-Gast wants to merge 22 commits into
sharkdp:mainfrom
Ryan-D-Gast:feature/struct-operator-methods
Draft

Add struct defaults, methods, and operator overloading decorators#847
Ryan-D-Gast wants to merge 22 commits into
sharkdp:mainfrom
Ryan-D-Gast:feature/struct-operator-methods

Conversation

@Ryan-D-Gast

@Ryan-D-Gast Ryan-D-Gast commented Mar 13, 2026

Copy link
Copy Markdown

This draft consolidates the changes from #845 and #846 into one branch. Read this PR as the combined follow-up, not as an independent line of work.

Summary

This adds struct-owned operator overloads via inline method decorators and moves +/-/*// resolution onto the same deferred-constraint path used for other inference-sensitive operations.

This branch is currently stacked on top of:

So this PR should be read as a stacked draft for now, not as an independently mergeable branch against main in its current form.

It is also closely related to:

Motivation

The immediate motivation is #462: built-in DateTime operator overloading currently fails when an intermediate type variable is involved, for example:

fn id(x) = x
id(now()) + 4 days

This change fixes that deferred-resolution case and also exposes a constrained, coherence-friendly way for user-defined structs to participate in operator overloading.

The implementation follows the same general direction that fixed #459: do not require the expression type to already be concrete at the first lookup site, and instead carry a deferred constraint until more type information is available.

Syntax

Operator overloads are declared on instance methods inside a struct body:

struct Point {
    x: Scalar,
    y: Scalar,

    @add
    fn add(self, rhs: Self) -> Self =
        Point { x: self.x + rhs.x, y: self.y + rhs.y }

    @add
    fn add_scalar(self, rhs: Scalar) -> Self =
        Point { x: self.x + rhs, y: self.y + rhs }
}

let p1 = Point { x: 1, y: 2 }
let p2 = Point { x: 3, y: 4 }

let a = p1 + p2
let b = p1 + 3

Supported decorators in this PR:

  • @add
  • @sub
  • @mul
  • @div

For these decorators, the compiler infers:

  • the lhs type from self
  • the rhs type from the method's second parameter
  • the output type from the method return type

These decorators are only valid on struct instance methods. Top-level operator decorators are rejected, and overloads remain directional rather than generating an implicit reversed form.

Design

This intentionally takes a narrower path than a full trait/typeclass system.

Instead of introducing user-visible Add/Sub traits and qualified operator bounds, operator implementations are owned by the lhs struct and lowered into an internal operator-implementation table. Binary expressions can emit deferred operator constraints, which are resolved after more type information is known.

That gives us:

What this does not do is infer or expose most-general operator-bounded function types such as a hypothetical:

fn add_four_days<A, B>(dt: A) -> B
  where A: Add<Time, Output = B>

That remains future work if Numbat eventually grows a real trait/typeclass mechanism.

Implementation Notes

  • Adds parser/decorator support for @add/@sub/@mul/@div on struct methods.
  • Infers rhs and output operator types from the decorated method signature instead of requiring them to be restated in the decorator.
  • Validates that decorated methods are instance methods with exactly one rhs parameter.
  • Introduces deferred binary-operator constraints in the typechecker.
  • Resolves built-in DateTime operator behavior and struct-owned operator methods through the same deferred path.
  • Rewrites resolved deferred struct operators into explicit typed method calls before bytecode compilation.

Tests

Added coverage for:

  • parser acceptance/rejection of operator decorators
  • typechecking of valid operator methods
  • invalid decorated method signatures
  • multiple overloads for the same operator with different rhs types
  • runtime dispatch for struct operator overloads
  • the id(now()) + 4 days deferred-resolution case from Add/Sub type classes for operator overloading #462

Addresses #462.
Related: #459, #845, #846.

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

Follow-up on this draft: the branch now also supports explicit reverse operator decorators for rhs-owned dispatch.

Added decorators:

  • @radd
  • @rsub
  • @rmul
  • @rdiv

Resolution order is intentionally Python-like and directional:

  1. try the lhs-owned operator method (@add/@sub/@mul/@div)
  2. if that does not match, try the rhs-owned reverse method (@radd/@rsub/@rmul/@rdiv)

Example:

struct Point {
    x: Scalar,
    y: Scalar,
}

struct Shift {
    amount: Scalar,

    @radd
    fn add_to_point(self, lhs: Point) -> Point =
        Point { x: lhs.x + self.amount, y: lhs.y + self.amount }
}

Point { x: 1, y: 2 } + Shift { amount: 3 }

This stays explicit and directional; it does not infer commutativity or auto-generate reversed overloads.

@Ryan-D-Gast

Ryan-D-Gast commented Mar 13, 2026

Copy link
Copy Markdown
Author

Added two follow-up commits on top of this draft:

  • ba0594e8 adds indexing support for structs via @index and builtin indexing for List, so xs[i] now works directly for lists.
  • e61cff4a adds a runnable matrix example that uses nested lists for storage, struct indexing for matrix[row, col], and @mul for matrix multiplication.

Example:

struct Matrix {
    rows: Scalar,
    cols: Scalar,
    data: List<List<Scalar>>,

    fn from_rows(data: List<List<Scalar>>) -> Self =
        Self {
            rows: len(data),
            cols: if is_empty(data) then 0 else len(data[0]),
            data: data,
        }

    @index
    fn get(self, row: Scalar, col: Scalar) -> Scalar =
        self.data[row][col]

    fn dot_product(lhs: Self, rhs: Self, row: Scalar, col: Scalar, i: Scalar) -> Scalar =
        if i == lhs.cols
            then 0
            else lhs[row, i] * rhs[i, col] + Matrix::dot_product(lhs, rhs, row, col, i + 1)

    fn product_row(lhs: Self, rhs: Self, row: Scalar, col: Scalar) -> List<Scalar> =
        if col == rhs.cols
            then []
            else cons(
                Matrix::dot_product(lhs, rhs, row, col, 0),
                Matrix::product_row(lhs, rhs, row, col + 1),
            )

    fn product_rows(lhs: Self, rhs: Self, row: Scalar) -> List<List<Scalar>> =
        if row == lhs.rows
            then []
            else cons(
                Matrix::product_row(lhs, rhs, row, 0),
                Matrix::product_rows(lhs, rhs, row + 1),
            )

    @mul
    fn mul(self, rhs: Self) -> Self =
        Matrix::from_rows(Matrix::product_rows(self, rhs, 0))
}

let identity_2 = Matrix::from_rows([
    [1, 0],
    [0, 1],
])

let wide = Matrix::from_rows([
    [1, 2, 3],
    [4, 5, 6],
])

let tall = Matrix::from_rows([
    [1, 2],
    [3, 4],
    [5, 6],
])

let product = wide * tall
let unchanged = identity_2 * identity_2

assert_eq(identity_2.rows, 2)
assert_eq(identity_2.cols, 2)
assert_eq(identity_2[1, 1], 1)

assert_eq(wide.rows, 2)
assert_eq(wide.cols, 3)
assert_eq(wide[0, 2], 3)
assert_eq(wide[1, 0], 4)

assert_eq(tall.rows, 3)
assert_eq(tall.cols, 2)
assert_eq(tall[2, 1], 6)

assert_eq(product.rows, 2)
assert_eq(product.cols, 2)
assert_eq(product[0, 0], 22)
assert_eq(product[0, 1], 28)
assert_eq(product[1, 0], 49)
assert_eq(product[1, 1], 64)

assert_eq(unchanged[0, 0], 1)
assert_eq(unchanged[1, 1], 1)

print("2x2 identity: {identity_2}")
print("2x3 matrix:   {wide}")
print("3x2 matrix:   {tall}")
print("product:      {product}")

This is mainly meant to demonstrate the intended use case for the new indexing/operator-overloading features. I didn't want to do PR spam so I just included it in this draft. Obviously a built in Matrix/Tensor type is optimal so this example of a pure native numbat matrix type is more so to show the use case rather then recommending this be in the standard library.

@Ryan-D-Gast

Copy link
Copy Markdown
Author

This draft PR consolidates the work from #845 and #846 into one branch. I also ran cd book && uv run build locally to address the documentation-check failure, and the build completed successfully in this worktree.

This was referenced Mar 23, 2026
@Ryan-D-Gast Ryan-D-Gast changed the title Add struct-owned operator methods Add struct defaults, methods, and operator overloading decorators Mar 23, 2026
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.

Struct field access fails if expression type is not yet concrete

1 participant