(you can find previous post about same topic here)
Polymorphism through interfaces is handled differently between Zig and Go. While Go defines an interface keyword and proposes a very lightweight implementation translated into a motto like "if it looks like a duck, then it is one", in Zig there is no dedicated syntax and therefore one must resort to structures and compile-time generation.
Let's see a first example of polymorphism between two models that have common methods and can be collected together.
The Go Approach
First, let's define the interface
type Priceable interface {
GetTotalPrice() uint16
IsSKU() bool
}
Let's define the structures that represent the entities we need to manage
type SKU struct {
ID string
Price uint16
}
type Product struct {
Code string
Quantity uint32
Skus []SKU
}
In Go, to implement the Priceable interface, it's enough to define methods for the 2 structures and these will automatically implement the interface
func (s *SKU) GetTotalPrice() uint16 {
return s.Price
}
func (s *SKU) IsSKU() bool {
return true
}
func (p *Product) GetTotalPrice() uint16 {
var result uint16
for _, s := range p.Skus {
result += s.GetTotalPrice()
}
return result * uint16(p.Quantity)
}
func (p *Product) IsSKU() bool {
return false
}
We just need to instantiate the entities and we can use the common methods
func main() {
sku1 := SKU{
ID: "1",
Price: 150,
}
sku2 := SKU{
ID: "2",
Price: 250,
}
product := Product{
Code: "product1",
Quantity: 1,
Skus: []SKU{sku1, sku2},
}
items := []Priceable{
&sku1,
&sku2,
&product,
}
for _, item := range items {
if !item.IsSKU() {
fmt.Println(item.GetTotalPrice())
}
}
}
In Zig there are at least 2 ways to approach the problem.
First, let's define the two structs to represent the entities to manage and their related methods
const SKU = struct {
id: []const u8,
price: u16,
pub fn getTotalPrice(self: *const SKU) u16 {
return self.price;
}
pub fn isSku(_: *const SKU) bool {
return true;
}
};
const Article = struct {
code: []const u8,
quantity: u16,
skus: []*const SKU,
pub fn getTotalPrice(self: *const Article) u16 {
var result: u16 = 0;
for (self.skus) |sku| {
result += sku.getTotalPrice();
}
return result * self.quantity;
}
pub fn isSku(_: *const Article) bool {
return false;
}
};
Tagged Union
With this approach, we'll group the possible types that we collect with a union which specifically is of type tagged, meaning with an enum that explicitly defines the fields. The union itself will expose methods in which we'll distinguish the types to be able to call the methods we're interested in; in this case they correspond as signatures but could obviously be different since we've already distinguished the type upstream.
The disadvantage in this case is that every type added to the union will need to be propagated in the various exposed methods. Let's see an example:
// define enum
const PriceItemEnum = enum {
sku,
article,
};
// define tagged union, enum type is optional because it can be inferred
const PriceItemType = union(PriceItemEnum) {
sku: SKU,
article: Article,
pub fn getTotalPrice(self: PriceItemType) u16 {
// distinguish the type by enum
return switch (self) {
// value is captured by pointer
.sku => |*s| s.getTotalPrice(),
.article => |*a| a.getTotalPrice(),
};
}
pub fn isSku(self: PriceItemType) bool {
// in this case struct method is not used
return switch (self) {
.sku => true,
.article => false,
};
}
};
const s1: SKU = .{
.id = "1",
.price = 150,
};
const s2: SKU = .{
.id = "2",
.price = 250,
};
var skus = [_]*const SKU{ &s1, &s2 };
const a: Article = .{
.code = "article1",
.quantity = 2,
.skus = &skus,
};
// collection definition with unions
const itemsB = [_]PriceItemType{ .{ .sku = s1 }, .{ .sku = s2 }, .{ .article = a } };
for (itemsB) |itemb| {
if (!itemb.isSku()) {
std.debug.print("{d}\n", .{itemb.getTotalPrice()});
}
}
Interface-like
This approach makes the usage experience more similar to how interfaces are used and is used in Zig, for example, for memory allocators.
// Define the "interface" struct
const Priceable = struct {
// Opaque pointer where reference concrete implementation
ptr: *const anyopaque,
// Function pointers for interface methods
getTotalPriceFn: *const fn (ptr: *const anyopaque) u16,
isSkuFn: *const fn (ptr: *const anyopaque) bool,
// ------------------------------------------------------------
// Interface methods: re-dispatch to concrete implementation
pub fn getTotalPrice(self: *const Priceable) u16 {
return self.getTotalPriceFn(self.ptr);
}
pub fn isSku(self: *const Priceable) bool {
return self.isSkuFn(self.ptr);
}
// ------------------------------------------------------------
// Generic constructor: creates interface from any concrete type
pub fn init(pointer: anytype) Priceable {
// Extract concrete type at compile time
const T = @TypeOf(pointer);
// Extract pointer type information
const ptrInfo = @typeInfo(T);
// Generate type-specific wrapper functions at compile time
const concreteMethods = struct {
pub fn getTotalPrice(ptr: *const anyopaque) u16 {
// Cast opaque ptr to concrete type and call method
const self: T = @ptrCast(@alignCast(ptr));
return ptrInfo.pointer.child.getTotalPrice(self);
}
pub fn isSku(ptr: *const anyopaque) bool {
// Cast opaque ptr to concrete type and call method
const self: T = @ptrCast(@alignCast(ptr));
return ptrInfo.pointer.child.isSku(self);
}
};
// Return interface struct that wraps concrete implementation
return Priceable{
// Cast concrete type to opaque pointer
.ptr = @ptrCast(pointer),
.getTotalPriceFn = concreteMethods.getTotalPrice,
.isSkuFn = concreteMethods.isSku,
};
}
};
Certainly, at first glance this implementation seems complex, and to some extent it is when compared to Go, but the advantage is that everything is resolved at compile-time since in the init method we use the anytype keyword which forces resolution at compilation: this means that at runtime we have no type of reflection and therefore no abstraction to resolve. A use case like this is very useful when we have heterogeneous collections and don't need to know the types in advance, but only need to implement the methods which, if missing, would be identified by the compiler as an error. Note the pointer casts in both directions, from unknown to concrete and vice versa.
Let's complete the example with the use of this approach to polymorphism using the same implementations of Article and SKU used for the example with tagged unions.
const s1Priceable = Priceable.init(&s1);
const s2Priceable = Priceable.init(&s2);
const aPriceable = Priceable.init(&a);
const items = [_]*const Priceable{ &s1Priceable, &s2Priceable, &aPriceable };
for (items) |item| {
if (!item.isSku()) {
std.debug.print("{d}\n", .{item.getTotalPrice()});
}
}
One of the best posts to fully understand all the details about this last approach can be found here.
Top comments (0)