Luce Base
LuceSourceLuciaOS

5. Types

Base is statically and nominally typed. Every expression has one type. Inference is local: from an initialiser to its binding, from arguments to generic parameters, from a declared result into return, from context into literals and none. It never crosses a public signature, which always spells its types. There is no subtyping. The implicit conversions are exactly these:

  • an integer to a wider integer of the same signedness (§7.5);
  • a pointer to a more-qualified pointer of the same pointee (T* to const T*, volatile T*, or both);
  • any object pointer to void*, and any read-only object pointer to const void*;
  • a mutable T[N] to T[], any T[N] to const T[], and T[] to const T[];
  • a string literal, or a str produced by format, to c.str;
  • an ASCII character literal to u8, and to an index;
  • T to T?, and T to a successful T!;
  • a named non-fallible function to the corresponding fallible function type, and a named function that reads through a const T* parameter to the function type taking T* there (§9.4);
  • a pointer to a conforming type to an interface view (§14.3).

Everything else is written.

5.1 Scalars#

TypeMeaning
booltrue or false; one byte holding 0 or 1; no numeric conversion
u8, u16, u32, u64unsigned integers
i8, i16, i32, i64two's-complement signed integers
usize, isizeunsigned and signed integers the width of a pointer on the target
f16, f32, f64IEEE 754 binary floats
charone Unicode scalar value
unitthe single value () of a function that returns nothing
neverthe type of an expression that cannot complete: error(...), trap(...), a function that never returns

usize and isize are the types of memory.size_of, memory.align_of, memory.offset_of, span lengths, array indices, and pointer differences. Their width is that of the target: 64 bits on every target (§19.5). The compiler always compiles for one target, so a usize expression built from literals, memory.size_of, and arithmetic is a constant: it may be a top-level let, an array length, and the condition of a module-level assert (§11.6). The shared intermediate representation carries such a constant symbolically and the backend folds it, so the representation stays target-neutral while the source does not have to.

never coerces to any type because the value never exists. Operands before a never operand are still evaluated, left to right.

5.2 The c module#

C's types live in the standard c module, so that a C signature can be written exactly and luce bind (§17.5) never has to guess a width. A c type whose width and signedness are the same on every supported target is an alias of the Base type with that width, and either spelling may be used. A c type whose width or signedness varies between targets is a distinct nominal type with explicit conversion, so that code cannot depend on one target's answer by accident.

c typeC typeBase typeWidth and signedness
c.int, c.uintint, unsigned intalias of i32, u3232 bits everywhere
c.short, c.ushortshort, unsigned shortalias of i16, u1616 bits
c.schar, c.ucharsigned char, unsigned charalias of i8, u88 bits
c.longlong, c.ulonglonglong long, unsigned long longalias of i64, u6464 bits
c.float, c.doublefloat, doublealias of f32, f6432 and 64 bits
c.bool_Boolalias of bool1 byte, 0 or 1
c.size, c.uintptrsize_t, uintptr_talias of usizepointer width
c.ssize, c.ptrdiff, c.intptrssize_t, ptrdiff_t, intptr_talias of isizepointer width
c.charchardistinct8 bits, signed
c.long, c.ulonglong, unsigned longdistinct64 bits on SysV and AAPCS64 targets, 32 on Windows x64
c.wcharwchar_tdistinct32 bits except on Windows, where it is 16
c.va_listva_listopaquemay only be passed through to C
c.strconst char*distinctNUL-terminated text of unknown encoding (§5.5)

A binding therefore reads w: i32, h: i32, flags: u32, and uses c.long or c.char only where C's width varies. long double and _Complex have no Base type; luce bind refuses them and names the shim recipe. c.long(x) from i64 is a checked conversion; c.str is const c.char*.

Why aliases. A distinct c.int would make every binding say c. on every parameter for no information: int is 32 bits on every target Base supports. The distinct types are kept for the cases where the answer differs by target, because those are the cases where a program written against one target would be wrong on another.

