vtables, protocol

This commit is contained in:
agra
2026-02-24 06:20:38 +02:00
parent 0cc7b69441
commit 170e236764
16 changed files with 3032 additions and 294 deletions

167
specs.md
View File

@@ -278,6 +278,165 @@ Struct values in string interpolation print as `TypeName{field:value, ...}`:
print("{}", v1); // Vec4{x:1.0, y:2.0, z:3.0, w:0.0}
```
### Struct Methods
Functions declared inside a struct body become methods, registered as `StructName.method`:
```sx
Point :: struct {
x, y: s32;
sum :: (self: *Point) -> s32 { self.x + self.y; }
}
p := Point.{ x = 3, y = 4 };
print("{}\n", p.sum()); // 7
```
Methods receive the struct (typically as a pointer) as their first parameter. Dot-call syntax `obj.method(args)` resolves struct methods — it is **not** UFCS for arbitrary free functions. The pipe operator `|>` remains the universal UFCS mechanism.
### Protocol Types
Protocols define a set of method signatures that types can implement. They enable:
- **Static dispatch**: compile-time checked constraints on generic type parameters.
- **Dynamic dispatch**: type-erased protocol values with runtime method dispatch through function pointers.
#### Declaration
```sx
Allocator :: protocol #inline {
alloc :: (size: s64) -> *void;
dealloc :: (ptr: *void);
}
```
Protocol methods have an **implicit receiver** — no `self` in the protocol signature. The compiler adds `*Self` automatically. The `#inline` modifier embeds function pointers directly in the protocol value (no vtable indirection).
#### `#inline` vs default layout
| Layout | Declaration | Value layout | Dispatch cost |
|--------|-------------|--------------|---------------|
| `#inline` | `protocol #inline { ... }` | `{ ctx: *void, fn_ptr1, fn_ptr2, ... }` | Zero indirection |
| Default | `protocol { ... }` | `{ ctx: *void, __vtable: *Vtable }` | One pointer chase |
Use `#inline` for protocols with few methods where call overhead matters (e.g., allocators). Use the default layout for protocols with many methods to keep the value size small.
#### `impl` Blocks
```sx
impl Allocator for GPA {
alloc :: (self: *GPA, size: s64) -> *void {
self.alloc_count += 1;
malloc(size);
}
dealloc :: (self: *GPA, ptr: *void) {
self.alloc_count -= 1;
free(ptr);
}
}
```
- Top-level declarations (not inside struct bodies)
- Enable retroactive conformance — implement a protocol for types you don't own
- Impl methods are also registered as struct methods (`GPA.alloc`) for direct calls
- Duplicate `{Protocol, Type}` pair in the same compilation unit is a compile error
#### Protocol Values and `xx` Conversion
Convert a concrete type to a protocol value with `xx`:
```sx
gpa := GPA.init();
a : Allocator = xx gpa; // concrete → protocol value
ptr := a.alloc(64); // dynamic dispatch through fn-ptr
a.dealloc(ptr);
```
`xx` works at assignment, call sites, and return positions:
```sx
use_allocator(xx gpa); // at call site
make_alloc :: () -> Allocator { xx gpa; } // in return position
```
Protocol values can be stored in struct fields, arrays, and passed through function calls:
```sx
Arena :: struct {
parent: Allocator; // protocol value as struct field
// ...
}
allocators : [2]Allocator = .[xx gpa, xx arena]; // protocol values in array
```
#### Default Methods
Protocol methods can have bodies. `self` dispatches through the vtable (dynamic dispatch):
```sx
Writer :: protocol {
write :: (data: string) -> s64; // required
write_line :: (data: string) -> s64 { // default
n := self.write(data);
n + self.write("\n");
}
}
```
Default methods are used unless overridden in the impl. Default methods calling `self.method()` dispatch through the vtable, so they work correctly with any concrete type.
#### `Self` Type
`Self` is a contextual keyword in protocol declarations — resolves to the concrete type in impls:
```sx
Eq :: protocol { eq :: (other: Self) -> bool; }
impl Eq for Point {
eq :: (self: *Point, other: Point) -> bool {
self.x == other.x and self.y == other.y;
}
}
// Static dispatch:
p1.eq(p2); // calls Point.eq directly
// Dynamic dispatch:
e : Eq = xx p1;
e.eq(p2); // dispatches through vtable, Self params erased to *void
```
For dynamic dispatch, `Self` parameters are erased to `*void` — the caller passes a pointer to the argument, and the thunk loads the concrete value.
#### Generic Constraints
`$T/Protocol` syntax validates that a type parameter implements the required protocol(s):
```sx
are_equal :: (a: $T/Eq, b: T) -> bool { a.eq(b); }
// Multiple constraints:
eq_and_hash :: (a: $T/Eq/Hashable, b: T) -> bool { ... }
```
Constraints produce clear errors at monomorphization: `"s64 does not implement Hashable"`. Dispatch is static — same as unconstrained generics but with compile-time validation.
Constraints also work on struct type parameters:
```sx
SortedPair :: struct ($T: Type/Comparable) {
lo: T;
hi: T;
}
```
#### Generic Struct Impls
```sx
Pair :: struct ($T: Type) { a: T; b: T; }
impl Summable for Pair($T) {
sum :: (self: *Pair(T)) -> s32 { xx self.a + xx self.b; }
}
```
The impl is instantiated per concrete type argument, like generic struct methods.
#### Dispatch Rules
| Usage | Dispatch | Cost |
|-------|----------|------|
| `gpa.alloc(64)` on `*GPA` | Static — direct call | Zero |
| `$T/Allocator` constraint | Static — monomorphized | Zero |
| `a : Allocator = xx gpa; a.alloc(64)` | Dynamic — fn-ptr / vtable | Indirect call |
Static dispatch is automatic when the concrete type is known. Dynamic dispatch only when explicitly type-erased via `xx` into a protocol value.
### Tuple Types
Anonymous product types with optional field names. Tuples are first-class values — they can be stored in variables, passed to functions, and returned.
@@ -1155,7 +1314,11 @@ if handler := btn.on_click {
```
#### Memory
Closure env is heap-allocated via `malloc`. The caller is responsible for freeing `closure.env` when the closure is no longer needed. Auto-promoted closures have a null env and require no freeing.
Closure env is allocated via `context.allocator`. The compiler auto-initializes `context` with a default GPA (malloc/free wrapper) at the start of `main()`. Use `push Context` to override with a custom allocator. Auto-promoted closures have a null env and require no allocation.
```sx
f := closure((x: s64) -> s64 => x + 10); // env allocated via default GPA
print("{}\n", f(5));
```
### Function Call
```sx
@@ -1283,7 +1446,7 @@ Context :: struct {
context : Context = ---; // global mutable variable
```
Inside the pushed block, any code (including called functions) can read `context.allocator` and `context.data`. The standard library's `cstring()` and `alloc_slice()` functions use `context.allocator` for allocation when its `.ctx` is non-null, falling back to `malloc()` otherwise.
The compiler auto-initializes `context` with a default GPA (malloc/free wrapper) at the start of `main()`. Inside the pushed block, any code (including called functions) can read `context.allocator` and `context.data`. The standard library's `cstring()`, `alloc_slice()`, and `closure()` all allocate via `context.allocator`.
`push` requires a global mutable variable named `context` to be in scope (provided by `std.sx`).