A RetroSearch Logo

Home - News ( United States | United Kingdom | Italy | Germany ) - Football scores

Search Query:

Showing content from https://medium.com/@jerrythomas_in/exploring-compile-time-interfaces-in-zig-5c1a1a9e59fd below:

Exploring Compile-Time Interfaces in Zig | by Jerry Thomas

Exploring Compile-Time Interfaces in Zig

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 Zig

Today, 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.

Union of enum

The union of enums approach is like grouping together similar objects but only one of these objects is active at any given time.

The Circle

Below is a simple Circle object with a radius property and an area function.

const std = @import("std");

pub 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()});
}
The Rectangle

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");

pub 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()});
}
Combining Shapes

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()});
}

sample code for enum union

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.

Factory

We 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 };

shape = 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()});
Using Reference Pointers

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.

Shape

The 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.

Circle
const std = @import("std");
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()});


}
Rectangle
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});


}

Reference pointer example

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)

pub 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);
}
Usage
const allocator = std.mem.Allocator;
const rectangle = Rectangle.create(10, 20, allocator);
const rectangleAreaWithMemoryMgmt = rectangle.area();
rectangle.destroy(allocator);

Memory managed example

Returning errors

By introducing error handling, we can better manage unexpected scenarios, ensuring our program behaves predictably.

pub fn area(ctx: *anyopaque) !f32 {
const self: *Rectangle = @ptrCast(ctx);
if (self.width < 0.0 or self.height < 0.0) {
return error.InvalidShape;
}
return self.width * self.height;
}
Usage:

After creating a `Rectangle` instance:

const rectangleAreaWithErrorHandling = try rectangle.area();

Error propagation example

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 Interface Using Reference Pointers

You 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