5.3 Pointers#

T*                 # pointer to mutable T; never null
const T*           # pointer to read-only T; never null
volatile T*        # pointer whose loads and stores are observable effects
T*?                # nullable pointer; `none` is C's null; one word
void*              # untyped pointer; C's void *
const void*        # untyped read-only pointer
const (T*)*        # C's T *const *: a pointer to a read-only pointer to T
  • A pointer type is a complete type followed by *. const and volatile prefix the pointee and may combine. A qualifier applies to the innermost type; to qualify a pointer itself, parenthesise it. ? may follow any *, so Link*?* is a pointer to a nullable pointer; the one-layer rule of §5.8 forbids only ??.
  • void may appear only immediately before *, optionally after const.
  • A bare pointer is never null. Every operation that could produce a null pointer produces T*? instead: an integer-to-pointer cast (§7.5), an extern slot declared ? (§17.1), a nullable field read. T*? is an ordinary optional whose representation is the null niche: none is address zero and the type is one word. It is unwrapped like any optional (§11.1). There is no NULL literal.
  • T* converts implicitly to const T*, volatile T*, and const volatile T*. A qualifier is never removed implicitly. Any object pointer converts implicitly to void*, and any const object pointer to const void*. The reverse directions are casts. A nullable pointer T*? converts as T* does, implicitly to void*? or gaining const, and by cast to any other nullable pointer; none stays none.
  • == and != compare addresses between any two object pointers, across qualifiers and with void*. <, <=, >, >= between two object pointers are the total order of addresses, and Base guarantees that order across objects, which C does not. Pointers are Hashable by address, with the process seed of §7.4.

Why never null. In C, every pointer is nullable and almost none of them should be; checking for null before every dereference is the source of many of C's crashes and of much of its redundant code. A bare T* that cannot be null lets a function's signature say which pointers may be absent, and lets the compiler put the single check where the ? is. Hare, Zig, and Cyclone reached the same design.

Why a niche. A nullable pointer must be one word so that a struct holding one has the layout its C definition has. Full Luce represents optional foreign handles as a token plus a flag, because a C library may legitimately use a zero handle and the two must remain distinguishable. Base's pointers are not handles: zero is null, so the niche is correct, and the compiler's intermediate representation gains a nullable-pointer type for Base and for native Luce's raw pointers (§19.2).

5.4 Arrays and spans#

T[N]               # fixed-size value array, N a positive constant
T[]                # span: pointer plus length, elements mutable, non-owning
const T[]          # span with read-only elements
  • [N] or [] after a complete type is an array or span. A bracket after a type name that contains types is a generic argument list (§13.1); a bracket that contains a constant expression, or nothing, after a complete type is always an array or span suffix. A bracket holding one bare name is decided by what the name resolves to, never by its spelling: i32[NIns] is an array when NIns is a constant, List[Item] an instantiation when Item is a type. An array of optionals is spelled with the element parenthesised, (c.str?)[3], since ? suffixes the whole type before it. Pair[i64, str][4] is four pairs; u8[4][4] is an array of four arrays of four bytes, read inside-out as in C; Node*[] is a span of pointers; u8[sizeof(Header)] is a byte array the size of a header.
  • N is a positive constant expression (§6.4). C forbids zero-length arrays and Base does not declare what C cannot.
  • An array is a value: it copies element by element, may live inline in a struct or a local, and has C's layout.
  • A span is two words, a pointer and a usize length. It does not own its elements and does not keep them alive. It is made from an array, from a pointer and a count, or by slicing:
