PythoC provides an optional linear type system for enforcing explicit resource cleanup without introducing hidden control flow (no RAII, no destructors).
A linear value is a zero-size token (a proof marker). The compiler enforces that each
linear token is consumed exactly once before leaving its lifetime.
This document describes how to use linear tokens, how ownership moves through code, and what control-flow restrictions are enforced.
- Overview
- Core API
- Ownership Rules
- Control Flow Rules
- Composite Types and Field Paths
- Defer + Linear
- Scoped goto/label + Linear
- linear_wrap: Generate Linear-Safe Resource APIs
Linear tokens are designed for proof-carrying resource management patterns.
A common pattern is to pair an acquire function with a release function using a linear token as a proof that the resource must be released:
from pythoc import compile, linear, consume, void, ptr, i8, i32, struct
from pythoc.libc.stdlib import malloc, free
@compile
def lmalloc(size: i32) -> struct[ptr[i8], linear]:
return malloc(size), linear()
@compile
def lfree(p: ptr[i8], prf: linear) -> void:
free(p)
consume(prf)
@compile
def safe_usage() -> void:
p, prf = lmalloc(100)
# ... use p ...
lfree(p, prf) # must consume prfThe token has no runtime cost: it is a zero-size value used only for compile-time tracking.
It is also possible to create a borrowed view of a resource that cannot outlive the resource:
from pythoc import compile, linear, consume, void, ptr, i8, i32, struct, refined, assume
@compile
class Resource:
x: i32
# A linear proof type that is distinct from other linear types
ResourcePrf = refined[linear, "ResourcePrf"]
@compile
def new_owned_res(size: i32) -> struct[Resource, ResourcePrf]:
r: Resource
r.x = size
prf = assume(linear(), "ResourcePrf")
return r, prf
@compile
def del_owned_res(r: Resource, prf: ResourcePrf) -> void:
consume(prf)
# Create a view of the resource that cannot outlive the resource
ViewPrf = refined[linear, "ViewPrf"]
@compile
def get_res_view(r: Resource, prf: ResourcePrf) -> struct[ptr[Resource], ResourcePrf, ViewPrf]:
pr = ptr(r)
view_prf = assume(linear(), "ViewPrf")
return pr, prf, view_prf
@compile
def release_res_view(pr: ptr[Resource], prf: ResourcePrf, view_prf: ViewPrf) -> ResourcePrf:
consume(view_prf)
return prf
@compile
def safe_usage() -> void:
r, prf = new_owned_res(100)
pr, prf, view_prf = get_res_view(r, prf)
# del_owned_res(r, prf) # If prf is consumed before view_prf, view_prf cannot be consumed properly
prf = release_res_view(pr, prf, view_prf)
del_owned_res(r, prf)| Construct | Meaning | Ownership effect |
|---|---|---|
linear() |
Create a new linear token | produces a fresh token |
consume(t) |
Consume a linear token | consumes t (makes it invalid) |
move(t) |
Explicitly transfer ownership | consumes t and returns a new owner handle |
f(t) where f takes linear |
Pass token to another function | consumes t in the caller |
return t |
Return token to caller | consumes t in the callee |
linearis a builtin type.- It is represented as an empty LLVM struct (zero size).
consumeis a builtin function.- It is a no-op at the IR level; its purpose is to trigger the ownership transition.
move is a small helper in pythoc.std.utility:
from pythoc.std.utility import move
@compile
def example() -> void:
t = linear()
t2 = move(t)
consume(t2)A token must be consumed before it leaves its lifetime (scope / function exit).
from pythoc import compile, linear, consume, void
@compile
def ok() -> void:
t = linear()
consume(t)Missing consume is an error:
from pythoc import compile, linear, void
@compile
def bad() -> void:
t = linear()
# error: not consumedDirect assignment copies are rejected. Use move instead.
from pythoc import compile, linear, consume, void
from pythoc.std.utility import move
@compile
def ok_move() -> void:
t = linear()
t2 = move(t)
consume(t2)
@compile
def bad_copy() -> void:
t = linear()
t2 = t # error: cannot assign linear token (use move())
consume(t2)Reassigning an unconsumed token is rejected:
from pythoc import compile, linear, consume, void
@compile
def bad_reassign() -> void:
t = linear()
t = linear() # error: previous token not consumed
consume(t)After consumption, reassignment is allowed:
from pythoc import compile, linear, consume, void
@compile
def ok_reassign() -> void:
t = linear()
consume(t)
t = linear()
consume(t)A declared linear variable is invalid until assigned a token.
from pythoc import compile, linear, consume, void
@compile
def bad_undefined() -> void:
t: linear
consume(t) # error: undefined / invalid
@compile
def ok_assign_then_consume() -> void:
t: linear
t = linear()
consume(t)Linear checking is CFG-based and path-sensitive.
At a high level:
- Merge points: all incoming paths must have compatible linear states.
- Function exit: all tokens must be consumed.
If a token is consumed in one branch, it must be consumed in all branches.
from pythoc import compile, linear, consume, void, i32
@compile
def ok_ifelse(cond: i32) -> void:
t = linear()
if cond:
consume(t)
else:
consume(t)If only one branch consumes, it is rejected:
from pythoc import compile, linear, consume, void, i32
@compile
def bad_ifelse(cond: i32) -> void:
t = linear()
if cond:
consume(t)
else:
pass # error: inconsistent states / not consumed at exitIf you want to consume after the if, keep the token active in all paths:
from pythoc import compile, linear, consume, void, i32
@compile
def ok_consume_after_if(cond: i32) -> void:
t = linear()
if cond:
pass
consume(t)The same rule applies to loops:
- All merge points must have compatible linear states (including the loop back edge)
- That implies in generally tokens should not be consumed inside the loop unless there is no loop back edge
from pythoc import compile, linear, consume, void, i32
@compile
def ok_loop_internal() -> void:
i: i32 = 0
while i < 3:
t = linear()
consume(t)
i = i + 1This pattern is rejected:
from pythoc import compile, linear, consume, void, i32
@compile
def bad_loop_external() -> void:
t = linear()
i: i32 = 0
while i < 3:
consume(t) # error: loop body changes linear state
i = i + 1OK, because the loop is exited without any condition:
from pythoc import compile, linear, consume, void, i32
@compile
def bad_loop_external() -> void:
t = linear()
i: i32 = 0
while i < 3:
consume(t) # error: loop body changes linear state
i = i + 1
break
else:
consume(t)match/case behaves like branching: all arms that reach a merge must be compatible.
Returning a value transfers ownership for any linear tokens contained in the return value. That means:
return tconsumestin the callee.- Returning
struct[...]that contains a linear field consumes those linear fields.
Linear tokens can be nested inside structs and other composite values.
The checker tracks linear tokens by field path.
Example: struct[ptr[i8], linear] has one linear path at index 1.
from pythoc import compile, linear, consume, void, i32, struct, ptr, i8
from pythoc.libc.stdlib import malloc, free
@compile
def lmalloc(size: i32) -> struct[ptr[i8], linear]:
return malloc(size), linear()
@compile
def lfree(p: ptr[i8], prf: linear) -> void:
free(p)
consume(prf)
@compile
def ok_unpack() -> void:
p, prf = lmalloc(10)
lfree(p, prf)You can consume tokens inside a struct using indexing: All tokens in the struct must be consumed for the struct to be consumed.
from pythoc import compile, linear, consume, void, struct
DualToken = struct[linear, linear]
@compile
def make_dual() -> DualToken:
dt = DualToken()
dt[0] = linear()
dt[1] = linear()
return dt
@compile
def destroy_dual(dt: DualToken) -> void:
consume(dt[0])
consume(dt[1])You can also return a linear token from a struct field.
@compile
def consume_first_return_second(dt: DualToken) -> linear:
consume(dt[0])
return move(dt[1])defer(f, *args) registers f(*args) to execute when the current scope exits.
Linear semantics:
- Linear arguments are not transferred at registration time.
- Linear arguments are transferred when the deferred call executes.
This allows a defer to hold a token until scope exit.
from pythoc import compile, defer, linear, consume, void
@compile
def consumer(t: linear) -> void:
consume(t)
@compile
def ok_defer() -> void:
t = linear()
defer(consumer, t)
# do work...
# consumer(t) executes at scope exit and consumes tImportant implication:
- If you manually consume or otherwise transfer the token before the defer runs, the deferred call will fail (use-after-consume).
Defer executes in FIFO order within the same scope.
PythoC supports scoped goto/label for low-level control flow.
Linear rules still apply:
- Any merge point (including label end targets) requires compatible linear states.
- If one path consumes a token before jumping to the same target, all paths must.
Example: consume before goto_end on all paths:
from pythoc import compile, linear, consume, void, i32, label, goto_end
@compile
def ok_goto(cond: i32) -> void:
t = linear()
with label("main"):
if cond:
consume(t)
goto_end("main")
else:
consume(t)
goto_end("main")Alternatively, keep token active across the label and consume after the merge point.
pythoc.std.linear_wrapper.linear_wrap can generate wrappers around an
acquire/release function pair.
from pythoc.std.linear_wrapper import linear_wrap
from pythoc.libc.stdlib import malloc, free
lmalloc, lfree = linear_wrap(malloc, free)Behavior (conceptual):
lmalloc(*args)returnsstruct[proof, resource].lfree(proof, *args)calls the original release function and then consumesproof.
Typical usage:
from pythoc import compile
from pythoc.std.linear_wrapper import linear_wrap
from pythoc.std.utility import move
from pythoc.libc.stdlib import malloc, free
lmalloc, lfree = linear_wrap(malloc, free)
@compile
def ok_linear_wrap() -> None:
prf, p = lmalloc(100)
lfree(prf, p)You can request a named proof type (implemented as refined[linear, "Name"]):
from pythoc.std.linear_wrapper import linear_wrap
from pythoc.libc.stdio import fopen, fclose
FileHandle, lfopen, lfclose = linear_wrap(fopen, fclose, struct_name="FileHandle")This produces a distinct proof type that cannot be mixed with other wrappers.