Aether Language Reference
Complete syntax and semantics of the Aether programming language.
Overview
Aether is a statically-typed compiled language with actor-based concurrency and type inference. It compiles to C.
Paradigm placement
Aether is a hybrid that leans closer to functional than object-oriented, sitting next to Go and Rust in the middle of the paradigm spectrum:
pure FP OO + FP hybrid pure OO
Haskell Scala Rust Go Smalltalk
|--------|--------|--------|--------|--------|--------|--------|
↑
Aether
The placement is deliberate, Aether picks the pieces of each tradition that compose cleanly and skips the ones (deep inheritance hierarchies, monad transformers, etc.) that don't earn their cost in a compiles-to-C language. Concretely:
Where Aether leans functional
- Closures + trailing blocks are first-class and are the idiomatic control-flow construction. The "DSL with scope" pattern (
window("Demo") { vg { circle(...) { fill("#F00") } } }) is built from closures running in the caller's lexical scope, not from method dispatch on a builder object. See Closures and Builder DSL. - No classes, no inheritance, no
self/this. Structs are plain data records (struct Circle { cx: float, cy: float, r: float }) with no methods attached. Behaviour comes from free functions taking the struct as an argument:circle_area(c)notc.area(). - Type-annotated parameters with full inference and multi-value returns (
-> (long, string)for value-and-error tuples). Failure is carried in the return value rather than thrown, so the happy path has no exception machinery in it. - Modules with explicit
exports, namespaced free functions, not classes-as-namespaces. The visible surface of a module is the export list, not a public-method set. fnas a first-class type, passed, stored, boxed, unboxed. Aether's closures lower to_AeClosure { void(*fn)(void); void* env; }with structural sharing through ref cells (ref()/ref_get/ref_set); the runtime treats them as values, not as method receivers.- No method-dispatch machinery in the compiler. There's no vtable, no virtual call. Every function call resolves to a direct C function call. The bare-fn-to-closure adapter machinery and the
fn ↔ ptrcoercion rules exist to make first-class function values work end-to-end, a strictly functional priority.
Where Aether borrows from OO (in a limited way)
- Actors with state and message-receive handlers are the one piece of OO-shape machinery present, encapsulated state behind a message boundary. This is the Erlang / Pony actor model rather than Smalltalk-OO: no polymorphism, no inheritance, no method overriding. An actor is a stateful object reachable only through messages.
- The
_ctx/ builder-context stack DSL lets you write code that reads like a fluent OO method chain (panel("Settings") { button("OK") }), but the underlying mechanism is closure scope + an implicit context argument, not method dispatch. The compiler injects_ctxautomatically; no receiver is involved. - Receiver-namespace fallback for trailing blocks (
bash.test(b) { script("...") }resolvesscriptasbash_script) makes free-function calls look OO at the call site, but it's a name-resolution rule, not method dispatch. - Struct-field assignment and member access (
h.cb = c,c.r) are present, but they're data-oriented (record reads/writes), the same shape Go and C have without claiming OO.
Where Aether is neither, it's just systems-pragmatic
- Manual heap discipline is visible,
box_closure/unbox_closure/release_closure,ref()cells, counted strings and closures released where their places end (memory-management.md),deferfor resource cleanup. Closer to Rust's explicit ownership story than to either paradigm's purist position. - Aether's closure model trades Lisp/Smalltalk-style runtime-owned environments for generated C structs whose environments the compiler reference counts. See Closure lineage and runtime tradeoffs for the comparison.
- The C ABI /
externboundary is a first-class concern, not a hidden detail. The typechecker pragmatically allowsint ↔ ptrandfn ↔ ptrcoercions because surface ergonomics matter more than type-purity at the FFI boundary. - The capability + sandbox system (compile-time module gate / scope-level
hide/seal except/ LD_PRELOAD libc gate) is its own concern, orthogonal to both paradigms.
What this means in practice
If you've written Go, you'll recognise the shape immediately: free functions, structs, channels (! send, ? ask), explicit error returns, no classes. If you've written Elixir, the actor model and trailing-block DSLs will feel familiar. If you've written Ruby or Groovy, the "DSL with scope" idiom is borrowed directly from your lineage (Matz's term for it). If you're coming from Java or Python, the biggest adjustment is the absence of method dispatch (circle_area(c) instead of c.area()); the rest of the surface is approachable.
Aether is not, and has no plans to be, a pure FP language (no Hindley-Milner inference, no purity tracking, no monadic effects). It's also not, and has no plans to be, a class-based OO language (no inheritance, no virtual dispatch, no self). It picks the cross-paradigm subset that survives compilation to readable C and stays useful for systems work, concurrent servers, language ports, build tooling, embedded scripting.
Table of Contents
- Types
- Variables
- Functions
- Pattern Matching Functions
- Control Flow
- Match Statements
- Switch Statements
- Defer Statement
- Scope Directives:
hideandseal except - Memory Management
- Optionals
- Enums, Bit Sets, Bitstructs
- Sum / Variant Types
- Structs
- Messages
- Actors
- Operators
- Modules and Imports
- Extern Functions
- Built-in Functions
- Compiler Errors and Warnings
- Match Expressions
- Keywords
- Source Files and Comments
- Conditional Compilation
- Compilation
- Type System
Types
Primitive Types
| Type | Description | Example |
|---|---|---|
int | 32-bit signed integer | 42, -17, 0xFF, 0b1010 |
float | 64-bit floating point | 3.14, -0.5 |
f32 | 32-bit floating point (C float): GPU buffers, C structs, single-precision maths; f32 op f32 is a float op | f32 x = 1.5, v as f32 |
f32x4, f64x2, f32x8 | SIMD lanes: four single-precision, two double-precision or eight single-precision values | lanes.splat4(1.0), a * b, v.x |
i32x4, i64x2, i32x8 | The mask a lane comparison yields (four 32-bit, two 64-bit or eight 32-bit lanes), and integer lanes in their own right | a > b, lanes.select4(m, a, b) |
string | UTF-8 encoded strings | "Hello" |
bool | Boolean type | true, false |
byte | Unsigned 8-bit (0..255) | byte b = 0xFF |
void¹ | No value (for functions) | extern free(p: ptr) -> void |
long (also int64) | 64-bit signed integer | long x = 0, int64 y = 0 |
uint8, uint16, uint32, uint64 | Unsigned 8/16/32/64-bit, lowering to C uint8_t … uint64_t (uint8 and byte share uint8_t) | uint32 x = 4000000000 |
longdouble | C long double widest float (C interop) | extern strtold(...) -> longdouble |
ptr | Raw pointer (for C interop) | null |
¹ void is not a reserved word, there is no void token; it is a plain identifier used by convention to spell the absence of a return value. A function that omits its -> Type annotation is the canonical void declaration (see Functions), and -> void is the same type as the omitted one: f() -> void and f() declare the same function, and fn(ptr, int) -> void and fn(ptr, int) are one function-pointer type. It is never a value type and cannot be used for a variable or field.
byte unsigned 8-bit
The byte type maps to unsigned char in C. Use it for type-precision in struct fields, function parameters, returns, and locals where a value is genuinely an octet, packed-tag bytes (flags & 0x80), opcode discriminators, NaN-boxing pointer tags, network protocol headers, on-disk file format fields. For bulk byte storage (binary buffers, byte arrays), reach for std.bytes (the mutable byte buffer) instead.
struct OpCode {
byte op
byte flags
int imm
}
set_tag(t: byte) -> byte {
return t & 0x7F // bitwise byte op byte → byte
}
main() {
byte b = 0xA5
byte high = b & 0xF0 // stays byte
n = b + 1 // byte + int → int (promotes)
}
Range check on integer literals. Assigning an out-of-range integer literal to a byte slot is a compile-time error: byte b = 256 is rejected. Non-literal int → byte assignments compile and truncate at runtime (byte b = some_int keeps the low 8 bits), matching how other narrowings (int64 → int) behave.
Arithmetic. byte op byte → byte; mixed byte op int → int (the wider type wins). This keeps NaN-boxing / packed-tag patterns expressible (tag & 0x07 stays a byte) while letting general arithmetic widen naturally.
f32 32-bit float
f32 is C's float: the number every GPU reads (vertex buffers, instance transforms, bone palettes, uniform blocks, VkViewport), and the one thing float (a C double) cannot be. A struct field of it has C's layout and alignment, so an extern taking such a struct by pointer sees a C struct of floats; an f32[] view over a ptr stores one C float per element; a local or parameter of it is a C float; and arithmetic between f32 values is a C float operation.
struct Viewport { x: f32, y: f32, w: f32, h: f32 }
extern malloc(n: int) -> ptr
extern free(p: ptr)
area(v: Viewport) -> float { return v.w * v.h }
main() {
v = Viewport { x: 0.0, y: 0.0, w: 3.0, h: 2.0 } // float literals narrow into the fields
verts = malloc(16) as f32[] // a float32 store per element
verts[0] = 0.25
verts[1] = area(v) as f32
scale = 1.5
f32 half = scale / 3.0
println("${area(v)} ${verts[1]} ${half} ${verts[0] as float}")
free(verts)
}
6 6 0.5 0.25
Conversion. f32 converts with float and the integer kinds exactly as C converts float and double: x as f32 and f as float are the explicit spellings, and assignment performs the same conversion (verts[i] = v, Viewport { x: 1.0 }). A cast to or from a non-numeric kind is refused.
Arithmetic is single precision. a * b on two f32 values is an f32, computed in C float, and so is f32 op <integer>, by C's usual arithmetic conversions. A numeric literal beside an f32 operand takes the f32 (an untyped constant, as in Go): v * 0.5, or v * -0.5, is one float multiply, and the literal reaches the C as 0.5f. A float (double) value on the other side widens the expression to float, as in C, so a pipeline that wants double maths keeps its operands float and narrows once, at the buffer; one that wants single precision (a physics engine matching its reference's float solver) keeps them f32 and gets float ops throughout. Comparison follows the same conversions.
main() {
a = 0.1 as f32
b = 3 as f32
c = 0.3 as f32
half = a * 0.5 // f32: the literal is a float constant
println("${a * b == c} ${half}") // 0.1f * 3.0f rounds to exactly 0.3f
}
true 0.05
Printing goes through %g/%f like float (println(x), ${x}, print("%f", x)).
The other widths follow C's usual arithmetic conversions: uint8/uint16 promote to int, uint32 op int → uint32 (unsigned int wins over int, so u + 1 with u = 4000000000 is 4000000001, not a negative int), and any 64-bit operand makes the result 64-bit. A shift (<<, >>) has the type of its promoted left operand; the count's type does not take part (-8 >> n is -1 whatever n's width). print("%d", b) is the right conversion for a byte/uint8/uint16 argument; a uint32 takes %u, and the compiler corrects a mismatched specifier with a warning.
f32x4, f64x2, i32x4, f32x8 SIMD lanes
Four single-precision values in one register, two double-precision ones, eight single-precision ones, and the integer vector a lane comparison yields. Arithmetic and comparison are written as ordinary operators and happen lane-wise; std.lanes is what a lane value is built from and read back through (splat4, f32x4, load4 / store4, lane4, sum4, min4 / max4, select4, the mask reductions). The types lower to the GCC/Clang vector extensions, and every std.lanes entry point is a static inline helper in the generated C, so a lane operation is the instruction it names.
import std.lanes
main() {
a = lanes.f32x4(1.0, 2.0, 3.0, 4.0)
c = a * lanes.splat4(2.0) + lanes.splat4(1.0)
over = c > lanes.splat4(5.0) // a mask, not a bool
kept = lanes.select4(over, c, lanes.splat4(0.0))
println("${c.x} ${c.w} ${lanes.sum4(c)} ${lanes.sum4(kept)}")
}
3 9 24 16
A lane type is nominal. It converts with nothing: not with f32, not with another width, not with an array. A scalar becomes lanes through splat4 (or f32x4(a, b, c, d)), lanes become scalars through .x / .y / .z / .w, lane4 or sum4. f32x4 + f64x2 is a type error, and .z on an f64x2 names a lane it does not have.
A comparison yields a mask, not a bool. a < b on lane values is an integer vector of the same width whose lane is all-ones where the comparison held: i32x4 for f32x4, i64x2 for f64x2. The widths match because a mask of the other width would reinterpret the same register and scramble the lanes. That is what lanes.select4 / select2 take, what mask_and / mask_or / mask_not (and their mask2_* siblings) combine, and what any4 / all4 / any2 / all2 reduce to a bool when a branch is wanted. A scalar if over lanes is a mistake the type catches.
A scalar operand splats. v * 2.0, v + n (an integer) and v > 2.0 apply to every lane, as in C: arithmetic and comparison alike, with the scalar on either side, and a variable as well as a literal: v * s for a float s beside an f32x4 converts s to the lane's element type and then broadcasts it. The same holds for every lane type and for a distinct type over one (type vec4 = distinct f32x4; vec4 * s is a vec4, see distinct types). Mixing two different lane widths does not. % and ~ apply to integer lanes only: a float lane has no remainder or bit pattern in that sense, and either one on an f32x4 is a type error at the source line.
Eight lanes: f32x8 and its mask i32x8. The f32x4 surface at twice the width (splat8, f32x8(...), load8 / store8, lane8, sum8, min8 / max8, select8, sqrt8, abs8, lt8…eq8, mask8_and / mask8_or / mask8_not, any8 / all8). .x to .w read lanes 0-3, and lanes.lane8 reads any of the eight. The width is a build choice: with -mavx2 (or -march=native) in aether.toml's [build] cflags, an f32x8 is one 256-bit register and each operation one instruction; without AVX2 it is two four-lane halves, which costs what two f32x4 operations cost. The same source runs either way, and gives the same results, so a kernel written eight-wide is never slower than the four-lane one and runs twice as wide where AVX2 is on. On Windows every build is assembled with -Wa,-muse-unaligned-vector-move, and ae cflags --cflags carries it, because GCC there can spill a 256-bit value with an aligned move to a stack that is only 16-byte aligned (#2476, #3023). GCC on Windows cannot compile an f32x8 local without optimization at all, so a -O0 build there takes the two-halves form even with -mavx2 (#3023).
See std/lanes/README.md for the full surface and the measured speedup.
longdouble C long double
The longdouble type maps to C long double, the widest floating type, for exact-decimal numeric paths a C interop layer expects it (libc strtold, INCRBYFLOAT-style score conversion). It supports arithmetic (+ - * /), comparison, and conversion to/from int and float; as the widest numeric it wins promotion (longdouble op int / longdouble op float → longdouble). It's usable in locals, function params/returns, struct fields, and extern signatures. There is no longdouble literal, values arrive from an extern (extern powl(x: longdouble, y: longdouble) -> longdouble) or by widening an int/float. Declare a libc function with the signature libc actually has: strtold's second parameter is char **restrict, which ptr lowers to void*, and the C compiler rejects the redeclaration. Interpolation and print format it with %Lg/%Lf. With GCC on Windows it is the x87's 80-bit format at 64-bit precision on every thread, the main one and every thread Aether starts (#3027).
extern powl(x: longdouble, y: longdouble) -> longdouble
incr(cur: longdouble, by: longdouble) -> longdouble { return cur + by }
main() {
base = powl(2, 64) // libc, exactly as C declares it
next = incr(base, base) // long double arithmetic, full precision
println("v=${next}") // %Lg
}
C-side mapping. byte lowers to unsigned char in the generated C, not uint8_t. The two are typedef-compatible on most platforms but C's strict-aliasing rules give unsigned char * an exemption (it can alias any type for read/write); uint8_t * does not. Since byte is exactly the type used to inspect the bytes of other types' storage, unsigned char is the right choice.
Composite Types
| Type | Description |
|---|---|
struct | User-defined composite data |
actor | Concurrency primitive with state |
message | Structured data for actor communication |
array | Fixed-size homogeneous collections |
Array Types
int[10] numbers; // Array of 10 integers
string[5] names; // Array of 5 strings
float[100] values; // Array of 100 floats
uint32[4] ids = [ 1, 2, 3, 4 ] // element type pinned: uint32_t ids[4]
Pair[8] pairs // an array of structs, by value
The element type may be any type name: a keyword (int, byte, long), one of the unsigned widths (uint8, uint16, uint32, uint64) or a struct. The declared element type is what the C carries (uint16_t hs[3]), so a value that fits the width is never widened to int. A uint32 prints as itself through print, println, a %u format and ${x} interpolation (4000000000, not -294967296).
The length is an integer constant expression (#3115): a number in any base the language reads (byte[0x40], int[1_024]), a const of the same file that holds one, or + - * / % << >> and parentheses over those. A const may be declared anywhere in the file, above or below the array, and may itself be built from other constants. The length is at least 1. A name that is not such a constant, a length below 1 and a division by zero are compile errors at the length.
const QWORDS: int = 128 // lanes per 1024-byte block
const NW = 8
struct SkeinCtx {
ks: long[NW + 1] // the extended key: NW words and their parity
}
fill(mem: long[]) {
long[QWORDS] scratch
}
Array-to-pointer decay. A named fixed-size array decays to a pointer to its first element in pointer context, when assigned to an inferred binding, passed as a ptr-typed argument, or compared against a pointer (the same rule C uses):
byte[128] static_ids
ids = static_ids // `ids` is inferred as a `ptr` (decays), not an array
heap = null
if id_count > 8 {
heap = malloc(256)
ids = heap // legal: `ids` is a reassignable pointer
}
This is the stack-buffer-with-heap-fallback idiom (T buf[N]; T* p = buf; if (n > N) p = malloc(...)). Only a named array decays; an array literal initializer (x = [1, 2, 3]) still binds a real array. To keep the array type, annotate the binding explicitly (x: byte[128] = ...).
A fixed-size array is a value. A parameter typed int[3] is the callee's own copy of the caller's elements, so a write to it, by the callee or by a closure in it, stays in the callee, and xs.len is the declared length; the argument must be an array of exactly that length (a slice parameter, int[], takes any length). Binding an array to a variable that already holds one of the same length copies its elements (a = b), and so does declaring one from another (int[3] b = a) or binding one to an array field (Shelf { names: arr }, s.names = arr, and an actor's array state field); an array keeps the length of its first binding, and a field its declared length, so binding one of another length is a type error. An array literal may be shorter (the rest is zeroed) but not longer. A slice has no length known before the program runs, so binding one to a fixed-size array field is a type error too: copy its elements one by one. An element of a const array cannot be written: copy the table into an array of your own first.
sum3(xs: int[3]) -> int {
xs[0] = 100 // the callee's copy
return xs[0] + xs[1] + xs[2]
}
main() {
a = [1, 2, 3]
b = [7, 8, 9]
println("${sum3(a)} ${a[0]}")
a = b // copies b's elements
b[0] = 0
println("${a[0]} ${b[0]}")
}
105 1
7 0
An index into a fixed-size array is checked, as a slice's is. a[i] on a T[N] with i outside 0 ..< N is a runtime panic naming the source line, the index and the length (array index 4 out of range for length 3), the same ordinary panic a slice index raises: try catches it, and a negative index is out of range. The test covers every index into a T[N]: a read, a store, += and ++, through a local, a field, a parameter, an actor's state or a const table. An index known when the program is compiled (a literal, a const, or arithmetic over them) that is out of range is a compile error. With a constant N and a loop bounded by it, the C compiler drops the test.
An array literal's element type is the join of its elements. [1, 2.5, 3] and [0.18 * math.PI, 0.25 * math.PI] are float arrays; an all-integer literal is int, widening to long if any element needs it. The type is decided after every element is typed, so a module constant or a call in any position counts. A literal of another type is an array of its first element's type, or, in a place that is an array of a sum, of that sum, each variant wrapped (Constructing, implicit wrap); null and none have no type of their own: [null, s] is an array of s's type, its null the null string, pointer or empty closure as in any slot of that type, and [none, x] an array of x's optional type. Every element must fit the element type: [1, "x"], or [p, null] with p a struct, is a compile error naming the element. Arrays are one-dimensional: an array literal cannot contain an array literal (use a flat T[rows * cols] indexed as row * cols + col, or a list of arrays).
import std.math (math_pi)
main() {
angles = [ 0.25 * math_pi(), 0.5 * math_pi() ]
println("${angles[0]} ${angles[1]}")
mixed = [ 1, 2.5, 3 ]
k = mixed[1] // float, like the element it is bound to
println("${k}")
}
0.785398 1.5708
2.5
Binding an array literal to an array that already exists stores its elements. The array keeps the size of its first binding: a shorter literal zeroes the elements past its own, and a longer one is a compile error. This is also what happens to an array first bound inside a loop body or a branch, since such a local is one variable for the whole function: each pass through the loop stores the literal again, its elements evaluated in order.
main() {
total = 0
d = 0
while d < 3 {
row = [ d, d * 10, d * 100 ]
total = total + row[0] + row[1] + row[2]
d = d + 1
}
println("${total}")
a = [ 1, 2, 3 ]
a = [ 4, 5 ]
println("${a[0]} ${a[1]} ${a[2]}")
}
333
4 5 0
Slices (T[])
A slice is a non-owning view over a run of elements: one value carrying a pointer and a length ({ ptr, len }, a fat pointer). It is spelled T[], the unsized array type. Where a fixed T[N] array owns its storage, a slice only points into someone else's, so passing one costs two words and copies nothing.
sum(xs: int[]) -> int {
total = 0
for i in 0..xs.len {
total = total + xs[i]
}
return total
}
main() {
arr = [10, 20, 30, 40, 50]
println("${sum(arr)}") // a T[N] array flows into a T[] parameter with its length
mid = arr[1..4] // a sub-slice: shares arr's storage, no copy
mid[0] = 99
println("${mid.len} ${arr[1]} ${sum(mid)}")
println("${arr[..2].len} ${arr[3..].len} ${arr[..].len}")
}
150
3 99 169
2 2 5
s.lenis the element count, anint. It is read, never written: a slice's length is fixed when the slice is made, and an array's is a constant, sos.len = n(or+=,++) is a type error that points ats[lo..hi]for fewer elements (#3104). It is the only member a slice or an array has: any other name,s.lengthors.size, is a type error that names.len(#3092).s[i]is bounds-checked. An index outside0 ..< s.lenis a runtime panic naming the source line, the index and the length (slice index 5 out of range for length 3). It is an ordinary panic:trycatches it, and an actor step that trips it dies cleanly. A negative index is out of range, never a read before the buffer. An index into a fixed-sizeT[N]array is checked the same way, againstN(Array Types).s[lo..hi]is the half-open sub-slice[lo, hi), checked the same way (lo <= hi <= s.len).s[lo..]runs to the end,s[..hi]from the start,s[..]is the whole thing. The base may be a slice or aT[N]array; the result is always aT[]over the same elements, so a write through it is visible through the original.- A
T[N]array converts toT[]implicitly wherever a slice is expected: a parameter, aT[]-typed binding or field, areturnfrom a-> T[]function. The length travels with it.null, and the empty literal[], convert to the empty slice:.lenis0, it views no storage, and it compares equal tonull. Sos == []iss == null(it asks whethersviews any storage; tests.len == 0for emptiness), a C function'sT[]parameter takes[]as the null pointer, and an if-expression or amatchgiven to aT[]converts each arm on its own. A module-levelvarorconstof typeT[]takes either as its initial value. - A string is not a slice. Passing one where a
T[]is expected (a parameter, a binding, areturn) is a type error;string.bytes(s)is the bounded, read-onlybyte[]view of a string's bytes, and it decays to their pointer at a C extern like any slice. Reading it costs what any slice's read does; a store through it (an element store,+=,++), or through any view made from it (a sub-slice, a pattern's tail), in this function or one it is given to, panics with "a write through a read-only view of a string", and one the checker can see through a local bound to it is a compile error (E0200) (#3025). A string's own bytes are read-only too:s[i]reads one, and a write (s[i] = v,s[i] += v,s[i]++) is a compile error (E0200). Build the new text as a new string, or work on the bytes in abyte[N]array of your own. - A slice decays to its pointer wherever a
ptris expected, exactly as aT[N]array does: aptrparameter, a C extern's parameter (including one declaredT[]), a comparison againstnull, and pointer arithmetic (s + nis aptrto elementn).s as ptrspells the same conversion explicitly, for aptrreturn or binding. The length is dropped at that boundary; nothing else about the extern ABI changes. make([]T, n)allocatesnzeroed elements and returns them as a bounded slice;free(s)releases them, and first what they hold: each element is a place a store into it takes a reference for (a string, a closure, a struct that holds one), so the free gives each back, as a struct's destroy does its fields (#3159). The elements of C memory viewed as a slice hold no reference, and its free releases only the memory. Only the slice that holds such a buffer, or a view of a raw pointer the program owns from its start ((p as T[])[0..n]), may be freed: a view owns nothing.freeof a sub-slice (b[2..4],b[1..], a list pattern's tail), of an array viewed as a slice, ofstring.bytes(s), or of an arrayT[N], is a compile error where the checker sees it (a local bound once to one, or the expression itself), and a named panic at run time anywhere else (free of a view of elements another slice or an array holds, which owns nothing). Before, the allocator was given a pointer it never handed out. In a module that defines afreeof its own (bignum.free,json.free),free(s)of a slice is still this release, unless the module'sfreeitself takes a slice: the module's function would only see the slice's pointer (#3116).- A slice does not own the storage it views. It is valid for exactly as long as that storage: an array in an enclosing scope, a
makebuffer until itsfree, a C buffer until C reclaims it. The elements are the storage's: a string read out of astring[]slice (t = s[i], a return, a field) takes a reference of its own, and a store into an element (s[i] = v) takes the new string and releases the one it replaces, as a store into the array itself does. Memory C owns, viewed asp as string[], owns nothing: a store there gives C the characters and takes no reference (memory-management.md). - A slice of a function's own storage stays in the function (E0900). An array literal in a slice slot, and a function's own fixed-size array, live in its frame, which ends when the function returns. A slice of either may be used while the function runs and passed down to the functions it calls, but it may not leave the function: returned (alone, in a struct or tuple, or in a closure that captured it), stored in a module-level
var, or stored in a field of something that may outlive the function: a struct behind a pointer, or a global (a struct value of the function's own may take it, and is then itself such a slice). Each of those is refused at the value that leaves,'lit' returns a slice of its own storage (an array literal). Keep such a slice in storage that lives as long as it: amake([]T, n)buffer, a slice the caller passed in, or a module-level array. A slice parameter (xs: int[]) views the caller's storage and may be returned; a fixed-size array parameter (a: int[3]) is the function's own copy of the caller's array, so a slice of it is a slice of the function's storage. What a name holds where it leaves is what counts, path by path: a local or a field of a local struct re-bound to amakebuffer before thereturnholds no slice of the frame any more, but one that still holds it on any path to thereturn(anifthat re-binds it in one branch only, a loop that maybreakbefore the re-bind) is refused. A call of a closure the function binds (reset()afterreset = || { v = make([]int, 4) }) counts what the closure re-binds for sure: a name of the function it binds on every way through its body before anyreturn(both branches of anif, every arm of amatchthat covers its value, not a loop's body) holds what the closure gave it after the call. The name is the function's own when the function binds it ahead of the closure, in a block around it, and the closure does not declare one of its own first; a closure bound more than once, or called before it is bound, re-binds nothing that counts. Atryaround such a call starts its handler from what held a slice before any statement of its body ran, or after any. The other way is counted, conservatively: a closure that may store a slice of the function's storage into one of its names (setview = || { v = arr }) gives that name a slice from where the closure is written on, whether and whenever it is called, and a store a closure makes through a pointer or into a module-levelvaris judged as the same store written in the function. What the closure stores is judged as the function's own stores are: a fixed-size array of the function or an array literal, a name that holds such a slice at some point of the function, a local of the closure bound to one, or a parameter of the closure it is called with one for (any parameter that can hold a slice, when the closure is passed on or stored). A slice parameter of the function, amakebuffer, a module-levelvaror[]stored by a closure leaves the name as it was. A call's result is taken to be no view of the caller, so a function that hands back the slice it was given is not seen through, in a closure (v = same(arr)) or not.
Views over a raw pointer are unbounded. p as T[] (the typed-array cast) and a T[] handed back by a C extern have no length the compiler can know. Such a view indexes without a check, as it always did, and its .len is -1. Bound it with an explicit end to get checking back:
extern malloc(sz: long) -> ptr
raw = malloc(64)
view = raw as byte[] // view.len == -1, view[i] is unchecked
bytes = view[0..64] // bytes.len == 64, bytes[i] is checked
A function that indexes a slice parameter is compiled twice when nothing in it binds that parameter again or takes its address, and it holds no closure (#3029). Its entry tests once whether every such parameter is an unbounded view. If so, a second body runs, where an index of them is the element's address with no test, and the loop around it can vectorize. Otherwise the function's own body runs, with every check. The answer and every bounds check are the same either way. What changes is the cost when the C compiler cannot see where the slice came from (the function is reached through a function value, from another translation unit or a library): there, an index of a view used to test the length sentinel and the bound at every element.
In C the slice is AetherSlice { void* ptr; uint64_t len; } (runtime/aether_slice.h). A T[] parameter or return of an Aether function is that struct by value; a T[] parameter or return of an extern C function, and a T[] field of an extern struct, stay the bare T* C declares. See c-interop.md for the boundary rules.
Sequence Types (*StringSeq)
*StringSeq is a cons-cell linked list of strings, Erlang/Elixir-shaped, with O(1) head/tail/cons/length and refcount-based structural sharing. Empty list is the NULL pointer; each cell carries a cached length.
import std.string
main() {
s = string.seq_empty()
s = string.seq_cons("c", s)
s = string.seq_cons("b", s)
s = string.seq_cons("a", s) // s = a -> b -> c
println(string.seq_length(s)) // 3 (O(1))
// Pattern-match destructure works directly:
match s {
[] -> { /* end */ }
[h | t] -> { println(h); /* h: string, t: *StringSeq */ }
}
string.seq_free(s)
}
The full surface lives in std.string (alongside string.array_* for the legacy AetherStringArray shape). See sequences.md for the worked examples and the literal-disambiguation rule ([a, b, c] builds a cons chain when the target type is *StringSeq, vs a static C array when the target is string[]).
Numeric Literal Formats
Integer literals support hex, octal, and binary notation.
| Format | Prefix | Example | Value |
|---|---|---|---|
| Decimal | (none) | 255 | 255 |
| Hexadecimal | 0x / 0X | 0xFF | 255 |
| Octal | 0o / 0O | 0o377 | 255 |
| Binary | 0b / 0B | 0b1111_1111 | 255 |
An underscore separates two digits, for readability, in every form: the digits of an integer, the fraction and the exponent of a float, a duration's digits, and the digits after a 0x, 0o or 0b prefix (#3155). It changes nothing about the value.
main() {
mask = 0xFF_FF
flags = 0b1010_0101
perms = 0o755
big = 1_000_000
long huge = 3_000_000_000
rate = 1_234.567_8
println("${mask} ${flags} ${perms} ${big} ${huge} ${rate} ${30_000ms}")
}
65535 165 493 1000000 3000000000 1234.57 30s
An underscore anywhere else, at the end of the digits, beside another _, beside the . or the exponent's e, or straight after the prefix, is a compile error at the _:
main() {
n = 1_000_
}
// error[E0100]: misplaced `_` in a number
// help: an underscore goes between two digits, as in 1_000_000 or 0xFF_FF
All numeric literal formats work with bitwise operators and in any expression context.
String Literals
A string literal is double-quoted. Its escapes are decoded once, in a plain literal and in the text of an interpolated one alike: \n, \t, \r, \\, \", \xN / \xNN (one or two hex digits), \N to \NNN (octal), and any other \c is the character c.
Every literal is emitted as a static, pinned AetherString that carries its length: its count never changes, it is never freed, string.free on it is a no-op, and taking it (a store, an argument, a return) is the literal itself, with no count operation. A literal may hold a NUL byte ("a\0b", "a\x00b", "a\0b ${n}"), and it keeps every byte: string.length("a\0b") is 3, == compares all of its bytes, and it is a whole key in a map or set, a whole value for std.json and std.fs, and a whole operand of string.concat, in a match arm and in a function-clause pattern, as a string built at run time is.
One boundary keeps the C view: a C extern whose parameter is string receives the literal's bytes up to the first NUL, since that is all a const char* can carry; a parameter declared @aether string receives the whole string. print and println write all of the literal's bytes.
Variables
Variables support both explicit types and automatic type inference:
// Type inference (recommended), bare assignment is the canonical form
x = 10
y = 20
name = "Alice"
pi = 3.14159
// Explicit types (optional)
int z = 30
string greeting = "Hello"
float temperature = 98.6
Variables are inferred from their initialization or usage context.
A local declared without a value starts at its type's zero value: 0 for a number, false, null for a string, a pointer or a closure, none for an optional, the empty slice, an array of zeros, and a struct whose every field is its zero. long x, int[3] k and P p are x = 0, three zeros and a P of zeros, so a read before the first write is defined, and an element a filling loop misses is 0. In a loop body each pass declares it afresh, as a declaration with a value binds it afresh: what an owning one held is released and it is zero again (#3114).
struct P {
a: long
w: long[4]
}
main() {
long x
int[3] k
P p
println("${x + k[1] + p.a + p.w[2]}")
}
0
let / var are optional keywords, accepted but not required. Bare Python-style assignment is the canonical form and is what most stdlib code uses. let x = 10 and var x = 10 parse identically to x = 10. There is no semantic distinction between let and var Aether is not Rust; mutability is a property of the binding's later use, not its declaration. There is no mut: let mut x = 0 is an error, because a let must bind something and mut is not a keyword the language has. A typed declaration is written without let (int n = 0, Pair p), so two names after let is always a mistake.
Semicolons are optional. Aether parses end-of-line as a statement terminator. The samples above use no semicolons; older samples in this doc may show ; for clarity. Either is fine, after any statement: if x { a } ; b is if x { a } then b.
Keywords are not names. A statement keyword used as a variable (when = 0.0, message = "hi") is refused at the keyword: 'when' is a reserved keyword and cannot be used as an identifier; rename it.
A local has one type: the one its first binding gave it. A later bare assignment is an assignment, not a new variable, so x = 5 then x = "s" (or x = true, x = null) is a compile error, cannot re-bind 'x' as string: it was bound as int by its first assignment, rather than a C error about const char*. Use a new name for the other value. A name first bound inside a branch or loop body and used after it is one variable for the whole function: it takes the join of every binding, the one an if-expression's branches take (if a { n = 1 } else { n = 4000000000 } makes n a long; f = 1.5 in one branch and f = 2 in the other keeps f a float; x = -1 beside a uint32 makes x a long, and a signed value beside a uint64 is the error below, as it is in an if-expression), unless a binding writes its type: uint64 x = 7 in one branch makes x a uint64 wherever the others are, and a binding of a value it does not hold (x = -1 or an int beside it, x = 1.5 beside int x = 0, x = 300 beside uint8 x = 7, x = "s" beside int x = 1), or one that writes another type, is a compile error in either order: cannot bind 'x' to this int value: it is written as uint64 in another branch or loop body of this function, which does not hold the value. A binding of another kind anywhere in the function, or of another struct or pointer type, is the same kind of error: cannot bind 'v' as int: it is bound as string in another branch or loop body of this function. A switch case is a branch here as an if arm is, and so is the body of a match arm (Match Statements). A local of a while body is declared before the loop, and a name both arms of an if/else (or every case of a switch with a default, or every arm of a match that runs an arm for every value) bind is declared before the if (or switch, or match), so each is one variable whether or not it is used after them: view = p as *Apple in one while and view = p as *Pear in the next is that error, where a bare ptr or null binding would fit either. A read of such a local in the body before its binding, which sees the value the previous pass bound, has the binding's type (out = "${out}${prev}" ahead of prev = "p${i}" reads a string). A name bound in the bodies of separate ifs or for loops and used only inside them is a separate variable per body, except a string local, which is always one variable for the whole function. The conversions the table below permits still apply at a re-bind, and the local keeps its first type: after f = 1.5, f = 2 stores 2.0 and f stays a float; after int x = 0, x = 2.5 stores 2. A string accepts null (it is a nullable const char*), and a value that would not fit an inferred int (a 64-bit integer, a uint32, a float) is the narrowing error described there. Each slot of a tuple destructure that names a bound local re-binds it the same way, whether the local is in the same scope or is an outer one a closure writes (a, b = two() in a closure after a, b = names()): a slot of another kind is the same error, reported at the slot, a slot C converts is stored as the local's type, and an int slot binds an inferred int, as x = 5 does (#2762).
A local's scope starts after its initializer. In count = count() + 1 the call is the function count, and from that binding on, to the end of the function, count is the local. So is a local declared ahead of its binding because both arms of an if bind it: until the checker reaches the first binding in source order, the name is still the function's. A reference that comes before the binding, such as the function passed as a value, is the function too:
count() -> int { return 41 }
apply(f: fn() -> int) -> int { return f() }
main() {
first = apply(count)
count = count() + 1
println("${first} ${count}")
}
41 42
After the binding the function cannot be called by that name in the function, since the name is the local's; the call is an error that says so:
count() -> int { return 41 }
main() {
count = count() + 1
again = count()
println("${count} ${again}")
}
// error[E0301]: 'count' is a local here, which hides the function 'count'
// help: a local's scope starts after its initializer and lasts to the end of
// the function, so the function cannot be called as 'count' here; give the
// local another name
A statement ends at its line. foo on a line of its own is an expression statement; bar = 2 on the next line is a separate binding, not the declaration foo bar = 2. The binding name of a typed declaration (Pair p, size_t n = ..., uint32[4] xs) is written on the type's line.
Casting between types
Aether has no general-purpose cast operator. The as keyword is reserved for module aliasing and three specific value-cast forms:
import mod as aliasmodule aliasing (only insideimportstatements)expr as *StructNamepointer-overlay struct cast (a leading*then a struct name; see § Pointer-to-struct type below)expr as fn(T1, T2, ...) -> Rfunction-pointer cast (call a stored pointer with type checking; see § Function-pointer parameters below)expr as NamewhereNameis a named function-pointer type (type Name = fn(...) -> Rorcfn Name(...) -> R), the same function-pointer cast under a name (see § Named C function-pointer types below)expr as T[]typed-array view cast (reinterpret a raw pointer as an unboundedT[]slice; see § Slices)
After as the parser also accepts a primitive value cast: n as int and other numeric casts compile and run, and a cast between a distinct type and its base type is allowed too, an enum and an integer included (Direction.West as int, 3 as Direction; two different enums never convert, #3157). Casts the type system can't justify (for example buf as string or p as ptr) still parse, but are rejected at type-check with E0200, wherever they appear (a return value included), use a named helper for those. The *StructName, fn(...) -> R, and T[] forms remain available. Other primitive conversions:
| From → To | How |
|---|---|
int → ptr (e.g. passing an int to a ptr-typed extern param) | Implicit. The compiler emits (void*)(intptr_t) automatically at the call site. |
string → C const char* (passing a string to a C extern, or through a fn(...) pointer) | Implicit. The auto-unwrap injects aether_string_data(arg) at call sites for string-typed extern parameters, and for string parameters of a typed function pointer (a cast local, a fn(...) parameter, a struct field). |
int → string | string.from_int(n) (and string.from_long(n), string.from_float(f)). |
C const char* → Aether string | Assignment to a string-typed variable, or a return from a function declared -> string. The place takes the value as it takes any string: C's characters are copied into a counted string the place owns, and a counted string behind the pointer is retained. An extern declared -> string @heap hands over a string the caller adopts without a copy. |
int ↔ int64 / byte ↔ int | Implicit safe widenings (byte → int, int → int64). The narrowing direction (int → byte) requires a literal-range check and truncates non-literals at runtime. See § byte. Spelled out with as, b as int widens a byte (always 0 to 255, it is unsigned) and n as byte keeps the low 8 bits, as for uint8. |
float → int | Into an explicitly typed slot (int k = 4.5, an int parameter or field, an element of int[N] xs) the value truncates, as in C. Into a slot whose int type was inferred (r = 0 then r = 2.5; xs = [1, 2] then xs[0] = 7.5) it is a compile error naming the fix (the same rule as a 64-bit value into an inferred int), because the truncation was never chosen. |
uint32 → int | Explicit slots convert as in C. Into an inferred int (k = 5 then k = some_uint32) it is the same narrowing error: values past 2³¹ do not fit. Annotate uint32 k = ... to keep it unsigned. |
Reinterpret a raw ptr as a typed struct view, function pointer, or array | expr as *StructName, expr as fn(...) -> R, or expr as T[] (the only as forms for values). |
Null
The null keyword represents a null pointer, typed as ptr. In a string slot it is the null string: it reads as the empty text, and s == null tells it apart from "" (Comparison Operators):
x = null // inferred: ptr
if x == null {
println("no value")
}
Constants
Top-level constants are declared with const:
const MAX_SIZE = 100
const GREETING = "hello"
const PI = 3
main() {
println(MAX_SIZE) // 100
half = MAX_SIZE / 2 // constants work in expressions
}
Constants cost nothing at run time: each is a file-scope static const in the generated C. A program's own constants are emitted under the reserved name ae_const_<NAME>, so no C header can claim the name: a constant may be called SIZE, NEAR or MAX_PATH on every platform, although windows.h owns all three (#2292). A parameter or local named like a constant is still that parameter or local. An imported module's constants carry the module's prefix instead.
A constant of an optional type takes a constant of its inner type or none (#2807): const LIMIT: int? = 3, const NO_LABEL: string? = none. It reads as any optional does (LIMIT ?? 0, match LIMIT { ... }); the value is wrapped once, in its static const.
Module-level constant arrays, lookup tables
A const array lowers to a file-scope static const table, allocated once and shared across every call (not re-initialised per call), the right shape for a table-driven algorithm:
const PRIMES[] = [2, 3, 5, 7, 11] // element type inferred (int)
const CRC16TAB: uint16[256] = [ 0x0000, 0x1021, /* ... */ ] // element type pinned
const NAME[] = [...]infers the element type from the literals (intfor integer literals).const NAME: T[N] = [...]pins the C element type,Tmay beuint8/uint16/uint32/uint64/int/longso e.g. a CRC16 table emitsstatic const uint16_t NAME[256]rather than a 4×-widerint[], and matches a C header that expects the packed type. Integer literals narrow to the chosen element type (the explicit, compile-time-constant intent, likebyte b = 5). Indexed access (NAME[i]) reads the table directly; the array is read-only.
The RHS of const must be a compile-time constant expression. Allowed forms: literals (int / float / bool / string / null), other consts referenced by name, unary / binary expressions over those, and string interpolation where every interpolated value is itself const. Function calls are rejected at typecheck time:
const G_BUF = malloc(64) // ERROR: const initializer must be a
// compile-time constant expression
The reason is const's substitution-at-each-use semantics: the compiler inlines the RHS at every reference rather than storing the value. For literal RHSs this is fine; for make_thing() it would re-call the function on every reference, allocating fresh state. If you need a process-global heap object initialised once and read everywhere, use std.config for string state or std.actors for actor references, both are described in the stdlib reference.
Exception, sizeof / offsetof. The two layout builtins sizeof(T) and offsetof(T, field) are allowed in a const initializer (and arithmetic over them), even though they are spelled with call syntax. They lower to C compile-time constant expressions ((int)sizeof(struct T) / (int)offsetof(struct T, field)), no evaluation, allocation, or side effect, so the substitution-at-each-use semantics are harmless:
extern struct JSString { gc_mark: uint64 : 1 len: uint64 : 60 buf: byte[] }
const SIZEOF_JSSTRING = sizeof(JSString) // ok, folds to a C constant
const STR_BUF_OFFSET = offsetof(JSString, buf) // ok
const STR_PADDED = sizeof(JSString) + 8 // arithmetic over them is ok too
This lets a port that mirrors C structs as extern struct overlays centralise its offset/size table as named consts that are self-verifying by construction, the C compiler computes each value, so it cannot drift from the real layout, instead of hand-maintaining numeric offsets plus _Static_assert drift guards. The general "no function calls in a const initializer" rule is unchanged; these two layout builtins are the only carve-out.
sizeof takes any type a declaration takes (#3174): a keyword type (int, long, float, byte, bool, ptr, string), a width (uint16, uint32, f32, size_t), a pointer (*Node), a fixed-size array (int[4], its element's size times its length), a struct, an enum or a distinct type, each the size C gives the type it lowers to. offsetof takes a struct, bare or qualified (gpu.Shader); another type has no fields and is an error at the type.
struct Pair {
a: int
b: long
}
main() {
println("${sizeof(int)} ${sizeof(long)} ${sizeof(uint16)} ${sizeof(int[4])} ${sizeof(Pair)} ${offsetof(Pair, b)}")
}
4 8 2 16 16 8
Mutable module-level globals
Where a const is a fixed value, a module-level var is one persistent, mutable word of state shared by every function in the module, a PRNG seed, a monotonic counter, a one-slot cache:
var rand48_x: uint64 = 0x1234ABCD330E // module scope, mutable
next_rand() -> uint64 {
rand48_x = (rand48_x * 0x5DEECE66D + 0xB) & 0xFFFFFFFFFFFF
return (rand48_x >> 17) & 0x7FFFFFFF
}
- The type annotation is optional:
var hits = 0infersintfrom the initializer, exactly like a local. - A
varlowers to a file-scopestaticin the generated C. Reads and writes from same-module functions are plain identifier access, no accessors, no indirection. Every binding of a modulevar's name in a function or a closure writes that global (the name is in scope everywhere in the module): aname = exprstatement, and a declaration as well,let name = expr,let name: int = exprorint name = expr, which binds the global asname = exprdoes and never declares a local of that name. Pick a different name for a function-local. A closure that reads or writes a modulevarreaches the global itself, never a copy or a cell of its own, so its writes are seen by every function and the enclosing function's writes by the closure (#2805); only a parameter of the same name shadows it. - The initializer must be a compile-time constant expression, the same restriction as
const(and for the same underlying reason, C requires astatic's initializer to be constant). Initialize to a constant and compute the live value from a function at startup if you need more. - An optional
vartakes a constant of its inner type,none, or no initializer at all, which isnone(#2800):var limit: int? = 3,var name: string? = none,var origin: Point? = Point { x: 0, y: 0 },var cache: string?. A function writes it withname = valueor a tuple destructure slot, a bare value wrapped as for a local. Astring?var owns the string it holds, as astringvar does: a re-bind takes the new string and releases the one it held, and what a reader took out of it (v = name ?? "",v = name!, asome(v)binding) holds a reference of its own, so it outlives the re-bind. A call that may re-bind the var is given a reference held for the call, so an argument read from it stays valid inside the call. The var's last value lives as long as the program. - Globals are module-private and non-atomic, matching the C statics they replace. They are not shared across modules and carry no built-in synchronization; guard concurrent access yourself (or keep such state on an actor).
Functions
Aether functions have two canonical declaration forms, pick by whether you want explicit types:
// 1. Inferred (no type annotations), the most compact form
add(a, b) {
return a + b
}
greet(name) {
println("Hello, ${name}")
}
// 2. Explicit types, the recommended form for stdlib / public APIs.
// Parameter types after `:`, return type after `->`.
add(a: int, b: int) -> int {
return a + b
}
greet(name: string) -> string {
return "Hello, ${name}"
}
Both forms parse cleanly. The name(params) -> ReturnType { … } shape is what every stdlib module uses (std/string/module.ae, std/bytes/module.ae, …), match that style for new code.
Form to avoid: C-style prefix int add(int a, int b) { … }. This form parses for backwards compatibility but is not the recommended style and isn't used in any stdlib module. New code should use the arrow form (add(a: int, b: int) -> int { … }) instead.
Void functions simply omit the -> Type annotation:
print_hello() {
println("Hello")
}
There is no void keyword in the return-type position, a missing return-type annotation IS the void declaration (-> void, the conventional spelling, means the same). The main() function is the entry point and is always void.
Builtin names cannot be redefined. A call to a builtin (isolate, consume, release, make, sizeof, typeof, sleep, …) is lowered by name, so a user function spelled the same would compile and never run; the definition is refused instead: 'isolate' is a builtin function and cannot be redefined … rename it (e.g. 'isolate_'). A user function whose name is a C library symbol (read, time, index, remove, …) is fine: it is emitted under a mangled C name in every position: direct calls, f as fn(...), and as a bare fn value.
main() takes no parameters. There is no Aether-side main(argc, argv) form, the signature is uniform across every program. The runtime stashes the C-side argc / argv at startup (the codegen wraps your zero-arg main() in a C int main(int argc, char** argv) that calls aether_args_init(argc, argv) before any user code runs), and Aether code reaches command-line arguments through std.os:
import std.os
main() {
n = os.args_count()
i = 0
while i < n {
println("argv[${i}] = ${os.args_get(i)}")
i = i + 1
}
}
os.args_get(i) returns an owned copy of argv[i], "" when out of range, and os.argv0() argv[0] the same way. os.aether_argv_raw() exposes the original char** for C-interop callers that need to forward it unchanged. See Standard Library Reference § std.os for the full surface.
Returning on every path [E0700]
A function whose result is not void returns a value on every path through its body. A path that reaches the closing brace instead is a compile error, reported at that brace with the statement that lets control through. In a { ... } body the last expression is not returned; only an arrow body returns its last expression:
label(n: int) -> string {
msg = "n=${n}"
msg
}
// error[E0700]: missing return: control can reach the end of 'label', whose result is string
// help: `msg` on line 3 is not returned: only an arrow body, `-> { ... }`, returns its
// last expression; write `return msg`
sign(n: int) -> int {
if n > 0 { return 1 }
if n < 0 { return -1 }
}
// error[E0700]: missing return: control can reach the end of 'sign', whose result is int
// help: the `if` on line 11 has no `else`, so control goes on past it when its
// condition is false: return a value after it
main() {
println(label(sign(2)))
}
Control cannot get past a return, a panic(...) or an exit(...), nor out of a loop whose condition is always true (while true, a for (;;), a const that is true) unless a break leaves it. An if lets it through when it has no else or when a branch does; a match when an arm does or when it may run no arm, which it cannot when it has a _ arm or covers its type: every member of an enum, every variant of a sum (both checked anyway), none and some(v) for an optional, true and false for a bool, [] and [h|t] for a list; a switch when it has no default, when a case does, or when a break in a case leaves it; a try when its body or its catch does. A call's trailing block runs inline, so a return in it returns from the function it is written in, and is one of its returns: a return with a value there gives a function with no written type its result, as one anywhere else in its body does. A condition the compiler decides (if true, if DEBUG over a const) takes only the branch it selects.
The rule is the same whether the result type is written or inferred: a function with no written type that returns a value on one path (so its result is inferred from that return) returns one on every path. One that returns nothing anywhere is void and may end anywhere. So is a clause of a clause set with no written type that returns nothing: its caller gets the set's default value. Each clause of a set, and each closure that returns a value, is checked as a function of its own; main() and actor receive handlers have no result. Before this check such a function compiled, and the generated C fell off the end of a non-void function: undefined behaviour, which on Windows printed a string result as (null).
What a return gives [E0200]
A return gives a value of the result type, by the rules a typed binding's value follows (x: T = v). A value of another kind is a compile error at the return, naming both types:
word(n: int) -> string {
return n
}
// error[E0200]: Type mismatch in return: 'word' returns string, but this value is int
both() -> (int, string) {
return 1, 2
}
// error[E0200]: Type mismatch in return: 'both' returns (int, string), and value 2 here
// is int, not string
main() {
println(word(1))
}
A bare return gives no value, so in a function whose written result is a value it is the same error, E0200, naming the result type. It ends a void function, and a function with no written type is void until one of its returns gives a value.
What a binding converts, a return converts, as C converts it: one numeric scalar to another (int to long or float; into a written int, a long or a float truncates, as int k = 4.5 does), a bool to an integer and an integer to a bool (a comparison returned from -> int is 1 or 0, an extern's int truth value returned from -> bool is whether it is nonzero), a T to T? and none to any optional, a variant struct to its sum or to the sum's optional, an enum to an integer (byte included) and an integer to an enum, null or an int to a ptr or a *T, null to a closure or a fn? (the empty closure), and null or [] to a slice. A string and a bare ptr are both a C const char*, so a C string pointer returned from -> string becomes a string (its characters copied into a counted string the caller owns), and a string returned from -> ptr is its pointer:
extern getenv(name: string) -> ptr
home() -> string {
return getenv("HOME") // C's characters, copied
}
ratio(n: int) -> float {
return n // int to float
}
first(n: int) -> int? {
if n > 0 {
return n // wrapped into int?
}
return none
}
main() {
println("${ratio(3)} ${first(0) ?? 7} ${home() == null}")
}
A multi-value return is checked value by value against the tuple it returns, and an if or a match whose value is returned arm by arm. A return in a call's trailing block is its function's, and is checked against that function's result type.
With no result type written, the checks use the type the first return of a value gives, a return none aside: a function that returns none on one path and a T on another returns T?, as an if-expression with a none arm is (#3068); for a closure, which has no written result type, the type of its first return, or string when any return gives a string, as the closure's C signature does. A later return that such an inferred int result would truncate (a long, a uint32, a float) is the narrowing error an inferred int local gives: write the result type (-> float) to keep the value, or -> int to make the narrowing explicit.
Some values a binding lets through are refused here, because C cannot convert them: a closure becomes a ptr only through box_closure(f) (the box holds a reference of its own, given back with release_closure(p)) and a ptr a closure only through unbox_closure(p), a pointer is no value of an optional (return none for no value), a slice is no *T, and [], the empty slice, is returned only as an array, a slice or a pointer. A bool is not a float, a pointer or an enum, and an enum is not a string, a pointer or a float.
A named function returns a fixed-size array, alone or at a position of a tuple, written (-> int[2], -> (int[2], int)) or inferred (return arr). C returns no array, so the function returns it in a struct of its own and each use takes the elements out: a binding moves them in, a struct field or a tuple position takes them, and a call used as an array (pair()[1], sum(pair())) is held until its statement is done. An array of strings, closures or structs that own them hands its caller a reference to each element, as any return does.
pair() -> int[2] {
return [1, 2]
}
main() {
a = pair()
println("${a[0]} ${a[1]} ${pair()[1]}")
}
1 2 2
A closure, an extern and a function type return no array (E0201): C gives an extern no array result, and a closure's or a function value's result has no struct to carry one. Return it from a named function, or return a struct that holds it (struct Pair { v: int[2] }).
main() {
g = || {
int[2] a = [1, 2]
return a
}
a = g()
println("${a[0]}")
}
// error[E0201]: this closure returns int[2], a fixed-size array: only a named
// function returns one, alone or in a tuple (#2894)
// help: return it from a named function, or wrap it in a struct: struct Pair { v: int[2] }
Default arguments
A parameter can carry a default expression, used when a call leaves the parameter out. A call can also name its arguments, which binds each to the parameter it names whatever the order (named-args-and-select.md):
greet(name: string, greeting: string = "Hello") -> string {
return "${greeting}, ${name}!"
}
main() {
println(greet("Ada"))
println(greet("Ada", "Hi"))
println(greet(greeting: "Hey", name: "Ada"))
}
Hello, Ada!
Hi, Ada!
Hey, Ada!
Rules:
- A positional call leaves out trailing parameters only; a named argument can skip a defaulted parameter in the middle (
box(2, d: 4)). The compiler does not require defaults to trail the required parameters, but a required parameter after a defaulted one can then only be given by position after it, or by name. - A required parameter the call leaves out is an error, as are an unknown name, a parameter given twice and a positional argument after a named one.
- The default expression is evaluated at the call site, not the declaration site. Default expressions cannot reference other parameters of the same function (they are type-checked in the caller's scope, where parameter values are not visible).
- The default is filled in at type-check time, so code generation sees a call with every argument.
Source-location intrinsics
Three globally-visible identifiers expand at codegen time:
| Intrinsic | Type | Substitutes to |
|---|---|---|
__LINE__ | int | The literal source line of the AST node |
__FILE__ | string | The source-file path |
__func__ | string | The enclosing C/Aether function name |
Used at an explicit call site, the values reflect that site:
my_log(msg: string, line: int, file: string, fn: string) {
println("[${file}:${line} ${fn}] ${msg}")
}
main() {
my_log("hello", __LINE__, __FILE__, __func__)
// -> [/path/main.ae:6 main] hello
}
Used as default values, they substitute the caller's location, which is what a logging or assertion helper needs:
my_log(msg: string,
line: int = __LINE__,
file: string = __FILE__,
fn: string = __func__) {
println("[${file}:${line} ${fn}] ${msg}")
}
main() {
my_log("compact form")
// -> [/path/main.ae:9 main] compact form
}
The substitution happens at typecheck time: the typechecker clones the default expression into the call's argument list and rewrites any embedded __LINE__ / __FILE__ / __func__ to use the call site's metadata.
Calling a function value
Parentheses after any expression whose value is a function call it: a local (g(3)), a field (h.cb(2), hs[0].cb()), an element (fs[i](x)), or another call's result (make_adder(5)(2)). call(f, args) is the same call written out. The callee is evaluated once, before the arguments (fs[pick()](3) calls pick once), and held for the length of its call, so a function that rebinds the element or field it was read from runs to its end. A callee whose value is not a function is a type error, "the value called here is not a function", naming its type (#3099). A typed function pointer (fn(int) -> int, a cfn type) is called the same way, from a local, a parameter, a field, an element or a result.
Trailing blocks
A call can be followed, on the same line as its ), by one of four blocks:
| Form | Written | The function gets |
|---|---|---|
| immediate | f() { … } | nothing: the block runs inline, in the function's context |
| closure | f() \|x\| { … } or f() \|x\| -> expr | a closure to call (call(fn, args)) |
| callback | f() callback { … } | a closure to call, capturing from the enclosing scope |
| quote | f() quote \| x: T \| { … }, quote -> expr, quote { … } | a tree describing the block, to read (quote.md) |
The first three are closures-and-builder-dsl.md. A quote's block is type-checked as Aether but not compiled to run: the function, whose last parameter is a quote.Tree (import std.quote), receives the block as a tree whose every node carries its checked type, and whose captures are the enclosing variables themselves (a change the program makes is seen through the tree). Its parameters must have types, and what it may contain is a subset of Aether (errors E0800 to E0805 below). A quote may call a @quotable function, whose body travels with the tree (docs/quote.md, "Helpers").
Pattern Matching Functions
A function can be written as several clauses, each matching a different shape of argument, with guards to narrow a clause further:
Basic Pattern Matching
// Match on literal values
factorial(0) -> 1
factorial(n) -> n * factorial(n - 1)
// Fibonacci with multiple clauses
fib(0) -> 0
fib(1) -> 1
fib(n) -> fib(n - 1) + fib(n - 2)
Guard Clauses
Guards add conditions using the when keyword:
// Classify numbers using guards
classify(x) when x < 0 -> println("negative")
classify(x) when x == 0 -> println("zero")
classify(x) when x > 0 -> println("positive")
// Factorial with guard
factorial(0) -> 1
factorial(n) when n > 0 -> n * factorial(n - 1)
// Grade calculation with multiple ranges
grade(score) when score >= 90 -> "A"
grade(score) when score >= 80 -> "B"
grade(score) when score >= 70 -> "C"
grade(score) when score >= 60 -> "D"
grade(score) when score < 60 -> "F"
List Patterns
A parameter can be a list pattern, as a match arm can: [], [x, y], or [h | t] for one element or more. It takes a slice (an array is viewed as one, [] is the empty one), and binds its names: each element named, and a cons pattern's tail, a slice of the rest. A literal element ([0 | t]) must be equal. The element type is the one the calls pass, or another clause's typed parameter at that position, else int.
sum([]) -> int {
return 0
}
sum([h | t]) -> int {
return h + sum(t)
}
main() {
xs = [1, 2, 3]
println(sum(xs))
}
6
Struct Patterns
A parameter can be a struct pattern: Name { field: pattern, ... } takes a value of the struct Name, and each field it lists is matched by a pattern of its own. A literal must equal the field (a string by content), _ takes any value, a name is bound to the field's value, and a struct pattern over a field of a struct type matches that field. Fields it does not list take any value. A match arm takes the same pattern (Struct Pattern Matching).
struct Pt {
x: int
y: int
}
kind(Pt { x: 0, y: _ }) -> string {
return "on the y axis"
}
kind(Pt { x: a, y: b }) -> string {
return "at ${a},${b}"
}
main() {
println(kind(Pt { x: 0, y: 5 }))
println(kind(Pt { x: 3, y: 4 }))
}
on the y axis
at 3,4
How a Clause Set Runs
The clauses are tried in order. A call runs the first clause whose literal, list and struct patterns all match its arguments and whose guard, if it has one, is true, and returns what that clause returns. Each guard is evaluated once, for the call that reaches its clause, and no clause after one that matches every call (no literal pattern, no guard) is ever tried. When no clause matches, a set that returns a value returns its type's zero value: 0, false, an empty string, a tuple of zeros with an empty string at each string position, a struct of zeros.
Each clause is a function of its own: whatever works in a single function's body works in a clause's. A closure in a clause can mutate the clause's parameters, a struct or fixed-size array parameter is the clause's own copy, two clauses may give one local name values of different types, and a clause's requires and ensures belong to that clause alone: they are checked when the call runs that clause, and the compile-time contract check applies a clause's requires only to the calls that can reach it.
name_of(0) -> string {
return "zero"
}
name_of(n: int) -> string
requires n > 0
{
return "n${n}"
}
main() {
println(name_of(0)) // the first clause: `requires n > 0` is not its
println(name_of(7))
}
zero
n7
A clause set is one function with one result, decided over every clause. A clause that writes a type gives it, and what every other clause returns must fit it, as a binding fits a local's written type: a literal fits when the type holds its value (g(0) -> 5 beside g(k) -> uint8 { ... }; 300 does not). With none written, the result is the join of what the clauses return, as an if-expression's branches join: pick(0, s: int, b: long) -> s beside pick(k, s: int, b: long) -> b returns a long, a clause that returns 0 beside one that returns a uint32 is a uint32, a clause that returns only null takes the pointer, string or closure type beside it, and one that returns only none makes the result an optional of the others' type. The two go together: f(0) -> none, f(1) -> null and f(k) -> "s" return a string?, f(1) the string null is, as return null from a -> string? function gives. A clause that only calls the set again (last(k) -> last(k - 1)) gives the set's result and no type of its own, so beside only none clauses the set has no T, the compile error a set of only none clauses is. Results no one type holds are a compile error naming both. A clause that returns nothing gives the result type's zero value, and a set none of whose clauses returns a value returns nothing. A clause set exported from a module is imported with all its clauses.
Multi-Statement Arrow Bodies
Arrow functions can have block bodies with -> { ... }. The last expression is the implicit return value:
// Single expression (existing)
twice(x) -> x * 2
// Multi-statement with implicit return
sum_squares(a, b) -> {
sq_a = a * a
sq_b = b * b
sq_a + sq_b
}
// Multi-statement with early return
clamp(x, lo, hi) -> {
if x < lo {
return lo
}
if x > hi {
return hi
}
x
}
This allows complex logic in arrow-style functions without switching to block syntax.
A written result type may come before an arrow body, -> T -> expr or -> T -> { ... }, with any contract clauses between them (#3158). The body's value is returned against T, as a block body's return is: a value of another type is E0200 at the value.
struct Point {
x: int
y: int
}
square(x: int) -> int -> x * x
origin() -> Point -> Point { x: 0, y: 0 }
label(n: int) -> string -> {
s = "n=${n}"
s
}
main() {
println("${square(7)} ${origin().y} ${label(3)}")
}
49 0 n=3
The last statement is the result only when it has a value: an expression, other than a call of a function that returns nothing, or a match one of whose arms gives a value. Any other statement there is a statement like any other, and the body ends with it:
- An
if, a loop, aswitch, atry, adeferor apanic. A body ending in one has toreturnon every path its own way:-> { if x > 0 { return 1 } }is a missing return whenxis not positive. - A binding or a store:
x = v, whether it declaresxor assigns an existing one,x += v,p.n = v,xs[i] = v,a, b = f(). With a globalvar hits = 0,bump() -> { hits = hits + 1 }stores and returns nothing, and so does-> { label = "n=${n}" }, which binds a local nobody reads. The single expression form is the same:store(p: *P, v: int) -> p.n = vstores. - A send,
a ! M { ... }, which has no value. - A call of a function that returns nothing (
println(...), or one of the program's with no result): with such anote,-> { note(x) }and-> note(x)end with the call and return nothing. So does amatchall of whose arms end in such a call or in a statement,-> { match x { 1 -> note(1) _ -> note(0) } }.
Whether a function returns nothing is settled for the whole program before any body is checked, so it does not depend on the order the functions are defined in: f(x) -> { g(x) } returns nothing when g(x) -> { note(x) } does, wherever g is.
A return written out follows the same rule. return note(x), where note returns nothing, is the call, then a return of nothing, as -> note(x) is: it fits a function or a closure that returns nothing. In one that returns a value on another path it has nothing to give back, and is an error naming the call.
A closure's arrow body follows the same rule: |x| -> { y = x * 2; y + 1 } returns y + 1, as |x| -> x * 2 returns x * 2, and || -> { count = count + 1 } stores into the captured count and returns nothing. A closure with a plain block body, |x| { ... }, returns only what it returns.
Multi-parameter Guards
// Max of two numbers
max(a, b) when a >= b -> a
max(a, b) when a < b -> b
// GCD with pattern matching
gcd(a, 0) -> a
gcd(a, b) when b > 0 -> gcd(b, a - (a / b) * b)
Mutual Recursion with Guards
is_even(n) when n == 0 -> 1
is_even(n) when n > 0 -> is_odd(n - 1)
is_odd(n) when n == 0 -> 0
is_odd(n) when n > 0 -> is_even(n - 1)
Control Flow
If Statements
if x > 0 {
println("positive")
} else if x < 0 {
println("negative")
} else {
println("zero")
}
The condition needs no parentheses; if (x > 0) is accepted too, the parentheses read as grouping.
If-Expressions
if/else can be used as an expression that produces a value (like a ternary operator):
// Assign based on condition
max = if a > b { a } else { b }
// Use inline in function calls
println(if x > 0 { x } else { 0 - x })
// Nested if-expressions
grade = if score >= 90 { 4 } else { if score >= 80 { 3 } else { 2 } }
The else branch is required, and the expression has the type its branches have in common. A value match types its arms the same way, one rule for every kind of value:
- Two numeric branches have their common type:
if c { 1 } else { big }with along bigis along, and anintbeside afloatis afloat, so neither branch is narrowed into the other's type. A float literal beside anf32branch takes thef32, as it does beside anf32operand. Two integers take the smallest type that holds both, where C's?:would take the unsigned one: a signed one beside an unsigned one is anintbeside abyte, auint8or auint16, and alongbeside auint32(if c { -1 } else { u }with auint32 ugives -1, not 4294967295). A literal that fits auint32oruint64branch beside it takes that type (if c { 0 } else { u }is auint32); of several literal arms or clauses beside one, every one must fit (0,-1and auint32are along). A signed value beside auint64is a compile error naming both, since no integer type holds both. - A
Tbeside aT?, or besidenone, is aT?. - A variant beside its sum, and two variants of one sum, are the sum:
if c { Circle { r: 1 } } else { Square { side: 2 } }is aShape, each branch wrapped into it. Where the two variants belong to more than one sum, the local's annotation or the function's result type says which (let s: Shape = ...). - Fixed-size arrays of one element type and length are that type (
int[3]). - Tuples of one length have each position's common type:
(1, "a")beside(2, "b")is an(int, string). A tuple literal is the tuple of its elements' types wherever it stands, sot = (5, "z")binds a tuple a latern, s = tdestructures.
A branch whose type the other branch's takes as a binding would (null beside a pointer) is converted into it, and the expression has the wider type. Two branches of no one type (an int and a string, arrays of another length or element type, tuples of another length) are a compile error naming both types.
Each branch is an expression that gives a value, so a branch made of a call that returns nothing is a compile error, and so is one made of exit(n), which never returns: an if expression chooses between two values and nothing leaves from one of its branches. To leave on one path, test it with an if statement first, or use a value match, whose arm may leave (_ -> exit(4)).
While Loops
i = 0
while i < 10 {
println(i)
i = i + 1
}
For Loops
The C-style loop: an init, a condition and a step. A loop over a range of numbers is usually written as a range-for (below).
for (i = 0; i < 10; i++) {
println(i)
}
A bare init (i = 0) is the binding i = 0 is anywhere. When the name is already bound where the loop is (a local, a module-level var, an actor's state field), it assigns that variable, which holds the value the loop ends with:
main() {
i = 5
for (i = 0; i < 3; i++) {
}
println(i)
}
3
For a new name it declares the loop's own variable, from the condition through the body to the end of the loop: a binding of its name in the body assigns it, so a loop can step its variable there, and after the loop the name is free to bind again. A typed init (for (int i = 0; ...)) and a range-for's variable always declare the loop's own, shadowing a binding of the name (#2734).
main() {
total = 0
for (i = 0; i < 10;) {
total = total + i
i = i + 3 // the loop's i: 0, 3, 6, 9
}
println(total)
}
18
A closure in the body that assigns the variable assigns the loop's own. The loop keeps one variable for all its passes, as i = 0 before a while does, so the condition, the increment, the body and every closure made in any pass read and write the same i, and a closure kept past the loop still holds it. A closure that only reads the variable takes the value it has when the closure is made.
main() {
for (i = 0; i < 6;) {
bump = || { i = i + 2 } // the loop's i
bump()
println(i)
i = i + 1
}
}
2
5
A step that binds a variable (i = i + 3, i += 3, s = string.concat(s, "b")) is that binding, made at the end of each pass, and a continue runs it before the condition is tested again. The loop's variable owns its value as any local does: a string the step or the body replaces is released, and so is the one the loop ends with.
main() {
total = 0
for (i = 0; i < 10; i += 3) {
if i == 6 {
continue // the step still runs: 9 is next
}
total = total + i
}
println(total)
}
12
Range-Based For Loops
Iterate over a range with for VAR in START..END:
// Prints 0 to 4, one a line
for i in 0..5 {
println(i)
}
// Sum with variable bound
sum = 0
for i in 1..n {
sum += i
}
The range start..end is exclusive of end (like Python's range(start, end)). It desugars to a C-style for loop internally. Its variable is the loop's own, a new one even where the name is bound outside the loop.
Loop Control
// break: leave the loop early
for i in 0..100 {
if i == 50 {
break
}
}
// continue: on to the next pass
for i in 0..10 {
if i == 5 {
continue
}
println(i)
}
Labeled break / continue
A while or for loop may carry a label (label: while ...), and break label / continue label then target that loop from inside a nested loop, break label exits the labeled loop, continue label jumps to its next iteration. This replaces the boolean-flag dance a C port would otherwise need for a nested-loop early exit (the C goto cleanup / labeled-break idiom):
outer: while have_more() {
while scan_row() {
if found_match() {
break outer // exit BOTH loops
}
if skip_row() {
continue outer // stop this row, advance the outer loop
}
}
}
- The label sits before the loop keyword (
outer: while,scan: for). It must be on the same line as thebreak/continuethat references it. - A
break label/continue labelwhose label names no enclosing loop is a compile-time error. - Defers in the loop scopes a labeled break/continue unwinds still run (in LIFO order) before the jump, the same cleanup guarantee as an ordinary scope exit.
- An unlabeled
breakleaves the innermost loop orswitch, whichever is nearer (amatchis neither, so abreakin an arm leaves what is around thematch); an unlabeledcontinueacts on the innermost loop.
Match Statements
Match statements provide pattern-based dispatch:
Integer Matching
match value {
0 -> { println("zero") }
1 -> { println("one") }
2 -> { println("two") }
_ -> { println("other") }
}
String Matching
Strings are compared by content (length and bytes), so string literal arms work correctly:
match (command) {
"start" -> { println("starting...") }
"stop" -> { println("stopping...") }
"help" -> { println("available: start, stop, help") }
_ -> { println("unknown command") }
}
Ranged and multi-value cases
A match arm (and a switch case) can match a range of ordinal values or a comma-separated list of values and ranges, instead of a single literal:
grade = match score {
90..=100 -> "A" // ..= inclusive: 90 through 100
80..<90 -> "B" // ..< half-open: 80 through 89 (excludes 90)
70..<80 -> "C"
60, 61, 62 -> "D" // comma-list of values
_ -> "F"
}
..=is an inclusive range (both ends match);..<is half-open (the high end is excluded), consistent with the exclusivefor i in 0..5.- Comma-separated elements in one arm match if any of them matches, and each element may itself be a value or a range:
97..=122, 65..=90 -> "letter". - Ranges are over integer ordinals (
int,long,byte). Aether has no character literals, so a character class matches the byte's integer value, e.g. fromstring.char_at:
kind = match string.char_at(s, i) {
97..=122, 65..=90 -> "letter" // a-z, A-Z
48..=57 -> "digit" // 0-9
_ -> "other"
}
A ranged arm lowers to a plain comparison (x >= lo && x <= hi) in the branch chain, with no runtime support and no allocation. Existing single-literal arms are unaffected.
List Pattern Matching
Arrays, slices and *StringSeq values can be matched with list patterns. A fixed-size array (T[N]) is matched by its length N, known when it is compiled, and a slice by the length it carries. Each name a list pattern binds has the element type of the value matched: a float over a float[], a string over a string[], a struct over a struct array. A cons pattern's tail (t in [h|t]) is a slice of the rest, its length t.len:
main() {
nums = [1, 2, 3]
match nums {
[] -> println("empty list")
[x] -> println("single element: ${x}")
[a, b] -> println("pair: ${a}, ${b}")
[h|t] -> println("head: ${h}, ${t.len} more")
}
}
head: 1, 2 more
A value with no length of its own, a bare pointer, takes it from a local of its name and _len. A local bound to [] alone is one (the literal gives it no element type):
empty = []
empty_len = 0
match empty {
[] -> println("empty")
_ -> println("not empty")
}
List Pattern Types
| Pattern | Matches | Bindings |
|---|---|---|
[] | Empty array | None |
[x] | Single-element array | x = element |
[x, y] | Two-element array | x, y = elements |
[x, y, z] | Three-element array | x, y, z = elements |
[h\|t] | Non-empty array | h = first, t = rest |
Struct Pattern Matching
A struct is matched by its fields with the struct pattern a clause head takes (Struct Patterns): Name { field: pattern, ... }, where a literal must equal its field (a string by content), _ takes any value, a name is bound to the field's value, and a struct pattern over a field matches that field. The value matched must be that struct. A pattern with a literal field takes only some values, so a match of such arms runs no arm for the rest unless it has a _ arm or ends with a pattern that tests no field.
struct Pt {
x: int
y: int
}
where_is(p: Pt) -> string {
return match p {
Pt { x: 0, y: 0 } -> "origin"
Pt { x: 0, y: _ } -> "on the y axis"
Pt { x: a, y: b } -> "at ${a},${b}"
}
}
main() {
println(where_is(Pt { x: 0, y: 0 }))
println(where_is(Pt { x: 0, y: 3 }))
println(where_is(Pt { x: 2, y: 5 }))
}
origin
on the y axis
at 2,5
Names an arm binds
Each arm's block is a scope of its own, as an if branch is: a defer in it runs when the arm ends, and only when the arm ran. A name an arm binds is one variable with the rest of the function exactly where a name an if branch binds would be (Variables): read after a match at the top of the function body, bound in a while body, or bound by every arm of a match that runs an arm for every value, one with a _ arm or whose arms cover its type (every member of an enum, every variant of a sum, none and some(v), true and false, [] and [h|t], a struct pattern that tests no field). Elsewhere a read after the match is Undefined variable. A match whose value is used (bound, returned, or the value of another's arm) keeps its arms' names in the arms.
A name an arm's pattern binds (some(v), [h | t], [x, y], Pt { x: a }) is that arm's own: v = v + 1 in the arm rebinds the pattern's value, and no rule declares the name ahead of the match, from that arm or any other. So some(v) -> { v = v + 1 } none -> { v = 0 } followed by return v is Undefined variable: there are two variables named v, and neither outlives its arm. Use another name for the one the match should leave behind. The value of a bound match goes to the variable it is bound to, whatever its arms bind: v = match o { some(v) -> v none -> 0 } sets the v outside to the value the arm's v holds.
pick(x: int) -> int {
match x {
1 -> { v = 10 }
_ -> { v = 20 }
}
return v
}
main() {
println("${pick(1)} ${pick(2)}")
}
10 20
Switch Statements
C-style switch for simple value dispatch:
switch month {
case 1: name = "January"
case 2: name = "February"
case 3: name = "March"
// ... more cases
default: name = "Invalid"
}
Aether's switch has no fall-through: each case auto-breaks after its body. Cases also accept comma-lists and ranges, the same as match arms:
main() {
code = 404
kind = ""
switch code {
case 200, 201, 204: kind = "success"
case 300..<400: kind = "redirect" // a ranged case, lowered to an if-chain
case 400..<500: kind = "client-error"
default: kind = "other"
}
println(kind)
}
client-error
A comma-list case lowers to several C case labels sharing one body; a switch containing any ranged case is lowered to an equivalent if-else chain (safe because there is no fall-through to preserve).
A switch over a string, a pointer or a float compares by an if-chain as well, a string by content. Its cases are checked against the value as a match arm's are (Match Expressions): a case naming a value the switch's value can never equal, or a name that is no variable, constant or enum member, is an error at the case, and a value that cannot be compared (an optional, a sum, a struct) is one at the switch. A bare enum member (case Red:) is that member, as in a match.
A break in a case leaves the switch, and nothing more, whichever of the two forms it lowers to: inside a loop, the loop goes on with the statement after the switch. On its way out it runs the defers of the scopes it leaves inside the case, not those of the loop body around the switch. A break in a match arm leaves the switch or loop around the match. continue goes on to the loop's next iteration, and break label leaves the loop it names. Each case is a scope of its own, as an if branch is: a defer in it runs when the case ends, and only when the case ran. A name a case binds is one variable with the rest of the function exactly where a name an if branch binds would be (Variables): read after a switch at the top of the function body, bound in a while body, or bound by every case of a switch with a default. Elsewhere a read after the switch is Undefined variable:
pick(x: int) -> int {
switch x {
case 1: v = 10
default: v = 20
}
return v
}
main() {
println("${pick(1)} ${pick(2)}")
}
10 20
main() {
hits = 0
i = 0
while i < 5 {
i = i + 1
switch i {
case 2..=3: break // leaves the switch; the loop goes on
default: hits = hits + 1
}
hits = hits + 10
}
println(hits)
}
53
Alternatives are the comma-list, in switch and match alike. A | between values is the bitwise OR (1 | 2 | 3 is the number 3), so the compiler refuses it in a selector and names the comma form.
Interpreter dispatch loops
A switch as the body of a while loop that is always true is an interpreter's dispatch loop, and the compiler lowers it to threaded dispatch (computed goto) where the C compiler supports it (GCC, Clang, MinGW):
while true {
op = code[pc] // head: runs before every dispatch
pc = pc + 1
switch op {
case OP_PUSH: { push(code[pc]) pc = pc + 1 continue }
case OP_ADD: { add() continue }
case OP_HALT: { return pop() }
}
// tail: reached when an arm breaks or falls off, or nothing matches
}
Each continue that targets the loop runs the head again and jumps straight to the next arm through a table, so every arm ends in its own indirect jump instead of all of them sharing the one a switch compiles to; on a mixed opcode stream that branch predicts far better. Nothing is annotated, and nothing about what the loop does changes: a value with no arm goes through the switch as before, and the code after the switch runs exactly when it did. Under MSVC, or when built with -DAETHER_NO_THREADED_DISPATCH, the output is the plain loop and switch.
The loop is recognised when its condition is always true (true, 1 == 1, a true constant), the switch is a statement of the loop body, its selector is an integer, every case is an integer constant from 0 to 4095 (literals, consts, enum members, module constants; no ranges), and some arm continues. The statements before the switch are repeated at each continue, so they may not build strings, capture closures, or contain defer, try, a loop, a switch or a break. Set AETHER_EXPLAIN_THREADED=1 when compiling to have the compiler say why a loop of this shape was not threaded.
Switch vs Match
| Feature | switch | match |
|---|---|---|
| Pattern types | Literals, ranges, comma-lists | Literals, ranges, comma-lists, lists, wildcards |
| Binding | No | Yes (captures variables) |
| Use case | Simple dispatch | Pattern destructuring |
Defer Statement
The defer statement schedules code to run when leaving the current scope:
process_file() {
handle = open_resource()
defer close_resource(handle) // runs when the function exits
use_resource(handle)
use_resource(handle)
// close_resource(handle) runs here
}
LIFO Order
Multiple defers execute in Last-In-First-Out order:
main() {
defer println("First") // runs third
defer println("Second") // runs second
defer println("Third") // runs first
}
Third
Second
First
defer try and defer catch, cleanup for one outcome only
A plain defer runs on every exit, a panic that a try catches further up included. Two qualified forms run on only one, and neither on a panic:
defer cleanup() // always: on every exit
defer try commit() // only when the function returns SUCCESSFULLY
defer catch rollback() // only when the function returns an ERROR
"Error" means the function returned a non-empty error slot, Aether's (value, err) convention, and T!, which is the same shape.
Together they give you the transactional shape in three lines: acquire, register the rollback, register the commit, and then let any error path bail out without the acquire leaking and without a half-built result being published.
acquire(path: string) -> (ptr, string) {
p = malloc(SIZE)
defer catch free(p) // on an error return, release it
cfg, err = parse(path)
if err != "" { return null, err } // ...and `p` is freed on the way out
init(p, cfg)
return p, "" // success: the caller owns `p` now
}
Without them, every early return needs its own if err != "" { free(p); return }, and the one you forget is the leak.
Ordering is unchanged: all defers still run in LIFO order, and the conditional ones interleave with the unconditional ones by registration order; they are not hoisted into separate groups. In
defer log("A")
defer catch log("C")
defer try log("T")
a successful return logs T, A; an error return logs C, A.
Cost. One predictable compare on the return path, and not even that where the outcome is known at compile time. An expr! propagation is always an error exit and a bare return v is always a success exit, so in a T! function both are resolved statically and no guard is emitted at all. As with a plain defer, the bodies are emitted inline at each exit; a plain defer is also recorded in the unwind journal while a try is live, so a caught panic runs it.
They only mean something in a function that can fail. In a function with no error channel, a defer catch could never fire and a defer try is just a plain defer, so the compiler warns rather than silently accepting code that does not do what it says:
warning: `defer catch` in a function that cannot fail: it will never run.
Give the function an error result (`-> (T, string)` or `-> T!`), or use a plain `defer`.
Use Cases
- Resource cleanup (files, connections)
- Unlocking mutexes
- Logging function exit
- Guaranteed cleanup regardless of return path
- Rollback on failure / commit on success (
defer catch/defer try)
Scope Directives: hide and seal except
Two scope-level directives let a block decline to see selected names from its enclosing lexical scopes. Both are compile-time only, no runtime overhead, no codegen change. Error code: E0304.
hide blacklist specific names
{
hide secret_token, db_handle
// secret_token and db_handle from outer scopes are invisible here.
// Reading, writing, or redeclaring them is a compile error.
}
seal except whitelist
handler(req, res) {
seal except req, res, inventory, response_write
// Every outer name is invisible EXCEPT the four listed.
// Local bindings created inside this block are still visible.
}
Key semantics
A directive is scope-level: its position within the block doesn't matter, and it blocks both reads and writes of the affected names. The denial propagates into nested blocks, with no way to un-hide deeper in. It does not reach through call boundaries, though: a visible function can still use hidden names via its own lexical chain, since this is name-resolution denial, not an effect system. Local bindings created inside the block stay visible, because directives only affect lookups that walk out to parent scopes. Qualified names are covered too, so hide http also blocks http.get(url). The directives work inside actor receive arms (handler bodies are block scopes), and a hidden name cannot be redeclared in the same scope, though a nested child scope may.
For the full design rationale, edge cases, and worked examples, see hide-and-seal.md.
Memory Management
Aether has no garbage collector. Values the program owns (strings, closures, string sequences, and the structs, tuples, optionals, sums and fixed-size arrays built from them) are reference counted by the compiler: every place holds one reference, a store takes one, and leaving a scope releases what the scope held, so the program writes no free for them (String ownership). Handles (a list, a map, a C buffer, an open file) are ptrs, which own nothing, and the program frees them, with defer. Memory Management Guide states the rules in full.
defer for handles
Allocate, defer the free at once, then use the handle. Cleanup runs at scope exit in LIFO order:
import std.list
main() {
items = list.new()
defer list.free(items)
list.add(items, "hello")
println(list.size(items))
// list.free(items) runs here (scope exit)
}
A defer runs any statement, not only a stdlib free.
Returning a handle
A function that returns a handle it allocated gives it to the caller, which frees it:
import std.list
build_list(n: int) -> ptr {
result = list.new()
for i in 0..n {
list.add(result, "item ${i}")
}
return result
}
main() {
items = build_list(10)
defer list.free(items)
println(list.size(items))
}
Multiple Return Values (Result Types)
Functions can return multiple values using comma-separated returns:
safe_divide(a: int, b: int) -> {
if b == 0 {
return 0, "division by zero"
}
return a / b, ""
}
main() {
// Check error first, handle it, then continue, no else needed
result, err = safe_divide(10, 3)
if err != "" {
println("Error: ${err}")
exit(1)
}
println("Result: ${result}")
// Discard unwanted values with _
val, _ = safe_divide(42, 7)
println(val)
}
The error convention: empty string "" means no error, non-empty string is the error message. Use != "" to check for errors.
Tuple destructuring creates typed local variables. The compiler generates C structs for each unique tuple type (_tuple_int_string, etc.).
A slot is a name, _ (the value is dropped), an element or a field (#3167): names[0], _ = pair(), p.name, p.n = pair(), last, counts[1] = pair(). An element or a field takes its value as place = value stores it, ownership included, and the names among the slots bind as they would in a destructure of names only. An element's index is evaluated first, left to right and before the value, as in Go: xs[i], i = pair() stores into the element i named before the statement (#3208).
Explicit tuple return type
The compiler infers the return tuple from return a, b statements in the body, but you can also declare it up front using the same parenthesised form accepted on extern:
safe_divide(a: int, b: int) -> (int, string) {
if b == 0 { return 0, "division by zero" }
return a / b, ""
}
Stating the return type at the signature is preferred when the function is part of a public API or when readers shouldn't have to scan the body to know the return shape. The two forms (-> { ... } with inference vs. -> (T1, T2) { ... } explicit) are interchangeable from the caller's perspective and produce the same C struct return.
Each element takes any type a parameter or a single return takes, pointers included, so a caller keeps the pointer types instead of casting ptrs back:
struct Foo {
n: int
}
pair_ptr(a: *Foo, b: *Foo) -> (*Foo, *Foo) {
return b, a
}
main() {
x = Foo { n: 1 }
y = Foo { n: 2 }
p, q = pair_ptr(&x, &y) // p and q are *Foo
p.n = 20 // writes through to y
println("${q.n} ${y.n}")
}
1 20
A parenthesised arrow body is still an expression: sum(a: int, b: int) -> (a + b) returns a + b. The group is a return type when it parses as one and the { body (or a requires / ensures clause) follows it. A dotted name after -> is read the same way: it is a return type, as in -> geo.Point { ... }, when the { body, a requires / ensures clause or a type suffix (?, !, [) follows it, and otherwise an expression body, so get(p: *P) -> p.n returns the field and store(p: *P, v: int) -> p.n = v stores it.
Error propagation across function boundaries works correctly:
checked_op(x: int) -> {
val, err = safe_divide(x, 2)
if err != "" {
return 0, err // propagate the error
}
return val, ""
}
The T! result type and or / ! sugar
-> T! is shorthand for the (T, string) result tuple above, it names the convention and unlocks ergonomic sugar for the three things you do with a fallible call. There is no hidden machinery: T! is the same tuple you can always destructure by hand, so the sugar and the manual form interoperate freely.
// `return v` auto-wraps to (v, ""); the error path is explicit.
safe_divide(a: int, b: int) -> int! {
if b == 0 { return 0, "division by zero" }
return a / b
}
expr or default use the value, or a fallback on error:
q = safe_divide(10, 0) or -1 // q == -1
expr or { ... } run a block on error, with err bound to the message. The block's last statement is its value, so a handler can log, compute, and still yield a fallback, or the block leaves and never falls through: return, break, continue, panic, exit(n) (or os.exit(n)), or an if/else or match every path of which leaves:
q = safe_divide(x, y) or {
println("math failed: ${err}") // `err` is the error string
return
}
r = safe_divide(x, y) or {
println("using fallback: ${err}")
-1 // the block's value on the error path
}
A block that neither yields a value of the right type nor leaves is a compile error (` or { } handler must end with a value … or leave ), previously that shape compiled and read an **uninitialized** result on the error path. Whether a statement leaves is decided by one rule everywhere it matters: the or handler, a value match arm, a function's missing return and W1002's unreachable code all agree, except that W1002 does not fold an if` on a constant (Unreachable Code).
expr! propagate the error. Inside a function that itself returns T!, ! returns (zero, err) to the caller when the error slot is non-empty, so an inner failure short-circuits without manual if err != "" plumbing:
checked_divide(x: int, d: int) -> int! {
return safe_divide(x, d)! // forwards safe_divide's error unchanged
}
Used outside a T! function, expr! is unwrap-or-trap: it panics on a non-empty error slot (catchable with try/catch). Both ! and or accept any (value, err) tuple, not just T! returns.
Panics: panic, try and catch
An error result is for a failure the caller expects and handles; a panic is for a bug. panic("reason") unwinds to the nearest enclosing try, whose catch name { ... } block runs with the reason bound to name. The runtime raises the same kind of panic for a broken contract, an index out of range, x! on none and a call through an empty closure, each reason starting with its category. Outside any try a panic prints its reason and ends the program; in an actor's handler it ends that actor. What the scopes it leaves hold is released, and their plain defers run.
average(xs: int[]) -> int {
if xs.len == 0 {
panic("average of no numbers")
}
total = 0
for i in 0..xs.len {
total = total + xs[i]
}
return total / xs.len
}
main() {
result = 0
try {
result = average([4, 8])
result = average([])
} catch reason {
println("caught: ${reason}")
}
println(result)
}
caught: average of no numbers
6
try is a statement and gives no value: to keep what its block computes, assign it inside the block, as above. A try where a value goes is one error (#3150):
work() -> int {
return 42
}
main() {
r = try {
work()
} catch e {
println("caught ${e}")
}
}
// error[E0100]: `try` is a statement and gives no value
// help: bind the value inside it: `try { r = work() } catch e { ... }`
Function contracts: requires / ensures
Eiffel-style runtime-checked preconditions and postconditions. Clauses appear after the typed return arrow and before the body; each is a single boolean expression, panic on violation.
add(a: int, b: int) -> int
requires a >= 0
requires b >= 0
ensures result >= a
ensures result >= b
{
return a + b
}
Lowering:
requires <expr>lowers toif (!(<expr>)) aether_panic("precondition_violation: <expr> in <fn>");emitted at function entry, after parameters are declared and before any user code runs. Parameters are in scope.ensures <expr>lowers to a pre-return wrapper{ <T> result = <return-expr>; if (!(<expr>)) aether_panic("postcondition_violation: <expr> in <fn>"); return result; }emitted before eachreturnstatement. The syntheticresultlocal is the return value about to be returned and is scoped to the wrapper block; it shadows any outerresult.
Clauses may appear in any number and order. Each is checked on its own, so the panic message names the predicate that failed, and the panic's stack trace names the call.
A parameter may carry its precondition inline: b: int where b != 0 is the same check as requires b != 0.
Panic categories
Every unrecoverable failure prints a stable category token, then a colon, then the human detail and the source location. The token is the contract: CI and downstream triage match on it, so it does not change with the prose after it.
| category | raised by |
|---|---|
precondition_violation | a requires clause that evaluated false |
postcondition_violation | an ensures clause that evaluated false |
forced_unwrap_none | x! on an optional holding none |
empty_closure_call | a call through the empty closure (a closure slot or a fn? holding null) |
null_function_call | a call through a typed function pointer (fn(int) -> int, a cfn type) holding null |
A failure that forwards an existing error value (expr! on a fallible) prints that error's own message rather than a category, because the message came from the callee and is not one of these classes.
--no-contracts build flag:
aetherc --no-contracts script.ae out.c # zero per-call cost
Suppresses contract-check emission entirely. Equivalent to C's -DNDEBUG for assert. Intended for release builds where the contracts have been validated upstream.
Compile-time folding (design: docs/contract-folding.md): predicates are evaluated at compile time whenever every operand is a compile-time constant, literals, top-level const names, enum members, arithmetic (+ - * / %, exact in int64), comparisons, && || !. Three outcomes:
- Provably true → the runtime check is elided; the emitted C carries a
/* precondition elided (always-true): <text> */comment and is byte-for-byte identical to a function without the clause. This includes const-operand predicates (requires cap > MIN_CAPwith aconst MIN_CAP), not just literal ones. - Provably false at the definition → compile error: no call could ever satisfy the clause. The realistic way to hit this is a refactor that stales a constant.
- Provably false at a call site, with the actual arguments substituted for the parameters → compile error at that call:
divide(a: int, b: int where b != 0) -> int { return a / b }
divide(10, 0) // error: precondition violation at compile time:
// b != 0 in divide, this call's constant
// arguments can never satisfy it
divide(10, n) // n is runtime → runtime check, exactly as before
This is trait-bound/concepts-like checking from the contract syntax you already wrote. Anything the evaluator cannot decide, a runtime variable, a call, member access, a partial set of constant arguments, keeps today's runtime check and produces no diagnostic. Platform-dead code cannot false-positive: when arms are pruned before the typechecker runs.
Compile-time contract errors fire even under --no-contracts, that flag removes runtime checks, not free compile-time correctness findings. If a provably-violating call is intentionally unreachable, route the value through a runtime variable; only constant arguments participate in folding.
Limitations / out-of-scope for v1:
- Postconditions are checked only at explicit
return <expr>statements with a single value. Multi-value (tuple) returns and fall-off-the-end of void functions are not yet wrapped, callingaether_panicfrom those paths is a follow-up. - Short-circuit folding is asymmetric on purpose.
x || true(unknownx) keeps the runtime check, because evaluatingxmay carry a side effect the user expects to fire, buttrue || xelides, since the runtime would short-circuit pastxanyway; andx && falsemay be reported always-false, because a false verdict never skips any runtime evaluation (it is either a build error or an emitted runtime check). - Call-site folding sees only the call itself: arguments must be constants right there. A constant flowing through an intermediate variable or a helper function is not tracked (that would be dataflow analysis).
- The
--emit=libaether_describe()metadata doesn't yet surface contracts to FFI consumers; that's the next layer.
See examples/01-language/15-contracts.ae for a runnable example.
String ownership
Every place that holds a string (a local, a struct field, an element, a module-level var, a closure's capture) owns one reference to it, and releases it when the place is given a new value or goes away (scope exit on every path, a struct destroyed). You do not write defer string.free(s). The model is docs/design/ownership-model.md.
- A function returning a string hands its caller one reference, from every return. A literal is handed over as it is (literals are static and never freed); a fresh string, or a local at its last use, is moved; a string the function only borrows (a parameter, a field of one) is retained, and a plain C string copied.
- A parameter is borrowed for the call. The caller owns the string and releases it. A function that keeps a parameter (stores it in a field, a module-level
var, a container) takes a reference of its own there. - A function may not free a parameter (E0901):
string.free(p)orrelease(p)on astringparameter, orstring.seq_free(p)on a*StringSeqone, would release a reference the function does not own. Store it to keep it; a parameter the body re-binds holds a string of its own and may be freed. string.free(x)releases the referencexholds and leavesxempty, for a local, a field or a module-levelvar. Nothing frees it again.- A
ptrparameter is borrowed for the call unless it is declared@keep(E0902). Aptrowns nothing, so a pointer a function keeps (stores where it outlives the call, returns, sends in a message, puts in a std container, or passes to another@keepparameter) is declaredp: @keep ptr; a pointer computed from it (p + n,p - n) points into the same memory and counts asp, and so does a slice made from it (p as byte[],(p as byte[])[0..n]), which is the pointer and a length (#3294). A struct local to the function, and a closure that runs and is dropped inside the call, do not outlive it: putting the pointer in one (s = Pair { a: p },s.a = p,run(|| { use(p) })) keeps nothing, and the struct or the closure is a keep only when it escapes itself, returned, stored in a field, a module-levelvaror a container, or passed to a@keepor@retainparameter. A tuple (t = (p, 1)), an optional, and anything read out of a holder (a field, a tuple part, an optional's payloado!oro ?? d, amatchbinding) hold it the same way, except that a part of a tuple or a field of a struct built from parts holds only what went into that part or field (aftert = (p, q)anda, b = t,bholdsqand notp; afters = Pair { a: p, b: q }ors.b = q,s.bholdsq), and so does what a closure that captured it returns (call(f)). A closure that captured the pointer and stores it somewhere that outlives the call keeps it too, and so does one that reads it through an address of a local, or a local a closure assigns, stored there after the closure was written: it is checked where it is called or passed (g = || { kept = pb.item },b.item = p,call(g)). The checker verifies each function against its own body, and a call against the callee's signature; a loop's body is checked with what every pass through it binds, sokept = qplaced beforeq = pin a loop keepsp, unless the body first bindsqanew (q = list.new()). Astringreaches a@keep ptronly throughstring.keep(s), which hands over a reference of its own (string.release_kept(p)gives it back), or as a literal or aconstbound to one, which is pinned, and a*StringSeqonly throughstring.seq_retain(s)(string.seq_freegives it back). C memory viewed asstring[]((names as string[])[i] = s) is given the string's characters and no reference, so the string must outlive C's reads: a string built in the statement never does, and a local does only when the memory ends with the function (an argv array the function allocates, hands to C and does not let escape). Memory that outlives the function (reached through a parameter, a module-levelvar, a field or a heap box, or whose pointer is returned or stored) takes(names as ptr[])[i] = string.keep(s), given back withstring.release_keptwhere the memory is freed, and refuses any other string but a literal or aconstbound to one. A closure'sptrparameter borrows unless it is declared@keep(|el: @keep ptr, t: float|), and the mode is part of a function's type,fn(@keep ptr, float): a call through a value of that type is checked as a direct call is, and a function goes only where its type says what it keeps, never into a barefn, aptror a typed C function pointer, whose parameters borrow (Closure types that say what they keep). A typed pointer (*Node) and adistincttype overptrare not checked: a string converts to neither. - A
fnorfn?parameter, and a struct or sum parameter taken by value that holds aptror a closure (in a field or a variant, or an optional of one), are borrowed the same way (E0902). A closure may capture a borrowedptrof its caller, and a struct'sptrfield points at the caller's memory, so a function that keeps such a parameter (stores it where it outlives the call, boxes it there withbox_closure, puts it in a container, or passes it to a parameter that keeps it) declares it@keep:on_event(cb: @keep fn),register(h: @keep Handler). What a call keeps is read from the callee's declaration, never from the name it spells: std's list and map stores (list.add,list.set,map.put, their raw forms and theircollections.spellings) declare the item@retainat the C boundary, so they keep it, and a program's ownlist_addkeeps what its own body keeps. The function's own body is checked, as for aptrparameter, and a caller is then checked against the declaration: a closure that captures a borrowedptr, given to a@keep fnparameter, is E0902 at the caller. A closure the function only calls, or hands to a borrowing parameter (run(|| { use(p) })), keeps nothing, and neither does handing the parameter back withreturn. A store into memory the function allocates and frees (an array it hands to C) keeps nothing either, for any parameter, and neither does one into a std container the function makes (list.new(),map.new()) and lets go nowhere: the stores name the container that holds what they keep (@holds), and a list a function makes, uses and frees, or hands only to functions that borrow it, ends with the function. A container that is a parameter, a field or a module-levelvar, or that is returned or stored, keeps it past the call. The declaration changes no count: the call borrows the closure as every call does, and the callee's store takes its own reference. - A
ptrparameter declared@intomay reach the function's result and nowhere else (E0902). A function that builds a value around its argument (a recorder, an iterator, a view) declaresp: @into ptrand mayreturnit, as the pointer, a view of it (p as *Node,p + n), in a struct, a tuple part or an optional, in a closure that captures it, or in the result of another@intocall; any other escape is E0902 as for a borrowed parameter. Its result type must be able to hold a pointer (a pointer, a slice, a function, or a struct, tuple, optional, array or sum with such a part), and only a bareptrtakes@into. The caller holds the result as it holds a struct local that holds its argument: the result may live in a local, be read and be lent to borrowing parameters ((&q) as ptr), and it escapes, with the argument, only where the argument itself would (returned, stored in a module-levelvar, a field through a pointer or a container, sent, captured by a closure that escapes, given to a@keepor@retainparameter). With several@intoparameters the result holds every one of them. A function that returns another@intocall's result made from its own parameter declares that parameter@intotoo, and its callers are checked the same way. A string reaches an@into ptronly throughstring.keep(s)(or as a literal or aconstbound to one), a closure given to one is boxed on the heap with a reference of its own (release_closuregives it back), and@intois part of a function's type as@keepis (fn(@into ptr) -> Recorder): a call through such a value holds its result to the same rule. An extern's parameter may be@intotoo (@retain,@noescapeand@into). - What a call does to the memory it is given:
@freesand@moves(#1300). Aptr,*Tor slice parameter declared@freessays the call frees what the argument points at:free(b: @frees ptr). One declared@movessays the call may move the storage the argument holds, as a buffer that grows does:append(b: @moves ptr, s: string). C'sfree(asfree, or an extern bound to it,@extern("free") libc_free(p: ptr)) andheap.freefree their argument without a word; std's release functions (bytes.free,list.free,map.free,json.freeand the rest) declare@frees, and std.bytes' growing calls@moves. A function that declares@freesfrees the parameter on every path to its end, or finds it null there, without first binding it to something else; one that declares@movespasses it, on some path, to a parameter that may move or free it (E0904). Neither changes what the call compiles to: they say what happens, so the next rule can follow it. An extern's parameter takes them too (@retain,@noescapeand@into). - Nothing is used or freed after it is freed (E0903). Where a local is freed on every path that reaches a later use, the use is an error: a read (
n.vafterheap.free(n)), a second free (free(p)twice), or a free a loop repeats on its next turn without binding the local afresh in between (free(p)in a loop body,pallocated before the loop). A view dies with the storage it views: a slice of a local (buf[1..3]), a cast of it (p as int[],p as *Node) and the result of a call whose@intoparameter it was given (bytes.view(b)) are views of the local's storage, and a read of one after that storage was freed, or after a call that may move it (bytes.append(b, ...)), is the same error; take the view again after the call. Only a fault that happens whichever way the program goes is reported: a free in one branch of anifand not the other, followed by a use, says nothing, and so does comparing a freed pointer withnullor reading a slice's own.len. A local whose address is taken, or that a closure or adefernames, is not followed from there, and a local bound to a fresh value is clean again. Freeing through a value (a closure, a function pointer) is not seen.
struct Recorder { context: ptr, count: int }
recorder(context: @into ptr) -> Recorder {
return Recorder { context: context, count: 0 } // reaches only the result
}
record(rec: ptr) {
q = rec as *Recorder
q.count = q.count + 1
}
visit(context: ptr) -> int {
q = recorder(context) // q holds `context`
record((&q) as ptr) // lent to a borrowing parameter
record((&q) as ptr)
return q.count // `last = q` into a module-level var would be E0902
}
main() {
x = 0
println("${visit((&x) as ptr)}")
}
2
my_concat(a: string, b: string) -> string {
return string.concat(a, b) // a fresh string, moved to the caller
}
banner(name: string) -> string {
if string.length(name) == 0 {
return "guest" // a literal, handed over as it is
}
return string.concat("hi ", name)
}
s = ""
i = 0
while i < 1000000 {
s = my_concat(s, "x") // O(1) memory: the store releases the old s
i = i + 1
}
See examples/01-language/17-ownership.ae for a runnable example.
Migrating from 0.835
0.836.0's E0902 rules refuse two shapes that 0.835.0 compiled. Both refusals are intended, each has a one-line fix, and the E0902 help names it.
1. A pointer into a borrowed argument, returned. p + n carries what p carries, and a borrowed ptr parameter may not be returned. Declare the parameter @into: the function's result holds it, and a caller holds the result as it holds the argument. The help names the declaration, here bytes: @into ptr.
slice(bytes: ptr, offset: int) -> ptr { return bytes + offset } // E0902 on 0.836.0
main() {
x = 0
println("${slice((&x) as ptr, 0) != null}")
}
slice(bytes: @into ptr, offset: int) -> ptr { return bytes + offset } // the result holds it: @into
main() {
x = 0
println("${slice((&x) as ptr, 0) != null}")
}
true
The common real case is an indexed accessor into an array the caller owns, body_at(bodies: @into ptr, i: int) -> *Body { return (bodies as *Body) + i }: thirteen of those were the whole of aephysics' migration.
2. A function that keeps its argument, stored as a plain function pointer. A plain fn type is a C function pointer, which borrows every ptr, so a function with a @keep (or @into) parameter is refused there, cast or not. Give the slot the function's modes: the function goes there as it is, with no cast, and a call through the slot is checked as a call of the function is (Closure types that say what they keep). The help names the slot's type, here fn(ptr, @keep ptr).
struct Holder { kept: ptr }
keep_it(state: ptr, item: @keep ptr) { (state as *Holder).kept = item }
struct Table { set: fn(ptr, ptr) }
main() {
h = Holder { kept: null }
t = Table { set: keep_it as fn(ptr, ptr) } // E0902: a plain fn type borrows every ptr
println("${t.set == null} ${h.kept == null}")
}
struct Holder { kept: ptr }
keep_it(state: ptr, item: @keep ptr) { (state as *Holder).kept = item }
struct Table { set: fn(ptr, @keep ptr) } // the slot says what it keeps
main() {
h = Holder { kept: null }
t = Table { set: keep_it }
call(t.set, (&h) as ptr, (&h) as ptr)
println("${h.kept != null}")
}
true
Migrating from 0.837
E0902 checks a fn parameter, and a struct parameter taken by value that holds a ptr or a closure, as it checks a ptr one: a function that keeps it declares it @keep. On 0.837.0 a closure given to a function that kept it went unchecked, and one that captured a borrowed ptr outlived the call holding it. The help names the declaration, here cb: @keep fn.
var handlers: ptr = null
on_event(cb: fn) { handlers = box_closure(cb) } // E0902: the function keeps `cb`
main() { on_event(|| { println("hi") }) }
var handlers: ptr = null
on_event(cb: @keep fn) { handlers = box_closure(cb) } // the parameter says it is kept
main() {
on_event(|| { println("hi") })
call(unbox_closure(handlers))
release_closure(handlers)
}
hi
std and contrib declare the parameters they keep: worker.run, run_detached, set_main_poster and map, observe.observe and observe_fields, schema.refine, spec.before_each and after_each, quickjs.function, and tinyweb's route, filter, websocket, SSE and on_* registrations. A closure that captures a borrowed ptr, given to one of them, is E0902 where it is given: copy what it needs, or declare the ptr @keep too.
Migrating from 0.838
What a call keeps is read from the callee's declaration (#3086). std's list and map stores declare their item @retain at the C boundary: list.add, list.add_raw, list.set, map.put and map.put_raw, and their collections. spellings, which are now externs over C functions of the same names with the same results. string.seq_cons declares its head and its tail @retain too. So a borrowed ptr parameter put in a list or a map is E0902, as storing it in a module-level var is. On 0.838.0 only a closure or a struct that held one was checked there, and the pointer itself outlived the call in the container. Declare the parameter @keep, or store a copy. A list or a map the function makes itself (list.new(), map.new()) and lets go nowhere is no such place: a borrowed ptr stored in it, as a worklist or a chain of the caller's lists, keeps nothing past the call. A string, a literal or a closure of the function's own is handed a reference of its own, as before.
import std.list
var seen: ptr = null
remember(p: ptr) { _r = list.add(seen, p) } // E0902: the list keeps `p`
main() {
seen = list.new()
remember(null)
}
import std.list
var seen: ptr = null
remember(p: @keep ptr) { _r = list.add(seen, p) } // the parameter says it is kept
main() {
seen = list.new()
remember(null)
println(list.size(seen))
}
1
A store that copies what it is given keeps nothing: a map's key, a set's item. @scoped reads the same declarations: a @scoped value given to a parameter that keeps it escapes its block ("it is passed to a parameter that keeps it"), as one given to list.add, map.put or string.seq_cons (head or tail) does, and one given to set.add, which copies it, does not.
Owned pointers, type T = owned ptr drop f
A ptr owns nothing (R4): whoever frees it does so by hand. An owned pointer type names the function that releases it, and then the place that holds one releases it when it lets go (#3142):
import std.bytes
type Buf = owned ptr drop bytes.free
message Chunk { data: Buf }
actor Sink {
state kept: Buf = null
receive {
Chunk(data) -> {
use(data) // lent to a function, as a `ptr` is
if keep_it() {
kept = move data // the state keeps it, releasing what it held
}
} // else the binding releases it here
}
}
main() {
s = spawn(Sink())
b = bytes.new(64) as Buf // `as` makes a pointer owned
s ! Chunk { data: move b } // b holds nothing now
}
A value of an owned type has one holder at a time, and that holder releases it with the type's drop function:
- Who holds it. A local (released at every exit of its function, and when the local is bound again), a handler's binding of a message field (taken out of the message, released when the handler ends), an actor's state field (released when it is stored into, and when the actor ends), a message field (released when the message is dropped undelivered: its actor died, it was never handled).
nullis a value holding nothing; the state field above starts empty. movehands it on, and leaves the place it left holding nothing: into a message field (data: move b), a state field (kept = move data), a local (c = move b). Areturnof a local is amove. The move checker refuses a later use of the name (move).- A function borrows it, as a
ptr: an owned value goes where a bareptris taken, and the holder keeps it. A parameter of an owned type is borrowed too, so it is neither rebound nor returned. - Refused (E0905): a copy into a second holder without
move(c = b), a message field set from a holder withoutmove, a call that would free it (an@freesparameter, C'sfree,heap.free, through a cast too) or keep it (@keep,@retain, a std container), amoveinto a function argument, and a holder that could not release it: a struct field, an element, a module-levelvar, a closure's capture, an optionalT?(useTandnull). - The drop function takes one
ptrand frees it: C'sfree, or a function or extern whose parameter is@frees(bytes.free, a function of the program's own). The type's base is a bareptr.
A handler that panics while it holds an owned binding does not release it; the message's other owned fields are released with the message.
Optionals
An optional type T? holds either a value of type T or the empty sentinel none. It is the type for "maybe a value", distinct from the (value, err) result convention, which is for fallible operations. Use (value, err) when an operation can fail and you want to say why; use T? when a value is simply present or absent (a missing map key, an empty list's first element, a search that found nothing).
let maybe: int? = 69 // a present value, implicitly wrapped
let empty: int? = none // the absent sentinel
T? works for any element type, value types (int?, float?, bool?, an enum's Color?, a distinct type's Meters?) and reference types (string?, *Node?) alike, with one uniform representation, so there is no ambiguity between "the value is a null pointer" and "the key was absent". An optional can also be an element of a tuple, -> (string?, int), returned as return "pos", n or return none, n, and a tuple can be optional: let t: (int, int)? = (3, 4) wraps the tuple literal as it wraps any value (#2833).
A value wraps into an optional on its own, and an optional never unwraps on its own: a T? is no T, wherever a T goes. A binding, a return, a field, an element and an argument all refuse it, shout(maybe(1)) for a shout(s: string) included (E0200, Argument 1 's' of 'shout': expected string, got string?), and the way out is to say what happens when it holds none: maybe(1) ?? "fallback", maybe(1)! (which panics on none), or a match. A local bound to a T in one branch or loop body and to a T? in another is one T? variable (a local first bound in a branch or loop body is one variable for the whole function), so a read of it in either body is optional too, and passing it where a T goes names that (#3153).
A struct field can be optional (#2820): struct Box { v: int? at: Point? }. A struct literal (Box { v: 7, at: none }) and a field store (b.v = 9, b.v = none) take the value as an optional binding does, a bare value wrapped. An optional field whose value owns memory (a string?, an optional fn, or an optional of a struct, a fixed-size array or a tuple that holds a string or a closure, such as (string, int)?) owns it as a field of the bare type does: a literal or a store takes the value, and a later store and the struct's destroy release it (#2822).
none and equality
none is a reserved literal (like true, false, and null): it cannot be used as a variable, parameter or constant name, and the compiler refuses the declaration, because every later use of such a binding would read as the literal instead. Compare against it with == / !=:
if empty == none {
println("absent")
}
if maybe != none {
println("present")
}
Two optionals compare equal when they have the same presence and (when present) equal values: a == b for int? compares the ints, and for string? compares the contents (via the same header-aware string comparison the language uses elsewhere), not the underlying pointers.
An optional compared with a bare value compares as the value wrapped into the optional, as a binding wraps it: maybe == 69 is true when maybe is present and holds 69, and false when it is none. Both operands are evaluated. A value the optional cannot hold (maybe == "69" for an int?) is a type error, as is an optional of another inner type.
An optional is a single presence layer, T?? is rejected at parse time ("nested optional T?? is not supported"). If you find yourself wanting one, you almost certainly want the distinct "fallible-and-absent" spelling instead, which composes from a result and an optional rather than nesting two optionals.
Force-unwrap !
Postfix ! yields the wrapped value, or panics if the optional is none:
let n: int = maybe! // 69
let boom: int = empty! // panics: "forced unwrap of `none`"
Use it only where absence is a programmer error. For a safe default, prefer ??. (Postfix ! is shared with the (value, err) unwrap-or-trap: on an optional it unwraps the value, on a tuple it unwraps the first slot and traps on a non-empty error, the meaning follows the operand type.)
Null-coalesce ??
opt ?? default yields the wrapped value when present, otherwise default. It binds tighter than arithmetic, so 5 + (opt ?? 7) groups as expected:
let a: int = maybe ?? 0 // 69
let b: int = empty ?? 0 // 0
let c: int = 5 + (empty ?? 7) // 12
Optional chaining ?.
opt?.field reads a field through an optional struct and is none-propagating: it yields fieldT?, which is none when the receiver is none. Combine with ?? to default the result:
struct Vec2 { x: int y: int }
let v: Vec2? = Vec2 { x: 7, y: 8 }
let xm: int? = v?.x // some(7)
let x: int = v?.x ?? 0 // 7
let nv: Vec2? = none
let zm: int? = nv?.x // none, short-circuited
Chain assignment is a no-op when the receiver is none:
v?.x = 42 // writes, v is present
nv?.x = 99 // skipped, nv is none, no crash
A field of an optional is read through ?., after ! (v!.x), or where a none-check narrows it; v.x on the optional itself is a type error, Type 'Vec2?' has no field 'x'.
Matching on optionals
match destructures an optional with the none and some(binding) arms, as a statement or as an expression:
match maybe {
none -> println("missing")
some(v) -> println("got ${v}")
}
let label: string = match maybe {
none -> "missing"
some(v) -> "got ${v}"
}
Narrowing
A none-check on an optional variable narrows it in the guarded branch: inside if x != none { ... } (and the else of if x == none { ... } else { ... }), x is its inner type T and is used directly, without the ! force-unwrap. Presence is proven by the guard, so the runtime none-check is elided, this is a compile-time analysis with zero runtime cost:
find(id: int) -> User?
let u: User? = find(42)
if u != none {
println(u.name) // no `!`, `u` is a User here
greet(u) // passed by value as User
}
Narrowing is refused (soundly, the branch is unchanged) when the guarded branch reassigns the variable, or uses it with an optional-only operator (== none, != none, !, ??, or ?.), since those need the optional form. A nested guard on the same variable still narrows its own innermost block.
The narrowed value flows through expressions, field access, function arguments and stores: existing = x stores the inner value into a local of the inner type, and a new local bound to the narrowed variable (v = x) has the inner type (#2848).
Functions
Optionals flow through parameters and return types. A bare T (or none) is implicitly wrapped at the call site and in return:
first_positive(n: int) -> int? {
if n > 0 {
return n // wrapped into int?
}
return none
}
describe(x: int?) -> string {
let label: string = match x {
none -> "nothing"
some(v) -> "got ${v}"
}
return label
}
main() {
println(describe(5)) // "got 5", 5 wrapped into int?
println(describe(none)) // "nothing"
let r: int = first_positive(3)! // 3
}
A match or an if-expression whose value goes into an optional (a binding, a return, an argument, a field) is wrapped arm by arm, each arm's value as it would be on its own (none is the empty optional, an optional arm passes as it is). A function whose result is an optional tuple returns one as return a, b or return (a, b), the tuple wrapped:
pick(n: int) -> int {
int? o = match n {
1 -> 41
_ -> none
}
return o ?? 7
}
span(n: int) -> (int, int)? {
return if n > 0 { (n, n + 1) } else { none }
}
main() {
int? c = if pick(1) > 40 { 3 } else { none }
a, b = span(4)!
println("${pick(1)} ${pick(2)} ${c ?? 0} ${a} ${b}") // 41 7 3 4 5
}
Codegen
A value optional lowers to typedef struct { int has; T val; } ae_opt_<T> (one per concrete element type); none is {0}, a wrapped value is { .has = 1, .val = … }. !, ??, and ?. evaluate their operand once, into a temporary, so an operand with side effects is evaluated exactly once. The generated C is plain C11 here (a comma expression whose temporary is declared ahead of the statement, #2765), so a C compiler that is not GCC-compatible takes it.
Enums
An enum is a named set of integer constants, a distinct type over int with named members. It lowers to a C typedef enum with zero runtime cost.
enum Direction { North, East, South, West } // implicit 0, 1, 2, 3
enum Errno { Ok = 0, NotFound = 2, Perm = 13 } // explicit values
A member with no = value is the previous member's value plus one (the first defaults to 0), matching C. Members are separated by commas and/or newlines.
Members are referenced by their qualified name, EnumName.Member:
d = Direction.East // d has type Direction
if d == Direction.East { ... } // compare (nominal: same enum only)
An enum is used like any other type, on parameters, returns, and locals, and is matched with member arms. Inside a match on an enum the member may be written qualified (Direction.North) or bare (North), since the scrutinee already fixes the enum:
label(d: Direction) -> string {
return match d {
North -> "N"
East -> "E"
South -> "S"
West -> "W"
}
}
An enum match is exhaustive-checked: if it covers every member, no _ arm is needed (as above); if a member is left uncovered and there is no _, it is a compile error naming the missing members (the same guarantee sum types give). A _ arm still catches the rest when you want a default.
Because an enum is integer-backed, its members interconvert with integer scalars (x: int = Errno.Perm gives 13; code == Errno.NotFound compares as ints), byte included, which is C's uint8_t as uint8 is: an enum coded as a byte on the wire is bound, passed and returned as a byte and back. Two different enums are never compatible with each other.
Where the expected type is already a known enum, a member may be written bare, without the enum prefix. This applies to a function argument, a typed initializer, an assignment, a return, and either side of an enum comparison, and to the value of each arm of a match or an if in one of those places, since such a value is converted arm by arm (#3156):
paint(c: Color) -> string { ... }
paint(North) // function argument
let c: Direction = North // typed initializer
c = South // assignment
if c == North { ... } // comparison (either order)
heading() -> Direction { return West } // return
flip(d: Direction) -> Direction {
return match d { // each arm's value, too
North -> South
South -> North
_ -> d
}
}
let e: Direction = if c == North { East } else { West }
A real binding always wins: if a variable named North is in scope, North refers to it, not the member. A bare name that is not a member of the expected enum stays an ordinary "undefined variable" error.
Enum-indexed arrays
[E]T is a fixed array with exactly one slot per member of enum E, indexed by an E value rather than a raw integer. It is sized at compile time to the enum's member range and lowers to a plain C array (zero runtime cost):
enum Dir { N, E, S, W }
const LABELS: [Dir]string = ["north", "east", "south", "west"] // one per member
LABELS[Dir.E] // "east", indexed by the enum member
var t: [Dir]string = ["n", "e", "s", "w"]
t[Dir.N] = "NORTH" // element assignment
A positional literal supplies one value per member in declaration order; the count must match. The index must be a value of E (a raw int is rejected), so the type keeps the table and its keys in lockstep. With explicit member values the array spans 0 ..= max value, so a sparse enum reserves the intervening slots. Array-typed function parameters and empty [] initialisers share the current fixed-size-array limitations and are a separate follow-up.
Bit Sets
A bit set is a set of members of an enum, bit_set[E]. It is backed by a single unsigned 64-bit word (one bit per member, at the member's enum value), so every set operation is a bitwise operation with zero runtime cost.
enum Perm { Read, Write, Exec }
let a = bit_set[Perm]{ Perm.Read, Perm.Write } // a two-member set
let b = bit_set[Perm]{ Write, Exec } // bare member names also work
let empty = bit_set[Perm]{} // the empty set
Inside a bit_set[E]{ ... } literal each element is a member of E, written qualified (Perm.Read) or bare (Read). The type is written the same way it is constructed, so a set is used like any other type, on locals, parameters, return types, and struct fields:
grant(base: bit_set[Perm]) -> bit_set[Perm] {
return base + bit_set[Perm]{ Perm.Exec }
}
struct File { name: string, perms: bit_set[Perm] }
The operators:
| Operation | Syntax | Result | Lowers to |
|---|---|---|---|
| membership | Perm.Read in a | bool | (a >> Read) & 1 |
| union | a + b | bit_set[E] | a \| b |
| difference | a - b | bit_set[E] | a & ~b |
| subset | a <= b | bool | (a & b) == a |
| superset | a >= b | bool | (a & b) == b |
| equality | a == b / != | bool | a == b |
| cardinality | card(a) | int | popcount(a) |
let u = a + b // union
let d = a - b // difference (members of a not in b)
if Perm.Exec in u { ... } // membership test
if a <= u { ... } // is a a subset of u?
let n = card(u) // how many members are set
A bit set is nominal and strictly typed: it never implicitly converts to or from an integer, and two bit sets interoperate only when they are over the same enum. Members must have values in 0..63 (the width of the backing word). card is a reserved call form, like sizeof, and applies only to a bit set.
Bitstructs
A bitstruct is a named bit layout over a single unsigned integer. It is the tool for packed headers, hardware registers, and wire formats.
bitstruct DnsFlags : uint16_t {
qr: bool 15 // a single bit
opcode: int 11..=14 // an inclusive range: bits 11,12,13,14
aa: bool 10
rcode: int 0..<4 // an exclusive range: bits 0,1,2,3
}
f = 0 as DnsFlags
f.opcode = 2
f.qr = true
w = f as uint16_t // 0x9000 | ...
A bitstruct never lowers to a C bitfield. It lowers to shift-and-mask arithmetic on the backing integer. This is the whole point of the feature: C's :n bitfields have implementation-defined signedness, allocation order, and straddling behaviour, which makes them unusable for anything that has to match a byte-exact layout. In particular gcc gives int x : 3 a signed representation, so a stored 0b111 reads back as -1. A bitstruct field cannot do that, the backing word is unsigned and the mask is applied after the shift, so there is nothing to sign-extend from.
The rules, each of which exists to keep the layout exact:
- The backing type is mandatory and must be one of
uint8_t,uint16_t,uint32_t,uint64_t. Naming the storage explicitly is what fixes its width and its signedness. Omitting it is a compile error, not a default. - Bit positions are explicit. A bare index (
0) is a one-bit field. A range may be written inclusively (1..=3) or exclusively (1..<4), both denote the same three bits. Aether does not pick one for you (C3, which this borrows from, hardcodes inclusive ranges and leaves you to remember); the source says which it means, using the same..=/..<spellings as match-range labels. - Overlapping fields are an error unless the bitstruct is annotated
@overlap, which permits union-like views of the same bits. - A range that runs off the end of the backing integer is an error.
- Fields are
bool, an integer type, or an enum. Aboolfield is exactly one bit. - Writing a field never disturbs its neighbours. An over-wide value truncates to its own field rather than bleeding into the next one.
- A bitstruct is nominal. It never implicitly converts to or from its backing integer, and two bitstructs over the same backing type are still different types. Crossing the boundary is an explicit
asin either direction, a bitstruct is a packed layout, not a number you do arithmetic on.
Bit layout and byte order are separate concerns
A bitstruct says which bits. It deliberately says nothing about which byte order, there is no @bigendian annotation, and no hidden byte-swapping on field access. Byte order is a property of serialisation, not of the layout, and Aether already has endian-explicit accessors for it in std.mem:
import std.mem
// Read a big-endian word off the wire, then interpret its bits.
w = mem.get_u16_be(buf, 0)
hdr = w as DnsFlags
kind = hdr.opcode
// ...and back out again.
mem.set_u16_be(buf, 0, hdr as uint16_t)
Keeping the two apart means there is exactly one place where a byte swap can happen, and it is visible in the source. Folding endianness into the type would make the swap implicit and raise the question "does it happen on every field read, or only at the byte boundary?", the sort of ambiguity that makes C bitfields untrustworthy in the first place.
Sum / Variant Types
A sum type is a value that is exactly one of N named struct variants:
struct Circle { r: float }
struct Rect { w: float h: float }
struct Empty {}
type Shape = Circle | Rect | Empty
Each variant is an existing struct. A sum needs at least two variants (a single-name type T = A alias is not a supported form).
Constructing, implicit wrap
A variant struct value flows into a Shape slot directly; there is no some(...)-style constructor. The wrap happens at let bindings, function parameters, return values, and call arguments (a closure's too, call(f, Circle { ... })), a struct field of the sum's type (Holder { sh: Circle { r: 1.0 } }, h.sh = Rect { ... }), a branch of an if expression beside a Shape, and as the default of ?? over a Shape?; into the sum's optional (Shape?) the variant is wrapped into the sum first. So is each element of an array literal whose place is an array of the sum: a let with that type, an argument, a written result, a struct field, or a store into such an array (let xs: Shape[2] = [c, r], draw_all([c, r])). Without such a place the literal is an array of its first element's type, and a second variant beside it is refused:
let s: Shape = Circle { r: 2.0 } // Circle wraps into Shape
draw(Rect { w: 3.0, h: 4.0 }) // wraps at the argument
make() -> Shape { return Empty {} } // wraps at the return
let all: Shape[2] = [Circle { r: 1.0 }, Empty {}] // each element wraps
Matching, narrowing + exhaustiveness
match over a sum dispatches on the variant and narrows the scrutinee to that variant struct inside the arm, so s.field reads the right member:
area(s: Shape) -> float {
let a: float = match s {
Circle -> 3.14159 * s.r * s.r // here `s` is a Circle
Rect -> s.w * s.h // here `s` is a Rect
Empty -> 0.0
}
return a
}
The match must be exhaustive: every variant must be handled, or a _ wildcard arm must catch the rest. Omitting a variant is a compile error, the payoff that tells you, when you add a variant, exactly which matches to update. An arm naming something that isn't a variant is also rejected. Narrowing applies when the scrutinee is a plain variable (match s { … }). A new local bound to the narrowed scrutinee in an arm (t = s in the Circle arm) is a copy of that variant, so t.r reads its field. A variant's field read on a sum that is not narrowed (s.r outside a match, h.sh.r in an arm of match h.sh) is a compile error naming the sum and the field: bind the value to a local and match on that (v = h.sh then match v { Circle -> v.r ... }).
Recursive sums
Self-referential shapes (trees, ASTs, JSON values) use explicit pointer fields:
struct Leaf { v: int }
struct Pair { left: *Tree right: *Tree }
type Tree = Leaf | Pair
Codegen
A sum lowers to a tag enum plus a tagged union, no allocation, no vtable:
typedef enum { Shape__Circle, Shape__Rect, Shape__Empty } Shape_tag;
typedef struct Shape { Shape_tag tag; union { Circle Circle_; Rect Rect_; Empty Empty_; } data; } Shape;
match becomes a switch on .tag with the matched value narrowed to the active union member in each case. v1 is monomorphic; type parameters are a follow-up.
Structs
Structs group related data, each field with its name and its type:
struct Point {
x: int
y: int
}
struct Person {
name: string
age: int
}
main() {
p = Point { x: 10, y: 20 }
who = Person { name: "Ada", age: 36 }
println("${p.x} ${p.y} ${who.name} ${who.age}")
}
10 20 Ada 36
Field Types
A field's type may also come first, C's way, with commas between the fields:
struct Config {
string name,
int timeout,
float threshold
}
A struct literal's value for a typed field fits it by the rules a store into the field does (c.timeout = v): it converts where an assignment converts (an int into a float field), wraps into an optional or a sum field, and a string field takes null. Anything else is a compile error naming the field: Config { timeout: "5" } is field 'timeout' of 'Config' is int: it cannot take a value of type string, and a byte field refuses a literal out of 0..255.
Field injection with using (composition, no vtables)
A field declared using embed: Sub embeds a sub-struct and promotes its fields into the outer struct's namespace. This is composition-over-inheritance without method sets or vtables, a pure compile-time member-access rewrite:
struct Entity { x: int, y: int }
struct Frog {
using entity: Entity // promotes x, y
hops: int
}
f = Frog { entity: Entity { x: 10, y: 20 }, hops: 3 }
f.x // == f.entity.x (reads through the embed)
f.x = 99 // writes through the embed too
f.entity.x // the explicit path still works
f.x, when x is not a direct field of Frog, resolves to f.entity.x at compile time (both reads and writes). A name that no direct or using field provides is still a "no field" error. Zero runtime cost: the outer struct just contains the embedded struct as an ordinary field.
Only the field form exists. Odin's using statement (dumping a struct's fields into local scope inside a function body) is deliberately not adopted, it is a readability footgun.
Function-pointer struct fields, a vtable of callbacks
A struct field can be a function pointer (fn(T1, T2) -> R), the dictType-style vtable. The field emits as the C function-pointer member R (*name)(T1, T2), and a call through it is a real indirect call:
struct Ops {
hash: fn(int) -> int
combine: fn(int, int) -> int
}
main() {
o = Ops { hash: my_hash as fn(int) -> int, combine: my_add as fn(int, int) -> int }
o.hash(5) // value struct → (o.hash)(5)
}
dispatch(p: *Ops, k: int) -> int {
return p.hash(k) // pointer struct → (p->hash)(k)
}
Assign each field an Aether function's address via as fn(...) (or a C function pointer from an extern). Dispatch works for a value struct (o.field(args)) and a pointer-to-struct (p.field(args) lowers to p->field(args)); the receiver must be a single local, and the field's signature is taken from the struct definition. This is the field form of the same typed-fn-pointer machinery as as fn(...) locals.
Pointer-to-struct type, *StructName and expr as *StructName
For systems-programming code that overlays a struct header on a raw ptr (e.g. linked-list nodes in C-allocated memory, on-disk file headers read into a buffer), Aether has a first-class pointer-to-struct type spelled *StructName and a postfix as cast operator that produces a value of that type:
extern malloc(size: int) -> ptr
extern free(p: ptr)
struct ListHead {
next: ptr
prev: ptr
flags: int
}
// `*ListHead` is usable in any type position, params, returns,
// struct fields, locals.
init_head(h: *ListHead) {
h.next = 0
h.prev = 0
h.flags = 1
}
main() {
raw = malloc(64)
head = raw as *ListHead // type of head is *ListHead
init_head(head)
free(raw)
}
The cast is a view, not an allocation, the operand pointer's lifetime is the caller's problem (the same contract as raw extern interaction). Reach for this only when the storage is C-allocated and Aether wants to manipulate fields. For Aether-owned data, use the normal struct-literal form (Point { x: 1, y: 2 }) so refcounting and lifetime tracking apply.
A typed pointer is checked like any other type, at an assignment and at a call alike: buffer_size(b: *Buffer) given &p.ints, an *Ints, is a compile error (Argument 1 'b' of 'buffer_size': expected *Buffer, got *Ints), as &local, &p.a.b or a *Ints local would be. A bare ptr converts to and from every typed pointer, the way C's void* does, and so does a pointer to a @c_struct overlay, which is a void* in the generated C. A typed pointer moved by an integer (head + 1, head - n, n + head) is the same typed pointer, C's element step, and its fields read. A bare ptr has no fields: raw.next is a compile error, and (raw as *ListHead).next reads the field.
as accepts a primitive value cast, a struct overlay (*StructName), a function-pointer cast (fn(...) -> R), or a typed-array view (T[]). A value cast like n as int (numeric to numeric, or between a distinct type and its base) compiles and runs. Non-numeric casts such as buf as string or raw as ptr parse but are rejected at type-check with E0200. For converting between primitive types, see the Casting between types table above, most conversions are either implicit (Aether's type system inserts the necessary cast in the generated C) or use a named helper (string.from_int, string.from_long, …).
The as keyword is the same token used for import x as y aliasing; the two parses don't collide because import-aliasing is recognised only inside import statements. Full semantics (operand type rules, error cases, the shared-token interaction) are in c-interop.md § Struct overlay on raw pointers.
Constructors returning *StructName
The constructor shape, allocate raw storage, overlay the struct, initialise the fields, return the typed view, is the natural way to retire C "container holder" shims to Aether:
struct Txn {
revision: int
paths: ptr
state: int
}
mk_txn(revision: int) -> *Txn {
raw = malloc(64)
t = raw as *Txn
t.revision = revision
t.paths = 0
t.state = 0
return t
}
The pointer-to-struct type is accepted in every type position, including the return-type position used here. Earlier compiler versions tripped on -> *Foo in this slot (the parser's typed-return-vs-arrow-body disambiguator missed TOKEN_MULTIPLY); current builds parse it cleanly.
Self-referential structs
Pointer-to-struct fields may point back at the enclosing struct, which is how recursive shapes (linked-list cells, error chains, n-ary tree nodes, dependency graphs) are spelled:
struct ErrChain {
code: int
msg: string
cause: *ErrChain // self-pointer, chain cell or null
file: string
line: int
}
mk_err(code: int, msg: string, cause: *ErrChain, file: string, line: int) -> *ErrChain {
raw = malloc(64)
e = raw as *ErrChain
e.code = code
e.msg = msg
e.cause = cause
e.file = file
e.line = line
return e
}
walk(e: *ErrChain) {
if e == 0 { return }
println("[${e.code}] ${e.msg} (${e.file}:${e.line})")
walk(e.cause)
}
The recursion above reads naturally and is fine for the short chains errors usually form. But Aether does not turn tail calls into loops, so each cell costs one C stack frame: walking (or freeing) a *Struct chain that can grow without bound, a long linked list, a deep tree assembled from untrusted input, an error chain accumulated in a hot retry loop, risks a stack overflow. For unbounded shapes, walk the spine iteratively instead, which is O(1) stack at any depth, exactly as the refcounted *StringSeq does internally (sequences.md documents "O(n) time, O(1) stack" for 10k+ cells):
// Iterative traversal: O(1) stack regardless of depth.
cur = e
while cur != 0 {
println("[${cur.code}] ${cur.msg} (${cur.file}:${cur.line})")
cur = cur.cause
}
Lifetime is the operand's, mk_err owns the raw allocation it returned, and the caller is responsible for freeing the chain. Free it with the same iterative spine walk, capturing each cell's successor before freeing it so the walk never dereferences freed memory:
// Iterative free: capture next before freeing the current cell.
cur = e
while cur != 0 {
next = cur.cause
free(cur as ptr)
cur = next
}
There is no generic library helper for this: each user shape names its link field differently (cause, next, tail, ...), and the loop is three lines. Prefer it over recursion for any chain whose length you do not control. (For Aether-managed lifetimes on a similar shape with refcount-aware structural sharing, see *StringSeq in sequences.md.)
Messages
Messages define structured data for actor communication:
message Increment {
amount: int
}
message Greet {
name: string
}
message SetPosition {
x: int,
y: int
}
message Reset {} // Empty message
A field can be a typed C function pointer (run: fn(int) -> int). The sender stores a named function's address (w ! Job { run: double_it }), and the handler's binding is called through the field's signature (run(21)). A closure cannot go there; a message carries a closure in a bare fn field.
Actors
Actors are the core concurrency primitive with encapsulated state and message handling.
Actor Definition
message Increment {
amount: int
}
message Move {
dx: int
dy: int
}
message Show {}
actor Counter {
state count = 0
state x = 0
receive {
Increment(amount) -> {
count = count + amount
}
Move(dx: step, dy) -> {
x = x + step + dy
}
Show -> {
println("count ${count}, x ${x}")
}
}
}
main() {
c = spawn(Counter())
c ! Increment { amount: 5 }
c ! Move { dx: 2, dy: 1 }
c ! Show {}
wait_for_idle()
}
count 5, x 3
An arm binds the message's fields by name: Increment(amount) binds the field amount, Move(dx: step, dy) binds the field dx as step and dy as itself, in any order, and an arm may leave fields out (Show binds none). A name that is no field of the message is an error at the arm, naming the fields it has (#3164); so is a binding named like a state of the actor, which the field: name form renames. An actor has one arm per message: an arm matches by the message alone, so a second arm for a message the actor already receives (in the same receive block or another) could never run, and the compiler refuses it, naming the first.
Receive Timeouts
The after clause fires a handler if no message arrives within N milliseconds:
actor Monitor {
state alive = 1
receive {
Heartbeat -> { alive = 1 }
} after 5000 -> {
println("No heartbeat for 5 seconds")
alive = 0
}
}
The countdown starts when the actor waits on an empty mailbox: at spawn, and again each time it has handled a message. A message that arrives within the interval stops it, and the next wait starts it from the beginning, so the Monitor above warns every time five seconds pass without a heartbeat, however many came before. The timeout fires once per countdown: when its handler has run, only a message starts the next one. A handler that sends the actor a message keeps a cycle going, as a poller does:
actor Poller {
state ticks = 0
receive {
Tick -> { ticks = ticks + 1 }
} after 1000 -> {
self ! Tick {}
}
}
and a handler that sends nothing leaves the actor idle, so wait_for_idle() returns. wait_for_idle() waits for every countdown that is running, so a poller that never stops keeps it waiting (#3162). The end of main waits for the messages sent to be handled, but not for countdowns: a countdown still running then does not hold the exit, and its timeout does not run (#3219). A program that wants a countdown to fire before it ends calls wait_for_idle() first.
State Variables
State persists across messages. A state takes its type from its value, or from a type written as a struct field's is, state name: T = value (#3163), for every type a field takes: a number of any width, a string, a struct, an enum, a tuple, an optional, a fixed-size array, a closure, and an actor_ref, which holds another actor's reference. The value is checked against a written type as a typed binding's is, and a state written with no value starts at its type's zero. The type-first form state int[100] history is accepted too. An array of references takes its size after actor_ref, a number or a const of the file (state members: actor_ref[8]), and a typed one after its parameter (actor_ref[Member][8]): a name after actor_ref[ that is not such a const is the actor type (#3189).
message Deposit {
amount: int
}
message Connect {
to: actor_ref
}
message Report {}
message Balance {
amount: long
}
actor Account {
state balance: long = 0
state history: int[4]
state count = 0
state auditor: actor_ref = null
receive {
Connect(to) -> {
auditor = to
}
Deposit(amount) -> {
balance = balance + amount
history[count % 4] = amount
count = count + 1
}
Report -> {
auditor ! Balance { amount: balance }
}
}
}
actor Auditor {
receive {
Balance(amount) -> {
println("balance ${amount}")
}
}
}
main() {
a = spawn(Account())
a ! Connect { to: spawn(Auditor()) }
a ! Deposit { amount: 70 }
a ! Deposit { amount: 30 }
a ! Report {}
wait_for_idle()
}
balance 100
Spawning Actors
counter = spawn(Counter())
calculator = spawn(Calculator())
Sending Messages (Fire-and-Forget)
counter ! Increment { amount: 10 }
counter ! Reset {}
A send, or an ask, of a message the target actor has no receive arm for is a compile error (E0600) wherever the target's type names its actor: the result of spawn(Counter()), and a parameter, field or result typed actor_ref[Counter] (#385). At run time such a message would be dropped. An actor with a _ arm (see Wildcard Handler) takes every message. A bare actor_ref names no actor and is not checked: it is the way to send a message chosen at run time, and the type of references that come from a registry or a map.
w = spawn(Writer()) // Writer has an arm for Open only
w ! Close {} // error: actor 'Writer' has no receive arm for 'Close'
A function can take or return a typed reference, close_it(w: actor_ref[Writer]) or -> actor_ref[Writer], and a spawn result passes where one is wanted.
Ask Pattern (Request-Reply)
The ? operator sends a message and blocks until the actor replies. The compiler infers the reply type from the actor's receive handler, so the result is typed without an annotation. Multiple concurrent asks to the same actor are supported, each message carries its own reply slot.
// Synchronous request-reply, result is an int (from Result.value)
result = calculator ? Add { a: 5, b: 3 }
A handler replies in one of two ways, and the ask takes its type from whichever it used:
message GetName {}
message GetSize {}
message SizeReply { bytes: int }
actor Store {
state name = "primary"
receive {
GetName() -> { reply name } // bare expression
GetSize() -> { reply SizeReply { bytes: 4096 } } // reply message
}
}
label = store ? GetName {} // string, from the replied expression
size = store ? GetSize {} // int, from the reply message's first field
A reply message hands back its first declared field, so put the value there. A string reply is deep-copied before it crosses back, so the asker's copy outlives the handler's own scope, and the asker owns it: it is reclaimed at scope exit like any other owned string, and freeing it by hand is not required.
An ask that gets no reply gives the reply type's zero value (0, ""). Destructured into two names, it says why, in the result convention: the reply and "", or the zero value and the reason (#3151):
size, err = store ? GetSize {}
if err != "" {
println("no size: ${err}")
}
Two names after an ask are always the reply and the reason, whatever the reply's type: a reply that is a pair itself is the first name, whole, and is destructured from there (p, err = store ? GetRange {}, then lo, hi = p).
err | when |
|---|---|
"" | the handler replied |
"ask timed out after 5000 ms" | no reply within the timeout (default 5 seconds) |
"the actor is dead" | the actor had died (or was released) before the ask reached it |
"the actor died before it replied" | the handler panicked with the ask in hand |
"the handler returned without replying" | the handler that took the ask finished without a reply |
An ask to a dead actor, in either form, ends at once rather than after the timeout, and so does one whose actor dies before replying or whose handler returns without a reply: a reply answers the ask its own handler took, so none can come later.
An ask made inside a handler blocks the asking actor's core until the reply, and no actor is moved onto that core meanwhile. An asked actor that is the core's own (an actor the handler spawned starts there) is stepped by the waiting thread itself, as a send steps an idle actor of the core, so it answers, and one that asks another of the core's actors in turn has it stepped the same way: a chain of asks among one core's actors answers (#3196, #3220). The core's other actors keep running (#3197): a millisecond into the wait, and every millisecond after, the waiting thread delivers what is queued for them and moves each one with work to a core that runs it, the core waiting on that very actor when there is one, else an idle one. So an asked actor on another core that asks one of them in turn gets its answer. Nothing else is run inside the waiting handler: an actor of the core that asks the waiting actor waits until its handler is done, as it would have. An actor with more messages queued than its mailbox holds moves as well, with all of them in order: what its mailbox cannot take goes into an overflow of its own that moves with it, and what it is sent later goes behind that, so each sender's messages still arrive in the order they were sent (#3241).
Reply Statement
The reply statement sends a response back to the waiting ? caller. Omitting reply in a handler that was invoked via ? causes the caller to time out (err is "ask timed out after 5000 ms" in the two-name form above).
Actors respond using the reply statement:
actor Calculator {
receive {
Add(a, b) -> {
result = a + b
reply Result { value: result }
}
}
}
Wildcard Handler
The _ pattern catches unmatched messages:
receive {
Known() -> { /* handle */ }
_ -> { println("unknown message") }
}
The _ arm runs for every message type that no other arm of the actor names, wherever it stands among the arms, and binds nothing; the message is then released as a handled one is (#3289). An ask that only the _ arm takes, and that it does not answer with reply, fails at once with "the handler returned without replying". An actor has at most one _ arm: a second can never run, and is an error. An actor without one drops a message no arm names, which is why a send of such a message, where the compiler knows the actor, is refused (see Sending Messages).
Protocols
A protocol says which messages a reference to an actor may be sent, and in which order (#385). Each line is a transition, state -> Message -> state: the message it allows in the first state, and the state it leads to. The first line's state is where a new reference starts; a state with no line out of it takes no message.
message Open { path: string }
message Write { data: string }
message Close {}
protocol FileWriter {
start -> Open -> writing
writing -> Write -> writing
writing -> Close -> done
}
actor Writer implements FileWriter {
receive {
Open(path) -> { /* ... */ }
Write(data) -> { /* ... */ }
Close() -> { /* ... */ }
}
}
main() {
w = spawn(Writer())
w ! Open { path: "out.txt" }
w ! Write { data: "hello" }
w ! Close {}
w ! Write { data: "late" } // error: 'w' is in state 'done'
}
The compiler follows each reference through the function that holds it, and a send or an ask the protocol does not allow in a state the reference may be in there is an error (E0600) naming the states and what each takes:
- Branches and loops. After an
if, amatchor atry, a reference may be in the state of any path that reaches the next statement, so a message must be allowed in all of them:if c { w ! Close {} }followed byw ! Write {}is refused. A loop is followed until its states settle, so aCloseinside a loop is refused on its second turn. A condition the compiler can decide (if DEBUG, aconst) takes only its branch. - A reference whose state is not known. A parameter typed
actor_ref[FileWriter], oractor_ref[Writer]for an actor that implements it, starts in no known state: a send through it must be one of the protocol's messages, and leads to the state that message leads to.actor_ref[FileWriter]takes a reference to any actor that implements FileWriter, and is sent only FileWriter's messages. - Out of sight. Passing the reference to a function, copying it to another variable, storing it, capturing it in a closure or naming it in a
deferlets its state change where the compiler does not follow: sends through it after that are checked as through a parameter's. A function that takes a bareactor_refis the way to send a message outside the protocol, and a message the protocol does not have is refused otherwise.
A protocol's messages are declared ones, each state has at most one line per message, and an actor that implements a protocol has a receive arm for each of its messages (a _ arm does not count). A protocol and its actors can come from an imported module, as messages do. protocol and implements are words only in these places: they stay usable as names.
Operators
Arithmetic Operators
| Operator | Description | Example |
|---|---|---|
+ | Addition | a + b |
- | Subtraction | a - b |
* | Multiplication | a * b |
/ | Division | a / b |
% | Modulo | a % b |
Comparison Operators
| Operator | Description | Example |
|---|---|---|
== | Equal | a == b |
!= | Not equal | a != b |
< | Less than | a < b |
> | Greater than | a > b |
<= | Less or equal | a <= b |
>= | Greater or equal | a >= b |
String comparison: When both operands are strings,==and!=compare by content, not by pointer identity: two strings are equal when they have the same length and the same bytes, embedded NULs included, however they were allocated.<,<=,>and>=order them byte by byte over their whole length, a string after any proper prefix of it. A string comparison in a function-clause guard (f(s) when s == "bob") works the same way. A null string is the empty text wherever it is read as text:==,!=and the ordering operators,string.equals,string.length, printing and interpolation (#3129). Soos.getenv("X") != ""is false for an unsetX, and"[${s}]"is"[]"for a nulls, where it used to give C's(null). Whether there is a string at all iss == null(ors != null), which compares the pointer: an unset variable isnull, a variable set to the empty string is not.
Bitwise Operators
| Operator | Description | Example |
|---|---|---|
& | Bitwise AND | flags & mask |
\| | Bitwise OR | flags \| bit |
^ | Bitwise XOR | a ^ b |
~ | Bitwise NOT | ~mask |
<< | Left shift | 1 << 4 |
>> | Right shift | n >> 2 |
Bitwise operators work on int and long values and map directly to C operators (zero runtime cost).
flags = 255
mask = flags & 15 // 15
set = flags | 256 // 511
flipped = flags ^ 255 // 0
shifted = 1 << 4 // 16
Logical Operators
| Operator | Description | Example |
|---|---|---|
&& | Logical AND | a && b |
\|\| | Logical OR | a \|\| b |
! | Logical NOT | !a |
The words and and not are no operators (or is the handler of a T! result): before a name, x and y reads as a declaration of y of a type and, which the program does not define, so it is an error naming the operator to write (E0302).
Assignment Operators
| Operator | Description | Example |
|---|---|---|
= | Assignment | x = 5 |
+= | Add and assign | x += 5, p.n += 5, xs[i] += 5 |
-= | Subtract and assign | x -= 5 |
*= | Multiply and assign | x *= 5 |
/= | Divide and assign | x /= 5 |
%= | Modulo and assign | x %= 5 |
&= | Bitwise AND assign | x &= mask |
\|= | Bitwise OR assign | x \|= bit |
^= | Bitwise XOR assign | x ^= mask |
<<= | Left shift assign | x <<= 4 |
>>= | Right shift assign | x >>= 2 |
Increment and Decrement
| Operator | Description | Example |
|---|---|---|
++ | Increment, postfix or prefix | i++, ++i |
-- | Decrement, postfix or prefix | i--, --i |
A postfix step gives the value from before it (j = i++), a prefix one the value after. The operand, in either position, is storage holding a number: a local, a parameter, a module-level var or actor state that is not a const, a field (p.x++) or an element (a[i]++), of an integer type (int, long, uint64, uint32, uint16, uint8, byte) or a float type (float, f32, longdouble). Any other operand is a compile error (E0200) naming what it is and the fix:
- a literal (
++5), a call's result (f()--: bind it to a local and step that) or any other expression ((y + 1)++: assign the variable instead,y += 1); - a
const, an enum member or a field or element of a call's result; - a value that is not an integer or a float: a
bool(b = !b), astring(++would move where its text starts, not change it), a pointer, aDuration(d = d + 1ms), an array, a struct, an optional, a tuple, and a distinct type, which steps through its base asx += 1must (p = (p as int + 1) as Port).
Actor Operators
| Operator | Description | Example |
|---|---|---|
! | Send message (async) | actor ! Msg {} |
? | Ask (request-reply) | actor ? Query {} |
Member Access
| Operator | Description | Example |
|---|---|---|
. | Field access | point.x |
[] | Array index | arr[0] |
Operator Precedence (High to Low)
()[].- Grouping, indexing, member access!-~(unary)++--- Unary operators*/%- Multiplicative+-- Additive<<>>- Bitwise shift<><=>=- Relational==!=- Equality&- Bitwise AND^- Bitwise XOR|- Bitwise OR&&- Logical AND||- Logical OR=+=-=*=/=%=&=|=^=<<=>>=- Assignment!?- Actor send/ask
Modules and Imports
Standard Library Imports
import std.fs // files and directories
import std.string // string utilities
import std.list // a list that grows
import std.http // HTTP client and server
import std.json // JSON
// Use with namespace syntax
loud = string.to_upper("hello")
if fs.exists("config.txt") == 1 { }
Selective Imports
Import only specific symbols from a module:
import std.math (sqrt, pow)
main() {
x = sqrt(16.0) // a selected name, written bare
y = math.pow(2.0, 3.0) // the qualified form works too
z = math.sin(1.0) // and reaches the rest of the module
println("${x} ${y} ${z}")
}
A selective import decides which names your file may write bare; it does not narrow the module. The whole module is merged into the build and every one of its functions is type-checked, the ones you did not select and the ones nothing calls included, so an error anywhere in it is reported by your build (#2613). Functions nothing calls are still left out of the emitted C. That is also what lets a selected function read a constant you did not select, and a qualified m.other() reach a function you did not select (#2630).
What a file can reach
A qualified ns.name resolves against the imports of the file it is written in. A module that some other module of the program imports is loaded, but it is not visible to your code, and a library module is held to the same rule: a module calling low.f() must import low itself, even when another module it imports already does. ae build and ae check agree on this; the error is E0301, and its help line names the missing import (#2614). A module does see itself: m.a() and m.K inside module m name its own definitions, its private ones staying private (E0303), in a build and in ae check of the module's file alike (#2631).
Bare names follow the same rule. A module's selective or glob import binds names for that module, including a module's extern-backed names (import std.http (server_create) for its extern http_server_create), and a program's selective or glob imports bind names for the program only. A module that calls server_create(...) without importing std.http itself fails with E0301, whichever program imports it; it used to build when the program happened to import the name, and fail when it did not.
Module Public API, exports (…)
Each module declares its public surface once at the top of the file via an exports (…) list. Names in the list are callable from outside the module via either qualified (mod.name(…)) or short-alias (import mod (*)) forms. Names not in the list are private, still callable from inside the module's own functions, but rejected at qualified-call sites from outside.
// At the top of greeter/module.ae:
exports (say_hello, greet_world, GREETING)
const GREETING = "hello"
say_hello() {
return GREETING // public, listed
}
greet_world() {
return _format(GREETING, "world") // public, listed; calls private helper
}
// Not listed → private. Leading underscore is a naming convention only;
// the exports list is the contract that actually enforces visibility.
_format(prefix: string, target: string) {
return "${prefix} ${target}"
}
The form is additive in v1: a module without an exports (…) list keeps the legacy default-public behavior so existing code continues to work unchanged. v2 will flip the default to private once every module in std/ and contrib/ has been migrated.
The legacy per-function export <fn> form is still accepted but emits a one-shot deprecation warning per module. Migrate by collecting every export-tagged name into a single exports (…) line at the top, then removing the export keywords from each declaration. Mixing both forms in one module is a hard error.
Glob Import, import mod (*) (a.k.a. unqualified import)
Expose every public name in a module as an unqualified short alias, without enumerating each symbol individually. Names with a leading underscore (_helper, _internal) stay private and are not aliased. This is Aether's spelling for what other languages call "unqualified import" (Rust's use mod::*;, Java's import static mod.*, Python's from mod import *).
import std.math (*)
main() {
x = sqrt(16.0) // works, short alias registered
y = pow(2.0, 3.0) // works
z = math.sin(1.0) // qualified form still works alongside the glob
}
It also applies to any module with a wide, short-named surface, e.g. std.bits, where unqualified popcount32(...) / rotl32(...) / clz32(...) reads better than the namespace-prefixed form:
import std.bits (*)
main() {
println(popcount32(0xFF)) // 8, one-bits set
println(rotl32(1, 4)) // 16, rotate left
println(clz32(1)) // 31, leading zeros
println(bits.lsr32(256, 4)) // 16, qualified form still works
}
The glob form composes across module boundaries: a library module that does import std.fs (*) and calls a glob-brought clean(...) resolves correctly whether it is the compilation entry point or is imported by another module. (The bare glob-brought names are rewritten to their canonical prefixed form when the module is merged into a consumer, the same way selective and qualified imports are, extern-backed ones such as std.string's length included (#2637). A local or parameter of that name in the module is the local, not the import, as in any other file.)
A file's own names win over its globs. A glob import binds only the names the importing file does not define itself. A function, builder, constant or extern the file declares under the same name stays the file's own, the way a local item shadows a glob import in Rust:
import std.string (*)
// std.string has a `bytes` too; this file's calls reach its own.
bytes(a: int, b: int, c: int) -> int {
return a + b + c
}
main() {
println("${bytes(1, 2, 3)} ${length("abc")}") // 6 3
}
The same holds in a module, whether it is checked on its own or merged into a program (#2632). A selective import is different: import std.string (bytes) beside a local bytes is the E1000 clash, because the import wrote the name out (see "Selective-import shadow rejection" in module-system-design.md).
Use the glob form when you'd otherwise list 20+ symbols just to use the module without the namespace prefix. Bare import std.math (no parens) loads the module but does not register short aliases, you have to write math.sqrt(...) for everything. The selective form import std.math (sqrt, pow) aliases only the named symbols and keeps everything else qualified, the same shape as import static in Java, useful when you want exactly two or three symbols bare and the rest namespaced.
The qualified mod.fn(...) surface is always available whenever a module is imported in any form, bare, selective, or glob, the way Java's fully-qualified name is always legal regardless of imports. A selective import is purely additive: import std.math (sqrt) adds the bare-name binding sqrt(...) on top of the always-available qualified surface, so math.pow(2.0, 3.0) still resolves even though pow was not in the selection list. The import form decides only which bare names exist; it never restricts the qualified form.
import std.math (sqrt)
main() {
a = sqrt(16.0) // bare-name binding the selective import adds
b = math.pow(2.0, 3.0) // qualified, always available, even un-selected
}
A type can be qualified the same way. mod.T and *mod.T are types wherever a type is written (a parameter, a return type, a struct field, an as cast, a typed declaration), with no selective import of T:
import gpu
compile(id: int) -> *gpu.GpuShader {
return gpu.make_shader(id) as *gpu.GpuShader
}
gpu.GpuShader is the struct import gpu (GpuShader) would bring in. The qualifier is checked: gpu has to be a module the file imports, and it has to define and export the type (module 'gpu' has no type 'Missing', 'Scratch' is not exported from module 'gpu').
Struct types belong to their module. Two modules may each define a struct of one name, and a program may use both: gpu.Shader and core.Shader are different types, as gpu.compile and core.compile are different functions.
import gpu // struct Shader { id: int, stage: string }
import core // struct Shader { name: string, uniforms: int }
struct Frame {
program: *gpu.Shader
material: core.Shader
}
A struct name resolves where it is written, the way a function's does:
gpu.Shader,*gpu.Shaderandgpu.Shader { ... }name gpu's.- A bare
Shaderis the file's own struct of that name, if it has one; a module's code names its own structs bare, as before. - Otherwise it is the one struct of that name among the modules the file imports. When two imports define one, a selective import naming it decides (
import gpu (Shader)); without one the bare name is an error,struct 'Shader' is ambiguous here: the imported modules 'gpu' and 'core' each define one, and the qualified spelling is the fix.
Diagnostics spell a module's struct qualified (expected *core.Shader, got *gpu.Shader). Two modules defining a struct identically, field for field, still share one type. In the C a module's struct is <ns>_<Name> (gpu_Shader), its module's namespace as its functions carry it; the program's own structs keep their names, and so, in an --emit=lib build, does a struct its module exports, since that name is part of the library's ABI (see emit-lib.md). Messages and actors are still one namespace across modules.
Import with Alias
import mod as alias gives the module a second prefix in the importing file. A qualified call through the alias reaches the module as its own name does, and the module's own name keeps working beside it:
import std.string as str
main() {
s = str.concat("Hello, ", "Ada")
println("${str.length(s)} ${string.to_upper(s)}")
}
10 HELLO, ADA
An alias is resolved in the file that writes it, a library module's file included (#2209), and an alias-only import of a local package merges the package's functions as a plain import does.
@target("avx2") import m as alias a second copy compiled for AVX2
An f32x8 is one 256-bit register in a build with -mavx2 and two four-lane halves in any other, and a -mavx2 binary faults on a CPU without AVX2. A program that ships one executable for every CPU imports the module holding its lane code a second time, at the target, and chooses at run time (#2607):
import std.lanes
import physics.wide
@target("avx2") import physics.wide as wide8
solve(constraints: ptr, n: int) {
if lanes.cpu_has_avx2() { wide8.solve(constraints, n) } else { wide.solve(constraints, n) }
}
- The import is a copy of the module of its own (its own functions and module variables), compiled for AVX2 while the rest of the program keeps the build's flags. It is named by its alias, which is required, and imported whole: no
(names)list, no glob. - Both copies compute the same bits; only their width differs.
lanes.native8()is true in the copy, andlanes.cpu_has_avx2()says whether this CPU can run it (false off x86). - Two files importing the same module at the target share one copy.
- An eight-lane value (
f32x8,i32x8, or a struct, pointer, array, tuple or optional holding one) cannot cross between the copies, or from the copy into C compiled apart: the two copies spell it as different C types, so the call is a compile error (E0200). The entry points take scalars and pointers to the data. - A closure inside the copy is a compile error: its body would be compiled outside the copy.
"avx2"is the one target, the one that changes anf32x8's width. With a compiler other than GCC or Clang on x86, on theAETHER_GCC_COMPAT=0path, and with GCC on Windows at-O0(which cannot compile a 256-bit local there, #3023), the copy is plain code with no target attribute.
std/lanes/README.md ("One binary, two widths") has the rest.
Local Module Imports
import utils // loads lib/utils/module.ae
import helpers // loads lib/helpers/module.ae
result = utils.double_value(21)
In utils.name, utils is the imported module unless a value of that name (a local, a parameter, a global) is in scope, whose field it then reads. A function of that name is no value and hides nothing: a program's frame() leaves frame.SETS the constant of an imported frame module, in the program and in a module that imports frame itself, beside a function of the module's own named frame too. A module's code sees its own declarations, what it imports and the builtins, never the program's: a module that calls a program's helper() without importing it is refused, and a program's struct frame is no frame in the module.
Available Standard Library Modules
| Module | Namespace | Description |
|---|---|---|
std.file | file | File operations (file.open(), file.exists()) |
std.dir | dir | Directory operations (dir.list(), dir.create()) |
std.path | path | Path utilities (path.join(), path.basename()) |
std.string | string | String manipulation (string.new(), string.length()) |
std.list | list | Dynamic array (list.new(), list.add()) |
std.map | map | Hash map (map.new(), map.put()) |
std.set | set | Unique-string set (set.new(), set.add()) |
std.pqueue | pqueue | Priority queue (pqueue.new(), pqueue.push()) |
std.json | json | JSON encoding/decoding (json.parse(), json.free()) |
std.http | http | HTTP client & server (http.get(), http.server_create()) |
std.tcp | tcp | TCP sockets (tcp.connect(), tcp.write()) |
std.math | math | Math functions (math.sqrt(), math.sin()) |
std.log | log | Logging utilities (log.init(), log.write()) |
std.io | io | Input/output (io.print(), io.getenv()) |
Extern Functions
Declare external C functions:
extern puts(s: string) -> int
extern malloc(size: int) -> ptr
extern free(p: ptr) -> void
main() {
puts("Direct C call!")
}
Externs are useful for:
- Calling C standard library
- Custom C extensions
- Platform-specific APIs
A bare extern belongs to the file that declares it. Two files may declare one C function differently, as extern memcpy(dst: ptr, src: int[], n: long) in one module and extern memcpy(dst: ptr, src: ptr, n: long) in another: each file's calls are checked, and generated, against its own declaration (its parameter types, @heap, @retain), whatever else the program imports. A file that calls an extern it does not declare gets another file's declaration of that name.
Typed and qualified C pointers
Plain ptr lowers to C void *. C headers use richer pointer spellings, and when an Aether extern (or an Aether-owned public function) must match a header exactly, so the generated C compiles with that header force-included, and the C compiler still cross-checks the signature against every caller, use these forms. Each affects only the emitted C spelling; the Aether-side kind (and so all typechecking) is unchanged.
| Aether spelling | Emitted C | For |
|---|---|---|
const ptr | const void* | memcmp(const void *, const void *, size_t) |
cstring | char* | a mutable C-string parameter |
cstring_const | const char* | strlen(const char *) (plain string also emits const char*) |
*T | T* | a pointer to a header-defined type T |
const *T | const T* | a const pointer to T |
const string | const char* | qualified string |
extern type dictEntry // opaque header-defined C type
extern dictGenHashFunction(key: const ptr, len: size_t) -> uint64_t
extern strlen(s: cstring_const) -> size_t
extern dictGetKey(de: *dictEntry) -> ptr
// An Aether-OWNED public symbol whose prototype must match its header:
pqsort(a: ptr, n: size_t, es: size_t, cmp: const ptr, lr: size_t, rr: size_t) { ... }
Passing a plain ptr where the C conversion is safe stays allowed at call sites; only the emitted prototype carries the exact spelling. C ABI scalar aliases (size_t, uint64_t, …) emit their exact C name the same way. const-qualification survives into the generated C so the C compiler diagnoses writes; Aether-side write rejection is not (yet) enforced.
An extern's effects are unknown
A C function's body is not visible to the compiler, and its declaration does not narrow what it does: it may write through any pointer it is given and any state of its own (a reader of stdin, a random generator with no handle parameter). So a call of an extern is evaluated ahead of any later operand that calls anything or reads memory through a pointer, see Evaluation order under Built-in Functions; pair(pqueue.pop(q), pqueue.pop(q)) pops in source order, and pair(strbuilder.append(b, "xy"), strbuilder.length(b)) reads the length after the append.
@retain, @noescape and @into: what an extern does with a parameter
An extern borrows what it is given, as every call does: a fresh argument is released after the call, and a local's value stays the caller's. An extern that keeps a closure or a string past the call (an event handler, a server route, a timer) declares that parameter @retain, and the call then hands it a reference of its own: a fresh value as it is, a place's retained. C gives a kept closure back through the closure's own destructor. A closure passed to a ptr parameter is boxed: on the caller's stack when the parameter only borrows, so nothing is allocated, and on the heap, with its own reference, when the parameter is @retain or @into.
extern string_seq_each(s: *StringSeq, f: @noescape ptr) // calls f per element, keeps nothing
extern register_handler(cb: @retain fn) // stores the closure: it gets a reference
extern http_server_get(server: ptr, path: string, handler: @retain ptr, user_data: ptr)
extern memchr(s: @into ptr, c: int, n: int) -> ptr // the result points into `s`
@noescape states what an unmarked parameter already is: the C function uses the argument only during the call, neither storing it, handing it to another thread, nor freeing it. It is accepted on ptr and fn parameters. The std externs that keep a closure are declared @retain: observe, the worker pool's jobs and main-thread poster, and the HTTP server's route, SSE and WebSocket handlers. So are the stores that keep what they are given (#3086): the item of std's list and map stores (list.add, list.add_raw, list.set, map.put, map.put_raw), and the head and the tail of string.seq_cons. A *StringSeq given to an @retain parameter is handed a reference of its own as a string is: a fresh one as it is, a place's retained. What a call keeps is read from these declarations: a borrowed ptr given to one is E0902, and a @scoped value given to one escapes.
A store names the argument that holds what it keeps with @holds on that ptr parameter (std's list and map stores, their container), and an extern whose result is memory the call allocates, which nothing else holds, says so with @fresh after its result type (list_new() -> ptr @fresh, std's list and map constructors). A keep through a store then outlives the caller only when the holder does: a local bound only from @fresh calls that goes nowhere a borrowed ptr would escape to ends with the function, so a borrowed ptr stored in it keeps nothing past the call.
@into on a ptr parameter says the C function's result points into or holds the argument, and keeps it nowhere else. The compiler cannot read C, so it is trusted, as @retain is, and a call is checked as one to an Aether function's @into parameter (String ownership): the caller may hold the result in a local, and E0902 refuses it where it would carry a borrowed ptr parameter out of the caller. @into does not combine with @retain (C may keep the argument anywhere) or @noescape (the argument stays out of the result too).
An extern's ptr, *T or slice parameter may be @frees (the C function frees what it is given, or ends the caller's hold on it: extern aether_bytes_free(b: @frees ptr)) or @moves (it may move the storage the argument holds, as a buffer that grows does). Neither changes the call; they say what happens to the memory, and E0903 follows it: a local given to an @frees parameter is freed from there, and a view of one given to an @moves parameter is dead (String ownership). C's free frees its argument under any name an extern gives it. @frees does not combine with @retain, @holds, @into, @keep or @noescape.
An extern's parameter may also be @keep: that is how ae declares a binary import's export whose Aether parameter is @keep ptr (#3013), and a call is checked as one to that Aether function: a borrowed ptr given to it is E0902, and a string goes only through string.keep(s). @retain would be wrong there, since it hands C a string's characters with a reference of its own, not the counted string the Aether function keeps.
@extern("c_name") bind to a renamed C symbol
When the Aether-side name should differ from the C symbol (for example, to expose a clean module surface without trailing _raw suffixes), prefix the declaration with @extern("c_symbol"):
@extern("EVP_MD_CTX_new") md_ctx_new() -> ptr
@extern("strerror") describe_errno(errno: int) -> string
The Aether-side name is what callers write; the annotated C symbol is what the linker sees. No wrapper function is emitted. See docs/c-interop.md for the full FFI reference.
extern const NAME: type @c_import import an object-like C macro
Object-like C macros (EAGAIN, O_NONBLOCK, LLONG_MAX, a generated REDIS_GIT_SHA1, …) are invisible to Aether, there is no symbol to link against. extern const … @c_import makes one usable by name:
extern const EAGAIN: int @c_import
extern const O_NONBLOCK: int @c_import
extern const REDIS_GIT_SHA1: ptr @c_import // string macro -> const char *
if syncio_errno() == EAGAIN { ... }
- The declaration teaches the typechecker a name and an Aether type; the type is trusted as declared, the same model as
externfunctions. - Generated C emits the macro name verbatim at every use site and emits nothing for the declaration itself, no value, no
#define, no forward declaration. The macro's value is never needed at Aether compile time, so per-platform values come out right by construction: the including translation unit's headers are the sole source of truth. Ensure the owning header is in scope (e.g.cflags = "-include errno.h"inaether.toml, or a header your build already force-includes). - Usable in expression and comparison contexts.
@c_importis required, it is the marker that selects the emit-verbatim semantics. - Object-like macros only. Function-like macros (
CPU_SET(i, &set)) are out of scope; wrap those in a smallexternC function.
extern ... @c_import let a C header own the prototype
By default Aether emits its own forward declaration for every extern. That declaration is ABI-compatible with the header's but not always spelled the same: int where the header says uint8_t, int where it says size_t, void* where it says a typed pointer. Two differently spelled declarations of one function in the same translation unit are a hard conflicting types error, and under LTO the surviving cases show up as type-mismatch warnings that are hard to tell apart from real porting mistakes.
@c_import after the signature says the header owns the prototype, so Aether emits none:
extern type blob
extern api_scale(v: int) -> int @c_import // header: uint8_t api_scale(uint8_t)
extern api_span(a: int, b: int) -> int @c_import // header: size_t api_span(size_t, size_t)
extern api_blob() -> *blob @c_import
extern api_inline_twice(v: int) -> int @c_import // static inline in the header
- The Aether-side types are still declared, and are still what the typechecker uses, the type is trusted as declared, the same model as every other
extern. They just never reach the generated C. - The header's spelling ends up being the only declaration in the translation unit, so the two cannot disagree. The C compiler still checks every call site against the header, so a genuinely wrong Aether signature is still caught, at the call site rather than as a prototype clash.
- Ensure the owning header is in scope, e.g.
cflags = "-include api.h"inaether.toml. Same requirement asextern const @c_importandextern struct @c_import. - This is the only correct shape for a
static inlineheader helper: it has no linkable symbol, so a non-static prototype referring to it is meaningless. With@c_importthe call inlines directly and needs no hand-written C shim. - Stacks with the other extern attributes:
-> string @heap @c_importand variadicextern printf(fmt: string, ...) -> int @c_importare both legal.
va_list forward a variadic tail to C
A function declared with a trailing ... can open its variadic tail and hand it to the v* half of a printf-style C pair:
extern vsnprintf(buf: ptr, n: int, fmt: ptr, ap: va_list) -> int
format_into(buf: ptr, n: int, fmt: ptr, ...) -> int {
let ap = va_start()
let written = vsnprintf(buf, n, fmt, ap)
va_end(ap)
return written
}
va_start()opens the tail (valid only inside a...function),va_arg(ap, T)reads the next argument asT,va_end(ap)closes it. The type given tova_argis trusted, exactly as in C.- The receiving parameter must be spelled
va_list, notptr.va_start()yields a cookie that points at theva_listso it can live in an ordinaryptrlocal, while the callee needs the list itself. Declaring the parameterptrcompiles clean and then formats garbage.va_listtypechecks as an opaqueptr; only the emitted C spelling differs.
See docs/c-interop.md for the full treatment, including -Wformat checking of literal format strings.
@source("file.c") a C file the module ships
A module whose externs are implemented in its own C file names that file at the top of its module.ae, relative to the module's directory:
@source("lanes.c")
exports(add4)
extern lanes_add4(a: int, b: int) -> int
add4(a: int, b: int) -> int { return lanes_add4(a, b) }
Every program that imports the module gets lanes.c compiled in, with no --extra flag and no extra_sources entry; a module deeper in the import graph contributes its file the same way, and an import inside a losing when defined(...) region contributes nothing. The path must exist when the module is compiled (the error is reported at the directive), and editing the file rebuilds a cached ae run. @link beside it declares the libraries the file needs. The directive is also accepted in a program's entry file, resolved beside it. See docs/build-system.md.
@c_callback export an Aether function as a C callback
The inverse of @extern. Marks an Aether function as having a stable, externally-visible C symbol so it can be passed across the linkage boundary as a function pointer to C externs that take callbacks (HTTP route handlers, signal handlers, qsort comparators, libcurl write callbacks, sqlite hooks):
extern http_server_add_route(server: ptr, method: string, path: string, handler: ptr, user_data: ptr)
@c_callback
my_handler(req: ptr, res: ptr, ud: ptr) {
// …
}
main() {
http_server_add_route(server, "GET", "/hello", my_handler, null)
}
By default the C symbol matches the Aether-side name (or its namespace-prefixed form when the function lives in an imported module). For a specific C symbol, use the parenthesised form: @c_callback("aether_signal_handler") on_sigint(sig: int) { … }. See docs/c-interop.md for the full reference.
Function-pointer parameters, fn(T1, T2) -> R
A parameter typed fn(T1, T2) -> R is a typed C function pointer: it emits the exact R (*name)(T1, T2) in the generated prototype and is directly callable in the body, so callback-taking functions port cleanly:
walk(cb: fn(ptr, ptr) -> void, a: ptr, b: ptr) {
cb(a, b) // a real indirect call
}
reduce(f: fn(int, int) -> int, x: int, y: int) -> int {
return f(x, y) // multiple callbacks per signature are fine
}
Pass an Aether function's address with the as fn(...) cast, walk(my_handler as fn(ptr, ptr) -> void, p, q) or a C function pointer obtained from an extern. A string argument reaches the callee as its bytes: the call wraps it in aether_string_data(arg), as a call to an extern does, so a heap string (interpolated, concatenated) arrives as its characters and not as its AetherString header. This holds for every typed-pointer call: a fn(...) parameter, a cast local (f = p as fn(uint32, string) -> int; f(7, name)) and a function-pointer struct field. A closure cannot go there (it carries an environment, and a C function pointer has none), and the compiler says so at the call (a closure cannot be passed as a typed function pointer); a callback that may be a closure takes a bare fn parameter and is invoked with call(cb, …), or, when it keeps a ptr it is given, a closure type that says so. This is the parameter form of the same typed-fn-pointer machinery used by as fn(...) locals and function-pointer struct fields; the prototype matches the C signature exactly (needed for callback APIs like qsort, dictScan, signal handlers, libcurl/sqlite hooks).
A named Aether function bound to a typed function pointer is its address there too, without the cast: an argument to such a parameter, a struct or message field of that type, a let local of it, a module-level var (its initializer, and an assignment to it in a function body) or const (const CB: fn(int) -> string = label, called CB(1)), an element of an array literal going into an array of that type (let ops: Op[2] = [twice, half], a field, an argument, a result), and a function's result, or a part of a tuple result of that type. The signature is checked: the parameter types must match, and so must the result, where fn(ptr, int) and fn(ptr, int) -> void are the same type.
A closure cannot be stored in any of those slots, as it cannot be passed to the parameter: a closure literal, or a local bound to one, given to a struct field, an array element, a local, a module-level var or a function result of a typed function pointer type is a type error naming the field or variable. Store a named function there, or declare the slot a bare fn and call it with call(...).
A typed function pointer's own parameter can be a typed function pointer, fn(fn(string) -> string, string) -> string, and a function with that parameter fits it.
A call through a typed function pointer that holds null panics at the call with the category null_function_call and the call's source position (Panic categories), which a catch receives, as a call through the empty closure does (#3098).
A call through a typed function pointer converts a slice argument as a direct call does: [] and null become the empty slice, and an array a view of its elements (#2856). A closure whose signature the compiler knows converts its arguments the same way, an optional's bare value and none included.
A function's result is written as any other typed function pointer, -> fn(ptr) -> string (the inner -> string is part of the type) or -> Getter for a cfn Getter, and a call to the function is that pointer: it binds to a local, a field or a parameter, and f as ptr gives back the pointer it holds.
extern free(p: ptr)
noop_free(block: ptr, size: int) {
println("noop_free")
}
system_free(block: ptr, size: int) {
free(block)
println("system_free")
}
var g_free: fn(ptr, int) = noop_free // noop_free's address
main() {
g_free(null, 0)
g_free = system_free
g_free(null, 0) // free(null) does nothing
}
noop_free
system_free
Closure types that say what they keep, fn(@keep ptr, float)
A function type whose parameter is written with a mode, @keep or @into (String ownership), is a closure's type that carries it: fn(@keep ptr, float) holds a closure or a named function whose first parameter keeps the pointer it is given, and fn(@into ptr) -> Recorder one whose result holds it. A call through such a value (tick(el, t) or call(tick, el, t)) is checked as a direct call is: a borrowed ptr given to the @keep parameter is E0902, a string goes there only through string.keep(s), and the result of an @into call holds the argument. A closure declares the mode on its parameter (|el: @keep ptr, t: float|); one that leaves a parameter untyped (|el, t|) takes the parameter's type and mode from the closure type it is given for. A named function with a @keep or @into parameter is a value of its type: given, bound to a local (g = fill) or called through one.
The modes are ordered, borrowed, then @into, then @keep: each lets the function do more with the pointer and asks more of the caller. A function may stand where its own modes, or ones further along, are expected (a borrowing closure where fn(@keep ptr, float) is, an @into function where fn(@keep ptr) -> ptr is), never the reverse, since a call through the weaker type would hand it a pointer the caller still owns. So a keeping function does not go into a bare fn (which borrows every parameter), a closure type with a weaker mode at that parameter, or a ptr (the box unbox_closure opens is a bare fn); each is E0902. A value that is one of several functions (an if-expression or a value match over them, or a local bound to one in each branch) takes the strongest of their modes at each parameter, the type every one of them may stand for, so a call through it is checked as a call to the one that keeps. A typed C function pointer's parameters borrow, so neither as fn(...) nor a type Name = fn(...) alias names a mode.
A closure value, of such a type or a bare fn, may hold no function: a field a struct literal leaves out, or one given null. c == null and c != null ask whether it holds one (a bool), and c = null empties it, giving back the closure it held, so an optional callback slot is written if s.build != null { s.build(ctx, 1) }. null is that empty closure wherever a closure goes: a binding, a field, an argument, a result, an element of an array literal ([f, null], [null, f]), a part of a tuple literal, a module-level var, the default of ??. A fn? given null holds the empty closure, as a ptr? given null holds a null pointer: it is a present value, not none, so o == null is true, a match runs its some arm and o! gives the empty closure; none is the absence of any closure. A call through the empty closure, however it was reached, panics at the call with the category empty_closure_call and the call's source position (Panic categories), which a catch receives. A closure compares only with null: f == g between two closures, or a closure and a pointer, is a compile error, and so is one between two fn? or a fn? and a closure (Go's rule for function values). The same function over another environment is another closure, so there is no equality worth giving; to tell closures apart, keep a handle of your own beside each.
import std.list
var registry: ptr = null
element_set_fill(el: @keep ptr, color: string) {
list.add(registry, el) // kept: the parameter is `@keep`
}
animate(el: @keep ptr, tick: fn(@keep ptr, float)) {
tick(el, 0.5) // checked as a direct call: `el` is kept
}
main() {
registry = list.new()
shape = list.new()
animate(shape) callback | el: @keep ptr, t: float | {
element_set_fill(el, "red")
}
animate(shape) callback | el, t | { // takes `@keep ptr` and `float`
element_set_fill(el, "blue")
}
println("${list.size(registry)}")
}
2
Named C function-pointer types, type Name = fn(T1, T2) -> R
A C function-pointer signature can be declared under a name, the typedef form of fn(T1, T2) -> R, in two spellings that mean the same thing: type GenBuffers = fn(int, ptr), the signature as a use site writes it, and cfn GenBuffers(n: int, ids: ptr), the shape of the C prototype it mirrors. It exists for entry points a program fetches at run time: OpenGL past 1.1 on Windows and all of Vulkan come back from wglGetProcAddress / glfwGetProcAddress / vkGetDeviceProcAddr / dlsym as a bare ptr, and the signature has to be written down once, not at every use.
extern get_proc_address(name: string) -> ptr
type GenBuffers = fn(int, ptr)
cfn BufferData(target: int, size: long, data: ptr, usage: int) // same declaration, prototype-shaped
var gl_gen_buffers: GenBuffers = null // loaded once, called anywhere
load() {
gl_gen_buffers = get_proc_address("glGenBuffers") as GenBuffers
}
make_buffer() -> int {
ids = calloc(1, 4)
gl_gen_buffers(1, ids) // ((void (*)(int, void*))(gl_gen_buffers))(1, ids)
return (ids as int[])[0]
}
In the cfn spelling the parameter list takes the extern form (name: type, so a declaration can be pasted from the C prototype) or a bare type list (cfn Add(int, int) -> int); parameter names are documentation only. In both spellings, omitting -> R means void, and type Name = fn with no signature is an error (a bare fn is a closure, which cannot be a C function pointer). The name goes wherever fn(T1, T2) -> R goes, with the same meaning: a local (sc = p as Scale, let sc: Scale = ...), a module-level var, a parameter, a struct field, a return type, and a parameter of another cfn (cfn Visit(cb: Bump, t: *Thing)). The argument and return types are those extern accepts, *StructName pointers included; a struct passed by value is not (C would need the typedef, and Vulkan's VkCreateInfo family takes pointers); a struct returned by value works when an Aether struct with the same layout names it. A module lists it in exports (...) and an importer names it bare, as it names an imported struct.
Two things do not change from the anonymous spelling. Storage is a void*, and each call carries the typed cast, so no C typedef is emitted and the name cannot collide with one from a header (PFNGLGENBUFFERSPROC). And the type is structural: two names over the same signature are one type, and a GenBuffers value is assignable to a fn(int, ptr) slot. The operand of as Name must be a ptr (or an Aether function's name, which takes its address); a closure has an environment and cannot become a C function pointer.
@observable notify on every field store
struct Name @observable { ... } marks a struct whose values report each field assignment at run time. After every store into a field of such a value (m.count = 1, p.status = "ready", and the compound forms m.count += 1 desugars to), the generated code calls the runtime with the object's address and the stored field's index in declaration order, and the closures registered on that object through std.observe run, synchronously, in registration order. Nothing changes in the struct's layout: observers live in a runtime side table keyed by address, and a program that never observes pays one counter read per store.
import std.observe
struct Model @observable {
count: int
status: string
}
main() {
m = heap.new(Model)
token = observe.observe(m, | obj: ptr | {
println("count is now ${m.count}")
})
m.count = 1 // count is now 1
m.count += 1 // count is now 2
observe.unobserve(m, token)
m.count = 3 // silent
heap.free(m)
}
The rules for which object hears about a store:
- A
heap.newbox or any*Namepointer is the object; pass it as it is. A local value is its address:observe.observe(&m, ...). - A store into a nested value (
a.b.c = v, withb: Inner) notifies&a.b(fieldc) and then&a(fieldb, the outer field holding what changed), when each is observable, because both values' bytes changed. A store through a pointer field (a.p.c = v, withp: *Inner) notifies only the pointee;adid not change. - A store an observer makes on the object it is being told about lands but starts no nested pass (the re-entrancy guard is per object). A store on another object from inside an observer notifies that object at once.
- An observer removed during a pass does not run later in that pass; one added during a pass runs from the next.
- A first field shares its parent's address, so observing
&a.firstand&ais observing one object. Order the struct so a nested observable value is not the first field when the two must be told apart.
An observer registered with observe.fields receives the field index as a second argument (|obj: ptr, field: int|); one registered with observe.observe takes the object alone. A store made where the compiler cannot see it, by offset through std.mem, is reported with observe.notify_field(obj, field) or observe.notify(obj) (field observe.ANY_FIELD), which run the same pass.
Notification runs on the storing thread; marshal to a loop thread yourself (std.worker's poster is the tool). An observer holds its closure until unobserve / unobserve_all releases it; remove observers before freeing the object. The attribute combines with a leading @derive(...).
@derive(eq) synthesize an equality helper for a struct
Annotate a struct definition with @derive(eq) and the compiler synthesizes int <StructName>_eq(<StructName> a, <StructName> b) automatically, a field-by-field == chain that returns 1 when every field matches, 0 otherwise:
@derive(eq)
struct Point { x: int, y: int }
main() {
a = Point { x: 1, y: 2 }
b = Point { x: 1, y: 2 }
if Point_eq(a, b) == 1 { println("equal") }
}
Supported field types in v1: primitive numeric (int, long, float, byte, bool) and string. The codegen lowers string == string to a content comparison automatically, so the synthesizer doesn't need a special path.
@derive(format) / clone / hash and nested-struct fields surface a precise compile-time diagnostic, they're explicitly out of v1 scope and tracked for follow-up commits.
@derive(schema) a struct's field table, for inspectors, saves and replication
@derive(schema) makes the compiler emit a table describing the struct, as static constant data: its name and size, and for each field its name, its type as written, a kind tag, its byte offset and size, and the attributes written after its type. <StructName>_schema() -> ptr returns the table and std.reflect reads it. An editor's inspector, a save file, a network replicator or an agent then walks the struct's fields by name, with no second, hand-kept copy of the struct beside it.
import std.reflect
@derive(schema)
struct Bob {
rate: float @range(0.0, 10.0) @default(2.0)
height: float @default(6.0)
label: string @tooltip("shown in the inspector")
}
main() {
s = Bob_schema()
i = 0
while i < reflect.field_count(s) {
println("${reflect.field_name(s, i)}: ${reflect.field_type(s, i)} at ${reflect.field_offset(s, i)}")
i = i + 1
}
}
The table is what C's own offsetof and sizeof say about the struct the compiler emitted, so it cannot drift from the layout; nothing is built at run time. Fields are numbered in declaration order from 0, the index a std.observe field observer receives for the same field.
- Field attributes. Any number of
@nameor@name(arg, ...)after a field's type. The compiler does not interpret them; the table carries them for the program to read (reflect.attr_float(s, field, attr, arg)). An argument is a literal: a number (optionally negative; decimal,0x,0oor0b), a string,trueorfalse. A field names each attribute once, and attributes on a struct without@derive(schema)are an error, since nothing would carry them. - Nested structs. A field that holds an Aether struct by value, points at one (
*T), or is an array or slice of one names that struct's table (reflect.field_schema), whether or not that struct derives schema itself. A struct that points at itself names its own table. - Strings. A
stringfield owns a reference to the string it holds, so a generic writer releases the one there and stores one it took a reference to, as a compiledx.label = ...does. - Modules. The derive pass runs after imported modules are merged, so a module's own functions call
T_schema()by its bare name, and an importer calls it asmodule.T_schema()when the module lists it inexports. A derived function is named after the struct as written, so of two modules' same-named structs only one can derive (a second is an error naming both).
@derive(schema) combines with @derive(eq) (@derive(eq, schema)) and with @observable. The table's C layout is runtime/aether_schema.h, for a host that reads it without Aether.
Built-in Functions
I/O
| Function | Description |
|---|---|
print(value) | Print to stdout (no newline) |
println(value) | Print to stdout followed by a newline |
print_char(code) | Print a single character by ASCII/Unicode code point |
String interpolation is supported inside double-quoted strings using ${expr}:
name = "Alice"
age = 30
println("Hello, ${name}! You are ${age} years old.")
What renders. A value has a string rendering when it is a number (every width, signed or not, and float), a bool, a string, a duration or an enum member (its numeric value). One rule decides it for every place that renders a value: a ${...} segment, each argument of println and print, and the arguments of print's format form. Anything else is error E0200 before C, with the spelling that renders it: a struct (render a field, ${p.x}), a tuple (destructure it first, a, b = t), a call returning several values, a sum (match on it), an optional (give it a value, ${v ?? 0}), an array (an element), a function (call it), and a ptr, which may point at anything (a list, a buffer, a struct): one that holds an Aether string is read as one with string.from_ptr(p), one that holds C text is copied with string.from_cstr(p), and a container's string is read with its typed getter (list.get_string, map.get_string), as in A pointer given to a string local. Every std function that makes a string returns a string (string.from_int, string.pad_start, string.new_with_length and the rest), so none of them needs a conversion.
The text around the ${expr} parts takes the same escapes as a plain string literal, with the same meaning wherever they sit: "a\\0b ${n}" holds a backslash followed by 0b, "a\0b ${n}" holds a NUL byte that the result keeps, and \${ writes a literal ${ rather than starting an interpolation. A ${s} segment writes the string s by its length, so a value holding a NUL goes in whole; print and println write a string value the same way.
An interpolated string used as a value is a fresh, counted string, which the place it is stored in owns (String ownership):
msg = "Hello, ${name}!" // msg owns the new string
tcp.write(conn, msg) // borrowed for the call; msg still owns it
Used directly inside print/println, it compiles to a printf call, with no allocation. print flushes stdout after it writes, interpolated or not, so partial-line output appears at once and survives a process that is killed or leaves through _exit; println leaves stdout's buffering to the C library.
Evaluation order. The ${expr} segments of one interpolated string are evaluated left to right, in source order, on every C compiler and optimisation level. A segment with a side effect sees the effects of the segments before it, and a plain read beside such a segment sees the value from its own position in the string:
n = 0
bump() -> int { n = n + 1; return n }
println("${bump()} ${bump()} ${bump()}") // 1 2 3
println("${n} ${bump()} ${n}") // 3 4 4
The guarantee costs nothing when no segment has a side effect: those interpolations pass their segments straight to the formatting call. Once a segment calls a function, sends a message, or reads varargs, the compiler evaluates every segment into a temporary in source order first, so the order the C compiler chooses for the call's arguments can no longer show. A segment that writes a variable (${i++}, ${n = n + 1}) another segment uses is evaluated ahead the same way.
The same holds for every other list of operands: a call's arguments (a module call's too, and named arguments in the order written), the two sides of a binary operator, the indexes of an assignment's target and then its value, the fields of a struct literal or a message, the elements of an array literal, and the values of a multi-value return are evaluated left to right. An operand is evaluated first, into a temporary, when a later one depends on it: one writes a variable the other uses (f(i++, i), i++ + i++, arr[i++] = i), or one makes a call that can change what the other reads. A closure literal reads the variables it captures where it stands, so f(i++, || { return i }) makes the closure after the step. A call can change a module global, a variable it shares with a closure it runs, memory it is handed by reference, and memory it reaches through a pointer or a slice (a field of the struct a global pointer points at, say), so an operand that reads memory through one, itself or in a call it makes, and a call that writes through one are evaluated in source order, whichever comes first; what a function of the program reads and writes is read off its body and the bodies of the functions it calls, mutually recursive ones included. A call whose body the compiler cannot see is opaque: a C extern, a C function pointer, a closure, a message send, and a function of the program that makes such a call. An opaque call may write anything, so it is evaluated ahead of every later operand that calls anything, reads a module global or a variable shared with a closure, or reads memory through a pointer, a field or an index. A list with no such pair compiles as written: operands that read only plain locals and literals stay inline, beside a call or not. An array literal stored into an array that already exists is evaluated whole before it is stored:
pair(a: int, b: int) -> int { return a * 10 + b }
main() {
i = 0
println("${pair(i++, i)}")
a = [1, 2, 3]
a = [a[2], a[1], a[0]]
println("${a[0]} ${a[1]} ${a[2]}")
arr = [0, 0]
k = 0
arr[k++] = k
println("${arr[0]} ${arr[1]}")
}
1
3 2 1
1 0
The target of a compound assignment (a[i] += v) is read and then written, so a write inside it (a[i++] += v) is refused rather than run twice.
Heredoc strings
<<MARKER … MARKER captures a multi-line literal string, no ${} interpolation and no escape processing, so it's ideal for embedding another language's source verbatim (SQL, a contrib.host.* snippet) without escaping the guest's own quotes. The heredoc only triggers when << is immediately followed by an identifier; 1 << 4 stays the left-shift operator.
sql = <<SQL
SELECT id, name
FROM users
WHERE active = 1
SQL
The body runs from the line after <<MARKER to the closing-marker line: a line that is, in full, optional leading whitespace followed by exactly MARKER and then the end of the line. The closing marker may be indented, it does not have to sit at column 0, but its indentation must be at or below the shallowest body line (the terminator lives at the content's base level). Windows \r\n is normalized to \n, and the single newline immediately before the closing marker is dropped (it's syntax, not content).
Because the terminator sits at the content's base level, a body line that is more indented than the rest but happens to read exactly like the marker is body content, not a terminator, so it can never silently truncate the string. (Conversely, a lone marker indented past the body matches nothing and the heredoc is reported as unterminated, rather than dropping content. Put the closing marker at column 0, or aligned with the body's base indent, and it always closes.) The marker must also be alone on its line: done MARKER and xMARKER stay content.
Common-indent dedent. The longest run of leading whitespace shared by every non-blank line is stripped, so a heredoc can be indented to match its surrounding code without that indentation leaking into the string:
describe() -> string {
return <<TEXT
line one
line two
TEXT
}
// → "line one\n line two" (common 8-space prefix removed; the relative
// 2-space indent of "line two" is preserved)
Rules:
- Blank / whitespace-only lines don't constrain the common prefix (a single blank line won't force the prefix to zero), but they're still emitted.
- The prefix match is character-exact: if one line indents with spaces and another with a tab at the same column, that's a disagreement and the strip stops there, Aether never shifts past a column where lines differ. To keep a literal common indent in the string, indent one line one notch less than the rest (so the common prefix is shorter than the indent you want kept).
- A line indented less than the common prefix loses what leading whitespace it has and lands at column 0.
Timing
| Function | Description |
|---|---|
clock_ns() | Returns current time in nanoseconds (long) |
sleep(ms) | Pause execution (milliseconds) |
Concurrency
| Function | Description |
|---|---|
spawn(ActorName()) | Create actor instance |
wait_for_idle() | Wait for all actors to finish |
Environment & Process
| Function | Description |
|---|---|
getenv(name) | Get environment variable (returns string) |
atoi(s) | Convert string to int |
exit(code) | Terminate program with exit code (defaults to 0) |
Compiler Errors
An error stops the build and prints its code, error[E0301]: ..., with the source line and a help: line. The codes:
| Code | Kind |
|---|---|
E0100 | Syntax: the source does not parse, or a reserved word stands where a name must |
E0200 | Type mismatch, and the other type rules (a cast the types cannot justify, string + string, a match arm or switch case that can never equal the value it compares, an arm that gives no value to a match whose value is used: Match Expressions) |
E0201 | A fixed-size array as the result of a closure, an extern or a function type, alone or in a tuple (a named function returns one) (What a return gives) |
E0202 | A bare ptr given to a string local, which would read it as text whatever it points at (A pointer given to a string local) |
E0300 | Undefined variable, and a name in a match arm or switch case that is no variable, constant or member |
E0301 | Undefined function, a function of a module the calling module does not import, or a function a local of its name hides (a local's scope) |
E0302 | A type name that names no type of the program, as a local's, a parameter's or a field's type (Shape s with no Shape; x and y, where AND is &&) |
E0303 | A name the module does not export |
E0304 | A name hidden in this scope by hide or seal except (hide and seal) |
E0305 | A module exports two names that answer to one short name, add beside aether_set_add or join beside string_join: one name per operation (module-system-design.md) |
E0600 | An actor rule, such as a closure in a handler writing the actor's state, a send of a message the target actor has no arm for, a second _ arm, or a send its protocol does not allow |
E0700 | Missing return: control can reach the end of a function whose result is not void (Returning on every path) |
E0701 | A select() with no value for a platform (linux:, windows:, macos:) and no other: fallback (named-args-and-select.md) |
E0800 | A construct a quote cannot hold: a closure, callback or block inside one, defer, try, panic, an actor operation, match, string interpolation, new, an array literal (quote.md) |
E0801 | A quote parameter without a type |
E0802 | A quote given to a function whose last parameter is not a quote.Tree, or a closure, callback or block given to one that is |
E0803 | A quote writing a variable it captures, or taking its address |
E0804 | A @quotable function that reaches itself through @quotable calls |
E0805 | A @quotable function reading a variable outside it |
E0900 | A slice of a function's own storage (an array literal, an array of its own) leaves the function: returned, stored in a module-level var or in a field that outlives it, or captured by a closure that does (Slices) |
E0901 | A function frees a string or *StringSeq parameter it only borrows (String ownership) |
E0902 | A function keeps a ptr parameter not declared @keep, an @into parameter reaches anything but the function's result, a call's result that holds a borrowed ptr parameter (through an @into argument) escapes, @into is declared where it cannot apply (not a bare ptr, a result that cannot hold a pointer, beside @keep, @retain or @noescape), a string is given to a @keep ptr or an @into ptr without string.keep (a *StringSeq without string.seq_retain), a string C memory viewed as string[] would read after its owner frees it (one built in the statement, or any but a pinned literal where the memory outlives the function), or a function with a @keep or @into parameter goes where a call through it would be checked as borrowing (a bare fn, a closure type with a weaker mode, a ptr, a typed C function pointer, an as fn(...) cast) (String ownership) |
E0903 | A local used or freed again after it was freed on every path to that point, a free a loop repeats on its next turn, or a view (a slice, a cast, an @into call's result) read after its storage was freed or may have moved (String ownership) |
E0904 | A function's @frees parameter not freed (or found null) on every path, or bound to something else first, or a @moves parameter no call may move, or either mode where it does not apply |
E0905 | An owned pointer (type T = owned ptr drop f) given a second holder without move, freed or kept behind its holder's back, moved into a function, held where it cannot be released (a struct field, an element, a module var, a closure, an optional), or a drop function that does not free what it is given (Owned pointers) |
E1000 | A file defines a function under a name it also imports selectively (import std.string (length) beside its own length), so a bare call in the function's body would reach itself (module-system-design.md) |
Limits
The compiler counts two things, and passing either is E0100 naming the limit: nesting, of parentheses, blocks, else if chains and other constructs inside one another, 512 deep; and a chain on one level, the operators of a + b + c ... or the postfix operations of a.b.c ..., x[i][j] ... and calls, 1000 long. Split a deeper or longer one with functions or intermediate locals. Nothing else is counted: a file holds any number of declarations, a block any number of statements, a switch any number of cases and a match any number of arms.
A Pointer Given to a String Local [E0202]
A ptr is untyped, so a string local re-bound to one (s = map_get_raw(m, k), or a tuple slot, name, err = map.get(m, k)) would read it as text whatever it points at: a list, a struct, or bytes with no end. That is refused:
import std.map
main() {
m = map.new()
map.put(m, "name", "Ada")
name = ""
name, _ = map.get(m, "name")
println(name)
}
// error[E0202]: 'name' is a `string`, and a `ptr` given to it would be read
// as text whatever it points at
// help: say it is a string with `map.get_string(m, key)`
Say the value is a string with the typed accessor, which also checks it is one:
import std.map
import std.list
import std.string
main() {
m = map.new()
map.put(m, "name", "Ada")
name, err = map.get_string(m, "name")
println("${name} [${err}]")
l = list.new()
inner = list.new()
list.add(l, inner)
s, serr = list.get_string(l, 0)
println("${s == null} ${serr}")
p = map_get_raw(m, "name")
t, _ = string.from_ptr(p)
println(t)
list.free(inner)
list.free(l)
map.free(m)
}
Ada []
true not a string
Ada
map.get_string, list.get_string and string.from_ptr each return (string, string): the string, or null with "not a string" when the pointer is something else. C text (a char* with no Aether header, as string.string_array_get hands back) is copied with bytes.string_from_ptr(p, n) or string.concat(p, ""). A ptr result of a string.* function (string.from_int) is a string already and is accepted. This was the warning W1005 in 0.830.0 and 0.831.0.
Compiler Warnings
The compiler emits structured warnings for common issues:
Unused Variables [W1001]
Variables declared but never referenced produce a warning. Prefix with _ to suppress:
main() {
x = 42 // warning[W1001]: unused variable 'x'
_unused = 42 // no warning, intentional discard
y = 10
println(y) // y is used, no warning
}
Unreachable Code [W1002]
Code after a statement that control never goes on from is flagged: return, break, continue, panic, exit(n), an if/else or match every path of which leaves, or a loop nothing ends. The warning names what the code follows (unreachable code after break). The rule is the one the missing return check, the or { } handler and a value match arm use:
check(x: int) -> {
if x > 0 { return 1 }
else { return 0 }
println("never reached") // warning[W1002]: unreachable code
}
An if whose condition is a constant does not make the code after it unreachable: a flag that disables code on purpose (const DEBUG = false, then if !DEBUG { return }) is not reported. A loop on a constant still counts, since while true with no break never ends. A module var is never a constant, whatever it starts as: the compiler does not fold it in a condition, a requires or a case (#3026).
Constant-Expression Overflow [W1003]
An int expression whose operands are all constants is folded at compile time. When its exact value does not fit 32 bits, the fold wraps exactly as the runtime would and reports what happened:
main() {
cv = (250000000 - 200000000) * 50 / 225000000
// warning[W1003]: int constant expression overflows 32 bits: the
// exact value is 2500000000, which wraps to -1794967296 at runtime.
}
Folding wraps rather than keeping the wide value on purpose: otherwise the constant form and the identical expression written over int variables would disagree, and the literal form (the one you reach for while developing) would look correct while production code silently overflowed. Widen a term to keep the value:
long span = 250000000 - 200000000
cv = span * 50 / 225000000 // 11, no warning
A long expression is folded in 64 bits the same way, exactly, so 9007199254740993 + 0 keeps its low bit, and past LLONG_MAX it wraps with long constant expression overflows 64 bits and wraps to … at runtime.
The runtime is held to the same rule. Aether compiles to C, where signed overflow is undefined behaviour rather than a wrap, so every command line the toolchain uses to compile generated code carries -fwrapv: ae run, ae build at any optimisation level, the cross and wasm backends, and ae cflags for build systems that compile aetherc output themselves. Two's complement wrapping is the specified behaviour of int, not an artifact of the optimiser: an LCG, a hash, or a checksum written in Aether computes the same values on every target.
Use ae check file.ae to see warnings without compiling. It skips codegen and linking, so iteration is much faster than ae build. The two run the same checks: every function of every module the file imports is type-checked, whether or not anything calls it (#2613), and each module may only use the modules it imports itself (#2614). ae check lib/mylib/module.ae checks one module on its own, under the namespace a build gives it (mylib, so its own mylib.f() resolves, #2631); building any program that imports it checks it just as strictly.
Unreachable Match Arm [W1004]
match tries its arms in source order, so an arm that an earlier arm already covers can never run. Two shapes are reported: an arm below a _ catch-all, and a second arm for a case already handled.
main() {
x = 2
match x {
1 -> { println("one") }
2 -> { println("two") }
2 -> { println("never runs") }
// warning[W1004]: unreachable match arm: this case is already
// handled on line 5
_ -> { println("other") }
}
}
The check reports only where the shadowing is certain. The arms it compares with each other are literals, and over an enum, a sum type or an optional, where the cases are known, members (bare or qualified, Red and Color.Red being one case), variants and none / some(v) too. An arm that is a comma-list is reported when an earlier arm already names every one of its values. Over any other value a bare identifier arm is never compared: it names a variable already in scope (a local, a parameter, or a module const or var) and matches when the value equals that variable's value at the time, as if x == limit would. It binds nothing. That value is not known when the program is compiled, so an arm after it with the same value is not reported:
main() {
limit = 2
x = 2
match x {
1 -> { println("one") }
limit -> { println("at the limit") } // compares x with limit
2 -> { println("two") } // never runs, not reported
_ -> { println("other") }
}
}
at the limit
Over an enum a bare identifier names a member (Red for Color.Red) and over a sum type a variant; a variable is not accepted there. The arms that bind a name are some(v) over an optional, the list patterns ([h|t], [x, y]) and the struct patterns (Pt { x: a }). An identifier that names no variable binds nothing either, and is an error at the arm. To act on every other value, write _ and use the value matched on:
main() {
x = 5
match x {
1 -> { println("one") }
n -> { println("got ${n}") }
}
}
// error[E0300]: 'n' names no variable: a match arm compares the value
// matched with what it names, and binds no new name
// help: write `_` to take every other value, and use the value matched
// itself in the arm
A third shape is the mirror of the exhaustiveness check: a _ arm on a match over a sum type or enum where every case is already handled.
enum Direction { North, East, South, West }
match d {
Direction.North -> { println("N") }
Direction.East -> { println("E") }
Direction.South -> { println("S") }
Direction.West -> { println("W") }
_ -> { println("?") }
// warning[W1004]: every member of enum 'Direction' is already
// handled, so the `_` arm cannot run
}
Removing the catch-all is worth doing rather than silencing: with it gone, adding a member later becomes a compile error from the exhaustiveness check instead of quietly falling into _. A _ over a partially-handled enum is doing real work and is never reported.
This pairs with the exhaustiveness check, which reports the opposite direction, a match over a sum type or enum that is missing a case.
A Module Named Twice [W1006]
ae check warns on a call that names its module twice where the short form reaches the same function. A module's extern is spelt with the module's prefix, json_free, and json.free(v) reaches it; json.json_free(v) says the module twice. A module exports one name per operation (E0305), so the short form is always the same function:
import std.json
main() {
v, _ = json.parse("[1]")
json.json_free(v)
// warning[W1006]: `json.json_free` names its module twice: `json.free`
// is the same function
// help: write `json.free`, or run `ae fmt --short-names main.ae` to
// rewrite every such call
}
ae fmt --short-names rewrites every such call of the files it is given, and only those (formatter.md). A long name whose short form would start with a digit (sha3.sha3_256_hex) has no short form and is not reported. ae build and ae run do not report it.
Match Expressions
match can be used as a statement or as an expression:
// Statement, executes the matching arm
match status {
0 -> println("ok")
1 -> println("warning")
_ -> println("error")
}
// Expression, assigns the matching arm's value
msg = match status {
0 -> "ok"
1 -> "warning"
_ -> "error"
}
println(msg)
Arms are tried in source order and the first that matches runs. An arm is one of these patterns:
| Pattern | Matches | Over |
|---|---|---|
0, -1, 2.5, true, "stop" | a value equal to the literal; a string compares by content | a value of the literal's type |
limit, LIMIT | a value equal to that variable or constant at the time; the arm binds nothing | a value of the variable's type |
1..=9, 0..<10 | a value in the range, ..= inclusive and ..< half-open | an integer, a float or an enum |
10, 20, 30 | a value equal to any of them; each may be a value or a range | as its values |
_ | every value; an arm of its own, last | anything |
Red, Color.Red | that member, bare or qualified; every member needs an arm, or _ | an enum |
Circle | that variant, and inside the arm the value matched is that struct; every variant needs an arm, or _ | a sum type |
none, some(v) | no value, or a value bound to v for the arm | an optional |
[], [x], [a, b], [h\|t] | a list of that length ([h\|t]: at least one), binding its elements | an array, a slice or a *StringSeq |
const LIMIT = 100
classify(n: int, lucky: int) -> string {
return match n {
0 -> "zero"
lucky -> "lucky"
LIMIT -> "the limit"
1..=9 -> "a digit"
10, 20, 30 -> "a round ten"
_ -> "something else"
}
}
main() {
println(classify(0, 7))
println(classify(7, 7))
println(classify(100, 7))
println(classify(4, 7))
println(classify(20, 7))
println(classify(55, 7))
command = "stop"
answer = match command {
"start" -> "starting"
"stop" -> "stopping"
_ -> "unknown"
}
println(answer)
}
zero
lucky
the limit
a digit
a round ten
something else
stopping
Over an enum, a sum type or an optional the cases are known, so a bare name is a member or a variant, and a missing one is a compile error unless _ takes the rest:
enum Color { Red, Green, Blue }
struct Circle { r: int }
struct Square { side: int }
type Shape = Circle | Square
warmth(c: Color) -> string {
return match c {
Red, Green -> "warm"
Color.Blue -> "cool"
}
}
area(s: Shape) -> int {
return match s {
Circle -> 3 * s.r * s.r
Square -> s.side * s.side
}
}
describe(m: int?) -> string {
return match m {
none -> "nothing"
some(v) -> "got ${v}"
}
}
main() {
println(warmth(Color.Green))
println(warmth(Color.Blue))
println(area(Circle { r: 2 }))
println(area(Square { side: 3 }))
let empty: int? = none
println(describe(empty))
println(describe(5))
}
warm
cool
12
9
nothing
got 5
List patterns take a fixed-size array, whose length is known when it is compiled, a slice, which carries its length, or a *StringSeq (a bare pointer takes its _len variable, List Pattern Matching):
describe(xs: int[]) -> string {
return match xs {
[] -> "empty"
[x] -> "one: ${x}"
[a, b] -> "two: ${a} ${b}"
[h|_] -> "starts with ${h}"
}
}
main() {
all = [4, 5, 6]
println(describe(all[0..0]))
println(describe(all[0..1]))
println(describe(all[0..2]))
println(describe(all))
}
empty
one: 4
two: 4 5
starts with 4
Each value an arm names must be one the value matched can equal, and the compiler says so at the arm (E0200): a string arm over an int, none or some(v) over a value that is not an optional, a range over a string, a list pattern over a scalar, or _ inside a comma-list. A match over an optional takes only none, some(name) and _ arms, and one over a sum type one variant per arm or _. A name that is no variable, constant or member is E0300 at the arm: an arm compares, it never binds a new name (_ takes every other value).
When the value of a match is used (bound, returned, or the value of an arm of such a match), every arm that ends in a value gives the match its value. An arm that ends in a call of a function that returns nothing has none, and is an error at the call. An arm that ends in a statement, such as a return, gives no value and is accepted:
note(x: int) { println("note ${x}") }
pick(n: int) -> int {
return match n {
1 -> note(1)
_ -> 5
}
}
main() {
println("${pick(2)}")
}
// error[E0200]: 'note' returns no value, so this arm gives the `match` none,
// and the value of the `match` is used
// help: give the arm a value: a block arm can make the call and end with one,
// `{ note(...) 0 }`
A match is an expression wherever a value goes, not only bound or returned: an operand (total = total + match k { ... }), an argument, a condition (an if, a while, which evaluates it on every pass, the right side of &&, which runs it only when the left side holds), an interpolation, the subject of another match, or an arm of one. It runs where the expression reaches it, in the order the expression is written. A match used this way has no variable or return to fall back on, so it must give a value on every path that reaches the expression: it runs an arm for every value (a _ arm, or arms that cover the subject's type), and each arm ends in a value or leaves with return, break, continue or panic (E0200 otherwise).
label(k: int) -> string {
return "k${k}"
}
main() {
total = 0
for k in 0..4 {
total = total + match k {
0 -> 10
1, 2 -> 5
_ -> 1
}
}
println("total ${total}")
if match total { 21 -> true _ -> false } {
println("as expected")
}
for k in 0..2 {
println("${match k { 0 -> "zero" _ -> label(k) }} and ${2 * match k { 0 -> 3 _ -> 4 }}")
}
}
total 21
as expected
zero and 6
k1 and 8
Keywords
The following identifiers are reserved:
| Keyword | Purpose |
|---|---|
if, else | Conditionals |
while, for, in, break, continue | Loops |
return | Function return |
match, switch, case, default | Pattern matching / dispatch |
when | Guard clauses, when defined(...) regions |
defer, try, catch, panic | Scope-exit cleanup and panics |
requires, ensures | Function contracts |
actor, receive, spawn, spawn_actor, send, reply, after, self | Actor system |
state | Actor state (only reserved inside actor bodies) |
message, struct, union, builder, func, callback | Definitions and trailing-block forms |
let, var, const | Bindings, module-level globals, constants |
import, as, export, exports, module, extern | Modules and C interop |
hide, seal, except | Scope-level name denial (see hide-and-seal.md) |
null, true, false | Literals |
main, print, make | The entry point and two builtins |
int, long, uint64, float, bool, byte, string, ptr, Duration, actor_ref, ActorRef, Message | Type names |
none is not a keyword but cannot be declared as a name either: it is the empty optional's literal (Optionals). enum, type, fn and cfn are recognised where a declaration starts and are ordinary names elsewhere.
Note: state is context-sensitive, it is a keyword only inside actor bodies. In all other code, state can be used as a regular variable name.
Note: move is not reserved. It is the operator that hands a ptr over (r ! Chunk { data: move buf }) only before a name on the same line (see isolated.md); fs.move(a, b), mem.move(dst, src, n), a program's own move() and a variable called move are unaffected.
Note: quote is not reserved. It quotes a trailing block only straight after a call's ) on the same line, followed by |, ||, { or -> (see quote.md); everywhere else it is an ordinary name, and std.quote is a module called quote.
Note: ptr, byte, func, state and after are usable as ordinary value identifiers, parameter names, local names, struct field names, struct-literal fields, and field-access targets, without the backtick escape. These tokens have meaning only in type position (ptr/byte), as a declaration head (func), or as a statement head (state/after), none of which overlaps a value position, so a bare ptr/byte/func/state/after in value position is unambiguously a name:
add(ptr: int) -> int { return ptr + 1 } // `ptr` is the parameter name
struct Node { func: int after: int } // keyword-spelled field names
main() { let byte = 7 println("${byte}") } // keyword-spelled local
Two members of that family stay reserved in value position: match (it heads a match expression, so match as a bare value is genuinely ambiguous) and union (a C keyword, a value named union could not be emitted as valid C). For those, rename or use the backtick escape below. As a type keyword, byte/ptr still works in type position too: byte b declares b of type byte (the <type> <name> C-style form), while byte: int / byte in name position is the name.
Note: void is not a reserved word, there is no void token. It is a plain identifier used by convention to spell the absence of a return value (e.g. extern free(p: ptr) -> void); a function that omits its -> Type annotation is the canonical void declaration. See Functions.
Raw identifiers, using a keyword as a name
A backtick-delimited identifier, ` name `, is always lexed as an ordinary identifier, bypassing keyword reservation. This lets a reserved word be used verbatim wherever an identifier is expected, parameter, local, struct-field, message-field, or function name:
struct Robj {
`reply`: int // `reply` is a keyword; the backtick escape keeps it
`message`: int
}
addReply(`reply`: int, `after`: int) -> int {
`ptr` = `reply` + `after`
return `ptr`
}
The escaped text is the name (` reply is the identifier reply), so a raw identifier and the plain spelling refer to the same binding. The primary use is keeping a C→Aether port faithful: C APIs routinely use names like reply, message, and when` that collide with Aether keywords.
(For the common port collisions ptr, byte, func, state and after, the backtick escape is not required, they are accepted bare as value identifiers, see the note under Keywords above. The escape is still the way to use a fully-reserved keyword such as reply, message, when, match, or union as a name.)
Writing an unescaped reserved keyword where a name is expected is an error (error[E0100]: '<kw>' is a reserved keyword …) whose hint points to both the rename and the ` <kw> ` escape.
Module paths are the exception: a segment there is a name and nothing else, so import std.message needs no escape. Calling through the bare module name still would (message.format(...) puts a keyword in expression position), so import the symbols you need by name, or give the module an alias (Import with Alias):
import std.message (format, catalog_new) // format(...), catalog_new(...)
import std.message as msg // msg.format(...)
Source Files
A source file is UTF-8 text. A file may start with the UTF-8 byte order mark (the bytes EF BB BF, U+FEFF), as some Windows editors save one: it is no part of the program, and the compiler, ae check, ae fmt (which keeps it where it found it), the language server and every import read the file as starting after it. Anywhere else U+FEFF is a character like any other, allowed only in a string or a comment.
Outside strings and comments, a character that begins no token is E0100, naming it: unexpected character '“' (U+201C) outside a string or a comment for a typographic quote pasted in place of ", a byte order mark inside the file, or bytes that are not UTF-8.
Comments
// Single-line comment
/* Multi-line
comment */
Conditional Compilation
Two axes: the platform, which the C compiler knows, and build symbols, which the build declares. Both are resolved at compile time.
Build symbols
A symbol is present or absent. It carries no value.
ae build -D TEST_SERVER app.ae # or -DTEST_SERVER
ae run -D TEST_SERVER app.ae # run, check and test take the same flag
aetherc -D TEST_SERVER app.ae
# aether.toml, for symbols the project always wants
[build]
defines = "TEST_SERVER VERBOSE"
ae run, ae build, ae check and ae test all read [build] defines, from a subdirectory of the project as well, so the same file is the same program under each of them. A command-line -D adds to what the file declares. A symbol must be an identifier: letters, digits and underscore, not starting with a digit. -D NAME=value is rejected rather than accepted and ignored, because a symbol has no value to carry.
when regions
when defined(NAME) { … } guards a group of declarations or a group of statements, with an optional else:
when defined(TEST_SERVER) {
driver_port() -> int { return 9222 }
start_driver() { println("driver on ${driver_port()}") }
} else {
start_driver() { println("driver absent") }
}
main() {
start_driver()
when defined(VERBOSE) {
println("verbose")
}
}
Conditions combine with !, &&, || and parentheses:
when defined(A) && !defined(B) { … }
when (defined(A) || defined(B)) && !defined(C) { … }
A losing region is dropped, not disabled. It is removed from the program before type checking, so nothing it names has to exist, and its symbols are not in the binary: nm on a build without TEST_SERVER finds no driver_port. That is the difference between this and wrapping code in a runtime if, which still compiles and links every line of the subsystem you meant to leave out.
A dropped import takes its native dependencies with it. The effect is not limited to your own symbols. A module declares its native libraries with @link, and those flags reach the linker only when the module is in the resolved import closure, so guarding the import removes the C library too, transitively and at any depth:
when defined(WITH_SQLITE) {
import contrib.sqlite
...
}
Built without WITH_SQLITE, the binary does not link libsqlite3 at all: ldd does not list it. That is usually the reason to reach for when in the first place: the measured difference on this example is 4.4x. A C file the module ships (@source("lanes.c")) is dropped the same way: it is compiled into the program only while the module is in the closure. See docs/module-system-design.md for how @link and @source propagate.
The corollary is that a disabled region is not type-checked. Code you never build is code you never check, so a symbol that is off in every CI configuration will rot. Build each configuration you ship.
select for values
select chooses a value rather than a region. Its keys are the platforms plus any build symbol:
port = select(DEV_MODE: 8080, other: 80)
tag = select(linux: "linux", macos: "macos", windows: "windows", other: "other")
A build symbol wins outright when it is set, since it is known at compile time; otherwise the platform decides, and other: is the fallback. Every branch must yield the same type, and a mismatch is an error rather than a surprise on whichever platform compiles the odd branch.
Use select for a value and when for anything larger: select cannot remove a declaration, and both of its branches still compile.
If you are trying to keep something out of the binary, select is the wrong tool. It picks between values that both survive to codegen; it cannot guard an import or a declaration, so it never removes a symbol or a native dependency. when is the construct that does that.
Compilation
Using the CLI
ae run program.ae # Compile and run (fast, -O0)
ae build program.ae -o out # Compile to optimised executable (-O2 + aether.toml cflags)
ae init myproject # Scaffold a new project
ae test # Discover and run .ae test files
ae cache # Show build cache info
ae cache gc # Trim to the limit (AETHER_CACHE_MAX_MB, default 5120)
ae cache clear # Purge build cache
ae run and ae build also accept:
# Include extra C source files (e.g. FFI helpers, renderer backends)
ae build main.ae -o app --extra src/ffi.c --extra src/renderer.c
# Multiple --extra flags are additive; also merged with extra_sources from aether.toml
Using the Compiler Directly
# Compile to C
aetherc program.ae output.c
# Emit a C header for embedding Aether actors in a C application
# Generates message structs, MSG_* constants, and spawn function prototypes
aetherc program.ae output.c --emit-header
# Print parsed AST (for debugging, no code generation)
aetherc --dump-ast program.ae
Type System
Aether uses static typing with type inference: an Aether function or local needs no annotation, and accepts one anywhere. An extern is the exception, below.
Inference rules
- Local variables: Inferred from their initializer (
x = 42→int) - Function parameters: Inferred from call sites across the whole program, including through deep call chains (
main → f → g → h) - Return types: Inferred from
returnstatements and arrow-body expressions; a function that returns a value on one path must return one on every path, as one with a written type must (E0700) - Constraint solving: Iterative constraint propagation handles complex interdependencies
Type annotations are optional
// All three are equivalent:
add(a, b) { return a + b; } // fully inferred from call sites
add(a: int, b: int) { return a + b; } // explicit
add(a, b: int) { return a + b; } // mixed
Annotations are useful for documentation or when the type cannot be determined from call sites alone (e.g. a function that is never called, or an extern parameter).
extern requires annotations
The compiler cannot infer types of external C functions, parameter types must be declared explicitly:
extern malloc(n: int) -> ptr
extern free(p: ptr) -> void
Explicit types are optional but can improve clarity:
// Both are valid:
x = 42
int y = 42
Example Programs
Hello World
main() {
println("Hello, World!")
}
Hello, World!
Factorial with Pattern Matching
factorial(0) -> 1
factorial(n) when n > 0 -> n * factorial(n - 1)
main() {
println(factorial(10))
}
3628800
Counter Actor
message Increment {
amount: int
}
message GetCount {}
actor Counter {
state count = 0
receive {
Increment(amount) -> {
count = count + amount
}
GetCount -> {
println(count)
}
}
}
main() {
c = spawn(Counter())
c ! Increment { amount: 5 }
c ! Increment { amount: 3 }
c ! GetCount {}
wait_for_idle()
}
8
Resource Management with Defer
extern fopen(path: string, mode: string) -> ptr
extern fclose(file: ptr) -> int
process_file(path: string) {
file = fopen(path, "r")
defer fclose(file)
// Process the file...
// fclose runs when the function returns
}