Repository navigation
Proposal: restricted function types #23367
Description
Preamble
Indirect calls through function pointers are a major blocker for both #1006 and #6025. While a solution can be designed for the generic case, there always seems to be some core function pointer usage, often related to interfaces, that the breaks the generic solution. This proposal is intended to provide a way to represent some common usages of function pointers, particularly in interfaces, in a way that is both more optimizable and decoupled from various difficult design problems..
Motivating Example
Let's consider some simple interface for demonstration purposes:
pub const Operation = struct {
vtable: *const VTable,
pub const VTable = struct {
operate: *const fn (x: i32) i32,
};
pub fn operate(op: Operation, x: i32) i32 {
return op.vtable.operate(x);
}
};
pub const addOne: Operation = .{ .vtable = &.{
.operate = &addOneOperate,
} };
fn addOneOperate(x: i32) i32 {
return x + 1;
}
pub const double: Operation = .{ .vtable = &.{
.operate = &doubleOperate,
} };
fn doubleOperate(x: i32) i32 {
return x * 2;
}
fn negate(x: i32) i32 {
return -x;
}
fn testOperation(name: []const u8, op: Operation) void {
for ([_]i32{ 1, 7, 12 }) |x| std.debug.print("{s}({d}) = {d}\n", .{ name, x, op.operate(x) });
}
const std = @import("std");
pub fn main() void {
testOperation("addOne", addOne);
testOperation("double", double);
var negate_op: Operation = undefined;
negate_op.vtable = &.{
.operate = &negate,
};
testOperation("negate", negate_op);
}$ zig run example.zig
addOne(1) = 2
addOne(7) = 8
addOne(12) = 13
double(1) = 2
double(7) = 14
double(12) = 24
negate(1) = -1
negate(7) = -7
negate(12) = -12
When the optimizer sees the indirect call op.vtable.operate(x), it would be nice if it could know that all of the possible callees are pure, or other useful properties that hold for all callees. This would be easily determinable by iterating all of the callees, but that requires knowing all of the callees in the first place. In a more complicated example, it would be easy for an Operation to come from somewhere where the optimizer is not sure which vtable it holds, and then it is forced to assume that op.vtable.operate(x); could mutate any and all global state.
Proposal
In pursuit of these goals, I propose having a language syntax that allows the compiler to be able to collect a complete list of all possible callees and communicate these to the optimizer at each call site. Here's a tentative syntax proposal, I'm sure it could be improved, but this is just to demonstrate the idea:
pub const Operation = struct {
vtable: *const VTable,
pub const VTable = struct {
- operate: *const fn (x: i32) i32,
+ operate: *const Operate,
+
+ pub const Operate = @RestrictCallees(fn (x: i32) i32);
};
pub fn operate(op: Operation, x: i32) i32 {
- return op.vtable.operate(x);
+ return op.vtable.operate(x); // This may only call `addOneOperate`, `doubleOperate`, or `negate`
}
};
pub const addOne: Operation = .{ .vtable = &.{
- .operate = &addOneOperate,
+ .operate = &@allowCallee(addOneOperate),
} };
fn addOneOperate(x: i32) i32 {
return x + 1;
}
pub const double: Operation = .{ .vtable = &.{
- .operate = &doubleOperate,
+ .operate = &@allowCallee(doubleOperate),
} };
fn doubleOperate(x: i32) i32 {
return x * 2;
}
fn negate(x: i32) i32 {
return -x;
}
fn testOperation(name: []const u8, op: Operation) void {
for ([_]i32{ 1, 7, 12 }) |x| std.debug.print("{s}({d}) = {d}\n", .{ name, x, op.operate(x) });
}
const std = @import("std");
pub fn main() void {
testOperation("addOne", addOne);
testOperation("double", double);
var negate_op: Operation = undefined;
negate_op.vtable = &.{
- .operate = &negate,
+ .operate = &@allowCallee(negate),
};
testOperation("negate", negate_op);
}The way this works, is that some syntax like @RestrictCallees creates a function type that does not compare equal to other function types with the same signature. Instead, this produces a unique type in the same way as container type definitions, where a combination of the source location and captured values determines equality. Syntax like @allowCallee casts a comptime-known function value into the result restricted function type, checking that the signatures are compatible and adding a callee to the set of possible callees for that restricted function type when analyzed.
It would be checked illegal behavior to make an indirect call through a pointer to a restricted function type when the value of that pointer is not in the set of possible callees that were analyzed during compilation. Additionally, restricted function types would be required to be callconv(.auto), because this feature is not designed to handle function pointers passed to and from external code, and to facilitate the aforementioned safety checks.
As an example implementation in the llvm backend, a metadata node forward reference could be reserved the first time an indirect call to a given restricted function type is encountered, and attached to every indirect call through that restricted function type. Then, during flush, these could be iterated and each metadata node filled in with all of the callees collected by the frontend.
Open Questions
Does this prevent an interface such asThis is indeed already illegal behavior.std.mem.Allocatorfrom being passed to another zig compilation unit? I believe this wouldn't work in the future anyway, because non-extern struct types would not be compatible between different ZCUs, due to the not-yet-implemented type tags not matching.How does this interact with incremental? Incrementally adding callees seems simple enough, but can we incrementally remove a callee when all instances ofUsing restricted function pointers that are distinct from the original function pointer makes this a non-issue.@allowCalleefor a given function become no longer referenced? Just leaving it in the callee set wouldn't work since that would cause a false negative a safety check.Is it useful to introduce a "restricted function pointer cast" that casts a runtime known normal function pointer into a compatible restricted function pointer and safety checks that the runtime function pointer is one of the analyzed allowed callees? Depending on how the indirect call safety check is implemented, this could be trivial or difficult.Not considered useful at this time.
Activity
mlugg commentedon Mar 26, 2025
This is considered IB anyway since the auto-layout struct is internal to a ZCU, as are auto-callconv function pointers. It'd technically work today, but yeah, the type tags in future will definitely be one thing that stops it from working.
Removing functions from the set is indeed problematic -- so, here's an idea. What if the return of
@allowCallee, rather than pointing to the same function at runtime, pointed to a trampoline to the original function? This trampoline could legally stay in the set across incremental updates, because the only way to get to it (without invoking unchecked IB) is through@allowCallee. Not only does this solve the incremental problem, but it also makes the safety check better when you consider this case:One downside of my idea above is that it would make this somewhere between "difficult" and "essentially impossible". However, I don't think this is a desirable feature. The whole point here is to determine characteristics of the program which can be statically known at compile-time (but are more complex than we know from the direct function call graph alone); personally I'd need to see a quite compelling use case to add this kind of runtime cast operation into the mix.
jacobly0 commentedon Mar 26, 2025
I think that if you want a
@ptrCastthat hasn't gone through@allowCalleeto fail the safety check, which sounds like a good idea, then the concept of a runtime cast doesn't really make sense anymore. Therefore, this features should not be taken into consideration when designing the implementation.mlugg commentedon Mar 26, 2025
The proposal mentions that this could go a long way towards solving issues with #1006 and #6025; let me elaborate on that, and discuss how I personally see this working.
#1006 is essentially the same problem as #157; we want to be able to statically determine the amount of stack space required of a function. This issue is also a major blocker for #6025 or similar language-level coroutine ideas. The problem is, function pointers hide a part of the call graph from the compiler, so it can't be statically known. That means we can't statically know the stack size in these cases. Perhaps that wouldn't be a problem if we didn't use (runtime) function pointers much; however, we use them for vtables, a lot! Most notably,
std.mem.Allocatoris based on vtables. This is a huge problem; for other vtable interfaces we could at least think about having to allocate new stacks to do those calls, but to allocate you need anAllocator, and we can't require allocation before calling into anAllocator.Enter this proposal. Let's redefine
Allocator.VTableto look like this (comments omitted for brevity):Anything creating an
Allocator.VTableuses@allowCalleeon these 4 functions (perhaps we provide a wrapper inAllocatorfor doing this, I don't know). This way, when the compiler sees a virtual call to aVTable.Alloc, it knows precisely the set of functions that call might be referring to.Okay, so, let's consider #157.
@stackSizeon a function which (somewhere in its static call graph) calls into aAllocator.VTable.Free(i.e. by callinggpa.freeor similar) would have to consider the full set of possible functions, and from that, determine the worst case of those functions' stack size. Essentially, we extend the call graph with edges going out to every possible callee. This means that if any of those functions can themselves call intoAllocator.VTable.Free, we get an error about a recursive call (there is a major unresolved question here which I will cover below), just like you would for direct recursion. Because we need to know the full set of possible callees for this,@stackSizecannot be eagerly known at comptime; we need to wait for all analysis to conclude in order to gather the full set of potential callees to determine the maximum possible stack usage.Given that
@stackSizecannot be eagerly comptime-known, there are two options:Option 2 doesn't seem very useful in practice; what good really is a comptime-known value if you can't resolve it? Well, I suppose you can store it in a global, but that seems of dubious usefulness. For now, I'll assume the first option (we treat it as runtime-known), but the second might be a better idea, I've not put extensive thought into this.
Let's think about avoiding stack overflow then. First, consider the case where we don't want to use recursion. My knowledge in this area was a little shaky, so I've confirmed with @alexrp that the main thread stack size is basically set in one of two ways:
setrlimit).In the first case, we can embed
@stackSize(whatever_the_entry_point_is)into the binary; this is fine, because we can resolve this value at the end of semantic analysis and patch it into the binary. In the latter case,std.startcode could even just literally use@stackSize(whatever_the_entry_point_is), and reserve that much space. Either way, everything is fine.Now, let's think about recursion. Direct recursion under #157 / #1006 becomes a compile error, because the required stack size is theoretically infinite. Instead, you need to allocate a new stack for the call somewhere else (perhaps using async/await #6025, or some other mechanism). In this case, what you'd probably do is something like
gpa.alloc(u8, @stackSize(recursiveFn)), and use that buffer as the new stack. Of course,allocitself is a vtable call, but that's fine, because allocators are not recursive (again, there's an unsolved problem here I'll discuss below; if you've guessed it by now, have a cookie!).So, this proposal allows implementing
@stackSizesanely in common vtable cases, and consequently solves #1006. If you have a direct function pointer call for any reason,@stackSizecould probably be made to work at runtime for arbitrary runtime function pointers so you can allocate the necessary stack at runtime, but that's definitely to do with #157 / #1006, not this proposal.One major concern I have is how this interacts with arenas.
std.heap.ArenaAllocatoris an implementation ofstd.mem.Allocatorwhich can itself call into otherAllocatorimplementations. This is problematic, because it makes all calls throughAllocator.VTablepotentially recursive. That's not a deficiency of the system, either; you absolutely could nest an arena in an arena in an arena in [...]. (I don't think there's any reasonable use case for this; it would very likely waste a lot of memory due to the arenas nesting their nodes weirdly.)I'm not quite sure how we could solve this, but I'm almost certain it would involve changing
ArenaAllocatorrather than amending this proposal in some way. For instance, perhapsArenaAllocatorcould become generic over a concrete implementation (potentially concerning for code bloat, but maybe not in practice?); or perhaps it calls into a newArenaBackingvtable, which is implemented only by "direct" allocators. It'll add a little complexity to the standard library, but I don't think that's necessarily a huge deal given the benefits we get from this proposal.mlugg commentedon Mar 26, 2025
After some discussion with @jacobly0, we've figured out a solution to the
ArenaAllocatorissue which doesn't require any significant API changes. It serves to demonstrate that this is truly a general-purpose mechanism which should work for all kinds of vtable.Here's how I think about this:
ArenaAllocator.allocfor the recursive callAllocator.alloc, so we seem to be stuckSo, here's what the new
ArenaAllocator.initlooks like:The key insight here is that because
ArenaAllocator.initisn't anywhere in anyAllocator.VTable, we can safely do this allocation directly. It doesn't matter ifbackingis anotherArenaAllocator, because that allocator already has its ownstack_buffor calling its own backing allocator! So, ifArenaAllocatorwants toallocfrom its backing allocator, that looks something like this (depending on details of #1006):This eliminates the potential recursion loop in
ArenaAllocator's vtable in just the same way you would for a simple call graph: heap-allocate the frame. Implementations ofAllocatorare by far the trickiest example here, because of the catch-22 issue of needing anAllocatorto allocate a new stack in the first place; if we've solved this, I'm confident that we've solved essentially all vtables.andrewrk commentedon Mar 26, 2025
@allowCalleerather than having the same semantics apply upon coercion?Regarding the discussion around safe recursion: one unsolved problem that I observe here is that the stack allocation should happen amortized O(1) like how it does in ArrayList, or at least it should be in large batches. From what I see here, what's conceptually solved so far would require to make an allocation for every recursive cycle. For instance, in the classic fibonacci example, it would make an allocation for every function call. That's going to be significantly worse performance that programming languages that have yolo recursion.
jacobly0 commentedon Mar 26, 2025
That's certainly a possible implementation, but it doesn't feel much like "restricting" callees anymore, so it would probably go with a wholly different syntax altogether.
Since it requires comptime-known functions to work, accepting a function body seems like a natural way to implement it. Since it only works with functions and not arbitrary pointers, I don't personally find that conceptually simpler.
That's just one possible naive allocation mechanism, arena allocators already provide continuous amortized
O(1)allocation by allocating a larger chunk each time, and you could pass the same arena all the way through your recursive call graph.mlugg commentedon Mar 26, 2025
Personally I feel that this operation should be explicit, because it effectively has global side effects; it's not simply converting data from one form to another, which is usually all a coercion really is.
I mean, we could do, but I don't see any reason to. You can convert between the two incredibly trivially (
.*and&), and it feels more conceptually correct for it to work on function bodies, although I don't have a strong justification there (EDIT: Jacob just posted a comment which I think justifies this). I wouldn't really mind if it worked on function pointers instead, but to be honest, this seems to me like a distinction without a difference.The obvious thing here is that you can allocate your frames into an arena or similar to amortize the allocation cost. There is still obviously a minor overhead to the frame allocation (a few comparisons and a pointer bump I suppose?), and you could argue that this overhead is a problem. However, that argument doesn't seem very convincing to me, because
fibonacci, while a typical example of recursion, isn't actually a realistic example. Any example of recursion which is that simple is usually better to implement in an iterative approach; it'll probably require less memory and be more performant. In my experience, most cases where recursion is a useful tool (for instance, traversing an AST) have relatively few points where recursion happens compared to the amount of logic in between, to the point that the overhead of an amortized arena allocation wouldn't be noticeable. (Bear in mind, you probably don't need much code in the middle to be more significant than a simple arena alloc!)andrewrk commentedon Mar 26, 2025
To illustrate the motivation behind my 2 points above, see if you prefer the before or after in these diffs:
⬇
and
⬇
To me these rules seem easier to reason about:
⬇
It took me 5m to type the first description, thinking hard, and 10s to type the second one, which felt relaxing.
mlugg commentedon Mar 26, 2025
Ignoring the builtin naming (that can be bikeshed later, I certainly don't love the names in this proposal), I hugely prefer the former:
I don't think you're being disingenuous with your definition of the builtins, but I do think you've unintentionally made the former far more complicated than it needs to be. You can follow the exact same scheme as the latter:
Your definition, for some reason, just clarifies unrelated language semantics:
i.e. "given a type
T, there is a type*T" -- we already know this!i.e. "result types propagate through expressions" -- we already know this!
mlugg commentedon Mar 26, 2025
Oh, also, your variant raises issues for our plan for runtime safety, because you might more reasonably expect
@intFromPtron the pointer to give the same address given that a) it looks like a trivial coercion and b) it applies to all pointers; whereas the safety plan, discussed in my first comment, is to make the result of@allowCalleeactually point to a different address in safe builds.andrewrk commentedon Mar 26, 2025
I think that's precisely my point. It interacts with other language features, which you have to keep in mind how they work in order to understand this feature. In other words the programmer faces a more mesh-like graph of language features, whereas my counter-proposal has only 1 edge between language features (function pointers and the proposed restricted type feature).
It's easier to reason about a smaller set of language features at the same time.
I don't really buy this reasoning. It's not actually doing that because there is no observable global state (at compile time) introduced by this feature. By the same reasoning, simply introducing another function into the compilation unit is mutating global state.
The proposed allow builtin adds intentional friction against adding a function to a set, but it's completely harmless and safe to add a function to the closed set. I see no reason for this friction. Can you come up with a more concrete reason for this friction to exist?
Have we considered this plan instead?
@ptrCastor@ptrFromInton this kind of pointer22 remaining items
sagittarius255 commentedon Apr 3, 2025
What about
@Tracked?pierrelgol commentedon Apr 3, 2025
Two suggestions :
@BoundedFn()or@DistinctFn()TaylanUB commentedon Apr 3, 2025
Maybe
@StackSafe(), because indicates the actual motivation rather than how it's implemented.pfgithub commentedon Apr 3, 2025
@Known(), because all the possible values the pointer can have must be known at comptimeKilianHanich commentedon Apr 3, 2025
Thinking about it, aren't we just creating a weird way of declaring an enum with function pointers are backing type here?
Just instead of declaring all possible values up front in one place we distribute it throughout the codebase.
ikskuh commentedon Apr 4, 2025
Ah yes, bikeshedding time! I'm going with
green@WithCalleeTracking(*const fn() void), because it reads welll and forms an "X with callee tracking" kind of sentence.On the semantics: I think a regular function pointer should be untracked, as stated by mlugg.
The ergonomics with foreign function pointers will be hard otherwise, also accidential confusion like in the ArrayList example.
gooncreeper commentedon Apr 5, 2025
I think
@ComptimeCalleeswould be a good option since it conveys the fact that all the callees are comptime known.jeffective commentedon Apr 5, 2025
Will the type created by:
be constructible by
@Type? If so, how will it be?Can I construct these types at comptime using a loop, for example, can the following struct be constructed using an inline loop?
If they are constructible by
@Type, does this leak analysis order (global comptime mutable state)? Is that ok?mlugg commentedon Apr 5, 2025
No. Just reify your pointer as normal, and then do
@Restricted(NormalPointer). Note that@Typeis going to be removed and replaced with other individual builtins (#10710).Yes, of course. You can literally just put
@Restrictedin a loop. I don't see the concern here.jeffective commentedon Apr 5, 2025
What will the
@typeInfoofOperatelook like?Can I reflect whether a pointer type is restricted or not?
Just trying to flesh out some of the details of this proposal.
It may be desirable for a user to add comptime assertions that a certain type is
@Restricted. For example, when working on a large codebase with many engineers, one could document this design decision with an assertion and make it more resistant to code churn.Batres3 commentedon Apr 9, 2025
This may have been asked before, but I don't understand how this is better in any way than directly implementing language level interfaces, it seems like this proposal is just a dressed up interface. What problem does this solve that an interface does not?
andrewrk commentedon Apr 15, 2025
There's an unsolved problem here with vtable interface implementation functions.
Consider the use case of
std.io.Writer.VTable. Many implementations used withit may also want to call into a child writer. Consider this simplified code:
Here, the restricted function set of
std.io.Writer.VTable.writewould includewrite_vtable_implementation, which itself callsstd.io.Writer.VTable.write.As is currently planned, this would be detected as a function call graph cycle,
even though it's not, really, unless the programmer nests two http requests.
Even in that case, it's statically bounded recursion, and therefore should not
require dynamic allocation for the function calls.
gooncreeper commentedon Apr 16, 2025
I do not see how it could be statically bounded; the implementation of the backing writer is runtime-known (e.g. you could allocate 100 http requests and construct a nice long chain.)
andrewrk commentedon Apr 17, 2025
Here's a litmus test for this feature.
Some notes:
io.Writer.failingWriteFileshould not be present in the executable as it is dead code.io.BufferedWriter.fixedWriteSplatshould be inlined intotest.readtest.readVecshould be detected as dead and deletedtest.discardshould be detected as dead and deletedtest.readshould be greatly simplified after things are inlined since a bunch of things become statically provable such as splat always being set to 1