A mean to attach methods to C structs

I’m currently in middle of refactoring the API code that I used for my PHP extension. The initial version was very low effort. For text strings I have this in php.zig:

pub const String = c.zend_string;

And associated functions:

pub fn getStringContent(str: *const String) [:0]const u8 {
    const s: [*]const u8 = @ptrCast(&str.*.val[0]);
    const len = str.*.len;
    return @ptrCast(s[0..len]);
}

pub fn compareStrings(s1: *const String, s2: *const String) bool {
    const sc1 = getStringContent(s1);
    const sc2 = getStringContent(s2);
    return std.mem.eql(u8, sc1, sc2);
}

pub fn parseBool(s: *String) bool {
    return c.zend_ini_parse_bool(s);
}

pub fn parseLong(s: *String) Long {
    return c.zend_atol(&s.val[0], s.len);
}

Just a straight mapping to the C API basically. Extremely low-effort.

When the refactoring, I want to make the API more idiomatic Zig. More OOPish. To that end, I decided to wrap the PHP/Zend string type in a struct and attach various helper methods to it:

pub const String = struct {
    impl: c.zend_string,    

    pub fn slice(self: *const @This()) [:0]const u8 {
        const s: [*]const u8 = @ptrCast(&self.impl.val[0]);
        const len = self.impl.len;
        return @ptrCast(s[0..len]);
    }

    pub fn length(self: *const @This()) usize {
        return self.impl.len;
    }
    
    // etc...
}

At the binary level, String contains exactly the data as c.zend_string. That allows me to freely between *c.zend_string and *String. Every times where this occurs though, it requires the use of @ptrCast(). That includes situations involving pointers to functions accepting strings as arguments.

It would be nice if the compiler would recognize that String and c.zend_string are the same thing and convert pointers automatically.

2 Likes

If the ABI of c.zend_string is part of the public API of the library you are porting, you could work as follows:

pub const ZendString = extern struct { // extern is required
    // fields here
    pub fn length(str: *ZendString) {
        return zend_length(str);
    }
    extern "c" fn zend_length(str: ?*ZendString); //optional bc C
};

This will work just fine, but it does require you to take some ownership of the translate-c output.

If the ABI of c.zend_tring is not part of the public API of the library, then usually the library itself has functions in charge of creating and destroying the struct and users should work only with handles to it. In this case, you could still work similarly to the above, but instead you’d write something like

pub const ZendString = opaque { // opaque types must be passed by pointer
    // fields here
    pub fn length(str: *ZendString) {
        return zend_length(str);
    }
    extern "c" fn zend_length(str: ?*ZendString); //optional bc C
};
4 Likes

The number of functions is quite large. And as we know, it’s not possible to attach new decls to a comptime-defined struct. In theory though, I could use comptime fields. It really shouldn’t matter whether I’m performing .[function name] on a namespace or a 0-byte struct.

An ability to attach methods to structs generator by translate-c would be a cleaner solution.

I don’t know if you didn’t understand what I was suggesting or what, but if you’re interested in going on beyond translate-c, you might appreciate having vulkan-zig as an example to follow. i’m quite sure you won’t have more functions than they do :slight_smile:

I have Zig bindings for MLIR zml/mlir/mlir.zig at f82a474971d557962ee110dda5a1aab746f8e3d8 · zml/zml · GitHub

It’s using the opaque wrapper struct.
It’s a bit annoying to write, but once it’s done I guess PHP C API is pretty stable. And if you bind the full API you can keep ptrCast in the binding layer (I put it behind as method so that (only specific castings are allowed). LLM are pretty good at generating this once you wrote the bindings for a few classes.

The more involved approach is copying translate c output into your codebase and locally improve some functions, and add helpers.

I also add some code where I reimplemented some complex C struct with union and bitfields as a Zig packed struct for better ergonomics, with comptime assert that the layout of the C struct and mine are the same. It worked well because this was a “context struct” and I could put most of the C api in this struct as method (avoiding ptrCast issues)

But yes I wish I could say const Foo = struct(c.lib_foo) {...}

2 Likes

A more radical solution I’ve entertained before is to separate out namespace and make it a distinct concept:

const Foo = struct {
    bytes: [4]u8,
} <= {
    pub fn doStuff(self: *@This()) void {
        // ...
    }
}

Here the struct { ... } part only provides the data portion of the type–its physical body, if you will. The namespace is something separate that needs to be attached (using the back arrow operator). It’s the type’s “spirit”, what animates it and give it personality.

For the PHP case I mentioned, we would do this:

pub const String = c.zend_string <= {
    pub fn slice(self: *const @This()) [:0]const u8 {
        const s: [*]const u8 = @ptrCast(&self.val[0]);
        const len = self.len;
        return @ptrCast(s[0..len]);
    }

    pub fn length(self: *const @This()) usize {
        return self.len;
    }

    // ...
}

We take the body from C and inject Zig personality into it to make it come alive :man_zombie:

Such a solution would kill multiple birds at once. I had originally thought of it in regards to parametric types. Using a function to define types is a very powerful mechanism in Zig. A serious drawback is that we can’t use a function as a namespace. Having namespaces that we can freely attach to types or functions would remove this short-coming.

It’d also solve the []const u8 identity issue. Right now, we can’t know for sure if the type represents a string or not. If we can stick a namespace onto a u8 then we can make that distinction. A []const u8 would still convert freely into a []const utf8 because the data type would match.

The PHP API is not stable. Functions change between point versions. During build, header files are downloaded from the project server depending on the PHP version selected. The solution can’t be fixed code. It has to be comptime generated from translate-c’s output.

i’d argue it should instead be build time generated.

1 Like