Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

ProtocolParticipantTypical spelling
publicpublicprotocol Base(Participant);
public Varprivateprotocol Var(PrivateType);
privateprivatestatic 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:

AdapterRequired conversion
Ordinary default parameter exactly Tparticipant to base
Ordinary default result exactly Tbase to participant
Dispatcher thunk parameter of Tbase to participant
Dispatcher thunk result of Tparticipant to base
Native alias occurrence of Tthe 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.

ResolutionDot callMapped punctuationDescriptor-backed box
implementedparticipant memberparticipant membertyped thunk
nativechecked native aliaschecked native aliasnot implied
ordinary base-defaultgenerated forwarding memberforwarding memberroutes through the same winner
no-membernonoanother protocol’s owner, else the base fallback
sig-conflictcompile errorcompile errorcompile 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:

SyntaxMember
+ - * / %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 valuecontains, with the right operand as receiver
indexing and indexed mutationgetindex, 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.