defopaque)An opaque type is a named, nominally-distinct newtype that wraps a single representation type. At the C ABI it is the representation type and nothing more -- so the wrapper costs zero bytes and zero instructions -- but to the type checker it is a distinct type that does not unify with its representation. That is the whole point: you get type-level separation between, say, a file descriptor and the byte count you were about to pass where the fd belongs, without paying for a struct.
(defopaque Fd :int)
(defopaque Pid :int)
(defopaque Chan :ptr<void> :linear)
(defopaque Future :ptr<void> :affine)
The representation type is one of:
| Representation | When to use |
|---|---|
:int |
A small integer handle (POSIX fd, OS pid, table index, generation-counted id, branded int). |
:ptr<void> |
A pointer to a C-allocated control block (channel, mutex, reactor, opaque library handle). |
:ptr |
A bare pointer when you don't need to thread the pointee type through. |
Both lower to int64_t at the C boundary, so an inline-C body that takes
or returns an opaque writes the C signature in terms of int64_t (and
casts to/from the real pointer type internally).
FILE*, pthread_mutex_t*, or
socket fd is just a pointer or int at the ABI. Wrapping it in a
defopaque means the checker rejects passing an unrelated :int (a
length, a byte count, the wrong handle) into the operations that expect
it.UserId and RoomId, EventSourceId and TimerId -- should be
distinct so a swap is a compile error, not a 3 a.m. page.(defopaque Token :int) and never reveal whether the token is
an array index, a hash, or a serial number. The consumer sees only the
accessor functions you choose to export.:linear or
:affine makes the handle exactly-once or at-most-once -- the checker enforces that a
Chan :linear is consumed by exactly one chan-free call, ruling
out leaks and double-frees.Opaques are not for:
defstruct.defdata or defgadt.T with a
Show instance") -- use existentials (see
existential-types-guide.md).(defopaque Name :rep-type)
(defopaque Name :rep-type :linear)
(defopaque Name :rep-type :affine)
The optional trailing keyword promotes the newtype to a substructural
handle. Without it the opaque is freely copyable (is_copy = true); with
:linear or :affine it becomes a resource the checker tracks for
single use.
The C ABI of a substructurally-marked opaque is identical to a freely
copyable one -- the handle still lowers to int64_t; only the
elaborator's usage tracking changes. See
substructural-types-guide.md for the
discipline; see
uniqueness-types-guide.md for the
^unique alternative.
A defopaque produces no constructors or accessors of its own. You
provide those as ordinary defns, using the (:: expr :Type) cast form
to move between the wrapper and its representation.
(:: expr :Type)The (::) form is a type ascription that doubles as the bridge between
an opaque and its representation. It compiles to nothing at the C level
-- only the static type of expr changes.
(defopaque Fd :int)
;; Wrap a raw int into an Fd:
(defn int->fd [n : int] : Fd (:: n :Fd))
;; Unwrap an Fd back to int:
(defn fd->int [fd : Fd] : int (:: fd :int))
;; Use either side as needed:
(defn fd-valid? [fd : Fd] : bool (>= (:: fd :int) 0))
The same pattern works for :ptr<void> opaques:
(defopaque Chan :ptr<void> :linear)
;; Inside an inline-C body the C type is int64_t; cast as you would
;; any other handle:
(defn chan-new [cap : int] : Chan
```c
ChanBlock *ch = (ChanBlock *)malloc(sizeof(ChanBlock));
/* ... init ch ... */
return (int64_t)(intptr_t)ch;
```)
(defn chan-send [^borrow ch : Chan val : int] : nil
```c
ChanBlock *c = (ChanBlock *)(intptr_t)ch;
/* ... use c ... */
return 0;
```)
Conventions worth following:
name->rep and rep->name helpers (fd->int / int->fd)
if external callers need the raw representation -- it's cheaper than
exporting (::) casts at every call site, and it gives you a place to
hang ;;; docstrings, validation, or future invariant checks.(::) cast inside the wrapper module. Consumers should be
able to use your opaque without writing a cast.:int handles that have a sentinel error value (e.g. -1 for
POSIX fds), expose a name-valid? predicate instead of forcing
callers to compare integers.Every opaque -- whether :int or :ptr<void> -- lowers to int64_t at
the C boundary. An inline-C body always sees int64_t parameters and
returns int64_t; cast to your real pointer or int type inside the
block.
(defopaque Pid :int)
(defn pid-kill [p : Pid sig : int] : int
```c
return kill((pid_t)p, (int)sig);
```)
(defopaque Reactor :ptr<void> :linear)
(defn reactor-new [] : Reactor
```c
TurReactor *r = tur_reactor_new();
return (int64_t)(intptr_t)r;
```)
This uniform int64_t carrier is the same mechanism described in
type-erasure-guide.md; opaques are one of the
three sites where the compiler collapses high-level types to the carrier.
When the constructor is fallible -- it acquires the handle in C and
can fail -- hand back a typed (Result Handle E) / (Option Handle)
rather than the bare opaque, built with the preamble helpers
tur_ok_ptr / tur_err_int / tur_some_ptr / tur_none. See
inline-c-results-guide.md for the worked
pattern.
Adding :linear or :affine flips two checker bits on the underlying
StructDef (is_copy = false, is_linear / is_affine = true):
:linear handle must be consumed exactly once. Letting it go out of
scope or copying it is TUR-E0100; using it twice is TUR-E0101.:affine handle must be consumed at most once -- dropping it is
fine, double-use is not.^borrow
so they observe the handle without consuming it. Only the
*-free/-close/-shutdown operation does the actual consume. See
stdlib/chan.tur for a worked example.There is no inheritance. A defopaque is a leaf node in the type lattice
-- it does not unify with its representation, nor with any other opaque,
even one declared with the same representation. To "convert" between two
opaques you write a function that unwraps to the shared representation
and wraps back:
(defopaque Celsius :float)
(defopaque Kelvin :float)
(defn celsius->kelvin [c : Celsius] : Kelvin
(:: (+ (:: c :float) 273.15) :Kelvin))
defopaque does not currently accept type parameters --
(defopaque Box[T] :int) is rejected. The same effect can be achieved
with a phantom-typed defstruct today; the open ticket is
docs/reported/parameterized-defopaque.md.
| Module | Opaque | Shape |
|---|---|---|
stdlib/fd.tur |
Fd |
:int -- POSIX file descriptor, -1 is the error sentinel |
stdlib/process.tur |
Pid, ChildHandle |
:int (one :linear) -- OS process ids |
stdlib/chan.tur |
Chan, AsyncChan |
:ptr<void> :linear -- channel control blocks |
stdlib/future.tur |
Promise, Future |
:ptr<void> with :linear / :affine -- write end vs read end of the same FutureCell |
stdlib/threadpool.tur |
WorkQueueHandle, ThreadPoolHandle, DynThreadPoolHandle, FutureHandle |
:ptr<void> -- static and dynamic pools have distinct block layouts and are nominally distinct |
stdlib/thread.tur |
ThreadHandle |
:ptr<void> -- returned by thread-spawn-fn; thread-join / -detach / cancel-thread take it |
stdlib/fiber.tur |
FiberHandle |
:ptr<void> -- consumed by fiber-resume / -free / scheduler unpark |
stdlib/mutex.tur, stdlib/condvar.tur, stdlib/rwlock.tur |
Mutex, CondVar, RwLock |
:ptr<void> -- condvar-wait [c : CondVar m : Mutex] rejects transposed callers |
stdlib/taskgroup.tur |
TaskGroup, TaskHandle |
:ptr<void> -- task-group-join [group : TaskGroup handle : TaskHandle] |
stdlib/reactor.tur |
Reactor, EventSourceId |
mixed -- pointer for the reactor, branded :int for the source id |
stdlib/atomic.tur |
AtomicCell |
:ptr<void> -- pointer to a heap-allocated atomic word |
stdlib/stm.tur |
TVar |
:ptr -- transactional-variable handle, distinct from the boxed :ptr values it holds |
stdlib/timer.tur |
TimerId |
:int -- branded handle returned by reactor-add-timer |
stdlib/fs.tur |
StatInfo, TmpFile |
:int -- stat block vs temp-file handle; can no longer be transposed |
stdlib/io.tur |
FileHandle, FileStream, DirListing, FileSystem |
:ptr<void> -- FileHandle is :linear; FileStream wraps FILE* |
stdlib/ref.tur |
RefHandle |
:int -- heap pointer from ref-new, distinct from the Ref struct |
Read those modules for the full pattern: a defopaque declaration
immediately followed by the constructor (*-new), one or more
borrow-taking operations, and a consuming *-free (when the resource is
substructural).
:linear / :affine discipline in depth.^unique,
an orthogonal ownership story.int64_t carrier, and the FFI boundary.