Luce Base
LuceSourceLuciaOS

9. Functions and methods

9.1 Declaration and calls#

import io
import luce
from io import Location

pub func clamp(value: f64, minimum: f64, maximum: f64) -> f64:
    if value < minimum: return minimum
    if value > maximum: return maximum
    return value

func render(scene: Scene*, samples: u32 = 64, denoise: bool = true) -> Image!:
    ...

func log(level: Level, message: fmt, at: Location = luce.location):
    _ = io.stderr().write(f"{at.file}:{at.line}: {message}\n") catch: recover 0

let image = try render(&scene, samples = 256, denoise = false)
log(.warn, f"lost {count} packets")
  • Parameter types and every non-unit result are explicit. A missing -> means unit. A fallible function that returns nothing is written -> !, the short form of -> unit!; both are accepted.
  • Arguments are positional or named with name = value; positional ones come first. A default is a constant expression embedded at the call site. luce.location is a compile-time Location (file and function as str, line as u32); as a default it expands at the call site, which is how a log records the caller without a macro. Duplicate, unknown, and missing arguments are compile errors.
  • A parameter of type fmt accepts a formatted string or a str. Inside the function it may be written to a Writer, passed to print or strings.format, or passed on to another fmt parameter, and nothing else: it cannot be stored, returned, or compared. The caller formats the text before the call, as for a Writer (§5.5), and the function receives it whole, whatever its length; a function that may discard the message is called behind a check at the call site, if verbose: log(f"..."), so a discarded message costs nothing. This is what makes a logging function possible without a macro.
  • One scope holds at most one callable with a given name: no overloading. Alternatives get semantic names: Image.open, Image.decode.
  • No variadic Base functions in this revision (§20). Calls to variadic C functions are §17.2.
  • Recursion is allowed; running out of stack is a trap, never a crash (§11.5).
  • A function that never returns is declared -> never; extern func abort() -> never binds C's _Noreturn.

Why = for named arguments. : already means "has this type" everywhere in the language. Using it for "takes this value" would put two relations behind one symbol at exactly the two places a reader confuses them.

Why fmt and luce.location. Function-like macros in C exist for two things above all: a logging call that takes a format string and forwards it, and a check that reports its own file and line. A parameter type that takes a formatted string as the text it formats to, and a compile-time source location expanded at the use site, cover both without a preprocessor. location() as a fake function would have been a runtime-shaped lie about a fact the compiler already knows.

9.2 Parameters and the calling convention#

A T[N] parameter is a value copy. A T[] parameter is a lent view. A T* parameter is a pointer. There is no inout; pass a pointer. A parameter of pointer or span type may be declared noalias, out: noalias f32[], asserting that the memory it addresses is not addressed by any other parameter or reachable pointer for the duration of the call; the backend may then keep loads in registers, and a violation is undefined (§12.6).

Every Base function whose signature is C-representable (§17.6) uses the target C calling convention whether or not it is exported, so its address may be handed to C. Sub-word parameters (bool, i8, u8, i16, u16, c.char) use the ABI's sub-word rules, which differ between AAPCS64 and Apple arm64; the backend handles them.

9.3 Multiple results#

