Function body type is unintuitive

I believe the function body type fn () void along with the function pointer type *const fn () void don’t make much sense.
Because the function body type is a comptime type, a pointer to a comptime type should also be a comptime type as it is for the rest of the language, I don’t really get the justification for mixing up the syntax for two different types like that, especially since Zig is usually very consistent with it’s syntax.
What would have made more sense in my opinion is to have the function body type be something like fnref () void and keep the function pointer type as *const fn () void
Thoughts?

Hey @2crayon, welcome to the forum :slight_smile:

When you say function body type I take it you mean function declaration type as function body usually refers to the implementation of a function.

I’m not sure I follow your argument here. All types are comptime information - we’re telling the compiler “this points to one of these.”

Take i32 for example - that’s comptime information and it tells us how to recognize and handle a set of bytes. A pointer to that type (*i32) isn’t fundamentally different in my opinion here.

I think what you’re getting at here is that functions have to be instantiated at compile time and pointing at it seems fundamentally different that pointing at an integer that gets calculated at runtime. The issue there is one of familiarity… this compiles:

const ptr: *fn (i32) void = @ptrFromInt(42);

ptr(42);

Now, keep in mind, it will end horribly if you run this code, but you don’t actually have to point at a function… that’s just how the address that you’re pointing at will be interpreted.

fn () void is a comptime only type in that you can’t store it in a runtime value, and it’s essentially like a macro that doesn’t exist after comptime evaluation

For example this doesn’t work and gives comptime-only type 'type' has no pointer address

pub fn main() !void {
    const a: *type = @ptrFromInt(42);
    std.debug.print("{}", .{a});
}

Essentially, going off of how *const fn () void behaves, I think the type fn () void should just refer to a hypothetical list of bytes of machine code that you can only use by getting a pointer to it

I’m not sure how dynamic libraries fit into that concept though.

Types are resolved once runtime begins - they’re not like functions which exist as a set of instructions that you can walk through using RIP. Like the error says, type is comptime-only data. Functions do have addresses and you can switch out which function your pointer is addressing during runtime.

Exactly, functions are inherently runtime values, and currently fn () void is a very specific kind of function type that is meant to refer to a function at compile time, it shouldn’t be thought of as “the function type” but just a specific kind of function type (which is why I have a problem with the syntax)

I think where we disagree is on the word “values”. I don’t see functions as a runtime value - they have addresses, so you can point at them, but we’re using the word value quite differently.

I take it your objection is about that discrepancy. I personally find it to be quite useful and it’s a way to do dependency injection directly. It’s just a way to allow the compiler to figure out a call and perform optimizations on it like it would any direct function call.

I suppose where I stand on this is function pointers as they exist make sense and this allows functions to be first-class citizens at compile time. I don’t personally find it to be confusing so I’ll respectfully disagree.

The way I’d phrase it is “are you carrying direct-call information or indirect-call information”. If you have direct-call information, that can be optimized by the compiler because it won’t be switched out at runtime. If you have indirect-call information, it can’t be optimized like a direct-call (take inlining as an example).

You may have mispoke here, but functions can be invoked at compile time as well as runtime. They aren’t inherently runtime only. For me, the issue comes down to whether or not you know exactly what the call will be. If you do, you can use the direct type syntax - if you don’t, you can use the pointer syntax.

The way I see it, there are semantically 2 kind of ways to refer to a function in Zig that the type system provides, you can refer to it as a memory address with instructions, or using a sort of compile time reference that can therefore be inlined by the compiler and what not.
Let me be clear I think this good, I just believe that these are two very different things and it doesn’t make sense that the runtime version is syntactically “a pointer to the other”

I don’t think this is very different from if statements, where we have a normal if statement if(<bool>) ..., one on options if(<option>) |payload| ... and one with error unions if(<error-union>) |payload| {} else |err| {}, semantically these are only loosely related, but they share a syntax that makes them look more similar to another. I argue it is the same thing with function body types and function pointers.
I even think it makes more sense in the function case because the function pointer points to a thing that has that function body type at compile time.

