Pointers & Raw Pointers
Kairo has two pointer types: safe pointers (*T) with compiler-tracked lifetime and a non-null guarantee, and raw pointers (unsafe *T) with no tracking and no checks. Both are 8 bytes on 64-bit platforms and hold a memory address.
Safe Pointers (*T)
*T is the default pointer type. The compiler tracks its provenance via Tether. It is never null, so a dereference needs no check:
var x = 42
var p: *i32 = &x // p points to x
*p = 100 // dereference and assign, no null check
std::println(*p) // 100
Non-Null Guarantee
*T cannot hold null. Attempting to assign &null to a *T is a compile error:
var p: *i32 = &null // compile error: *T is non-null
var x = 42
var p: *i32 = &x // ok: p is guaranteed valid
*p = 100 // no null check needed
If you need a pointer that might be absent, use *T?, a nullable pointer (see Pointers and Nullable Types):
var p: *i32? = get_pointer() // might be null
if p? {
std::println(*p) // safe: compiler has verified p is non-null
}
For a raw pointer with no compiler tracking, use unsafe *T. It is the only pointer that can hold the null address, written &null. A bare null is a nullable's null and never a pointer: var raw: unsafe *i32 = null is a compile error. A null check compares against &null by hand:
var raw: unsafe *i32 = &null // ok: raw pointers can be null
if raw != &null {
std::println(*raw)
}
Member access
Use -> to access members through a pointer:
class Config {
pub var port: i32
fn Config(self, port: i32) { self.port = port }
}
var cfg = Config(8080)
var ptr: *Config = &cfg
ptr->port // 8080
Raw Pointers (unsafe *T)
unsafe *T is an untracked pointer with no null checks, no bounds checks, and no Tether provenance tracking. It is equivalent to a raw C/C++ pointer:
var x = 42
var p: unsafe *i32 = unsafe &x // create raw pointer from safe binding
*p = 100 // no null check
Dereferencing a null unsafe *T is undefined behavior. The compiler will not insert a check.
Raw pointers are required for C/C++ interop, custom allocators, hardware register access, and any scenario where Tether tracking is not possible or not desired.
Creating raw pointers
// From a safe binding
var x = 42
var raw: unsafe *i32 = unsafe &x
// From heap allocation
var raw: unsafe *i32 = unsafe std::alloc<i32>(sizeof i32)
// From an integer address (requires an unsafe block: fabricates provenance)
unsafe {
var unsafe *raw = 0x7FFE_0000_1000 as unsafe *i32
}
// Null
var raw: unsafe *i32 = &null
Const Pointers
A pointer declaration makes two independent properties visible:
- Can I change the pointer? Answered by the declaration keyword:
var ptrorconst ptr. - Can I change the pointee? Answered directly after the
*:*Tor*const T.
| Declaration | Change the pointer? | Change the pointee? |
|---|---|---|
var ptr: *i32 | Yes | Yes |
const ptr: *i32 | No | Yes |
var ptr: *const i32 | Yes | No |
const ptr: *const i32 | No | No |
var a: *i32 = &x
a = &y // ok
*a = 10 // ok
const b: *i32 = &x
b = &y // compile error: the pointer is const
*b = 10 // ok
var c: *const i32 = &x
c = &y // ok
*c = 10 // compile error: the pointee is const
const d: *const i32 = &x
d = &y // compile error
*d = 10 // compile error
Each question has exactly one place it can be answered. var ptr: const *const i32 would mean the same thing as const ptr: *const i32, but it is rejected, because the pointer question belongs to the declaration keyword.
See Variables for the full const model.
Pointer Arithmetic
On safe pointers (*T)
Safe pointers support offset arithmetic with integers. The offset is in units of sizeof T:
var arr: [i32; 4] = [10, 20, 30, 40]
var p: *i32 = &arr[0]
var second = *(p + 1) // 20
var third = *(p + 2) // 30
p = p - 1 // ok: reposition pointer
Pointer-to-pointer arithmetic (p1 - p2) is not permitted on safe pointers use unsafe *T for that.
Safe pointer arithmetic is bounds-checked by Tether only when provenance is trackable. If the pointer originates from a context where Tether cannot determine the allocation bounds, the arithmetic compiles but bounds safety is not guaranteed. Prefer array/vector indexing over pointer arithmetic when possible.
On raw pointers (unsafe *T)
Raw pointers support all arithmetic operations with no checks:
var p: unsafe *i32 = get_buffer()
var q: unsafe *i32 = p + 10 // offset by 10 i32s (40 bytes)
var distance = q - p // 10 (pointer difference in units of sizeof i32)
Out-of-bounds access through raw pointer arithmetic is undefined behavior.
Array-Style Indexing
Pointers support bracket indexing, which desugars to offset + dereference:
var p: *i32 = &arr[0]
p[0] // same as *(p + 0)
p[2] // same as *(p + 2)
Bounds checking follows the same rules as pointer arithmetic Tether checks when provenance is trackable, no checks on unsafe *T.
Void Pointers
*void and unsafe *void are opaque pointers that hold an address without type information. They cannot be dereferenced cast to a typed pointer first:
var handle: unsafe *void = get_opaque_handle()
// *handle // compile error: cannot dereference void pointer
var unsafe *typed = handle as unsafe *i32
var value = *typed // ok: typed pointer
Void pointers are used for type-erased APIs, opaque handles, and C interop where the concrete type is not known at the Kairo call site.
Double Pointers
Pointers to pointers are legal and follow the same rules recursively:
var x = 42
var p: *i32 = &x
var pp: **i32 = &p
**pp = 100 // x is now 100
const applies at each level independently, one question per level:
const pp: *const *i32 = &p
// pp cannot be reassigned
// *pp (the inner pointer) cannot be reassigned
// **pp (the i32) can be modified
Smart Pointer Promotion
Tether analyzes pointer usage and determines when a safe pointer needs smart pointer ownership semantics. In debug builds it performs the promotion and warns; in release builds the same situation is a hard error naming the type to annotate. The smart pointer types are compiler intrinsics exposed through the standard library:
| Type | Description |
|---|---|
std::Unique<T> | Single-owner, exclusive access, freed on drop |
std::Shared<T> | Reference-counted, multiple owners, freed when count reaches zero |
std::Weak<*T> | Non-owning reference to a Shared allocation, does not prevent deallocation |
Tether picks the smart pointer type from how the pointer is used across the program:
fn make_config() -> *Config {
var *cfg = @create Config(8080)
return cfg
// debug: promote cfg -> std::Unique<Config>
// release: error, annotate the return type
}
If Tether cannot determine a safe promotion path (e.g., the pointer escapes in a way that prevents tracking), it emits a compile error in both modes rather than allowing unsafe behavior.
Release code spells the smart pointer type explicitly, which is also how you override Tether's choice in debug:
var ptr: std::Unique<Config> = @create Config(8080)
var shared: std::Shared<Config> = @create Config(8080)
See Tether for the full lifetime and promotion model, and Ownership for borrowing semantics.
Heap Allocation
Stack allocation is the default. Heap allocation uses @create T():
var stack_val = Config(8080) // stack-allocated
var *heap_ptr = @create Config(8080) // heap-allocated, Tether chooses pointer type
For raw heap allocation without Tether tracking:
var unsafe *raw = unsafe std::alloc<i32>(sizeof i32 * 10) // raw allocation, 10 i32s
// must be manually freed
unsafe std::free(raw)
See Tether for how allocation interacts with lifetime tracking.
Pointer Comparison
| Operator | Behavior |
|---|---|
== | Compares addresses do both pointers point to the same location? |
!= | Negation of == |
=== | Null-aware equality on nullable pointers (*T?): true when both are non-null and the values they point to are equal (one level: on (**T)? it compares the *T addresses) |
var a = 42
var b = 42
var p: *i32? = &a
var q: *i32? = &b
p == q // false: different addresses
p === q // true: both point to 42
var r: *i32? = &a
p == r // true: same address
=== needs at least one nullable operand; on two *T it is an error. To compare what two *T point to, dereference them: *x == *y. For raw pointers (unsafe *T), use == for address comparison and dereference manually after a null check.
See Operators for the full comparison model.
Function Pointers
Function pointers (fn(T) -> R) are a separate type from *T. They are opaque values that cannot be cast to data pointers or vice versa:
fn add(a: i32, b: i32) -> i32 = a + b
var f: fn(i32, i32) -> i32 = add
// var p = f as unsafe *void // compile error: function pointers are not data pointers
See Functions for function pointer syntax and Closures for closures as function pointers.
Pointers and Nullable Types
*T is non-null by construction. To represent a pointer that might not exist, use *T? which wraps the pointer in the standard Nullable<T> system.
*T? reads as (*T)?: the ? applies to the whole pointer type, so the pointer itself may be null. A pointer to a nullable value is a different type, written with parentheses as *(T?): the pointer is never null, and the value it points to may be.
var a: *i32? = find_ptr() // (*i32)?: maybe a pointer; check a? before *a
var b: *(i32?) = &slot // slot: i32?; b is always valid, *b may be null
A nullable safe pointer is one pointer wide: null is stored as 0x0, with no separate flag.
*T | *T? | unsafe *T | |
|---|---|---|---|
| Can be null | No | Yes | Yes |
| Null check mechanism | N/A | val?, == null, ?., ?->, ??, unwrap!() | ptr != &null (manual) |
| Dereference null | Cannot happen | Compile error (must check first) | Undefined behavior |
| Tether tracked | Yes | Yes | No |
*T? supports the same null-handling operators as any other nullable type. unsafe *T uses manual null comparison, it does not participate in the Nullable<T> system.
var p: *i32 = &x // always valid, no null check needed
var q: *i32? = find_ptr() // might be null must check before use
var r: unsafe *i32 = &null // raw pointer, may hold &null, no tracking
if q? {
std::println(*q) // compiler knows q is non-null here
}
var *val = q ?? &fallback // *i32: use fallback pointer if q is null
var *forced = unwrap!(q) // *i32: aborts if null
See Nullables for the full nullable type system.
Summary
// Safe pointer
var x = 42
var p: *i32 = &x
*p = 100
// Raw pointer
var raw: unsafe *i32 = unsafe &x
// Nullable pointer (*T itself is never null)
var maybe: *i32? = null
if maybe? {
std::println(*maybe)
}
// Const pointer
var ptr: *const i32 = &x // can change the pointer, cannot change the pointee
const ptr: *i32 = &x // cannot change the pointer, can change the pointee
// Pointer arithmetic
var arr: [i32; 4] = [10, 20, 30, 40]
var p: *i32 = &arr[0]
*(p + 2) // 30
// Heap allocation
var *heap = @create Config(8080)
// Smart pointer (explicit)
var unique: std::Unique<Config> = @create Config(8080)
// Void pointer
var opaque: unsafe *void = get_handle()
var unsafe *typed = opaque as unsafe *i32
// Double pointer
var pp: **i32 = &p
**pp = 999
// Comparison
p == q // address comparison
*p == *q // value comparison
maybe === other // pointee equality on nullable pointers (one level)