func divide(value: i64, divisor: i64) -> (i64, i64):
    return (value // divisor, value % divisor)

let (quotient, remainder) = divide(17, 5)

Tuples replace C's out-parameters for the common case. Public data with names is better served by a struct. A tuple is not C-representable (§17.6), so its convention is the program's own: one of up to four words comes back in registers on every target, as a fallible result does (§11.2), with no memory between the callee's values and the caller's.

9.4 Function values#

func main(arguments: str[]) -> i32:
    let operation: func(i64, i64) -> i64 = add
    return i32(operation(2, 3))

A function value is a C function pointer (§5.6). A non-fallible function converts to the corresponding fallible function type, and a function that only reads through a const T* parameter, a method that changes nothing among them, to the type taking the T* there; nothing else converts. Either conversion is of a named function, f, module.f or Type.method, never of a value that holds one: the compiler calls the function through an adapter of exactly the wanted type, whose receiver, for a method, is the first parameter. A top-level let that names a function, pub let add = lib.add, is that function under another name: a call through it, qualified or not, takes the function's defaults and named arguments, so a module hands on another's function unchanged.

9.5 Methods#

import luce_std.math

struct Point:
    pub let x: f64
    pub let y: f64

    func distance(other: Point) -> f64:
        let dx = self.x - other.x
        let dy = self.y - other.y
        return math.sqrt(dx * dx + dy * dy)

    static func origin() -> Point:
        return Point(0.0, 0.0)

struct Cursor:
    pub var position: usize

    func advance(amount: usize):
        self.position += amount
  • A func declared inside a type is a method. self is implicit: it names the receiver inside the body, is not written in the parameter list, and cannot be used as a parameter name. point.distance(other) passes point as self.
  • static func declares a function of the type, with no receiver: origin above. It is called through the type, Point.origin(), as a static method is in C++ or Java; called through a value it is an error that names the type to call it through. Any other function in a type is a method, whether or not its body reads self, and is called on a value; through its type it is a function value taking the receiver first (§5.6), never called there. static is written only in a type, never on init, and a static function does not implement an interface's requirement (§14.1), which a view calls with a receiver.
  • A method changes its receiver when its body assigns to self or a part of it (a field or an array element reached without passing through a pointer or a span), calls a method that changes its receiver on self or such a part, stores to one of its atomics, allocates through an allocator it holds (new T in self.arena), or takes the address of self or a part, or a span of one, where a mutable pointer, a mutable span, or a view of a conformer that changes its receiver is wanted, or an optional one: as an argument, as the method's result, as the value of a binding or a field whose type is written, or in an assignment. A method whose result is such an address or view of its receiver hands out what may change it, so it is one that changes it. A local holding such an address counts as the part it reaches, let cell = &self.cells[i] and then cell.value = 0, and so does a for p in &self.items loop's binding. Nothing is written for any of this: the compiler reads it from the bodies, following methods that call each other until nothing changes, before it checks one.
  • A method that does not change its receiver may be called on anything: a let, a const T*, a temporary. The receiver of one that does must be a var, a mutable pointer, or a mutable span element, and the diagnostic names the method and why.
  • A method that does not change its receiver receives self as const Self*; one that does receives self as Self*, and so does init. In the body self names the receiver itself, a place of type Self: self.x reads a field and &self is the receiver's address, which is how a Self* is passed on. The address of self or a part, taken where no mutable pointer is wanted, let p = &self.part, is const in a method that changes nothing else; written with its type, let p: Part* = &self.part, it is mutable and the method changes its receiver. Because self aliases the receiver, a callee that mutates the receiver through another pointer changes what self.x reads mid-method. self = value stores through the pointer.
  • What the compiler found is part of the method's description (§17): describe lists a method, a mutating method, or a static method, so a module compiled apart and full Luce need not read the body. A linked method (§9.8), whose body is the library's, says it in its signature.
  • A method is callable on a value, a var, or a pointer; p.advance(3) on p: Cursor* needs no dereference. Calling on an rvalue materialises a temporary.
  • value.member without () is always a field. There are no computed properties.
  • A method named init is the initialiser (§10.1).

A type's methods may also be declared under extend Type: at the top level of any fragment of the module that declares Type (§16.1), when a type with many methods is better split by concern across files:

# canvas/selecting.lucb, a fragment of the module `canvas` that declares `Canvas`
extend Canvas:
    pub func select_all():
        self.selected = self.area()
  • An extension holds methods only (with pub, attributes, and doc comments as in the body), never fields, so a type's layout stays in its declaration.
  • Type is a struct, enum, or union this module declares; extending another module's type is an error, so a type's methods are all found within the module that owns it. It sees the module's private names and the type's private fields, as a method in the body does.
  • The methods are the type's methods like the ones in its body: one type, one set of methods, a name declared twice is an error, and the order of fragments does not matter. describe (§17) lists them with the rest, so full Luce sees every one.
  • extend is a word only in that position; elsewhere it is an ordinary name.

Why extensions. A facade type with hundreds of methods would otherwise hold every one of them in one file, or repeat each as a one-line method calling a function in another file. Swift's extensions and Go's methods anywhere in the package split a type across files; Base keeps Go's limit, the owning module, so the full set of a type's methods is still known in one place.

Why implicit self. Full Luce writes self as the first parameter, which is Python's convention and is justified in a language where a function inside a type may or may not be a method. In Base, having a receiver is what a function inside a type does by default, and a C programmer reading func distance(other: Point) inside a struct knows what it is. The explicit parameter would have been a parameter in every method that carries no information.

Why nothing is written about the receiver, and static is. Whether a method changes its receiver is in its body; a word that repeats it is one more thing to keep true, and the diagnostic about a wrong one is about the word, not the code. Swift asks for mutating and Rust for &mut self; Base reads the body, as Luce does, and a caller that needs a var is told so at the call, with the method's name. The rule is the body's own assignments and calls, not a guess: the compiler never makes a method changing because it might be, and what it found is in the description, so nothing downstream reads a body twice. Whether a function has a receiver at all is different: it decides how every caller spells the call, so it is stated where the function is declared, with the one word C++, Java and C# use, rather than left to a reader to infer from whether the body happens to read self. A method that does not need its receiver yet is still a method, and its callers do not change when it starts to.

Why self is a pointer. In full Luce a method that changes nothing receives a copy, which is safe under reference counting and invisible to the caller. In Base a copy of a large struct on every method call is a cost C programmers would notice, and a struct method in C3, Zig, and Odin takes a pointer. Making the convention deterministic, rather than "by value if small", is what lets the generated C header say const Point*.

9.6 Closures#

There are none. A capture-free lambda (x) => x + 1 converts to a function pointer. A callback that needs state is written the way C writes it: a function pointer beside a void* or T* context.

Why. A closure that captures by reference needs its environment to outlive the frame, and without reference counting there is no owner for that environment. Every manual-memory language that offers closures either restricts them to non-escaping, or hands the programmer an allocator at closure creation. Both are plausible later; neither belongs in the first revision.

9.7 The entry point#

pub func main(arguments: str[]) -> i32:
    print("Hello")
    return 0

main takes str[] or c.str[] and returns i32 or i32!. The startup shim builds arguments from argc and argv; with str[] it validates each argument as UTF-8 and traps invalid_utf8 on failure, so a program that takes paths from the command line declares c.str[], because a path on POSIX is bytes. A returned error is printed to standard error as its code and message and becomes exit status 1.

9.8 Declaration attributes#

A small closed set of words may precede a func or a top-level var, each one a fact the linker or the code generator needs and nothing else can express:

WordMeaning
inline funcinline at call sites; an implementation without an inliner ignores it
noinline funcnever inline
cold funcrarely executed; place and optimise accordingly
naked funcno prologue or epilogue; the body is one asm block per architecture (§8.9)
weak func, weak vara weak symbol that another definition may override
used func, used varkeep the symbol even if nothing references it
section("name") func, section("name") varplace the symbol in the named linker section
linked func, linked var, linked letthe definition is in a library the program links: the function has a signature and no body, the global a type and no initialiser, the constant its type and, when the interface carries it, the value the library gives it, which the program neither stores nor folds; a linked let names storage, so it is not a constant expression (§6.4). An interface written by luce-base interface is made of these; a generic or inline function is compiled where it is used and cannot be linked. A linked method has no body to read (§9.5), so it names its receiver as its first parameter: self: T* when it changes it, self: const T* when it reads it; a linked static func of a type names none

They combine, used section(".isr_vector") var vectors: Handler[64] = .... There is no general attribute syntax; this set is the language. A section name is passed to the target as written; a Mach-O target, whose sections live in segments, places a name without a comma in __DATA (a variable) or __TEXT (a function) under that name with its leading dot dropped, so .isr_vector is one spelling for every target.

Why. Embedded and kernel code needs an interrupt table in a named section, a weak default handler, a symbol the linker must keep, and a function with no prologue. Each is a fact about one declaration, and a word before the declaration is the shortest way to state it.