Repository navigation
Proposal: Stack-Capturing Macro #6965
Description
Summary
Stack capturing lambdas are normally unsafe, since the lambda may have a lifetime longer than the calling function. This proposal introduces a new type of lambda that is allowed to perform stack captures, but obeys special rules that allow the compiler to prove that these captures are safe. This proposal is based on Kotlin's concept of inline lambdas, though deviates from that implementation in some ways.
Description
Unlike functions, Stack-Capturing Macros (or "macros" for short) are not values. They may only exist as parameters to functions which are marked inline. You cannot create a variable or struct that contains a macro.
Macro parameters are declared with a macro prototype, which appears in a parameter list. Macro prototypes are not type declarations. They are a new syntax, similar to a function type declaration. Unlike function types, macro prototypes may use the !T syntax to infer error types from the macro body. When passing a macro as an argument, the token => is used to indicate that the following expression is a macro. The capture syntax (|x|) is optionally used to capture the macro parameters, and after that comes an expression (which may be a labelled or unlabelled block). "return" and "try" within this scope are relative to the function, not the macro. break :label may be used to exit to an outer scope. Doing this behaves as if the function and macro were inlined, and runs any defers in the function or macro.
/// iterates the entries in a bucketed hash map
inline fn foreach(self: *@This(), action: macro (key: *Key, value: *Value) void) void {
for (self.buckets) |bucket| {
var current = bucket.first;
while (current) |node| : (current = node.next) {
action(&node.key, &node.value);
}
}
}
fn countValues(self: *@This(), value: Value) usize {
var count: usize = 0;
self.foreach(=> |_, v| {
// references to stack variables
if (v.* == value) count += 1;
});
return count;
}
fn hasValue(self: @This(), value: Value) bool {
return blk: {
self.foreach(=>|_, v| {
// nonlocal return
if (v.* == value) return true;
});
break :blk false;
};
}
fn findKeysWithValue(self: @This(), value: Value, allocator: *Allocator) ![]*Key {
var list = std.ArrayList(*Key).init(allocator);
errdefer list.deinit();
try self.foreach(=> |k, v| {
if (v.* == value) {
// this try refers to the outer scope.
try list.append(k);
}
});
return list.toOwnedSlice();
}
// macro returning value
inline fn pollUntil(condition: macro (index: usize) bool) void {
var index: usize = 0;
while (!condition(index)) : (index += 1) {
poll();
}
}
fn pollFourTimes() void {
pollUntil(=> |x| x >= 4)
}
// macro with no parameters
inline fn retry(comptime T: type, max_attempts: usize, action: macro () !T) !T {
var attempt: usize = 1;
while (true) : (attempt += 1) {
if (action()) |_| {
return;
} else |err| {
if (attempt >= max_attempts) return err;
}
}
}
fn openNetworkFile(path: []const u8) !Handle {
return retry(Handle, 3, => blk: {
doSomeSetup();
defer doTeardown();
break :blk openNetworkFileUnstable(path);
});
}Because they cannot exist in variables, there is no TypeInfo entry for a macro. There are also no macro types. @TypeOf(macro_param) is a compile error. You would never need to do this though, because macro parameters are always known to be macros within the text of the function. When inspecting the TypeInfo of a function, macro parameters show as having a null type, just like anytype parameters. We could add more info to the FnArg structure if necessary to handle this case.
Macros may not be closed over in a function literal, but they may be referenced from within other macros, and they may be passed directly as a macro parameter to another inline function. Macros may accept macros as parameters, but only if the compiler can inline them all. Y-combinators which do not terminate at compile time will not work. This is governed by the eval branch quota. Inlining a macro into itself, directly or indirectly, counts as one backwards branch.
Unlabelled break and continue don't work in a macro in a loop. This prevents a potential class of errors:
for (some_list) |item| {
item.inner_list.foreach(=> |inner| {
if (inner.value == item.target) break;
});
}Here the programmer intended to break out of the inner loop, but the language would interpret this as a break from the outer loop. (because the inner loop is inside the function and can't be seen). Instead, this should be a compile error and a labelled break can be used to get out of the inner loop:
next_list: for (some_list) |item| {
item.inner_list.foreach(=> |inner| {
if (inner.value == item.target) continue :next_list;
});
}Macros provide a way to allow stack references in a way that is provably safe.
Edit: This proposal previously read "If a macro has no parameters, the capture list is omitted." This would be ambiguous with an expression block, so it was changed to require an empty capture list.
Edit 2: Macros were originally blocks, but upon reflection they make more sense as expressions. This also means that try and return in a macro refer to the outer function, rather than the macro expression. And a token (=>) is needed for declaring a macro. Also added a bunch more examples.
Activity
ikskuh commentedon Nov 3, 2020
This is a surprisingly elegant solution to the "modern usecase of lambda expressions". I'm not sure if zig needs this, but the basic idea is quite nice and clean. But some questions:
const? I might need a predicate or a sort criteria multiple times:I'm a fan, not sure though if zig needs this feature…
SpexGuy commentedon Nov 4, 2020
The proposal as written would say no, but this is an interesting use case. I think I'm still inclined to say no though. A property of macros as described above is that they execute only as part of the statement in which they are declared. You can't declare a macro on one line and then execute it on a different line. This means there is never any confusion about whether a macro is capturing the current value of a stack variable or a reference to the variable - it's both. This also ensures clearly that it's impossible for a macro to escape the bounds of the locals it references. A macro really isn't a function that executes later, it's a block that executes right now, but in a deeper scope.
No, but you can do this:
That's kind of how I feel too :P
rohlem commentedon Nov 4, 2020
I'm not as much of a fan.
If I understood this correctly (and maybe I overlooked something), we can already get 80% of same effect by
struct { fn action(self: *@This(), ...) !void }to encapsulate variables and methods to operate on them,actor: anytypeandactor.action.The overall design makes sense to me, but I don't appreciate the individual changes either:
returnandtrycan now (with the same syntax) only apply to an anonymous block, if that block is contextually passed as amacroargument.If we want block-scoped
try, then instead how abouttry :blocksyntax? (We already havebreak :blockfor "local returns".)This makes sense in the proposed design, but status-quo local
structs simply don't have these limitations. So if they get in your way, you need to "upgrade" frommacroback to a fullstruct.I personally appreciate the idea of
one way to do thingstoo much over having this "shorthand option" and having to decide between them (potentially for every single interface individually).To me it seems more valuable to unify/simplify common use cases of the current language constructs.
Some ideas for the use cases presented here:
scoped
try :block <expression>syntaxI think I would implement "nonlocal break" by returning an
enum {continue, break}to theforeachfunction.If we change that to
union {continue: void, break: label_name}theforeachcould exit and return the label to break from to the caller.So now what's missing would be a way to break to the right label based on an enum value, f.e.
breakto <expression> [optional value].(We could even limit the expressions to ConstantLiteral syntax
.labeled_blockso that it's all comptime-checkable.)For capturing stack variables, maybe we could go the opposite direction and allow
usingnamespaceon a stack-localstructinstance. That way we could do this:ikskuh commentedon Nov 4, 2020
Yeah that would be my opinion as well. It would get too messy quickly and most macros are unique anyways...
I really like this, we created a new language feature out of macros already, killing two birds (#229, #5610) with one stone (#6965)
Not really. Capturing local variables either requires a lot of indirections and storing the locals in a "local variables struct", which means every normal access is prefixed or you have to capture them by hand into pointer struct fields
I think this is worth a separate proposal, as this is solving a completly different use case which i discovered could be solved with this proposal
This is confusing and
usingnamespaceis not a tool that should be used often. It's also just a syntactical trick.rohlem commentedon Nov 4, 2020
I don't think there is much of a semantic difference between declaring stack variables separately and grouping them into a struct and declaring an instance of that. The only difference is in access syntax.
Why? As I understand it, importing symbols from a different namespace means your code depends on the symbols declared somewhere else, which is an implicit remote dependency.
What I proposed is a new mechanism that doesn't currently exist. In the example I showed, it links the function code to the function-local field declarations. All local, good visibility. I don't see an argument against allowing it.
If you think this shouldn't be allowed for non-local structs, then we can declare it invalid for non-locally defined types.
That's my point. Both solutions just present syntactical convenience over something we can already do with less limitations.
If field access feels too burdensome, we can enhance
usingnamespace, or introduce some other syntax if you'd prefer. Otherwise we can remain as we are.The new semantics that come with this proposal seem to me like a differently-flavoured tool for the same job. Unless they permit a new use case not solvable by structs and status-quo that I'm oblivious to.
alexnask commentedon Nov 4, 2020
I quite like this proposal personally.
The one modification I would make would be to require macro arguments to be comptime
SpexGuy commentedon Nov 4, 2020
This is ambiguous I think, I'll change it to require an empty capture list. That's also ambiguous with the
||operator but unary||isn't a thing so that should be enough to disambiguate.This is a fair point. This is actually where my proposal deviates from Kotlin's definition. Returns in a kotlin inline lambda return from the outer function. But this only works because the last expression in a kotlin lambda produces its value, which gives them an alternate syntax to return. I suppose we could allow a label on macros though, and use
break :macro_labelto yield a value. I could argue then thatreturnfrom inside a macro marks invisible control flow, but you could make the same argument about nonlocal breaks.It's true that you can accomplish the same computation with anonymous functions and state objects, but you cannot assume that that will be performant. Using functions here has several problems:
Essentially, using functions for this is taking something that is straightforward for the frontend (put that loop in my code and put this code in the loop), turning it into a really hard problem for the optimizer, and then hoping that the optimizer will figure it out.
This problem is one of the main reasons that many game developers avoid C++ lambdas.
The proposed macros sidestep this problem by forcing the frontend to emit something that is friendly to the backend (just a bunch of scopes, no pointers to locals, no extra functions). That's what makes this different. It's more constrained than lambdas, but it guarantees that it will not generate a function call, it will not generate extra copies, and it will not generate extraneous memory clobbers. IMO these guarantees are enough to make this a sufficiently different operation from passing a function (and therefore not another way of doing "the same thing").
This brings up a problem that I didn't think about before. I think putting
comptimeon a macro implies that it can be executed in a comptime block, meaning that it does not capture runtime locals.ikskuh commentedon Nov 4, 2020
Word. This proposal is a simple syntax transformation, should be easy to implement in both stage1 and stage2 by just doing some AST transformations whereas function pointers yield inherently worse results due to increased complexity
rohlem commentedon Nov 4, 2020
Indeed I was hoping that this type of transformation would be something fully in the realm of capability for an optimizing compiler.
In particular, here is how my implementation would work in my head:
foreachfunction is inlined, at some point during this.(Inlining is forced by the user, or chosen by the compiler, advisable since the unique argument type makes the instantiation unique. Same goes for inlining
actor.action.)It notices the pointer to the
structinstance doesn't escape the scope of the variable declaration, therefore chooses to statically resolve all references relative to the stack frames (i.e. as local variables) instead of producing runtime pointers.Even if Zig already had macros, I would advocate to implement these optimizations for the
structcase. I'd be interested to know which step of this is unrealistic.Interesting, I was under the impression people liked to use templates because (just like
comptimeparameters) they lead to the compiler inlining more code, which enables more optimization opportunities. C++ lambdas are 100% just syntactic sugar for template classes with overloaded call operators; the only barrier for optimizations using them would come from type erasure, f.e. bystd::function. But then again, I might not be in the loop.Amendment: I agree that this is "just an AST transformation" that "should be easy to implement". My concern is whether we want to facilitate this one use case with special-case syntax, type, and semantics, instead of reducing friction in the components we already have in the language.
SpexGuy commentedon Nov 4, 2020
The prevailing opinion in the game development community seems to be that templates and lambdas can be fine for runtime performance if all optimizations are turned on, but compilation time and performance in debug mode are also very important, and templates are terrible for both of these. Indeed, all of the optimizations you described are possible and will probably be done in release-fast mode. But the other build modes also matter, and so does compilation time.
That said, header parsing time is a massive sink for C++ compile time, and templates can only reasonably go in headers. Zig is better about this, so it may not be as much of a problem.
This is a good and valid concern, but I don't see any way to reduce the friction enough here without introducing a new concept.
Here's the status quo "null hypothesis" way to do this with functions:
The mental gymnastics that I have to do to try to understand this code are too much. It's not worth making the
foreachfunction at all here, it's more readable to just write the loop in each function. In this case I would probably instead make an iterator struct and use that. But iterators can be even harder for the optimizer to process, and worse on debug performance, so that doesn't really solve the problem either. They are also much more difficult to write than the iteration loop, and require more effort from readers to understand.I don't think the
usingnamespaceon instance idea helps any of these examples. The availability of the locals to the return statement is not the problem.Limiting this to comptime-known values doesn't actually work. There's no way to run a function at runtime that returns a comptime-known value. This would have to be some totally new system in order to be guaranteed to be statically resolved, and I can't imagine any version of that which produces readable code.
It is possible that generators may be able to help here, by making it easier to write iterators. But that also assumes a very powerful optimizer, and puts a burden on it to think more (and longer) about how to optimize.
tauoverpi commentedon Nov 4, 2020
Making this an expression
|x| xand|x| blk: { break :blk x }would be a bit nicer and not need to change whattryandreturnmean within the scope of a function which would make more sense with it being an inline AST trick and not a separate function on it's own as otherwise it becomes a special case of the two. Labled or otherwisetrywould fit better outside of macro definitions.Other than that, sounds rather neat.
rohlem commentedon Nov 4, 2020
Valid point for unoptimized
Debugmode and compilation time.I was a bit unclear. I meant that the syntax of
breakto <expression> [optional value]could limitexpressionto be aComptimeLiteral, but the compiler would end up creating anenumto represent all labels in the function. (Afaik automatic enumeration and a goto table is how suspend/resume point information is managed forasyncfunctions already, so basically expose an idea like that to userland.) EDIT: And I meant to switch on the result ofself.foreach, which would be that enum's type - yes, that would require specifying the enum in the union return type.Here's one more idea to help the syntax of the status-quo approach (though decidedly not the compilation complexity):
EDIT: fixed passing the address of the instance constructed by
@currentScopeMixin, not a copy.129 remaining items
moderatelyConfused commentedon Mar 23, 2024
Just adding a few thoughts here as a new zig user that recently googled "can you pass a block into a function zig", and eventually ended up here. Given all of the discussion by everyone in this thread about making macros identical in both syntax and semantics to blocks, it seems strange to call them macros/snippets/whatever, when
blockwould perfectly describe how they behave.This also simplifies the "rules" of macros, since if a "macro" is just a block, it's already true in zig today that you can't assign a block to a const value, you'll just get the evaluated result from the block if you do that:
And again it's already true in zig today that blocks with parameters are only allowed in certain places:
So instead of creating a new "macro" feature with a complicated set of rules around initialization and usage that make it indistinguishable from a block, this feature is just extending the list of places it's legal to put a block with parameters (and without) from
if,while,for,switchcases, etc, to include (inline) function parameters.This also makes the question of semantics pretty straightforward (and seem to match the proposal, if I'm reading it correctly):
(It also took me quite a long time to find this issue, because it never occurred to me to call this feature a macro - in my head the term "macro" is about naive code splicing and immediately calls up a red flag that reads "beware copy-paste issues and namespace collisions", which aren't relevant to this proposal.)
So maybe consider making the keyword
blockinstead ofmacro?rohlem commentedon Mar 23, 2024
@moderatelyConfused I like your simplified specification, however here's a nitpick:
This isn't strictly true: Blocks are expressions, so the following is already valid under status-quo (results in passing the result of the evaluation, just as in your assignment example):
To make the proposed behavior not depend on the parameter definition of the callee,
we need to have some way (f.e. proposed
=>syntax) to distinguish an eagerly-evaluated block from a lazily-passed block.Lazily-passed blocks are repeatedly evaluatable by call-syntax, making them look more like status-quo functions,
with the difference being they may unwind their caller function(s) up to their source function.
So another term would be function-ized/-ified/-shaped blocks (
which is very close to anonymous functions #1717 but with extra stack-unwinding capabilitiesrepeating myself).moderatelyConfused commentedon Mar 23, 2024
@rohlem This is a fair nit, I was imagining that blocks-as-expressions would be disambiguated by the callee definition, but there is definitely a readability tradeoff being made there.
One thing I'll point out is that any disambiguating syntax like
=>is acting as a modifier/visual indicator/sanity check for the programmer since it doesn't actually modify the semantics of the block, which are determined by the block syntax itself and the definition of the callee. All the modifier does is produce a user-friendly compile error if it's not present for ablockparameter to an inline function. So I think my point stands that macros are really just blocks, regardless of whether the syntax includes a modifier or not.I think talking about blocks-as-parameters as functions makes them more complicated to describe, not less. If you imagine a block parameter as a block that will get inlined some number of times within the function you define it in, you don't need to have a concept of stack-unwinding, because after inlining everything ends up in the same stack frame. The only thing you need to remember is that variables and control flow are resolved relative to the original lexical context of the block, and that the code for the block will end up somewhere in the function you're writing 0 or more times.
Considering zig already uses function call syntax to represent inlining for functions that are declared
inline, I think it's consistent with the existing style of the language that a call to a block/macroaction()is not a function call, but an inline done at compile time.Overall I think the fact that all of the intermediate calls inline away and collapse into a single stack frame makes this proposal significantly simpler than #1717 (and leaves the implementation with few to no tricky implementation choices), not more complicated.
(EDIT: What I really meant here is that #1717 with stack-unwinding would essentially be a full-blown closures proposal and would need to make tricky implementation choices, not that anonymous function syntax itself is all that complicated)
tmccombs commentedon Aug 24, 2024
Why was this closed?
The thread is very long, but I tried looking through it and couldn't find an explanation of why it was rejected.
mlugg commentedon Aug 24, 2024
This proposal was written a long time ago in Zig's development, and ended up being a very bad fit for the direction the language took. It introduces a significant amount of special-case syntax and semantics to solve a use case which is already adequately solved using existing language constructs via the "context struct" pattern.
tmccombs commentedon Aug 24, 2024
I searched for zig context struct pattern, and couldn't find anything useful.
Does it solve the control flow problems this tries to address, specifically being able to return, break, or continue in an outer scope?
mlugg commentedon Aug 25, 2024
I mean, the bigger issue is that it overloads the concept of a function parameter to include a whole new kind of entity which isn't a value despite looking superficially similar (supporting a tiny subset of typical operations). The problem is more the "semantics" part than the "syntax" part (although the syntax is still a bad fit for Zig due to how this looks just like a function parameter whose "type" is a
macro ...).Assuming you're referring to the
foreachexample as "this particular application", this is trying to bring FP style to Zig, which is ill-advised. Just make an iterator.Anyways, to be clear, I think this rejection is pretty decided.
andrewrk commentedon Aug 27, 2024
100%. And I will be closing most of the other open proposals with no explanation offered for the same reason.
jamii commentedon Feb 17, 2025
I get the 👎 but it's not reasonable to set the expectation that Andrew spends O(people with opinions) time debating features. That doesn't scale as zig becomes more popular.
Yay295 commentedon Feb 17, 2025
This issue is currently the 4th most commented on and 8th most reacted to issue on this repository. I understand he can't respond to all 12,000 issues, but why not even the top 10?