broaden type inference from context (first pass)
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user