I recently came to know about Zig, a general-purpose programming language and toolchain for maintaining robust, optimal, and reusable software. Zig has a very active community and they are very helpful and friendly.
While building my experiments, I came across a scenario which required using interfaces. Since there is no interface keyword (yet) this took me some time to figure out.
What are interfaces?Interfaces are foundational constructs in object-oriented programming (OOP) that define a contract or a set of abstract methods (functions or procedures) that classes must implement. They act as a blueprint that ensures certain methods are provided by a class, without specifying how these methods are implemented. This allows for a high level of abstraction and flexibility in code design.
Key benefits of interfaces include:In essence, interfaces promote a design-by-contract approach, ensuring that classes adhere to certain behaviors while providing the freedom to determine how those behaviors are achieved.
In Zig, compile-time interfaces offer a powerful mechanism to structure and optimize your code. Unlike runtime interfaces in many other languages, which determine method implementations during program execution, compile-time interfaces in Zig are resolved at compile time. This means the compiler determines the appropriate methods or functionalities to be used, resulting in highly efficient and tailored code.
Interfaces in ZigToday, we will delve into two unique approaches to harnessing this feature. To simplify our exploration, we’ll use the calculation of areas for various shapes as our illustrative example.
The union of enums approach is like grouping together similar objects but only one of these objects is active at any given time.
The CircleBelow is a simple Circle object with a radius property and an area function.
const std = @import("std");The Rectanglepub const Circle = struct {
radius: f32,pub fn area(self: *Circle) !f32 {
return 3.14159265359 * self.radius * self.radius;
}
};pub fn main() !void {
var circleInstance = Circle{ .radius = 10 };
std.debug.print("Area of circle {}\n", .{try circleInstance.area()});
}
An implementation of rectangle, similar to the Circle. Here I have added a check and return error if the check fails.
const std = @import("std");Combining Shapespub const Rectangle = struct {
width: f32,
height: f32,pub fn area(self: *Rectangle) !f32 {
if (self.width < 0.0 or self.height < 0.0) {
return error.InvalidShape;
}
return self.width * self.height;
}
};pub fn main() !void {
var rectangleInstance = Rectangle{ .width = 10, .height = 20 };
std.debug.print("Area of rectangle {d}\n", .{try rectangleInstance.area()});
}
This is where we combine the circle & rectangle together into a Shape object. Unlike interfaces, this is actually a union of multiple objects.
const std = @import("std");
const Circle = @import("circle.zig").Circle;
const Rectangle = @import("rectangle.zig").Rectangle;const Shape = union(enum) {
circle: Circle,
rectangle: Rectangle,pub fn area(self: *Shape) !f64 {
switch (self.*) {
inline else => |*case| return try case.area(),
}
}
};pub fn main() !void{
var shape = Shape{ .rectangle = Rectangle{ .width = 10, .height = 20 } };
std.debug.print("Area of shape {}\n", .{try shape.area()});shape = Shape{ .circle = Circle{ .radius = 10} };
std.debug.print("Area of shape {}\n", .{try shape.area()});
}
Here, `inline else` is a Zig construct that matches all other cases not explicitly listed in the `switch`. It’s a powerful way to handle multiple possibilities with one statement.
This approach makes it quite easy to combine multiple objects. However, using this approach means that anytime you wish to add a new object in the Shape, means that the code needs to be modified. It’s not suitable for a library where you potentially wan users to extend the interface with custom implementations.
FactoryWe can also add a factory method that takes an instance and it’s type to return a shape instance. In this situation we don’t need to worry about setting the Shape instance property explicitly.
pub fn from(ctx: *anyopaque, comptime T: type) Shape {
const ref: *T = @ptrCast(@alignCast(ctx));
switch (T) {
Circle => return Shape{ .circle = ref.* },
Rectangle => return Shape{ .rectangle = ref.* },
else => @compileError("Invalid type provided to Shape.from"),
}
}
Using this the implementation changes to:
var rect = Rectangle{ .width = 10, .height = 20 };Using Reference Pointersshape = Shape.from(&rect, Rectangle);
std.debug.print("Area of shape {}\n", .{try shape.area()});// or
shape = Shape.from(&rect, @TypeOf(rect));
std.debug.print("Area of shape {}\n", .{try shape.area()});
If you are familiar with zig, then you would know how the `Allocator` works. You can use multiple allocators, and the way it is implemented, developers can write their own Allocators. Based on the code it looks like zig treats modules similar to structs. I have used a struct here to make it easier for me to understand and use.
ShapeThe shape interface includes the following:
const Shape = struct {
ptr: *anyopaque,
impl: *const Interface,pub const Interface = struct {
area: *const fn (ctx: *anyopaque) f32,
};pub fn area(self: Shape) f32 {
return self.impl.area(self.ptr);
}
};
The `*anyopaque` is a pointer type in Zig that points to an unknown type. It’s useful when defining generic constructs, allowing us to work with different types without knowing their specifics during interface definition.
Circleconst std = @import("std");Rectangle
const Shape = @import("shape.zig").Shape;pub const Circle = struct {
radius: f32,pub fn area(ctx: *anyopaque) f32 {
const self: *Circle = @ptrCast(@alignCast(ctx));
return 3.14159265359 * self.radius * self.radius;
}pub fn create(self: *Circle) Shape {
return Shape{
.ptr = self,
.impl = &.{ .area = area },
};
}
};pub fn main() void {
var circle = Circle{ .radius = 10.0 };
const shape = Circle.create(&circle);std.debug.print("Area of circle {}\n", .{shape.area()});
}
const std = @import("std");
const Shape = @import("Shape.zig").Shape;pub const Rectangle = struct {
width: f32,
height: f32,pub fn area(ctx: *anyopaque) f32 {
const self: *Rectangle = @ptrCast(@alignCast(ctx));
return self.width * self.height;
}pub fn create(self: *Rectangle) Shape {
return Shape{
.ptr = self,
.impl = &.{ .area = area },
};
}
};pub fn main() void {
var rect = Rectangle{ .width = 10.0, .height = 20.0 };
const shape = Rectangle.create(&rect);
const area = shape.area();std.debug.print("Area of rectangle {}\n", .{area});
}
Memory Managed
Improving our approach gives us more control over memory and error management, ensuring efficient and safe code execution.
// ... (Rectangle struct with its area function)Usagepub fn create(width: f32, height: f32, allocator: std.mem.Allocator) Shape {
const instance = allocator.create(Rectangle) orelse unreachable;
instance.* = Rectangle{ .width = width, .height = height };
return Shape{ .ptr = instance, .impl = &.{ .area = area, .destroy = destroy } };
}pub fn destroy(ctx: *anyopaque, allocator: std.mem.Allocator) void {
const self: *Rectangle = @ptrCast(ctx);
allocator.destroy(self);
}
const allocator = std.mem.Allocator;
const rectangle = Rectangle.create(10, 20, allocator);
const rectangleAreaWithMemoryMgmt = rectangle.area();
rectangle.destroy(allocator);
Returning errors
By introducing error handling, we can better manage unexpected scenarios, ensuring our program behaves predictably.
pub fn area(ctx: *anyopaque) !f32 {Usage:
const self: *Rectangle = @ptrCast(ctx);
if (self.width < 0.0 or self.height < 0.0) {
return error.InvalidShape;
}
return self.width * self.height;
}
After creating a `Rectangle` instance:
const rectangleAreaWithErrorHandling = try rectangle.area();
Factory
Moving the create function from the implementations to the interface would reduce the code that needs to be written.
pub fn from(ctx: *anyopaque, comptime T: type) Shape {
const self: *T = @ptrCast(@alignCast(ctx));
return Shape{
.ptr = self,
.impl = &.{ .area = T.area },
};
}
This function now allows us to convert an implementation into a Shape object and call the functions in shape. We can hence swap instances and still get the areas.
Summary Enum Based InterfaceYou can get the code here and try it out yourself.
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4