broaden type inference from context (first pass)

This commit is contained in:
2026-06-25 23:57:12 +02:00
parent 70a6d69d29
commit ff69e1da83
3 changed files with 855 additions and 38 deletions
+359 -4
View File
@@ -198,9 +198,40 @@
a `float` param accepts an integer-literal argument as f64 (e.g. `f(3)`)
- a function result is narrowed to the constraint's family
14. broaden type inference to surrounding context
14. broaden type inference to surrounding context (implemented)
- a slot's concrete type is the join of demands reachable from its declaration,
flowing backward as well as forward to a fixpoint (the existing global/spec
fixpoint in `infer_all`), generalizing milestone 13's forward-only resolution
- an "open constant" (a global or local with no concrete annotation plus a compile-time
integer initializer) is sign-agnostic until used: a backward demand from any reachable
use picks its family/width as long as the value fits, so `A :: 10` followed by
`B u16 :: A` resolves both to u16 — the literal's smallest-signed default no longer
blocks an unsigned demand; absent any demand it defaults to the smallest signed type
- a concrete declared type flows backward through a chain of bare-name references:
`X :: 1000; Y int :: X; Z i32 :: Y` resolves X and Y to i32 (previously they stayed
at the literal's i16)
- locals resolve identically to globals (no scope asymmetry): demands flow through
bare-name typed declarations, call arguments (a concrete parameter type demands its
argument, e.g. `take_u16(a)`), and returns — including from inside a function body
back onto a referenced global
- demands flow only through bare names; they do not cross arithmetic or other operators,
nor back across a call's result (the result-to-argument direction is milestone 14.5)
- a non-fitting or family-conflicting demand is not applied (first demand wins); the
genuine mismatch then surfaces as the usual boundary coercion error at the use
(e.g. `C u8 :: BIG` where `BIG :: 100000`)
15. add slice-by-range
14.5. backward type-demand propagation through call boundaries (deferred)
- a callee's result/return demand flows back through the function body to constrain
the caller's arguments, so `R u32 :: echo(A)` (with `echo :: func(p int) int`)
resolves A to u32 instead of erroring at the call's result coercion
- requires reversing the per-call data flow: a specialization's argument types
(`spec.args`) become outputs to solve, not just inputs — a new back-edge threaded
through every call site and the specialization fixpoint
- only meaningful on top of milestone 14's open constants
15. broaden type inference to infer type of declaration based on arithmetic expressions too
16. add slice-by-range
- allow the use of a range in slice expressions:
```
excl_range range :: 0..10
@@ -210,6 +241,22 @@
some_arr[incl_range] # slice by named inclusive range
```
17. for if statements, allow `if (cond) one-line statement` (instead of forcing either `if (cond) { block }` or `if cond { block }`)
- if statements without a bracketed body must enclose the condition in parentheses
18. add `defer` statement (inspired by zig)
19. multi-line strings (see below)
20. unions and tagged unions
21. match statements with tagged unions payload unwrapping
22. dynamic heap allocation
- see below for direction
- notes below are too big in scope for a first pass and the language is not mature enough to support it yet
- this first pass should focus on just basic heap allocation, so we have something to work with
## A word on multi-unwrap
Unwrap multiple optionals with `and`. This **short-circuits**: if the first optional is none, subsequent expressions are not evaluated.
@@ -276,7 +323,7 @@ This rule keeps the grammar simple and forces clarity at the call site — no pr
For-loop captures are immutable and scoped to the loop body. Sequence index captures are `usize`. Pointer capture uses `|@item|`; arrays must be passed by pointer (for example `&items`), while slices can be used directly. Sentinel elements are not included in iteration.
# A word on distinct types
## A word on distinct types
Distinct types are considered distinct from their backing type. They do not implicitly coerce to their backing type.
@@ -288,7 +335,7 @@ UserID :: distinct u32
my_id UserID :: UserID(42) # value must have the exact backing type
```
# A word on enums
## A word on enums
```
# standard enums
@@ -347,3 +394,311 @@ value native.Imported_Enum :: native.IMPORTED_ENUM_VALUE
The imported enum type is an alias of its target-selected C integer backing, and imported
enumerators are package-level constants.
## A word on multi-line strings
Multi-line strings use the `` ` `` character to mark each line. Content starts immediately after the backtick. Newlines between lines are implicit.
```
config =
`# Database configuration
`host = localhost
`port = 5432
`
`[server]
`address = 0.0.0.0
```
Key properties:
* Content begins immediately after `` ` ``
* Newlines are automatically inserted between lines
* Empty `` ` `` produces a blank line
* No escape sequence processing (raw content)
* No trailing newline after the last line
Only the leading `` ` `` is special; the rest is treated as raw content.
If you need a trailing newline, add an empty line at the end:
```
# No trailing newline
msg =
`hello
`world
# With trailing newline
msg =
`hello
`world
`
```
Mixing multi-line strings with inline strings (using concatenation):
```
message =
"Header:\t" ++
`more content here
`even more content
`
++ "Footer"
```
Formatting alternative (purely aesthetics/preference, no effect on program):
```
message = "Header:\t" ++
`more content here
`even more content
`
++ "Footer"
```
## A word on memory allocation
(NOTE THAT SYNTAX MAY NOT MATCH BROLANG EXACTLY AND SHOULD BE TAKEN WITH A GRAIN OF SALT - INSPIRATION ONLY)
(FURTHER, EXAMPLES ASSUME ARGUMENTS WITH DEFAULT VALUES AND COMPTIME POLYMORPHISM IN THE FORM OF GENERIC TYPE PARAMETERS - MONOMORPHISED)
Memory allocation in Brolang is designed to be **explicit but not verbose**. We reject the dogma that global state is inherently evil — allocators are a cross-cutting concern that nearly every function needs, making them a perfect candidate for sensible defaults.
### Philosophy
```
┌─────────────────────────────────────────────────────────────┐
│ DESIGN PRINCIPLES │
│ │
│ 1. No hidden magic: allocation calls are visible │
│ 2. Sensible defaults: thread-local heap for common cases │
│ 3. Explicit override: custom allocators when needed │
│ 4. Build-mode aware: different behavior for debug/release │
│ 5. Immutable defaults: no "action at a distance" bugs │
│ 6. Escaping allocations: caller provides allocator │
└─────────────────────────────────────────────────────────────┘
```
### The Default Heap Allocator
Brolang provides a **thread-local global heap allocator** that is:
* Determined at compile time by build mode
* **Immutable at runtime** — cannot be reconfigured
```
import "std/mem/heap"
process :: func(input []u8) u64 {
# heap used for internal temporary work — does not escape
temp := heap.alloc(u8, size: input.len * 2)
defer heap.free(temp)
# ... work with temp ...
return compute_hash(temp) # only the result escapes, not the allocation
}
```
The behavior of `heap` depends on build mode:
| Build Mode | Allocator Behavior |
| -- | -- |
| Debug | Tracking allocator with leak detection |
| Release | Fast allocator, zero overhead |
| ReleaseSafe | Bounds-checking allocator |
This is configured at compile time. You cannot change which allocator `heap` uses at runtime. This is intentional — it prevents bugs where memory allocated with one allocator is freed with another.
### The Escaping Allocation Rule
**If a function heap-allocates memory that escapes its scope — whether via the return value or via writes through mutable parameters — the function must accept an allocator parameter.** The presence of an allocator parameter is the contract that says "heap memory escapes here, and you're responsible for it."
This rule makes ownership transfer visible at the function signature level. The caller never needs to read the function's implementation to know whether heap cleanup is involved:
```
import "std/mem"
import "std/mem/heap"
# Allocation escapes via return value — requires allocator
duplicate :: func(input []u8, allocator @mem.Allocator) []u8 {
result := allocator.alloc(u8, size: input.len)
mem.copy(result, input)
return result # caller manages this memory
}
# Allocation escapes via mutable parameter — requires allocator
init :: func(obj: @mut MyStruct, allocator: @mem.Allocator) void {
obj.buffer = allocator.alloc(u8, size: 100)
# caller now knows heap memory was written into obj
}
# No allocation escapes — no allocator needed
process :: func(input: []u8) u64 {
temp := heap.alloc(u8, size: input.len)
defer heap.free(temp)
# ... work with temp ...
return compute_hash(temp)
}
# No heap allocation at all — no allocator needed
reset :: func(obj: @mut MyStruct) void {
obj.count = 0
}
main :: func() void {
data := duplicate("hello", heap)
defer heap.free(data)
mut obj := MyStruct{ ... }
init(&obj, heap)
defer heap.free(obj.buffer)
}
```
**Why this matters:**
* Without the rule, a function like `init(obj: @mut MyStruct) void` is ambiguous — did it heap-allocate into `obj`, or just set some fields to stack/static data? The caller has no way to know without reading the implementation.
* With the rule, the allocator parameter is a clear signal: "this function produces heap memory that outlives its scope, and you are responsible for cleaning it up."
* Internal allocations (temporary buffers, scratch space) use `heap` directly and are freed before the function returns. No allocator parameter needed, no burden on the caller.
**The compiler enforces this rule.** If a function heap-allocates memory that escapes without accepting an allocator parameter, the compiler emits an error.
### Why Immutable Defaults?
Consider what would happen if you could reconfigure the default allocator:
```
# ❌ THIS IS NOT ALLOWED (and doesn't exist in Brolang)
mem.heap_set(my_custom_heap)
# Somewhere else in the codebase...
data := heap.alloc(u8, size: 100)
# Later, someone changes it again...
mem.heap_set(different_heap)
# Now who frees `data`? With which allocator?
heap.free(data) # 💥 Wrong allocator - undefined behavior!
```
This is "action at a distance" — the behavior of `heap.free()` depends on what some unrelated code did earlier. By making `heap` immutable, Brolang guarantees:
**Whatever you allocate with, you free with.**
### Custom Allocators
For specialized needs, you create explicit allocator instances. These are not global — you manage their lifetime and pass them where needed.
**Arena Allocator**: Fast bump allocation, bulk deallocation:
```
import "std/mem"
import "std/mem/heap"
process_file :: func(path: []u8, allocator: @mem.Allocator) !Data {
# arena manages its own backing memory via heap
arena := mem.Arena.init(heap, capacity: mem.megabytes(1))
defer arena.deinit()
# all temporary allocations from arena (fast bump allocation)
file_contents := arena.alloc(u8, size: file_size)
parsed := arena.alloc(ParsedData) # size defaults to 1
tokens := arena.alloc(Token, size: 1000)
# ... process ...
# escaping allocation uses the caller's allocator
result := allocator.create(Data)
mem.copy(result, parsed)
return result
# arena.deinit() frees all arena memory — no individual frees needed
}
```
**Pool Allocator**: O(1) fixed-size allocation, no fragmentation:
```
import "std/mem"
import "std/mem/heap"
EntitySystem :: struct {
pool: mem.Pool(Entity),
}
init_entities :: func(allocator: @mem.Allocator) EntitySystem {
return EntitySystem{
pool = mem.Pool(Entity).init(allocator, capacity: 10_000),
}
}
spawn :: func(sys: @mut EntitySystem) @Entity {
return sys.pool.alloc() # O(1), no fragmentation
}
despawn :: func(sys: @mut EntitySystem, entity: @Entity) void {
sys.pool.free(entity) # returned to pool for reuse
}
```
### Passing Allocators to Functions
As described in the escaping allocation rule, when a function heap-allocates memory that escapes its scope, it must accept an allocator parameter. The caller decides which allocator to use:
```
import "std/mem"
# Function that uses caller's allocator
parse :: func(input: []u8, allocator: @mem.Allocator) !ParseResult {
buffer := allocator.alloc(u8, size: input.len)
defer allocator.free(buffer)
# ... parse into buffer ...
result := allocator.alloc(ParseResult) # size defaults to 1
return result
}
# Caller decides which allocator to use
main :: func() void {
# use an arena for this parsing work
arena := mem.Arena.init(heap, capacity: mem.kilobytes(64))
defer arena.deinit()
result := parse(input, &arena) catch |err| {
# handle error
}
# or use a pool
pool := mem.Pool(ParseResult).init(capacity: 100)
defer pool.deinit()
result := parse(input, &pool) catch |err| {
# handle error
}
}
```
### Memory Allocation Summary
| What | How | When to Use |
| -- | -- | -- |
| `heap.alloc(T, size: n)` | Thread-local global | General purpose, 90% of cases |
| `heap.create(T)` | Thread-local global | Allocate single item |
| `allocator.alloc(T, size: n)` | Caller-provided | Escaping allocations (returned or written to caller's data) |
| `arena.alloc(T, size: n)` | Explicit instance | Temporary/scoped work, bulk free |
| `pool.alloc()` | Explicit instance | Many same-sized objects, O(1) |
Note that `heap` satisfies the `Allocator` interface, so callers can pass `heap` as the allocator argument when they don't need a specialized allocator — which is most of the time.
**The golden rule:** Allocate and free with the same allocator. The type system helps enforce this — memory from `heap` can only be freed with `heap`, memory from your arena can only be freed with that arena. The escaping allocation rule ensures the caller always knows which allocator was used.
### Compared to Other Languages
| Language | Approach | Brolang's Advantage |
| -- | -- | -- |
| C | Hidden malloc, easy to mismatch | Explicit allocator at call site |
| C++ | Allocator templates, complex | Simple, no template complexity |
| Rust | Explicit everywhere, verbose | Sensible defaults reduce noise |
| Zig | Allocator parameter threading | Only required for escaping allocations, not internal work |
| Odin | Hidden context parameter | Fully transparent, nothing hidden |
| Go | Hidden GC | Explicit control, no GC pauses |
Brolang sits in a sweet spot: explicit enough to always know what's happening, convenient enough that you don't drown in boilerplate.