var buffer: u8[4096]
let all: u8[] = buffer                  # an array converts to a span that points into it
let head = all[0..<16]                  # half-open slicing, checked
let tail = all[16..]
let view = u8[](pointer, count)         # from a pointer and a length; `pointer` must address `count` elements
  • Indexing and slicing are bounds-checked and trap on violation, in every build. span.length is usize; span.data is the pointer; span.first() and span.last() are T?; span.indexed() yields (usize, T) pairs for for. An array has length, a constant, first(), and last(); it has no data, because an array is a value and its address is &array[0], or the span it converts to.
  • An empty span's data is a non-null, correctly aligned, dangling pointer that must not be dereferenced. This keeps the empty span distinct from none, so T[]? uses the ordinary tagged optional representation, not a niche. A C caller that passes (NULL, 0) to an exported span parameter receives the empty span; the export wrapper normalises it (§17.6).
  • A span of a local array is a pointer into the frame; §6.6 states the escape rule.

Why spans are the common case. A common C pattern is "a pointer to N of these, beside N", and the bugs in that pattern occur when the pointer and the count are passed or stored separately. A span carries both, checks the index, and costs the same two registers.

5.5 Text#

str is an immutable UTF-8 view: a const u8* and a usize byte count, with the invariant that the bytes are valid UTF-8. Equality and ordering compare bytes. text.length is the byte count, O(1), as strlen is; text.bytes is a const u8[]; for character in text iterates Unicode scalars as char. str does not support integer indexing, because byte and scalar boundaries differ; slice text.bytes and convert, or iterate. Nothing about str allocates, and + on two strings does not exist; write into a buffer with format or a Writer.

c.str is const c.char* pointing at NUL-terminated text of unknown encoding. It exists for the C boundary, and like every c type it is visible after import c (§5.2, §16.6): Base text is str, C text is c.str.

Conversions between text and bytes use the two conversion spellings of §7.5:

  • str(bytes) from a const u8[] validates UTF-8 and yields str!; (str)bytes yields a str with no check, for bytes already known to be valid.
  • str(value) from a c.str scans to the NUL, validates, and yields str!.
  • (c.str)text borrows the text's data pointer without reading bytes, allocating, checking termination or extending its lifetime. Before using it as a C string, the caller must provide a readable terminating NUL and keep that storage alive. To preserve an arbitrary byte view exactly, use strings.copy(text), cast that terminated copy, and later call strings.release(copy). Reading beyond valid storage remains undefined (§12.6), as for a raw pointer in C.

A formatted string f"..." has no value of its own. It is consumed in one of four ways: print(f"..."); writer.write(f"...") on a Writer (§14.4); strings.format(buffer: u8[], f"...") -> str!, which writes into the caller's buffer, appends a NUL when there is room, and returns a view of the text; or a parameter of type fmt, which a function declares to accept a formatted string or a str and may only pass on to one of these four (§9.1). print streams the pieces to standard output and strings.format appends them to the caller's buffer, so neither builds an intermediate string. Passed to a Writer or to a fmt parameter, the text is formatted before the call into storage the caller owns, on the stack and on the heap once it outgrows that, which lives until the call returns; no length limit applies. This is the printf replacement.

