705 lines
31 KiB
Markdown
705 lines
31 KiB
Markdown
# "quick" / "easy" fixes
|
|
|
|
- for global initialization cycles, report also starting and ending lines
|
|
|
|
# milestones
|
|
|
|
1. interop type foundation (implemented)
|
|
- unsigned integers, floats, and target-dependent c scalar types
|
|
- atomic `c_*` primitive types remain distinct until target-aware lowering
|
|
- `c_func` and pointer-only `c_struct`; `c` remains an ordinary identifier
|
|
- keep binding mutability (`::` / `=`) separate from element or pointee mutability (`mut`)
|
|
- arrays and indexing
|
|
- `[N]T`: array with `N` logical elements
|
|
- `[N;S]T`: array with `N` logical elements followed by sentinel `S`
|
|
- pointers
|
|
- `@T` / `@mut T`: non-null single-item pointer without arithmetic
|
|
- `*T` / `*mut T`: non-null many-item pointer with arithmetic
|
|
- optional pointers represent nullable pointers (i.e. `?@T` / `?@mut T`, `?*T` / `?*mut T`)
|
|
- slices and slicing
|
|
- `[]T`: pointer and length
|
|
- `[;S]T`: pointer and length with a sentinel invariant
|
|
- ordinary slices do not guarantee null termination
|
|
- string literals as immutable sentinel slices backed by static arrays (superseded by milestone 3.5)
|
|
- character literals
|
|
- optionals with trapping unwrap and fallback operations
|
|
- native structs with compiler-controlled layout
|
|
- pointer-only `c_struct` support with target c layout
|
|
- `Some :: c_struct { ... }`: defined c-layout struct
|
|
- `Some :: c_struct`: opaque c-layout struct
|
|
- passing c structs by value was deferred until milestone 4.1
|
|
|
|
2. restricted c header imports (implemented)
|
|
- treat an imported header as a synthetic, file-local package namespace
|
|
- `native :: import "relative/path/to/header.h"`
|
|
- import functions, typedefs, scalar types, and pointers to opaque records
|
|
- keep implementation linking separate from header imports
|
|
- cache imports by canonical header path and target/include/define configuration
|
|
- diagnose unsupported declarations when referenced
|
|
- dynamically load libclang behind a replaceable c importer boundary
|
|
|
|
3. c variadic calls (implemented)
|
|
- represent c variadics as a fixed parameter count plus a variadic flag
|
|
- apply c default argument promotions at call sites
|
|
- emit LLVM c-variadic declarations and calls
|
|
- keep native brolang variadics and tuple design separate
|
|
|
|
3.5. sentinel pointers and c strings (implemented)
|
|
- add sentinel many-item pointers: `[*;S]T`
|
|
- represent string literals as immutable pointers to statically stored sentinel arrays: `@[N;0]u8`
|
|
- arrays expose `.len` but no `.ptr`; slices and pointers-to-arrays expose sentinel-preserving `.ptr`
|
|
- allow pointer-to-array `.len`, indexing, slicing, pointer decay, and slice construction without explicit dereference
|
|
- preserve or forget sentinel information through compatible pointer and slice coercions without copying arrays
|
|
- allow zero-terminated immutable byte pointer views to convert to immutable `*c_char` and `[*;0]c_char`
|
|
- keep `u8` and `c_char` distinct to preserve target-dependent scalar c semantics
|
|
- reject general `u8`/`c_char` interchange, slice-to-pointer coercion, and conversion to mutable c character pointers
|
|
|
|
4. advanced c interop
|
|
- by-value records and unions (implemented)
|
|
- complete plain imported structs/unions and manual `c_struct` values
|
|
- fixed C arrays inside imported records
|
|
- keyed struct literals and exactly-one-field union literals
|
|
- field reads/writes, storage, and fixed-signature calls/returns
|
|
- aarch64-macos small aggregate, homogeneous float aggregate, and indirect ABI lowering
|
|
- keep incomplete, bitfield, packed, flexible-array, qualified-field, and otherwise non-plain records pointer-only
|
|
- keep C variadic record arguments unsupported
|
|
- function pointers and callbacks (implemented)
|
|
- imported C function pointer typedefs lower to nullable pointer types
|
|
- manual `?*c_func(...) T` callback type spelling
|
|
- concrete `c_func` declarations/definitions can be passed as callback values
|
|
- postfix calls through non-null function pointers, including `callback?(...)`
|
|
- fixed and C-variadic callback ABI emission through LLVM indirect calls
|
|
- external variables (implemented)
|
|
- imported external C object variables lower to direct LLVM external global references
|
|
- top-level `const` object variables are read-only from brolang
|
|
- mutable external scalars/records can be assigned through qualified package globals
|
|
- unsupported variable types remain lazy diagnostics when referenced
|
|
- object-like macro constants (implemented)
|
|
- scalar integer/float literal macros import as immutable globals
|
|
- `CLITERAL(Type){ ... }` / `(Type){ ... }` record literal macros import as immutable globals
|
|
- function-like macros and non-literal macro expressions remain unsupported
|
|
- static inline functions (implemented)
|
|
|
|
5. control flow (implemented)
|
|
- boolean expressions (implemented)
|
|
- `bool` type with `true` / `false` literals
|
|
- comparison operators: `==`, `!=`, `<`, `<=`, `>`, `>=` (numeric operands widen; `bool` supports only `==` / `!=`)
|
|
- operators: `and`, `or`, `!`
|
|
- lazy evaluation / short-circuit evaluation
|
|
- if statements (implemented). example: `if condition { ... } else if { ... } else { ... }`
|
|
- conditions must be `bool`; block-scoped locals with shadowing across blocks
|
|
- lowered through new `Label` / `Br` / `Cond_Br` IR opcodes (alloca-backed locals, no phi nodes)
|
|
- conditional unwrapping for optionals (`?T`) (implemented): `if val |v| { ... } else { ... }` - unwrap `val` into `v` if it is not `none`
|
|
- single immutable binding scoped to the then-block; `v` not visible in `else` or after the `if`
|
|
- `|` lexes as a new `Pipe` token; the `.If` reuses AST `name` / HIR `local` to carry the binding (no new statement kind)
|
|
- new `Optional_Is_Some` / `Optional_Value` IR opcodes (the `Unwrap` presence-test + extract, minus the trap)
|
|
- conditional unwrapping with guard clause (implemented): `if val |v : v >= 10| { ... } else { ... }` - enter the then-block when `val` is not `none` and the guard is true
|
|
- multi-unwrap (implemented; see section below)
|
|
- while loops (implemented; operates on boolean conditions). examples:
|
|
- `while condition { ... }` - iterate while the condition is true
|
|
- `while condition : i = i + 1 { ... }` - execute the update after each completed iteration
|
|
- the condition and update may be parenthesized independently for visual clarity
|
|
- update targets must already be declared and mutable; loops do not introduce implicit induction variables
|
|
- update clauses support ordinary and compound assignment
|
|
- ranges (implemented; see section below)
|
|
- for loops (implemented; operates on ranges, arrays, slices, and pointers-to-arrays). examples:
|
|
- `for items |item| { ... }` - capture just the `item` value in the array/slice (uses copy semantics, i.e. gets a `T`)
|
|
- `for (&items) |@item| { ... }` - capture a pointer to each array element; its `@T` / `@mut T` mutability follows the iterable
|
|
- `for items_slice |@item| { ... }` - slices already refer to backing storage and support pointer capture directly
|
|
- `for items |item, idx| { ... }` - capture `item` and its index index in the array/slice
|
|
- `for 0..10 |i| { ... }` - iterate over the range `0..10` (exclusive)
|
|
- `for 0..=10 |i| { ... }` - iterate over the range `0..10` (inclusive)
|
|
- `for 0..(len) |i| { ... }` or equivalently `for 0..=(len - 1) |i| { ... }` - calculating range bounds, expressions must be parenthesized
|
|
- for all conditionals/guards, parentheses are optional but allowed for visual clarity
|
|
|
|
6. compound assignment: `+=`, `-=`, `*=`, `/=` (implemented)
|
|
- added the binary arithmetic operators `-`, `*`, `/` (previously only `+` existed); `*`/`/`
|
|
bind tighter than `+`/`-`, and prefix `-` (negation) is unchanged
|
|
- compound assignments preserve the target, operator, and right-hand side explicitly through
|
|
parsing and checking; lowering computes the target address once, then loads, applies the
|
|
operation, and stores through that address
|
|
- side-effecting index, field-base, and dereference expressions are evaluated once in
|
|
left-to-right order
|
|
- integer arithmetic traps on overflow (`Sub_Checked`/`Mul_Checked` via the LLVM
|
|
`.with.overflow` intrinsics) and integer `/` traps on divide-by-zero and `INT_MIN / -1`;
|
|
floats follow IEEE (`fadd`/`fsub`/`fmul`/`fdiv`, no trap)
|
|
- constant folding (global initializers) covers `-`, `*`, `/` alongside `+`
|
|
|
|
7. enums (native and c interop) (implemented; see below)
|
|
- native enums are nominal value types with integer runtime representations
|
|
- unbacked enums are non-empty, dense, zero-based, and use the smallest fitting unsigned backing
|
|
- explicitly backed enums require an integer type and strictly increasing literal values
|
|
- enum members support `Type.member`, `package.Type.member`, and contextual `.member`
|
|
- enum values support storage, calls/returns, and same-type equality/inequality
|
|
- explicitly backed native enums use their backing ABI in `c_func` signatures and variadic promotion
|
|
- imported C enum types alias libclang's target-selected integer backing and enumerators import as package constants
|
|
|
|
8. distinct types (implemented; see below)
|
|
- nominal declarations preserve identity across packages and reuse the backing runtime representation
|
|
- construction uses `Type(value)` with exactly one value of the exact backing type
|
|
- no implicit conversion to or from the backing type
|
|
- backing-type operators and reverse explicit conversions remain deferred
|
|
- concrete runtime backing types are supported; unresolved, `int`, `void`, function, and opaque backings are rejected
|
|
|
|
9. allow pointer field access pass-through (implemented)
|
|
- having a pointer (`ptr`) to a struct, we should allow access through `ptr.field` as opposed to mandating `ptr^.field`
|
|
|
|
10. make slice expressions on array variables implicitly address-taking (implemented)
|
|
- zig's slice expression on an array variable handles the address-taking implicitly (nice ergonomics)
|
|
- `arr[a..b]` on an array variable now slices without the explicit `&`; the
|
|
explicit `(&arr)[a..b]` pointer-to-array form keeps working unchanged
|
|
- array rvalues (e.g. a by-value array return) are materialized into a
|
|
temporary before slicing, matching the for-loop iterable lowering
|
|
|
|
11. c header imports and automatic native brolang bindings (implemented)
|
|
- `brolang translate-c <header.h> [--target ...] [--c-include-path ...] [--c-define ...]`
|
|
prints native `.bro` bindings for a C header to stdout (the offline counterpart of the
|
|
in-memory `native :: import "x.h"`); reuses the libclang `cimport.Result`
|
|
- emitter lives in `compiler/translatec`; `render_type` mirrors `loader.translate_c_type`
|
|
one-to-one so emitted source re-parses to identical types (guarded by a round-trip test)
|
|
- added a native type-alias declaration `Name :: alias T` (parser/lexer/token surface; the
|
|
`types.define_alias` / `.Alias` machinery already existed) so C typedefs and callback
|
|
typedefs round-trip
|
|
- emits functions, complete/opaque structs (collapsing `typedef struct {...} Foo`),
|
|
typedef aliases, and scalar/aggregate/enum-member constants
|
|
- C unions, external variables, static-inline functions, and unsupported declarations have
|
|
no hand-writable spelling and are emitted as `# unsupported in bindings:` comments
|
|
(functions that reference an un-spellable union therefore keep a dangling reference)
|
|
|
|
12. `undefined` as inspired by zig (implemented):
|
|
- allow mutable local declarations with `undefined`
|
|
- undefined values are assigned a poison value (0xaa...)
|
|
- allows for something like:
|
|
```
|
|
a int = undefined
|
|
if (condition) {
|
|
a = 42
|
|
} else {
|
|
a = -2
|
|
}
|
|
```
|
|
- disallow: `b :: undefined` since assigning undefined to something that can't change defeats the purpose
|
|
- disallow assigning `undefined` after declaration; use optionals and `none` for values that intentionally move back to an empty state
|
|
|
|
13. introduce `float` and `range` type constraints (the `int` family generalized) (implemented)
|
|
- `float` resolves a local binding to any float scalar (`f32`/`f64`) via static analysis;
|
|
widens `f32` -> `f64` across assignments, mirroring how `int` picks the smallest integer
|
|
- on a local declaration, integer literals satisfy `float` and default to `f64`
|
|
(`pi float = 3` is `3.0`); a runtime integer (`x float = some_i32`) stays a
|
|
`cannot implicitly convert` error
|
|
- `range` is now a spellable type/constraint: `r range :: 0..10` resolves to the inferred
|
|
range (element type preserved), and `func(start, end int) range { return start..end }`
|
|
monomorphizes the result per call. this replaces the prior `int`-as-passthrough hack that
|
|
was the only way to forward a range through a function
|
|
- `int`/`float`/`range` constraints now gate by family in every position (previously
|
|
params/results were unchecked generic passthroughs):
|
|
- a local initializer out of family errors instead of silently taking the natural type
|
|
- a param rejects an out-of-family argument (`cannot pass f64 to 'int' parameter 'x'`);
|
|
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 (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`)
|
|
|
|
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
|
|
some_arr[excl_range] # slice by named exclusive range
|
|
|
|
incl_range range :: 0..=10
|
|
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.
|
|
|
|
```
|
|
name: ?[]u8 = get_name()
|
|
age: ?u8 = get_age()
|
|
if name and age |n, a| {
|
|
# both n and a are guaranteed non-none here
|
|
print("{s} is {d} years old", {n, a})
|
|
}
|
|
```
|
|
|
|
**With guard clause on multiple values:**
|
|
|
|
```
|
|
if name and hat |n, h : n == "Huginn" and h.brand == .gucci| {
|
|
print("{s}'s got that drip\n", {n})
|
|
}
|
|
```
|
|
|
|
Parentheses around the expression are optional, but can aid readability when combined with guards:
|
|
|
|
```
|
|
# without parentheses
|
|
if name and hat |n, h : guard| { ... }
|
|
|
|
# with parentheses for clarity
|
|
if (name and hat) |n, h : guard| { ... }
|
|
```
|
|
|
|
## A word on lazy / short-circuit evaluation
|
|
|
|
The `and` in multi-unwrap short-circuits left-to-right:
|
|
|
|
```
|
|
if get_name() and get_hat() |n, h| {
|
|
# get_hat() is only called if get_name() returned non-none
|
|
}
|
|
```
|
|
|
|
This is important for avoiding unnecessary computation or side effects.
|
|
|
|
## A word on ranges
|
|
|
|
Ranges represent a sequence of values, commonly used in for loops, and is itself a value type:
|
|
|
|
```
|
|
0..10 # exclusive: 0, 1, 2, ..., 9
|
|
0..=10 # inclusive: 0, 1, 2, ..., 10
|
|
```
|
|
|
|
**Parenthesization rule:** Each side of `..` must be either a simple term (literal or identifier) or a parenthesized expression. This eliminates precedence ambiguity:
|
|
|
|
```
|
|
0..10 # OK: both sides are literals
|
|
0..n # OK: both sides are simple
|
|
0..(n + 1) # OK: complex expression is parenthesized
|
|
(a + 1)..(b - 1) # OK: both sides parenthesized
|
|
# 0..n + 1 # ERROR: must parenthesize complex expressions
|
|
```
|
|
|
|
This rule keeps the grammar simple and forces clarity at the call site — no precedence rules to remember. Also, being a value type, ranges can be assigned to variables and passed around like any other value. Range bounds are evaluated once, must have compatible concrete integer types, and descending ranges are empty.
|
|
|
|
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
|
|
|
|
Distinct types are considered distinct from their backing type. They do not implicitly coerce to their backing type.
|
|
|
|
```
|
|
# distinct type
|
|
UserID :: distinct u32
|
|
|
|
# instantiate distinct type
|
|
my_id UserID :: UserID(42) # value must have the exact backing type
|
|
```
|
|
|
|
## A word on enums
|
|
|
|
```
|
|
# standard enums
|
|
Animal :: enum {
|
|
dog
|
|
cat
|
|
bird
|
|
lizard
|
|
}
|
|
|
|
# enums with backing type
|
|
Nat :: enum(u8) { # in this case, a maximum of 256 values are possible
|
|
one # default: implicitly starts from value 0
|
|
two
|
|
three
|
|
four
|
|
five
|
|
}
|
|
|
|
# enums with backing type with explicit associated values
|
|
# note: must not be jumbled (i.e. `first_val = 1` must come before `other_val = 2`), but is allowed to be discontiguous (i.e. `one = 1` can be followed by `three = 3` without `two = 2` in between)
|
|
Nat :: enum(u8) {
|
|
one = 1
|
|
two = 2
|
|
three = 3
|
|
# no four
|
|
five = 5
|
|
}
|
|
|
|
# enums with backing type with semi-implicit associated values
|
|
Nat :: enum(u8) {
|
|
one = 1 # starts from value 1
|
|
two # implicitly gets value 2
|
|
three # etc...
|
|
four
|
|
five
|
|
}
|
|
|
|
# using enums
|
|
dog_tag1 Animal :: Animal.dog
|
|
dog_tag2 Animal :: .dog # type inferred
|
|
```
|
|
|
|
Unbacked enums cannot assign explicit values. Backed enum values must be decimal integer
|
|
literals, fit the backing type, and increase strictly; gaps are allowed.
|
|
|
|
Native enum types remain distinct from integers and from other enum types. They support
|
|
`==` and `!=`, but not arithmetic, ordering, casts, or backing-value extraction.
|
|
|
|
C enums follow C/Zig import semantics rather than native enum semantics:
|
|
|
|
```
|
|
native :: import "native.h"
|
|
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.
|