Protocols
A protocol declares methods that other concrete types can adopt. Its base is the type that declares those methods; a participant is a type that explicitly adopts them. The compiler selects each participant method or adapts a base method by converting its arguments and result.
The selected methods support dot calls, operators, and, for Var, calls on
boxed values. Adoption works at compile time; it creates no runtime interface
object. Functions with matching names do not imply adoption.
Protocols adapt values. Registration, ordering, and generated initialization code belong to decorators. Startup and shutdown operations with no receiver have no value to convert and are outside the protocol system.
Declaration and adoption
A declaration names the base type and uses a fresh name, here T, for the
participant. It then declares optional associated types and the methods:
protocol Var(T) {
associated Key = Var;
associated Value = Var;
String T.str(T);
int T.truth(T);
Value T.getindex(T, Key);
Value T.setindex(T, Key, Value);
}
Associated types must be declared before methods. Their names and the participant name are local to the protocol body. The compiler warns if the participant name shadows a visible type: that may be an adoption written as a declaration by mistake.
Participation is a separate, bodyless declaration:
typedef struct Vec *Vec;
Var Vec.var(Vec value);
Vec Var.vec(Var value);
protocol Var(Vec);
Vec must name an existing type. This declaration makes it a participant in
Var(T); defining converters or matching typedefs is not enough. The base
itself is not a participant. Expressions with the base’s exact static type
continue to use its own methods.
A typedef can inherit the nearest visible ancestor’s resolved protocol methods without its own adoption. It also inherits the associated types, conversions, and representation unchanged. The compiler creates no new adoption, adapter, alias, descriptor, tag, or registration for that use.
An explicit adoption on the typedef takes precedence and resolves those choices again. If it is invalid, the compiler reports the error; it does not fall back to an ancestor.
A type that is only a typed view of an existing Var representation can name
that representation explicitly:
typedef List Row;
Var Row.var(Row value);
Row Var.row(Var value);
protocol Var(Row) as List;
Row has its own methods, function signatures, protocol participation, and
conversions to and from Var. Compatible List methods satisfy Var(Row)
directly, without forwarding methods on Row.
Boxed values still carry <list> and use List’s descriptor for dynamic
calls; no Row descriptor is registered. Consequently, value is Row and
value is List mean the same thing. This is a compile-time distinction, not
a new runtime class. The type after as must already have a fixed Var tag,
as List, int, and void * do.
A distinct runtime class whose type name does not encode the desired tag can name a shorter custom tag:
protocol Var(ArrayString) tag <arraystr>;
The participant and its converters still use ArrayString, but boxed values
carry <arraystr>. The tag must be a Symbol literal, must not be built in,
and must be unique across the process. tag registers the participant’s own
descriptor; as reuses another type’s descriptor. The two modifiers cannot
be combined.
Any concrete adoption may be translation-unit-local:
static protocol Prepared(LocalPlan);
static makes an adoption local to its translation unit, even when all its
dependencies are public. It is also valid when the protocol, participant, or
converters are already private; then it states explicitly what the compiler
would infer. The resulting linkage is the same. Only an adoption can be
static, not a protocol body.
Three visibility patterns cover most code:
| Protocol | Participant | Typical spelling |
|---|---|---|
| public | public | protocol Base(Participant); |
public Var | private | protocol Var(PrivateType); |
| private | private | static protocol PrivateBase(PrivateType); |
A private type can therefore participate in the public Var(T) protocol
without becoming public itself:
#pragma private
typedef struct LocalPlan *LocalPlan;
Var LocalPlan.var(LocalPlan);
LocalPlan Var.localplan(Var);
protocol Var(LocalPlan);
A protocol body can be private below #pragma private.
static protocol Prepared(T) { ... } is invalid because static modifies
only adoptions. A local ordinary adapter is emitted as static inline in the
participant’s C file. Local native aliases and their signature checks also
stay in that file; none of these names appears in the generated header.
The same linkage rules apply to Var(T) adapters and descriptor thunks.
Descriptor registration is separate and process-wide. The participant’s unit
registers either its lowercase type name or its explicit tag for boxed
dispatch. Keep that tag unique, including across private types in different
translation units. The compiler retains the full lowercase name to diagnose
two types claiming the same encoded tag. Var(T) as R reuses R’s descriptor
and registers nothing for T.
A generated method cannot have both local and external linkage. If a participant requires both, the compiler reports an error at the source location. Local native aliases and local descriptor registration are still allowed.
Protocol declarations and adoptions appear at the top level. Neither creates a program object or an AST node. The compiler collects them with surrounding declarations for each translation unit; reading source, cached declarations, or a symbol snapshot gives the same result. Repeating a byte-identical declaration has no effect. Conflicting declarations produce errors at their source locations.
Placement and visibility
The compiler resolves an adoption in the translation unit that declares it. That unit must include the protocol, participant type, required converters, and any participant methods needed to determine associated types. Put the adoption beside its converters. A shared protocol module is suitable only when it already includes these declarations.
The runtime keeps built-in protocol bodies in lib/protocols.x and native
protocols and adoptions beside the participant typedef. User modules have no
required filename. A fully public native adoption emits aliases in the header;
a private adoption or one with a private dependency keeps them in the
participant’s C file.
Two private typedefs may adopt a private protocol in one translation unit:
static protocol PrivateA(PrivateB);. Omitting static has the same effect
because the dependencies are private. If the selected methods need
conversions in both directions, define PrivateB.privatea and
PrivateA.privateb. The entire relationship stays within the translation
unit.
Conversions are demanded by adapters
The selected methods determine which conversions an adoption needs. The compiler generates adapters for those methods and requires only the conversions each adapter uses:
| Adapter | Required conversion |
|---|---|
Ordinary default parameter exactly T | participant to base |
Ordinary default result exactly T | base to participant |
Dispatcher thunk parameter of T | base to participant |
Dispatcher thunk result of T | participant to base |
Native alias occurrence of T | the implicit C direction at that position |
For a participant P and base Base, name the forward conversion:
Base P.base(P)
The forward converter is a total view: every value of the declared
participant must produce a valid base value. A reverse converter has the
conventional spelling P Base.p(Base), with Base.as_p reserved for a real
name collision. It may be checked and partial because not every base value
must contain a P. Signatures cannot express this totality distinction, so it
is part of the protocol’s rules.
For example, Var(T) descriptor thunks both unbox arguments and box results,
so most object participants need both directions:
typedef struct Vec *Vec;
Var Vec.var(Vec value);
Vec Var.vec(Var value);
protocol Var(Vec);
A forwarding protocol whose defaults use T only as a parameter may need
only the forward conversion. A default returning T also needs the reverse
conversion to turn its base result into the participant type. The adapter
generator does not convert T inside compound parameter or result types, so
those types are rejected. A missing or unsupported conversion produces an
error at the adoption, naming the method that needs it.
Member resolution
Each adopted member resolves independently. Omitting one does not prevent the others from working.
| Resolution | Dot call | Mapped punctuation | Descriptor-backed box |
|---|---|---|---|
implemented | participant member | participant member | typed thunk |
native | checked native alias | checked native alias | not implied |
ordinary base-default | generated forwarding member | forwarding member | routes through the same winner |
no-member | no | no | another protocol’s owner, else the base fallback |
sig-conflict | compile error | compile error | compile error |
An ordinary base default calls a base implementation after converting the
participant through its total forward view. Dot syntax and punctuation use that
same semantic member, and Var(T) publishes it on T’s descriptor even
though Var(T) itself resolves the member as no-member. A Var fallback
such as Var.fallback_truth is only last-resort boxed behavior: a descriptor
slot that no protocol filled reaches it directly and does not grant a static
participant method or replace native C punctuation.
An explicit adoption can use a compatible method inherited through the participant’s typedef chain:
typedef List Domain;
protocol Iter(Domain);
Domain can already iterate through its inherited List adoption. The
explicit adoption resolves Iter again and selects List.iter. Iteration
therefore calls List_iter directly, with no Domain.iter forwarding method
or participant-to-base adapter.
Selection tries the participant’s own method first, then the nearest inherited
method, then an ordinary base default. Reaching the protocol base uses the
same default rule. When checking a method, the compiler treats parameters
with the method’s exact receiver type as participant parameters. Thus
List.equal(List, List) can satisfy int T.equal(T, T) for a List typedef.
A concrete result keeps its declared type; a Self result preserves the
participant’s static typedef.
Var(T) normally remains exact: an ordinary adoption does not search the
participant’s typedef chain. protocol Var(P) as R is the exception. When
P inherits from the explicitly named representation R, compatible
methods owned by R satisfy the adoption without P forwarding methods.
Boxed values already use R’s descriptor, so compile-time conformance uses
the same implementation without changing runtime dispatch.
A participant method with the right name but an incompatible signature is a
sig-conflict. The compiler reports an error at the adoption and identifies
the protocol member and conflicting definition. It does not treat the method
as omitted or issue only a warning.
Associated types
associated Name = Default; declares a type variable local to the protocol.
The compiler infers a participant’s binding by unifying the member signatures
the participant declares. Every occurrence must resolve to one type, and the
default applies only when no declared member constrains the name.
This lets one protocol preserve different static APIs. For example, Array can
bind an index Key to int, while Map binds it to Var. Calls through a
boxed value pass associated arguments and results as Var; the generated thunk
converts each exactly once. Direct calls keep the participant’s static
signature.
Punctuation
Operators call the same resolved methods as dot syntax. The mappings include:
| Syntax | Member |
|---|---|
+ - * / % | add sub mul div mod |
direct += -= *= /= %= | add sub mul div mod |
direct prefix/postfix ++ -- | add sub with a converted 1 |
unary - | neg |
== != | equal |
< <= > >= | compare against zero |
conditions, !, &&, ` | |
needle in value | contains, with the right operand as receiver |
| indexing and indexed mutation | getindex, setindex, updateindex, postfixindex |
An implemented or native member is eligible. An ordinary base default is also
eligible because it is an inherited member reached through a total view. An
empty Var descriptor slot is not a resolved static member. If no eligible
member exists, valid native C behavior still applies; otherwise the compiler
reports the ordinary operator error. The runtime uses the same rows itself:
String.add implements add for protocol Var(String), so String + and
+= are ordinary resolved punctuation, not compiler special cases.
Logical operators preserve C short-circuit evaluation: each operand’s truth
method runs only when C would evaluate that operand. Indexed compound and
postfix operations evaluate the receiver, key, and value once, then call the
update method once.
A direct participant update requires a resolved binary member with signature
Participant member(Participant, RHS). Compound assignment converts its
right operand to RHS. Increment and decrement convert the integer 1 to
RHS and use add or sub. The addressable participant lvalue is evaluated
once, its current value is passed to the member once, and the returned
Participant is stored once. Compound assignment and prefix forms return the
stored value; postfix forms return the value from before the call.
Plain =, ===, and !== are never overloaded. === and !== retain
native identity for static participants and Var-bit identity for boxed
values.
When protocols share a method
A participant implementation satisfies every adopted protocol that declares the same compatible member, and no protocol generates that member.
Two ordinary defaults are an error. The diagnostic names both protocols. Implement the member on the participant to choose its semantics. Declaration order does not decide.
A Var(T) adoption puts that same generated member on T’s descriptor when
Var(T) declares the member and T does not implement it, so static and
boxed behavior agree on one generated C name.
Before adding a protocol member, inspect the conformance dump for every participant homonym and its signature:
./builds/0/x2c translate --dump-conformance path/to/unit.x
An existing method with the same name must have both the intended meaning and a compatible signature. Rename it before extending the protocol if it means something else. Incompatible signatures are compilation errors.
Worked examples
Vec: declared boxed behavior
Vec supplies the conversions that Var(T)’s descriptor thunks use, implements
only str, and explicitly adopts the protocol:
typedef struct Vec {
int x;
int y;
} *Vec;
Var Vec.var(Vec value) {
return Var.new(<vec>, value);
}
Vec Var.vec(Var value) {
return (Vec) value.pointer();
}
String Vec.str(Vec value) {
return %"Vec(${value.x},${value.y})";
}
protocol Var(Vec);
int main(void) {
Vec value = Scope.malloc(sizeof(struct Vec));
value.x = 1;
value.y = 2;
Var boxed = value;
printf("%s\n", boxed.str());
return 0;
}
value.str() calls Vec.str; boxed.str() calls a typed descriptor thunk
that unboxes once and then calls Vec.str. Vec does not gain add, indexing,
or any other omitted member. Removing protocol Var(Vec); removes protocol
participation even though both conversion functions still exist.
Polar: a total forward view
Polar implements magnitude and inherits the Cartesian.angle member.
The generated Polar.angle needs only the total forward view:
#include <math.h>
typedef struct Cartesian {
double x;
double y;
} *Cartesian;
typedef struct Polar {
double radius;
double theta;
} *Polar;
protocol Cartesian(T) {
double T.magnitude(T);
double T.angle(T);
}
Cartesian Polar.cartesian(Polar value) {
Cartesian result = Scope.malloc(sizeof(struct Cartesian));
result.x = value.radius * cos(value.theta);
result.y = value.radius * sin(value.theta);
return result;
}
double Cartesian.magnitude(Cartesian value) {
return sqrt(value.x * value.x + value.y * value.y);
}
double Cartesian.angle(Cartesian value) {
return atan2(value.y, value.x);
}
double Polar.magnitude(Polar value) {
return value.radius;
}
protocol Cartesian(Polar);
int main(void) {
Polar value = Scope.malloc(sizeof(struct Polar));
value.radius = 2.0;
value.theta = 0.5;
printf("%.1f %.1f\n", value.magnitude(), value.angle());
return 0;
}
No Cartesian.polar reverse conversion is required because this conformance
generates only forward calls. A same-name incompatible Polar.angle would be
a hard error at protocol Cartesian(Polar);.
Bytes: ordinary defaults and punctuation
The built-in manifest declares protocol Block(Bytes); centrally. The
Bytes.block forward view is declared in common.x and implemented in
block.x; no reverse Block.as_bytes adapter is needed. Members such as
len, truth, pop, free, and truncate forward through that view.
int main(void) {
Bytes bytes = Bytes.new(sizeof(int));
int value = 7;
bytes.push(&value);
printf("%zu %d\n", bytes.len(), bytes ? 1 : 0);
bytes.pop();
bytes.free();
return 0;
}
bytes.truth() and truth punctuation select the same ordinary Block.truth
semantics. A scalar that adopts Var(T) without implementing truth or
equality gains no static method, so its native punctuation is not replaced by
Var.fallback_truth or Var.fallback_equal.
Native protocols
A native protocol maps members directly to C functions:
typedef void *Handle;
protocol void *(T) {
void T.release(T) = free;
}
protocol void *(Handle);
Native participation is declared by the same bodyless adoption form; a
matching typedef alone is insufficient. Each occurrence of T is checked in
the C conversion direction that the alias uses. No named bidirectional pair
is required.
The participant’s translation unit emits a _Generic/_Static_assert
signature check and an alias such as #define Handle_release free. A fully
public adoption places them in the participant’s generated header. If the
protocol, participant, native target, or adoption is private, both remain in
the participant’s C file.
Variadic native targets and unsafe conversion directions are rejected.