A field may lay its value out by a format specification, Python's format mini-language, written after the field's first colon outside its brackets: [[fill]align][sign][z][#][0][width][grouping][.precision][type].

func main(arguments: str[]) -> i32:
    print(f"{1234.5:,.2f} {42:>6} {255:#x} {"total":*^11} {0.25:.0%} {-7:+05}")
    return 0

prints 1,234.50 42 0xff ***total*** 25% -0007.

PartMeaning
fillany one scalar, written before an alignment; a space when not written
align< left, > right, ^ centred, = a number's padding put after its sign; a number aligns right and text left when not written
sign+ before every number, - before a negative one only (as when not written), a space before a positive one
za float that rounds to negative zero shows as zero
#the alternate form: 0b, 0o, 0x, 0X before an integer in that base; a float's point kept
0zeros fill a number after its sign, as 0= does; for text, a fill of 0
widththe least width, in scalars
grouping, or _ between groups of three digits; _ groups the digits of b, o, x and X by four
.precisiondigits after the point for f, e and %, significant digits for g and for no type, the scalars kept of text
typeof an integer: d decimal (when not written), b, o, x, X, and c, the scalar the number names; of a float: f and F fixed, e and E with an exponent, g and G general, % a percentage in fixed; of text, s

A specification applies to an integer, a float, a str, a char, or a bool, the last three as their text; a value that shows itself through Display takes none, and its field is {value}. The specification is plain text: it holds no field of its own, no escape, and no quote. One that does not read as that grammar, or whose parts do not fit the value, is a compile error: a type letter of another kind, a precision of an integer, a sign, z, #, = or grouping of text. An integer takes no float type, so {n:.2f} is written {f64(n):.2f}. Digits are rounded half to even on the exact binary value, as Python's are: f"{2.675:.2f}" is 2.67. A float with neither type nor precision shows its display (§14.4) where Python shows its repr (1e16, not 1e+16), an f32 its own shortest digits; a precision without a type is Python's general format with a digit always after the point. A c of a number that names no Unicode scalar traps. The layout is the standard module numerals, which a program may call itself, and strings.write_i64 and its siblings write a value by a specification known only at run time to any Writer (§16.6).

Why a view. Full Luce's str is owned and reference-counted, which is what makes concatenation safe there. Base has no reference counting, so a string is a view of storage that something else owns: static data, a buffer, an arena. Keeping the name str in both tiers means "text" reads the same in both; the difference is who owns the bytes, and Chapter 18 states how the two cross. The formatted-string rule follows: there is no owner for a fresh string, so the interpolation goes to whoever asked for it.

5.6 Function types#

func(A, B) -> R is a C function pointer: one word, never null; func(...)? is nullable with the null niche. A named function, a method through its type (Point.distance), and a capture-free lambda convert to it. Base has no closures (§9.6), so a function type is exactly a C function pointer, and an extern declaration's function-pointer parameters are written with func. A function type has no zero value (§6.1). Conversion between a function pointer and void* is an explicit cast (§7.5).

5.7 Tuples#

(i64, str) is a fixed-size anonymous value used for local grouping and multiple results (§9.3). Tuples have no field names, no methods, and no one-element form; () is the value of unit. A member is read by its position, pair.0, pair.1, or the tuple is destructured, let (a, b) = pair; a member is not assigned. A tuple has C's layout as a struct of its components.

5.8 Optionals#

T? is either .some(value) or .none. It is one layer deep: T?? cannot be written, and a generic instantiation that would produce it, such as first[T] with T = Node*?, represents the outer optional with a tag beside the value. Its representation is the null niche for pointer, function, and interface-view types, and a tag beside the value otherwise. Chapter 11 states how it is produced and consumed.

5.9 Atomic types#

@T is an atomic variant of T, permitted when T is an integer, bool, usize/isize, a pointer, or a nullable pointer, of at most pointer width; and, on targets with a double-width compare-and-swap, a struct of two pointer-width words, for which only cas, load, and store are defined. Chapter 15 specifies the operations. @ is part of the type: &x on an @u32 yields @u32*, and no cast removes @.

5.10 Type aliases#

pub type Pixel = f32[4]
type Callback = func(void*, i32) -> unit
type Link = FreeBlock*?

An alias is another spelling for the same type. It creates no distinction, has no methods, and is not generic. Its name follows the naming lint of §3.4 but is read as a type wherever a type is read, lib.bits included. For a domain distinction, declare a one-field struct.

Aliases are how pointer shapes are kept readable. T*, const T*, T*?, and T[] read well on their own; a type that stacks more than two of *, ?, [], and [N] does not, and should be named: Link[64] rather than FreeBlock*?[64]. The linter reports a spelling with more than two postfix operators.

5.11 Layout#

Every Base aggregate has the target C ABI's layout: declaration order, the ABI's alignment and padding, no reordering. A struct has at least one field, as in C. packed struct Name: removes padding; align(N) struct Name: raises alignment to the power of two N; align(N) before a field raises that field's alignment, which is how two hot atomics are placed on separate cache lines. Taking the address of a field of a packed struct is a compile error unless the field's natural alignment is one, because C makes the resulting dereference undefined and Base does not produce the pointer.

memory.size_of(T), memory.size_of(expression), memory.align_of(T), and memory.offset_of(T, field) are constants of type usize (§5.1): C's sizeof, alignof, and offsetof, which the standard module memory holds and the compiler answers where they are written (import memory, or from memory import size_of). align_of takes a type only, as in C.

Base has no type-based aliasing rule. Any pointer may alias any object of compatible size and alignment, and a backend may never assume otherwise. A parameter may be declared noalias (§9.2), which is the one place a program asserts non-aliasing, and violating it is undefined (§12.6).

Why. A Base struct is a C struct; that is what lets Base be the language C libraries are bound in and the runtime is written in. The aliasing guarantee exists because unions and (T*) casts are how C reinterprets memory, and a future backend adding type-based alias analysis would break every program that uses them. noalias gives the optimiser the fact it needs in the code that needs it, DSP loops and matrix kernels, without a global rule.

5.12 Vectors#

A vector is an array whose elements are one integer type of a stated width (i8 to u64), f32, or f64, and whose bytes number eight or sixteen: f32[4], f64[2], i32[4], u8[16], i16[8], i64[2], f32[2], i32[2], u8[8], i16[4], and the rest of that table. Nothing about the type changes: it is the array of §5.4, a value with C's layout and the element's alignment, indexed and sliced and compared as one. What a vector adds is arithmetic, lane by lane:

let a: f32[4] = [1.0, 2.0, 3.0, 4.0]
let b = f32[4](0.5)                     # every lane 0.5: the broadcast form
let c = a * b + a                       # lane by lane: [1.5, 3.0, 4.5, 6.0]
let d = c * 2.0                         # a scalar of the element type broadcasts
let total = c.sum()                     # 15.0; also `min()` and `max()`
var counts: u32[4] = [1, 2, 3, 4]
counts = counts +% counts               # the wrapping form, as on scalars
  • The operators of §7.2 and §7.3 that take two operands of one type take two vectors of one type, or a vector and a scalar of its element type in either position, and yield that vector type: +, -, * (checked; a lane's overflow traps), +%, -%, *%, +|, -|, *|, / (float vectors), &, |, ^, and the shifts, whose count is a scalar. Unary -, -%, and ~ apply to every lane. Each lane computes exactly as the scalar operator would, in lane order, traps included, so a vector expression means what the same expression means on each element, and the interpreter and every backend agree to the bit.
  • T[N](x) is the vector whose every lane is x; it is not the span constructor of §5.4, which takes a pointer and a count.
  • v.mul_add(b, c) on a vector of f32 or f64 lanes is mul_add lane by lane (§7.2): one fused instruction for sixteen bytes of lanes where the target has it.
  • v.sum(), v.min(), and v.max() fold the lanes with +, <, and > in lane order and yield an element; sum() traps on integer overflow as + does. A float min() or max() follows the comparison: a NaN lane is skipped when another lane compares, and is the result when every lane is NaN.
  • //, %, the ? forms, and lane-wise comparison are not defined on vectors; index a lane, or write the loop.
  • A lane-wise operator computes at run time: it is not a constant expression (§6.4). A top-level let of a vector type is an array literal, or T[N](x) with a constant x.

Why. A vector is what the processor computes sixteen bytes at a time, and every target of §19.5 has that unit: a backend keeps a vector in a vector register and computes a lane-wise expression in one instruction where the machine has it, checking overflow with the lanes' own comparison. The array is already the right value with the right layout, so a vector is a use of a type the program has, not a type it has to convert to and from; the same array indexes and slices as before, and a function that takes f32[4] takes it whether the caller thinks of it as four floats or as one value. The set of lane widths is the set every target has in one register.