/zoop

A Zig OOP solution

Primary LanguageZigMIT LicenseMIT

Zoop is an OOP solution for Zig

Install

In the project root directory:

zig fetch "git+https://github.com/zhuyadong/zoop.git" --save=zoop

If you want to install a specific version:

zig fetch "git+https://github.com/zhuyadong/zoop.git#<ref id>" --save=zoop

Define the class

// Define a class Human
pub const Human = struct {
    // The first field of the zoop class must be aligned to `zoop.alignment`
    name: []const u8 align(zoop.alignment),
    age: u8 = 30,

    // If there is no cleanup work, can skip define `deinit`
    pub fn deinit(self: *Human) void {
        self.name = "";
    }

    pub fn getName(self: *const Human) []const u8 {
        return self.name;
    }

    pub fn setName(self: *Human, name: []const u8) void {
        self.name = name;
    }
};

Creating and destroying class objects

const t = std.testing;

// Create a `Human` on the heap
var phuman = try zoop.new(t.allocator, Human, null);
// If the class field has a default value, the object field will be initialized to the default value
try t.expect(phuman.age == 30);
// Destroy the object and release the memory.
// If the class defines `deinit`, it will be called first and then release the memory.
zoop.destroy(phuman);

// Create a `Human` on the stack
var human = zoop.make(Human, null);
// Access object fields through `ptr()`
try t.expect(human.ptr().age == 30);
// Clean up the object (call `deinit` if any).
// If there is no work to clean up, you don't need to call `zoop.destroy`
zoop.destroy(human.ptr());

// Both `zoop.new` and `zoop.make` support creation-time initialization
phuman = try zoop.new(t.allocator, Human, .{.name = "HeapObj", .age = 1});
human = zoop.make(Human, .{.name = "StackObj", .age = 2});
try t.expect(phuman.age == 1);
try t.expect(human.ptr().age == 2);

Note about deinit: zoop.destroy will sequentially call the deinit method of the class and all its parent classes

Inheritance

// Define `SuperMan`, inherit from `Human`, 
// the parent class must be the first field and the alignment is `zoop.alignment`,
// The field name is arbitrary and does not have to be `super`, but it is recommended to use `super`
pub const SuperMan = struct {
    super: Human align(zoop.alignment),
    // SuperMan can live a long time, u8 can't satisfy it, we use u16
    age: u16 = 9999,

    pub fn getAge(self: *SuperMan) u16 {
        return self.age;
    }

    pub fn setAge(self: *SuperMan, age: u16) void {
        self.age = age;
    }
};

// First create a `SuperMan` object
var psuperman = try zoop.new(t.allocator, SuperMan, null);
//Call parent class method
psuperman.super.setName("super");
// Or call the parent class method like this. This method is suitable for situations where the
// inheritance hierarchy is too deep and you don't know which parent class implements the `setName` method.
// In addition, since it is called `upcall`, it means that even if `SuperMan` implements `setName`,
// The following call will still call the `setName` method of the nearest parent class
zoop.upcall(psuperman, .setName, .{"super"});
// You can also flexibly access all fields in the class inheritance tree. For example,
// if you want to access the `Human.age` field, you can do this:
var phuman_age = zoop.getField(psuperman, "age", u8);
try t.expect(phuman_age.* == 30);
// Access `SuperMan.age`, you can do this:
var psuper_age = zoop.getField(psuperman, "age", u16);
try t.expect(psuper_age.* == 9999);
// Note that if two `age` are of the same type and both are called "age",
// The above `zoop.getField` call will cause a compilation error to avoid bugs

Class type conversion

// First create a Human and a SuperMan
var phuman = try zoop.new(t.allocator, Human, null);
var psuper = try zoop.new(t.allocator, SuperMan, null);

// Subclasses can be converted to parent classes
t.expect(zoop.as(psuper, Human) != null);
t.expect(zoop.cast(psuper, Human).age == 30);
// The parent class cannot be converted to a subclass (if `zoop.cast` is used, a compilation error will occur)
t.expect(zoop.as(phuman, SuperMan) == null);
// A parent class pointer to a subclass can be converted to a subclass
phuman = zoop.cast(psuper, Human);
try t.expect(zoop.as(phuman, SuperMan) != null);

Define the interface

// Define an interface `IName` for accessing names
pub const IName = struct {
    // The interface can only define two fields, `ptr` and `vptr`,
    // and the names and types must be the same as below
    ptr: *anyopaque,
    vptr: *anyopaque,

    // Define the `getName` interface method
    pub fn getName(self: IHuman) []const u8 {
        return zoop.icall(self, .getName, .{});
    }
    // Define the `setName` interface method
    pub fn setName(self: IHuman, name: []const u8) void {
        zoop.icall(self, .setName, .{name});
    }
    // Don't worry about what `zoop.icall` is, just follow it
};

// Define another interface `IAge` for accessing age
pub const IAge = struct {
    ptr: *anyopaque,
    vptr: *anyopaque,

    pub fn getAge(self: IHuman) u16 {
        return zoop.icall(self, .getAge, .{});
    }
    pub fn setAge(self: IHuman, age: u16) void {
        zoop.icall(self, .setAge, .{age});
    }
}