Why should not being able to have a var of a function-body-type, mean that you can’t create a pointer to a const of a function-body-type value?
This seems fine to me:

const std = @import("std");

fn foo() void {
    std.debug.print("foo\n", .{});
}

pub fn main() void {
    const FnType: type = fn () void;
    const func1: FnType = undefined;
    const func2: FnType = foo;
    const ptr: *const FnType = &func2;

    @compileLog(FnType);
    @compileLog(func1);
    @compileLog(func2);
    @compileLog(ptr);
}

Output:

functiontypes.zig:13:5: error: found compile log statement
    @compileLog(FnType);
    ^~~~~~~~~~~~~~~~~~~

Compile Log Output:
@as(type, fn () void)
@as(fn () void, undefined)
@as(fn () void, (function 'foo'))
@as(*const fn () void, (function 'foo'))

Also if you use a comptime var you can use the function-body-type like a var:

const std = @import("std");

fn foo() void {
    std.debug.print("foo\n", .{});
}

pub fn main() void {
    const FnType: type = fn () void;
    
    comptime var func3: FnType = undefined;
    func3 = foo;
    func3();
}

Sure, but I think type syntax is much more important to keep consistent

It’s still mixing together comptime and runtime in a single type, should anyopaque be removed and just use *anytype instead of *anyopaque? logically it makes sense because if a *anytype was a runtime type it would mean that it just points to anytype at runtime which is technically true.
Even though that *anytype hypothetical works, I think it just makes the language more complicated for no reason

If *anytype was a thing I would expect it to be resolved to a concrete pointer type at compile time, while *anyopaque is just a pointer without any type information so every pointer is allowed there.

It isn’t mixed up, they are two different types with similar syntax that are related to another. The function pointer, points to the function-body-type value.

Lots of things can be comptime or runtime, but types only exist at compile and comptime, after that they are compiled into the code as code pathes, enums, addresses, offsets, etc…

I think your fnref () void is less consistent than what zig does, zig’s syntax is essentially like int and *const int just with function body types:

const fptr: *const FnType = &foo;
const iptr: *const i32 = &5;

std.debug.print("fptr: {}\n", .{fptr}); // fptr: fn () void@1025de0
std.debug.print("iptr: {}\n", .{iptr}); // iptr: i32@10c5d38

foo is more like a lazy function value at compile time, it is actually quite similar to a int literal value in that sense, that it doesn’t have a location yet but will be placed somewhere by the compiler.

I don’t really understand what we are arguing about here, it seems to boil down to using a different syntax, which is more confusing then the one used by Zig in my opinion.

2 Likes

I’m not sure if I agree that it’s unintuitive. There is a pretty significant difference between types and functions: types do not exist at runtime, whereas function bodies do.

Because types only exist at compile time, it follows that both var T: type and var ptr_to_t: *const type are also comptime-only.

For functions, their implementation must be known at compile time (so that the compiler knows what instructions to emit), so var f: fn () void is comptime-only. But because the functions themselves continue to exist at runtime, var ptr_to_f: *const fn () void is allowed at runtime.

So instead of saying “functions are inherently runtime values” I would maybe say “functions are inherently runtime values that must be constructed and made constant at compile time”. Unlike actually inherently runtime values like integers, you are not allowed to construct a new function from out of nothing at runtime. But you can change which already realized function a function pointer points to.

4 Likes

I guess I don’t see type as any less comptime than the function body type, you can pass around a fn () void without any guarantees that it won’t get inlined at every call site, I understand they’re very closely related but you would just be able to convert between them implicitly.
Just like you can implicitly convert a comptime only value *comptime_int to a runtime value *i32, you would be able to convert *fnref () void to *const fn () void.
This is consistent everywhere else in Zig, if you want to convert a comptime value to be used at runtime, you do an appropriate type conversion.
It doesn’t sit right with me that just taking a pointer to a comptime value makes it magically a runtime value.