Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generic function as types #21066

Closed
Darkfllame opened this issue Aug 13, 2024 · 3 comments
Closed

Generic function as types #21066

Darkfllame opened this issue Aug 13, 2024 · 3 comments

Comments

@Darkfllame
Copy link

I think I found that generic types are pretty fine, but when you need to not have a specific generic type, it just becomes a anytype rabbit hole, anytype is fine as is, but when using other person's libraries that uses them, it can become quite a bad design pattern if you need to have a really generic type. Right now interfaces like Allocator or Writer (Writer is deprecated, so i'm talking about AnyWriter) are plain structs with no generic patterns, so instead of having dynamic dispatched interfaces, we could have static ones, which can be faster and optimized in more ways (discard, inline call and everything else involving function optimization), with "more generic functions".

Full though process

Let's take:

fn GenericType(comptime T: type) type {
    return struct {
        const Self = @This();

        value: T,

        pub fn new(v: T) Self {
            return .{ .value = v };
        }
    };
}

Here a GenericType is a blank, boring generic function, let's say "I want to use it for a function which can print it's value:"

fn printGT(gt: GenericType(u32)) void {
    std.debug.print("{d}", .{gt.value});
}

Now the problem is that the "subtype" is restricted to u32, what you could do to fix it is to use anytype:

fn printGT(gt: anytype) void {
    std.debug.print("{any}", .{gt.value});
}

"Nice, but wait... now gt can be anything, an int, float or opaque, crap, now i need to check the type of gt:"

fn printGT(gt: anytype) void {
    const tinfo = @typeInfo(@TypeOf(gt));
    if (tinfo != .Struct or
        !@hasField(@TypeOf(gt), "value") or
        @TypeOf(gt) != GenericType(@TypeOf(gt.value))
    ) {
        @compileError("printGT() only accepts GenericType(T)");
    }
    std.debug.print("{any}", .{gt.value});
}

"Mmh.. looks nice- wait, what if I need to modify GenericType in the future ? ok i'll do a function to check if a type matches:" GenericType:

fn checkGenericType(comptime T: type) bool {
    const tinfo = @typeInfo(T);
    if (tinfo != .Struct or
        !@hasField(T, "value") or
        T != GenericType(@TypeOf(@as(T, undefined).value))
    ) {
        return false;
    }
    return true;
}

and i'll use it in my printGT function:

fn printGT(gt: anytype) void {
    if (!checkGenericType(@TypeOf(gt))) {
        @compileError("printGT() only accepts GenericType(T)");
    }
    std.debug.print("{any}", .{gt.value});
}

This sure works, but it's very tedious and still have a problem: struct literals, this could be fixed with even more code, but you get it, there is even more boilerplate and checks to do in order for it to work as intended. This could be done in another, very intuitive, way: with Generic function types.

Generic function types as function parameters

Now function parameters can accept generic function which returns types and are completely compile time, to act as a type. They acts as anytype but with further type checking that the compiler can do when the function gets called. Here is a use example:

fn printGT(gt: GenericType) void {
    std.debug.print("{any}", .{gt.value});
}

We can now easily extend our GenericType function:

fn GenericType(comptime T: type) type {
    return struct {
        const Self = @This();

        value: T,

        pub fn format(
            self: Self,
            comptime fmt: []const u8,
            _: std.fmt.FormatOptions,
        // could be used for format function for example
            writer: std.io.GenericWriter,
        ) @TypeOf(writer).Error!void {
            try writer.print("{" ++ (fmt: {
                const tinfo = @typeInfo(T);
                switch (tinfo) {
                    .Int, .ComptimeInt, .Float, .ComptimeFloat => break :fmt "d",
                    .Pointer => |ptr| {
                        if (ptr.child == u8) {
                            break :fmt "s";
                        }
                        break :fmt "any";
                    },
                    else => break :fmt fmt,
                }
            }) ++ "}", .{self});
        }

        pub fn new(v: T) Self {
            return .{ .value = v };
        }
    };
}

And our printGT function can just be:

fn printGT(gt: GenericType) void {
    std.debug.print("{any}", .{gt});
}

This could also be used for Allocator:

pub const AnyAllocator = struct {
    pub const VTable = struct { . . . };
    
    ptr: *anyopaque,
    vtable: *const VTable,
};

pub fn Allocator(comptime T: type) type {
    return struct {
        const Self = @This();

        // Same as in AnyAllocator.VTable but instead of
        // ctx being *anyopaque, it is *T
        pub const VTable = struct { . . . };

        ptr: *T,
        vtable: *const VTable,

        // all the other functions are adapted to match for this
        // directely call T.alloc, T.realloc and T.free.

        pub fn init(ptr: *T) Self {
            return .{
                .ptr=ptr,
                .vtable=&.{
                    .alloc = T.alloc,
                    .realloc = T.realloc,
                    .free = T.free,
                },
            };
        }

        pub fn any(self: *Self) AnyAllocator {
            return @as(*AnyAllocator, @ptrCast(self)).*;
        }
    };
}

They could be used for Writer too, as shown above.

Generic function instantiation and function casting

One thing that could be interesting with it, is generic function instantiation, i.e: given a generic function, generates a new, non-generic function, implementing it as function casting could be used to cast non-extern function to extern (if the type of the function to cast could match the target function type):

// given the code above
const printGT_u32: fn (gt: GenericType(u32)) void = @fnCast(printGT);

pub fn main() !void {
    printGT_u32(GenericType(u32).new(27));
    // error: expected GenericType(u32), got GenericType(u16)
    printGT_u32(GenericType(u16).new(40));
    printGT(GenericType([]const u8).new("Hey"));
}

This proposal could be integrated along decl literals nicely by guessing the result type (if the generic type is a simple return with a type). Struct literals could be used by also guessing the type in the same way. If you decide to change Allocator in the standard library, this will surely break pretty much all existing code, but having this could benefits in new projects/code and add some level of safety and predictability to the code using it.

@Andrew-LLL1210
Copy link

What's wrong with these signatures?

fn GenericType(comptime T: type) type;

fn printGeneric(comptime T: type, generic_value: GenericType(T)) !void;

@andrewrk andrewrk closed this as not planned Won't fix, can't repro, duplicate, stale Aug 13, 2024
@andrewrk
Copy link
Member

No language proposals please

@mlugg
Copy link
Member

mlugg commented Aug 13, 2024

This proposal has a few issues.

The first is that it makes generic functions non-obvious. Today, Zig has the nice property that a function is generic precisely if it has anytype or comptime parameters. Under this proposal, a function becomes generic depending on the type of the parameter type expression.

The second, and much more important, issue, is that what you are proposing isn't actually possible. The fact that GenericType(u32) "is a" GenericType isn't actually known or tracked by the language or the compiler. So, to figure out if a given type T can be returned by GenericType, the compiler has to either (a) try a bunch of inputs and see if any give back T, or (b) do some complex analysis to "work backwards" from T to GenericType. The first option is a non-starter, because there isn't a finite set of inputs which is sufficient to check. The second would hugely complicate the compiler (you'd effectively have to implement an entire second, more complex, form of semantic analysis), and is likely not even possible due to Turing-completeness of comptime. This situation is necessary because GenericType could, for instance, branch on its input type, and perform arbitrary complex logic to decide which type to return.

I was about to say I'll close this, but Andrew beat me to it :^)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants