zig
zig copied to clipboard
Behavior difference between struct fields containing functions and declaration functions
Zig Version
0.12.0
Steps to Reproduce and Observed Behavior
There are behavior differences when using structure fields that are typed as a function vs. structure function declarations and using those function during chaining operations.
Zig makes all fields and declarations in a structure public. A simple example:
const std = @import("std");
const builtin = std.builtin;
const debug = std.debug;
const testing = std.testing;
const assert = debug.assert;
const expect = testing.expect;
const print = debug.print;
/// Generic calculator interface.
pub fn CalculatorType(comptime T: type) type {
return struct {
value: T,
const Self = @This();
fn add(self: CalculatorType(T), num: T) CalculatorType(T) {
return CalculatorType(T){ .value = self.value + num };
}
fn sub(self: CalculatorType(T), num: T) CalculatorType(T) {
return CalculatorType(T){ .value = self.value - num };
}
};
}
/// Generic calculator implementation.
pub fn Calculator(comptime T: type, num: T) CalculatorType(T) {
return CalculatorType(T){ .value = num };
}
My assumption why Zig makes all structure fields and declarations public is to maintain backward compatibility with C and structure declarations are transformed into structure fields. So the above example is perceived to be transformed into the analogous code:
const std = @import("std");
const builtin = std.builtin;
const debug = std.debug;
const testing = std.testing;
const assert = debug.assert;
const expect = testing.expect;
const print = debug.print;
/// Calculator2 add operation.
pub fn Calculator2FnAdd(comptime T: type) fn (self: Calculator2Type(T), num: T) Calculator2Type(T) {
const Self = struct {
fn add(self: Calculator2Type(T), num: T) Calculator2Type(T) {
return Calculator2Type(T){ .value = self.value + num };
}
};
return Self.add;
}
/// Calculator2 sub operation.
pub fn Calculator2FnSub(comptime T: type) fn (self: Calculator2Type(T), num: T) Calculator2Type(T) {
const Self = struct {
fn sub(self: Calculator2Type(T), num: T) Calculator2Type(T) {
return Calculator2Type(T){ .value = self.value - num };
}
};
return Self.sub;
}
/// Generic calculator2 interface.
pub fn Calculator2Type(comptime T: type) type {
return struct {
value: T,
Self: type = @This(),
add: fn (self: Calculator2Type(T), num: T) Calculator2Type(T) = Calculator2FnAdd(T),
sub: fn (self: Calculator2Type(T), num: T) Calculator2Type(T) = Calculator2FnSub(T),
};
}
/// Generic calculator2 implementation.
pub fn Calculator2(comptime T: type, num: T) Calculator2Type(T) {
return Calculator2Type(T){ .value = num };
}
The behavior difference between the first example and the second example involves chaining. With the first example you can do the following:
const std = @import("std");
const builtin = std.builtin;
const debug = std.debug;
const testing = std.testing;
const assert = debug.assert;
const expect = testing.expect;
const print = debug.print;
// Test generic calculator.
test Calculator {
print("\n", .{});
// Test calculator.
const v1 = Calculator(u32, 20).add(10).sub(5).value; // ability to chain functions together.
print("value = {any}\n", .{v1});
try expect(v1 == 25);
return;
}
Notice that Zig automatically supplies the self
argument to the add
and sub
functions when chaining them together. However, in the second example, chaining will generate an error indicating that 2 arguments are required rather than automatically supplying the self
argument.
const std = @import("std");
const builtin = std.builtin;
const debug = std.debug;
const testing = std.testing;
const assert = debug.assert;
const expect = testing.expect;
const print = debug.print;
// Test generic calculator2.
test Calculator2 {
print("\n", .{});
// Test calculator2.
const v1 = Calculator2(u32, 20).add(10).sub(5).value; // generates an error.
print("value = {any}\n", .{v1});
try expect(v1 == 25);
return;
}
The above example generates the following error:
test-func.zig:207:36: error: expected 2 argument(s), found 1
const v1 = Calculator2(u32, 20).add(10).sub(5).value; // generates an error.
~~~~~~~~~~~~~~~~~~~~^~~~
test-func.zig:169:9: note: function declared here
fn add(self: Calculator2Type(T), num: T) Calculator2Type(T) {
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expected Behavior
I would have expected the same behavior for both examples mentioned above when Zig does chaining and was wondering whether this was overlooked when generating or optimizing the code for chaining operations.
My assumption [...] is [...] structure declarations are transformed into structure fields.
Note that there is a difference between the following:
- Fields define what information an instance of the struct holds, while declarations are bound to the type, shared between all instances (like
static
in C++). The size (and information) of an instance is defined only by its fields, not by the declarations in its type. - Zig distinguishes between the types of functions (
fn (...) Result
) and function pointers (*const fn (...) Result)
). The equivalent code in C would be using function pointers, however your example declares functions directly as fields. Because only function pointers are types available at runtime, you'd probably hit this difference in further experimentation, but effectively because they aren't available at runtime, it's impossible to reassign them.
The main difference you're hitting in your example is the fact that the syntax a.f(...)
differentiates between fields and declarations:
- If
f
is a field ofa
, it looks upf
ina
and calls it with the given arguments. - If
f
is a declaration ofa
, it looks upf
in@TypeOf(a)
, and calls it with either&a
ora
(whichever fits) as first argument, followed by the arguments given in parentheses.
(There's some discussion about this behavior in https://github.com/ziglang/zig/issues/14009, maybe there's more issues that I couldn't find right now.)
This means that in your second case, the only "correct" call sequence would have to look something like the following, because self
isn't implicitly passed when calling fields:
const c = Calculator2(u32, 20);
const c2 = c.add(c, 10);
const c3 = c2.sub(c2, 5);
const v = c3.value;
Note that you're allowed to alias functions: Instead of declaring them fields, your second example could instead use:
const add = Calculator2FnAdd(T);
const sub = Calculator2FnSub(T);
In this case, since they're declarations instead of fields, now self
is passed implicitly, and the chaining usage example will work again.