Contexts

In RL, everything exists within a context, which groups together semantically related objects, such as objects belonging to the same thread or container. Both the stack and the heap have their own contexts, which allows us to distinguish local and dynamic variables by type (as the context an object belongs to is part of its type). For example a pointer to stack memory is denoted as VOID {STACK} *, while a pointer to the global heap is denoted as VOID {HEAP} *. A context is not just a type tag, it can inject implementation details into affected objects, such as overriding a function or data type.

Memory domains and custom pointers

One great use case for contexts are memory domains, which is the usage of a context to replace the native pointer type and the global allocator. This allows us to create dynamic objects that replace all pointers with offsets to the base of their allocated memory. The object's memory pool is now a chunk of address-independent data, as all memory references within it become offsets. This allows the object to be serialised as raw bytes straight from memory, even if it has internal references (such as a tree, list, or graph).

Since pointers in memory domains can only refer to within that domain, it is not necessary to use machine words as pointers, and often, 16-bit or 24-bit pointers are sufficient, leading to memory savings without giving up the familiar pointer syntax.

Pointers of unrelated domains are incompatible and conversion is explicit, and functions each have an internal memory domain that is applied to their local variables, and inaccessible outside of its lifetime. This makes returning a reference to a local variable becomes impossible, because the return type would exist outside its context's lifetime.

Specifying a custom memory domain for a class looks similar to this:

/// Linear one-time allocator without free().
SmallSerialisableTempAlloc MEMORY(PTR=U2, BASE=Data, SIZE=Size, NEW=alloc, DELETE=NOINIT) {
    Data: VOID {THIS} *;
    Size: U2;
    Capacity: U2;

    alloc(size: U2) VOID * {THIS} :=
    {
        mem ::= Size;
        Size += size;
        IF(Size > Capacity)
        {
            Data := realloc(Data, Size);
            Capacity := Size;
        }
        = <VOID * {THIS}>(mem);
    }

    DESTRUCTOR { IF(Data) free(Data); }
}

Note that Data is of type VOID {THIS} *, meaning it is a normal pointer to memory within the memory domain specified by THIS (through the MEMORY directive of the class), while alloc() returns VOID * {THIS}, meaning it returns an offset inside the memory domain specified by THIS instead of a normal pointer. Of course, since the pointer is already of the memory domain type, the memory it points to is also within that domain, so implicitly, it is VOID {THIS} * {THIS}.

If we now create an instance alloc, we can inject it into existing classes that use the global allocator:

alloc: SmallSerialisableTempAlloc;
x: std::[INT]Dyn {alloc} := 5;
int_addr: INT * {alloc} := x.ptr();

Note that here, while we do specify a context for an instance type (std::[INT]Dyn {alloc}), the instance itself is not part of its memory domain. Implicitly, it has the type std::[INT]Dyn {alloc} {STACK}, where STACK is the surrounding local-variable context. The outer context's memory region is used to store the Dyn variable, while the inner context injects data types and functionality into the type.

Grouping objects semantically

Contexts are both a tag and a modifier for types and functions, and this allows us to annotate objects that belong within the same container with a common tag. This is useful for detecting foreign objects and checking at compile time that we do not cross object group boundaries accidentally (of course, by reinterpreting an object as part of a common ancestor context will allow us to access it again, but this has to be done manually). This allows for better static analysis and invariants, especially when writing multi-threaded code, as each thread has its own context.

Injecting and replacing functionality

During testing, one can instantiate production code with a custom context that replaces certain types or functions with ones more suited for testing (such as simulated or mocked objects). This allows us to write production code directly without any unnecessary abstractions, and then during testing, replace certain components that depend on the outside world with simulated components. Additionally, it allows writing generic algorithm implementations without the use of heavy templatisation or abstraction, while letting the caller tweak implementation details (such as injecting a domain-optimised hash function, or using a different internal container type for certain workloads).