What is the expected way of delegating the mutation of a comptime var to other functions?

Currently, this works:

const std = @import("std");

fn a(comptime i: *u32) void {
    comptime i.* += 1;
    comptime i.* += 1;
}

fn b(comptime c: fn (comptime i: *u32) void) void {
    comptime {
        var counter: u32 = 0;
        c(&counter);
        @compileLog(.{counter});
    }
}

pub fn main() void {
    b(a);
}

While this does not work:

const std = @import("std");

fn a(comptime i: *u32) void {
    comptime i.* += 1;
    comptime i.* += 1;
}

fn b(comptime c: fn (comptime i: *u32) void) void {
    comptime var counter: u32 = 0;
    c(&counter);
    @compileLog(.{counter});
}

pub fn main() void {
    b(a);
}

// error: runtime value contains reference to comptime var

and as expected, this work again.

const std = @import("std");

fn b() void {
    comptime var counter: u32 = 0;
    comptime (&counter).* += 1;
    comptime (&counter).* += 1;
    @compileLog(.{counter});
}

pub fn main() void {
    b();
}

Even though in function a, every usage of the parameter i is marked as comptime, the compiler refuses to compile, because there’s a pointer to comptime-var counter enters c/a.
I see it’s reasonable to forbid using comptime pointers in runtime, but in this case, the calls to b() or b(a) seem to be equally determinstic to me. May I ask if such use cases are not fit to Zig’s design in any perspective?

Comptime pointers are tricky. Basically, in runtime code they cannot leave the function in which they’re declared. In your example, b() is being called as a runtime function in main(). That’s why you get the error. If you change the call to comptime, then it’ll compile:

pub fn main() void {
    comptime b(a);
}

This limitation was imposed in 0.12.0 in order to allow for incremental compilation.

2 Likes

OK @chung-leong, thanks for the kindly explanation.
Frankly, I would consider a partial-application-like behavior to be more intuitive in this scenaio, but changing the interface to comptime may also be acceptable.
Thanks for your help!

One thing to remember about comptime is that comptime arguments don’t make the return value comptime:

fn num(comptime n: i32) i32 {
    return n;
}

pub fn main() void {
    @compileLog(num(0));
    @compileLog(comptime num(0));
}
Compile Log Output:
@as(i32, [runtime value])
@as(i32, 0)

A runtime value that can be determined at comptime will, however, get optimized to a constant (i.e. as though you’ve used the comptime keyword). In the following, f() and g() are the same once compiled:

fn num(comptime n: i32) i32 {
    return n;
}

export fn f() i32 {
    return num(0);
}

export fn g() i32 {
    return comptime num(0);
}

OK, thanks. It’s true that Zig’s comptime can be quite confusing.
C++ has a slightly looser constraint about pointers:

#include <iostream>

consteval auto a(auto update_func) {
    int count = 0;
    update_func(&count);
    return count;
}

int main() {
    auto func = [](const auto op) {
        *op += 1;
        *op += 1;
    };
    
    static_assert(a(func) == 2);
}

Although C++ also can’t infer usage of op as compile time variable. Which is necessary in my case, to generate runtime functions while recording their usage in a compile-time set.

int main() {
    auto func = [](const auto op) {
        return [op]() {
            *op += 1; 
            *op += 1;
        };
    };
    
    static_assert(a(func) == 0); // same if op is a compile time function
    //also op leaks to runtime, which would avoided by Zig in a serious way
}

I would agree more with C++ in this case (programmers should be able to take their own risk), even though neither seems to be suitable for my expected use case. Hoping Zig’s comptime would be more powerful someday.