// Interfaces can also be inherited
pub const INameAndAge struct {
    pub const extends = .{IName, IAge};

    ptr: *anyopaque,
    vptr: *anyopaque,
}

// can specify exclude APIs.
// Only methods defined in this interface can be specified,
// and inherited methods will not be affected.
pub const INameAndAge struct {
    pub const extends = .{IName, IAge};
    // exclude “eql" method
    pub const excludes = .{"eql"};

    ptr: *anyopaque,
    vptr: *anyopaque,

    pub fn eql(self: INameAndAge, other: INameAndAge) bool {
        return self.ptr == other.ptr;
    }
}

// Interfaces can also provide default implementations of methods,
// so that classes that declare to implement interfaces can still
// compile and work correctly without implementing these methods
// (the interface becomes an abstract class)
pub const IName = struct {
    ...// Same as above code

    pub fn Default(comptime Class: type) type {
        return struct {
            pub fn getName(_: *Class) []const u8 {
                return "default name";
            }
        }
    }
}

Implementing the interface

// We let `Human` implement the `IName` interface
pub const Human = struct {
    pub const extends = .{IName};
    ...// Same as above code
};

// Let `SuperMan` implement the `IAge` interface
pub const SuperMan = struct {
    pub const extends = .{IAge};
    ...//Same as above code
}
// The interface implemented by the parent class is automatically implemented by the class,
// so `SuperMan` also implements `IName`, although it only declares that it implements `IAge`.
// A subclass can repeatedly declare that it implements an interface that has already been implemented 
// by its parent class. This will not cause any problems and will not affect the results.
// For example, the following code is equivalent to the above:
pub const SuperMan = struct {
    pub const extends = .{IAge, IName};
    ...
}

Converting between classes and interfaces

// First create a Human and a SuperMan
var phuman = try zoop.new(t.allocator, Human, .{.name = "human"});
var psuper = try zoop.new(t.allocator, SuperMan, .{.super = .{.name = "super"}});

// Human implements IName, so it can be converted
var iname = zoop.cast(phuman, IName);
// SuperMan implements IAge, so it can be transferred
var iage = zoop.cast(psuper, IAge);
try t.expect(iage.getAge() == psuper.age);
try t.expectEqualStrings(iname.getName(), phuman.name);
// Human does not implement IAge, so the conversion will fail.
// (Note that now `iname` points to `phuman`, and `iage` points to `psuper`)
try t.expect(zoop.as(phuman, IAge) == null);
try t.expect(zoop.as(iname, IAge) == null);
// Now let iname point to psuper
iname = zoop.cast(psuper, IName);
// Or you can write it like this, but the performance is a little affected
// (`cast` is O(1), while `as` is O(n) in the worst case n=the number of interfaces implemented by SuperMan)
iname = zoop.as(psuper, IName).?;
// Now iname can be converted to IAge
try t.expect(zoop.as(iname, IAge) != null);
try t.expectEqualStrings(iname.getName(), "super");
// Everything can be converted to zoop.IObject
try t.expect(zoop.as(phuman, zoop.IObject) != null);
try t.expect(zoop.as(psuper, zoop.IObject) != null);
// Can also be converted back from IObject
var iobj = zoop.cast(psuper, zoop.IObject);
try t.expect(zoop.as(iobj, SuperMan).? == psuper);

To summarize cast and as:

  • cast is applicable
    • Subclass -> Parent class
    • Sub-interface -> Parent interface
    • Class -> Interfaces implemented by the class and its parent class
  • as is applicable
    • All the cases where cast is applicable and not applicable (everything can be as)

Method overriding and virtual method calls

// If SuperMan overrides the getName method
pub const SuperMan = struct {
    ...//Same as above

    pub fn getName(_: *SuperMan) []const u8 {
        return "override";
    }
}

// Now IName.getName will call SuperMan.getName instead of Human.getName
var psuper = try zoop.new(t.allocator, SuperMan, .{.super = .{.name = "human"}});
var iname = zoop.cast(psuper, IName);
try t.expectEqualStrings(iname.getName(), "override");
// Another style of calling interface methods
try t.expectEqualStrings(zoop.vcall(psuper, IName.getName, .{}), "override");
// Virtual method calls are also useful for converted classes
var phuman = zoop.cast(psuper, Human);
iname = zoop.cast(phuman, IName);
try t.expectEqualStrings(iname.getName(), "override");
try t.expectEqualStrings(zoop.vcall(phuman, IName.getName, .{}), "override");

Performance notes for vcall: vcall will use cast when possible, and as otherwise

zoop.IObject.formatAny for print

zoop.IObject can conveniently output the string content of the object through the format(...) mechanism of std.fmt.

// define a class that implemented `zoop.IObject.formatAny`
pub const SomeClass = struct {
    name:[]const u8 align(zoop.alignment) = "some";

    pub fn formatAny(self: *SomeClass, writer: std.io.AnyWriter) anyerror!void {
        try writer.print("SomeClass.name = {s}", .{self.name});
    }
}

// print string from `SomeClass.formatAny` 
const psome = try zoop.new(t.allocator, SomeClass, null);
std.debug.print("{}\n", .{zoop.cast(psome, zoop.IObject)});
// output: SomeClass.name = some