Interfaces And Dynamic Dispatch
Interfaces are Camp’s named dynamic contracts. They are useful when a caller should be able to work with “something that can write bytes” or “something that can report a value” without knowing the concrete type.
Camp does not make that work through reflection, runtime type information, or structural duck typing. An interface is a vtable-shaped contract, and a type implements it explicitly. That keeps interface calls visible enough to cross a native ABI while still giving ordinary Camp code a pleasant call surface.
This chapter starts with everyday interface use, then opens the box just far enough to explain what an interface pointer points at, how class and struct implementations differ, and why that difference matters for lifetimes.
A First Interface
An interface declares the operations a caller can use:
interface TextSink
{
void write(const char[] text);
}
A class or struct implements the interface by naming it in the type declaration and marking the method that fills the slot:
class LogSink: TextSink
{
void write(const char[] text): TextSink
{
writeLogLine(text);
}
}
The marker matters. A same-name method is not enough by itself. : TextSink says this method implements the TextSink.write slot.
When the method name differs from the interface slot, use the explicit slot form:
class AuditSink: TextSink
{
void appendAuditEntry(const char[] text): TextSink.write
{
writeAuditEntry(text);
}
}
That is useful when the concrete type wants a domain-specific method name but still satisfies a broader interface contract.
Interface And Interface*
Camp makes a distinction that is easy to miss at first:
| Form | Meaning |
|---|---|
TextSink | An interface table value; its unbound ordinary slots take TextSink*. |
TextSink* | An interface-instance pointer used for ordinary dynamic calls. |
vtableof(T: TextSink) | A witness for T; its unbound ordinary slots take T*. |
Most code uses TextSink*.
void writeHeader(TextSink* sink)
{
sink.write("header");
}
An interface pointer is not simply a pointer to the concrete object. It points to an interface-instance slot. That slot tells Camp which vtable to use, and the vtable entry knows how to get from the interface context to the concrete receiver.
You can think of bare TextSink as the vtable-level thing: it describes the table of callable slots. TextSink* is one step further out. It is the pointer you pass around when you want to call those slots on a particular object or adapter.
The vtable-level form is mostly visible in generic capability syntax such as vtableof(T: TextSink) or in low-level code that intentionally works with slot functions. If you are just calling interface methods, use Interface*.
How An Interface Call Works
You can read this source-level call directly:
sink.write("ready");
Conceptually, Camp does three things:
- It reads the vtable from the interface pointer.
- It finds the
writefunction pointer in that vtable. - It calls that function pointer, passing the interface pointer as the call context along with the ordinary arguments.
A simplified C-shaped sketch looks like this:
typedef struct TextSinkVTable TextSinkVTable;
typedef struct TextSinkSlot TextSinkSlot;
struct TextSinkVTable {
void (*write)(void *context, const char *text, size_t text_length);
};
struct TextSinkSlot {
const TextSinkVTable *vtable;
};
That is not a promise about exact emitted names. It is the useful model: interface dispatch is a vtable load followed by an indirect call. The context comes from the Interface* value, not from a hidden runtime object. The erased void* slot does not make source receivers interchangeable: an explicit TextSink table slot takes TextSink*, while a vtableof(Concrete: TextSink) witness slot takes Concrete*.
Readers coming from C# or Java can think of the vtable as the method table that tells the call which implementation to use. Readers coming from C can think of it as a struct of function pointers with a context pointer passed to each function. Camp’s source syntax lets both views meet at sink.write(...).
Class Implementations
Classes are the natural fit when an interface pointer needs to be stored, returned, or used for as long as the object lives.
class CountingSink: TextSink
{
int count;
void write(const char[] text): TextSink
{
this.count += 1;
writeLogLine(text);
}
}
For each directly implemented interface, a class instance has hidden dispatch storage. Conceptually, the object contains a vtable-pointer slot for that interface. Converting CountingSink* to TextSink* returns a pointer to that slot inside the object.
Camp also generates an interface accessor for the class. For CountingSink: TextSink, that accessor is named getTextSink():
CountingSink* sink = new CountingSink();
TextSink* view = sink.getTextSink();
You usually do not need to call the accessor yourself, because ordinary conversion works at assignment and call sites. The accessor is useful when you want to name the projection explicitly, or when you are looking at generated API surface and wondering where the interface pointer comes from.
That means the interface pointer’s lifetime is tied to the object:
CountingSink* sink = new CountingSink();
TextSink* view = sink;
view.write("created");
delete sink;
view is valid while the object is valid. After sink is deleted, the interface pointer is not safe to use, because it points into the deleted object.
If the hidden interface slot is not at the beginning of the object, the vtable entry can be a small adapter that recovers the containing object before calling the concrete method. You normally do not think about that adapter, but it explains why an interface call can start from an interface slot rather than a plain object pointer.
Struct Implementations
A struct can implement an interface too:
struct BufferSink: TextSink
{
nuint written;
void write(const char[] text): TextSink
{
this.written += text.length;
}
}
The lifetime story is different. Structs do not gain hidden interface fields. When a mutable struct local or mutable struct pointer is converted to an interface pointer, Camp creates a scoped adapter. That adapter contains:
- a vtable pointer for the struct/interface pair;
- a pointer back to the original struct storage.
Then the interface call uses the adapter as its context, and the vtable slot uses the stored struct pointer to call the concrete method.
void writeGreeting(TextSink* sink)
{
sink.write("hello");
}
BufferSink sink = default;
writeGreeting(sink);
writeGreeting(&sink);
The adapter does not copy the struct value and does not box it on the heap. It is scoped storage. That is the key rule.
Camp does not make interface views from const struct storage or from unnamed temporary struct results. Store the value in a mutable local first when you want to view it through an interface.
A struct-backed interface pointer is safe only while the adapter and the original struct storage are both alive. It is fine for a call or a fresh local whose scope contains the use. It is not a good value to store in an escaped field, return from a function, or keep after the struct storage goes away.
Use a class when you want an interface pointer that can naturally live with an object. Use a struct when the value is ordinary storage and the interface view is a short-lived way to call through a contract.
Required, Optional, And Defaulted Slots
An interface method without an initializer is required. Every implementing type must provide a compatible marked method.
interface CounterView
{
int getValue();
}
An interface method initialized with null or default is optional:
interface DebugSink
{
void write(const char[] text);
void writeDebugName(const char[] name) = null;
}
The slot still exists in the vtable, but an implementing type may leave it empty. Camp does not insert a null check before calling it. Check the slot when absence is possible:
void describe(DebugSink* sink)
{
sink.write("debug");
if (sink.writeDebugName != null)
sink.writeDebugName("parser");
}
A method initialized with a function target is defaulted:
interface LineSink
{
void write(const char[] text);
void writeLine(const char[] text) = defaultWriteLine;
}
void defaultWriteLine(LineSink* this, const char[] text)
{
this.write(text);
this.write("\n");
}
An implementing type can override the default by providing a marked method. If it omits the method, the vtable uses the default target when the method is called through the interface view:
LineSink* sink = openLineSink();
sink.writeLine("ready");
There is no hidden C#-style body attached to the concrete type. The default is an ordinary function pointer in the interface vtable slot.
Lifecycle Contracts
An interface can describe construction and cleanup too. This is useful when a generic API does not merely need “something that can be called”, but a type it can create, initialize in an allocation context, and clean up again.
interface Managed
{
Managed(within allocator);
~Managed(within allocator);
}
The implementing type writes ordinary constructors and destructors with the matching lifecycle shape. For example, when the interface says cleanup receives an allocation context, the implementation does too:
sealed class ManagedBuffer: Managed
{
ManagedBuffer(within allocator)
{
}
~ManagedBuffer(within allocator)
{
}
}
The within allocator parameter is part of the lifecycle contract. Code that constructs or deletes a Managed implementation can pass the allocation context through the same way it would for any other constructor or destructor.
Some classes retain their allocator in the constructor instead:
interface RetainedManaged
{
RetainedManaged(within allocator);
~RetainedManaged();
}
sealed class RetainedBuffer: RetainedManaged
{
RetainedBuffer(within this.allocator)
{
}
~RetainedBuffer()
{
}
}
In that shape, construction still accepts an allocation context, but cleanup does not need a within parameter because the object already remembers its allocator. Interface lifecycle contracts are exact: a parameterless destructor slot and a within destructor slot are different contracts.
Generic code constructs a value through the contract with new T(...) inside a within context. The call runs the constructor recorded in the vtableof(T: Managed) capability, which allocates and constructs the value using the allocation context passed for the within allocator parameter. stackalloc T(...) is not available through an interface constructor.
Deletion works the same way: generic code deletes a T* through the destructor recorded in the capability, and the pointer must be escaped (for example a result of new T() or a parameter declared escaped T*), because the destructor slot also frees the object. An interface pointer cannot be deleted; only the generic code that holds the capability can.
The capability may be a parameter or one retained by a generic class constructor, and a witness for a derived interface supplies the inherited constructor and destroy slots of its base interfaces.
Structs can implement constructor and destructor contracts. Classes can do so when they are sealed. An unsealed class has derived construction and deletion paths, so it cannot safely promise a single interface constructor shape for every object that might later inherit from it.
Interface Inheritance
Interfaces may inherit from other interfaces:
interface Reader
{
nuint read(byte[] buffer);
}
interface SeekableReader: Reader
{
void seek(nuint position);
nuint getPosition();
}
A SeekableReader* can be used where a Reader* is expected. The conversion may be a simple view or may require an adjusted interface pointer, depending on the inherited interface layout.
You do not need to know the exact adjustment rules to use the feature well. The practical rule is this: interface inheritance is nominal, and inherited members are part of the derived contract. If two inherited paths make a member ambiguous, write the interface you mean rather than relying on guesswork.
List each direct base interface once. Do not redeclare an inherited ordinary method in the derived interface, even to repeat the same signature. Two unrelated bases may each declare the same method name; use the appropriate base interface view to select one.
Generic Interfaces
An interface may take type parameters. A type implements one construction of it, and the marker names that construction exactly:
interface Check<A: int>
{
bool same(A a, A b);
A pass(A a);
}
class ByteCheck: Check<byte>
{
bool same(int<byte> a, int<byte> b): Check<byte> => a == b;
int<byte> pass(int<byte> a): Check<byte> => a;
}
struct Wide<T: int>: Check<T>
{
int pad;
bool same(T a, T b): Check<T> => a == b;
T pass(T a): Check<T> => a;
}
Check<byte>* and Check<short>* are different types. A ByteCheck* converts to Check<byte>* only; naming another construction in a marker, listing two constructions of one interface on a type, or converting to a different construction is an error. Inherited constructions follow the same rule: interface Derived<B: int>: Base<B> is implemented as Derived<short>, and a Derived<short>* calls the inherited Base<short> members directly or converts to Base<short>*.
A generic interface method is part of the contract. The implementation must have the same number of generic parameters in the same order, the same representation for each (U: int requires U: int, not a pointer-only U), and the same capability parameters by kind, operand and order. Names are immaterial, so tag<U> may be implemented as tag<V>:
interface Both<A: int>
{
A tag<U>(A a, U* p, sizeof(U));
}
class ByteBoth: Both<byte>
{
int<byte> tag<V>(int<byte> a, V* p, sizeof(V)): Both<byte> => a;
}
A reference to a member of an interface pointer keeps dynamic dispatch and is fully substituted: auto f = check.pass; has the construction’s type, and calling f runs the implementation.
Constness And Lifetimes
Interface method signatures carry the same kind of contract as ordinary method signatures. Parameter types, return types, out, thrown, constness, and lifetime annotations all matter.
interface CellView
{
const int* value(const this);
}
An implementation must satisfy that contract:
struct IntCell: CellView
{
int storage;
const int* value(const this): CellView
{
return &this.storage;
}
}
The interface says value can be called on a const receiver and that callers receive a read-only pointer. The implementation keeps both promises.
This implementation would be rejected:
struct MutableOnlyCell: CellView
{
int storage;
int* value(): CellView
{
return &this.storage;
}
}
The receiver is mutable-only, so it cannot implement a slot that requires const this.
The same rule applies to the whole signature. If the interface declares a thrown slot, the implementation must keep it:
enum ParseError
{
OK = 0,
EMPTY
}
interface Parser
{
int parse(const char[] text, thrown ParseError error);
}
struct SimpleParser: Parser
{
int parse(const char[] text): Parser
{
return (int)text.length;
}
}
SimpleParser.parse is missing the thrown ParseError slot, so it does not implement the interface method. An implementation cannot quietly drop a failure path, change an out shape, or retain a scoped value the interface promised not to retain.
The same principle applies to parameter lifetimes, out, thrown, and other signature details. The interface slot is the public promise; the implementing method must be at least that precise.
Interfaces can also be declared escaped:
escaped interface BackgroundTask
{
void run();
}
An escaped interface is designed for interface pointers that may be stored or called later. The lifetime belongs to the type: every BackgroundTask* is escaped, exactly as if it were written escaped BackgroundTask*, so APIs that take or store one need no lifetime annotation. A class-backed implementation is the usual fit, but the concrete class must be able to supply the escaped receiver contract too:
escaped class TimerTask: BackgroundTask
{
void run(): BackgroundTask
{
pollTimer();
}
}
void schedule(BackgroundTask* task); // escaped: BackgroundTask is an escaped interface
void startTimer()
{
TimerTask* task = new TimerTask();
schedule(task);
}
Because TimerTask is an escaped class, its instance methods default to an escaped receiver. That matches the escaped receiver expected by BackgroundTask.run. A TimerTask* is escaped by type in the same way a BackgroundTask* is.
A struct implementation may satisfy the interface for scoped use, but its automatic interface adapter is scoped storage:
struct StackTask: BackgroundTask
{
void run(): BackgroundTask
{
}
}
void badSchedule()
{
StackTask task = default;
schedule(task); // ERROR: a scoped struct adapter cannot become an escaped interface pointer
}
The error is at the conversion: any BackgroundTask* is escaped, and a struct on the stack cannot produce one. A struct in static storage, or one allocated with new, can. Use a class-backed implementation or another explicitly owned adapter when the interface pointer must escape.
Every member of an escaped interface requires an escaped receiver. An ordinary interface can place that requirement on just one member with escaped this. An escaped class supplies it by default; a class that is not escaped must declare it explicitly:
escaped interface Task { void run(); }
class ExplicitTask: Task { void run(escaped this): Task { } }
escaped class DefaultTask: Task { void run(): Task { } }
escaped class ExplicitEscapedTask: Task { void run(escaped this): Task { } }
struct StackTask: Task { void run(): Task { } }
// class MissingReceiver: Task { void run(): Task { } } // ERROR: needs escaped this
A struct keeps its default receiver; converting scoped struct storage to an escaped interface is rejected as shown above. An implementation must not explicitly demand escaped this when the interface member has an ordinary receiver, since that member’s callers may pass scoped receivers. An escaped class’s implicit receiver default can still implement ordinary members.
Converting to an ordinary interface preserves the source lifetime, even if some members require escaped receivers. Ordinary members remain callable through a scoped view; calling an escaped-receiver member requires an escaped value, just as a direct call does. Use an escaped receiver such as a new allocation.
An escaped interface may inherit an ordinary interface. An interface inheriting an escaped interface must also be declared escaped:
interface Stoppable { void stop(); }
escaped interface Job: Stoppable { void work(); }
interface Runner: BackgroundTask { void go(); } // ERROR: must be escaped
A by-value escaped interface, such as BackgroundTask table, is implicitly a pointer to a vtable and is escaped too. It can be copied, retained, or passed to an escaped parameter without another annotation. This differs from a BackgroundTask* instance pointer, which also carries the implementing object context. Define an escaped vtable’s backing initializer in static storage; local variables can copy its value or hold default.
The standard library’s Allocator is an escaped interface for this reason; the lifetimes chapter shows what that means for within.
Interface Conversions
Interface conversion is where the vtable model becomes a day-to-day rule. Camp can convert a value to an interface pointer only when it knows how to produce a real interface-instance slot.
For a class implementation, conversion can happen directly at assignment:
CountingSink* sink = new CountingSink();
TextSink* view = sink;
view.write("ready");
It can also happen at a call site:
void writeOnce(TextSink* sink)
{
sink.write("once");
}
CountingSink* sink = new CountingSink();
writeOnce(sink);
Both forms use the class object’s hidden interface slot. Calling the generated accessor makes the same projection explicit:
TextSink* view = sink.getTextSink();
For a struct implementation, conversion uses a scoped adapter:
BufferSink buffer = default;
writeOnce(buffer);
writeOnce(&buffer);
TextSink* localView = buffer;
localView.write("again");
That local interface pointer is useful inside the same scope, but it should not escape:
TextSink* badView()
{
BufferSink buffer = default;
return buffer; // ERROR: the adapter would outlive `buffer`
}
When interfaces inherit from other interfaces, derived interface pointers can convert to base interface pointers:
interface FlushableSink: TextSink
{
void flush();
}
void writeAndFlush(FlushableSink* sink)
{
TextSink* text = sink;
text.write("ready");
sink.flush();
}
The conversion may be a direct view or an adjusted interface pointer depending on the inherited contract layout. Source code should not depend on which one the compiler chooses.
Avoid raw casts that fabricate an interface pointer. An interface pointer has to point at a valid interface-instance slot. A plain object pointer or void* does not magically become that slot just because the address is non-null.
When an API expects TextSink*, pass a real implementation. When an API needs to store the pointer, make sure the implementation and lifetime are appropriate for storage.
vtableof At A Glance
Generic code is covered later, but interfaces introduce one generic-looking piece of syntax that is worth recognizing now:
void writeTwice<T>(
T* sink,
vtableof(T: TextSink))
{
sink.write("first");
sink.write("second");
}
<T> selects void representation. The concrete type must directly implement TextSink as a class or struct. vtableof(T: TextSink) supplies the capability needed for erased generic dispatch. It is a witness for this particular T and interface, not a TextSink table value. For example, vtableof(T: TextSink).write(sink, "first") uses a T* receiver; an unbound slot read from a TextSink table requires a TextSink* receiver instead.
You do not need vtableof for ordinary non-generic interface pointers:
void writeOnce(TextSink* sink)
{
sink.write("once");
}
For now, treat vtableof as the explicit capability generic code asks for when it wants to call interface methods on a type parameter. The generics chapter will return to this model.
Designing Interface APIs
Use an interface when callers need dynamic behavior through a named contract. Use a class when identity and long-lived interface pointers are central. Use a struct when the implementation is value-shaped and the interface view is short-lived.
Keep these habits in mind:
- mark implementation methods explicitly;
- keep optional slots rare and check them before calling;
- use defaulted slots for simple shared behavior, not as hidden interface bodies;
- remember that
Interface*points at an interface-instance slot, not necessarily at the concrete object; - prefer class-backed implementations when an interface pointer will be stored or escape;
- prefer struct-backed implementations when callers only need a scoped dynamic view over existing value storage.
Interfaces are one of Camp’s clearest examples of its larger design: write the high-level contract in source, but keep the ABI shape simple enough that a C caller can understand what is really being passed around.