← back to home

Facets in Zinc

· 1363 words · 7 min read

Facets in Zinc#

I have been thinking about how to handle the "shared behavior" problem in Zinc for a while. Most languages solve it with inheritance or interfaces. I don't want either of those. I want something that fits the model I already have - structs and functions - and adds just enough machinery on top to let me write generic code without throwing away type safety. I'm calling them facets. They're not implemented yet, but I want to write down exactly how they work and how I plan to build them, so that when I come back to this I don't have to rediscover everything from scratch.


Why not just interfaces#

Interfaces as most OOP languages implement them come with baggage. A class "implements" an interface by declaring it. There's a hierarchy. There's vtable machinery baked into every object. In C++ every class with virtual methods carries a hidden pointer to its vtable. In Java every object is a heap allocation with a header that points to its class metadata. Zinc structs are flat. A Rect is just two floats side by side in memory. Nothing more. I want to keep it that way. If you never use a facet, you should pay zero overhead. No hidden pointers. No metadata.

The other problem with classical interfaces is that you tie the declaration to the implementing type. In Java, Rect implements Shape lives in the Rect definition. If Rect is from a library you don't control, you can't retroactively make it implement your interface. Zinc facets are defined and attached separately from the struct itself, which gives more flexibility.


The syntax#

A facet is declared with the facet keyword and lists method signatures, nothing else:

facet Shape {
  f32 area()
  f32 perimeter()
}

To attach a facet to a struct, you name it in parentheses after the struct name:

struct Rect(Shape) {
  f32 Width
  f32 Height
}

struct Circle(Shape) {
  f32 Radius
}

The implementations are ordinary methods. Nothing special about them:

f32 area() Rect self {
  return self.Width * self.Height
}

f32 perimeter() Rect self {
  return 2.0 * (self.Width + self.Height)
}

f32 area() Circle self {
  return 3.14159 * self.Radius * self.Radius
}

f32 perimeter() Circle self {
  return 2.0 * 3.14159 * self.Radius
}

And you can write a function that accepts any Shape:

f32 totalArea(Shape shape) {
  return shape.area()
}

What a facet value actually is#

This is the core of it. A Shape is not a real type in the sense that a Rect is. There is no layout for a Shape. You can never allocate a raw Shape. When I say "this function takes a Shape," what I mean at the machine level is: this function takes two things.

  1. A pointer to the concrete object (a *Rect, a *Circle, whatever).
  2. A pointer to a dispatch table - an array of function pointers, one per method in the facet.

This pair is sometimes called a fat pointer or a trait object (Rust uses the latter term). The function that receives a Shape doesn't know and doesn't need to know what the concrete type is. It only knows: I have a pointer to some data, and I have a table that tells me how to call area() and perimeter() on that data.

The dispatch table for Rect implementing Shape would look like this conceptually:

RectShapeTable = [
  { name: "area",      fn: Rect__area      },
  { name: "perimeter", fn: Rect__perimeter },
]

And for Circle:

CircleShapeTable = [
  { name: "area",      fn: Circle__area      },
  { name: "perimeter", fn: Circle__perimeter },
]

These tables are static. They're generated at compile time, once per (struct, facet) pair. There is no runtime cost to creating them.


Calling a method through a facet#

When the compiler sees shape.area() inside a function that received a Shape, it knows shape is a fat pointer - it has the data pointer and the table pointer. The call becomes:

  1. Walk the table until you find the entry with name "area".
  2. Load the function pointer from that entry.
  3. Call it, passing the data pointer as the first argument (the self parameter).

In practice the table is small - facets shouldn't have dozens of methods - so the linear scan is fine. If it ever mattered, the names could be replaced by stable integer indices and the scan becomes a direct array lookup.

The important thing is that from the caller's side, shape.area() looks identical to rect.area(). The compiler figures out which path to take based on whether the receiver type is a facet or a concrete struct.


Semantic rules I need to enforce#

A struct that declares a facet must implement all of its methods. This is checked at the end of compilation (or at the end of the file where the struct is defined). If Rect claims to implement Shape but there is no perimeter method for Rect, that is a compile error.

Method signatures must match exactly. The return type and parameter types must be identical to what the facet declares. The receiver doesn't count - it's implicit. But everything else does.

Facets cannot hold state. A facet is only a list of function signatures. No fields.

A facet value is always borrowed. You can't own a Shape. You can only have a reference to something concrete that happens to implement Shape. This sidesteps ownership questions entirely - the concrete value lives wherever it lives, and the facet is just a view into it.

Facets can be used as function parameters, return types, and local variables. All three cases reduce to the fat pointer pair at the IR level.


Nested facets (facets requiring other facets)#

One extension I want eventually is the ability to say "a facet requires another facet." Something like:

facet Drawable(Shape) {
  u0 draw()
}

Meaning: anything that implements Drawable must also implement Shape. This is purely a semantic constraint - a struct claiming Drawable must have all the Shape methods too. The dispatch table for Drawable would include all of Shape's entries plus its own. I'll defer this until the base case works.


What the compiler needs to track#

During semantic analysis, I need to build up a mapping from each struct to the facets it implements, and for each (struct, facet) pair, a mapping from method name to the resolved function. This is the information that will later be used to generate the static dispatch tables.

During code generation, I need to:

  1. Emit the static dispatch tables as global arrays - one per (struct, facet) pair. These are constant data.
  2. When a facet value is created (e.g., passing a *Rect where a Shape is expected), emit code to construct the fat pointer: the data pointer and a pointer to the right static table.
  3. When a method call goes through a facet, emit the table lookup and indirect call.

The fat pointer itself can be represented as a two-field struct in the IR: one pointer field for data, one pointer field for the table. The table itself is an array of structs, each holding a string (or index) and a function pointer.


The tradeoff#

This design pays a small runtime cost for every method call through a facet - you're doing an indirect call instead of a direct one, plus a table scan. For most code this is invisible. If you're calling shape.area() a million times in a tight loop, you should probably not be using a facet - you should be working with the concrete type directly. Facets are for the abstraction boundaries, not the hot path.

The benefit is that the concrete types are untouched. A Rect in memory is still just two floats. The facet overhead exists only when you choose to use the facet. That feels like the right tradeoff for a language that cares about being explicit about what you're paying for.


That's the full picture. If you are interested in the zinc compiler here is the github


← back to home · rss