Skip to content

Proposal: Stack-Capturing Macro #6965

Closed as not planned
Closed as not planned

Description

@SpexGuy

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

  1. added
    proposalThis issue suggests language modifications. If it also has the "accepted" label then it is planned.
    on Nov 3, 2020
  2. added this to the 0.8.0 milestone on Nov 3, 2020
  3. ikskuh commented on Nov 3, 2020

    @ikskuh
    Contributor

    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:

    • Can i store a macro in a const? I might need a predicate or a sort criteria multiple times:
    var threshold = 0.4; // runtime value
    const is_included = |x| { return x > threshold; };
    
    var count: usize = 0;
    count += my_list_1.countAll(is_included);
    count += my_list_2.countAll(is_included);
    const err_or_void = ||{
        try foo();
        try bar();
    }();
    someCleanup();
    if(err_or_void) |_| { } else {
        std.debug.print("oh no!\n", .{});
        return error.Unexpected;
    }

    I'm a fan, not sure though if zig needs this feature…

  4. SpexGuy commented on Nov 4, 2020

    @SpexGuy
    ContributorAuthor

    Can I store a macro in a const?

    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.

    Can I call macros after declaring them?

    No, but you can do this:

    const localTry = inline fn(blk: macro () !void) !void {
        try blk();
    }
    
    const err_or_void = localTry(||{
        try foo();
        try bar();
    });
    someCleanup();
    if(err_or_void) |_| { } else {
        std.debug.print("oh no!\n", .{});
        return error.Unexpected;
    }

    I'm a fan, not sure though if zig needs this feature…

    That's kind of how I feel too :P

  5. rohlem commented on Nov 4, 2020

    @rohlem
    Contributor

    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

    • creating a local struct { fn action(self: *@This(), ...) !void } to encapsulate variables and methods to operate on them,
    • pass it as actor: anytype and
    • call it as actor.action.

    The overall design makes sense to me, but I don't appreciate the individual changes either:

    • If a macro has no parameters, the capture list is omitted.

      return and try can now (with the same syntax) only apply to an anonymous block, if that block is contextually passed as a macro argument.
      If we want block-scoped try, then instead how about try :block syntax? (We already have break :block for "local returns".)

    • Unlike functions, Stack-Capturing Macros (or "macros" for short) are not values. They may only [...]

      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" from macro back to a full struct.


    I personally appreciate the idea of one way to do things too 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> syntax

    • I think I would implement "nonlocal break" by returning an enum {continue, break} to the foreach function.
      If we change that to union {continue: void, break: label_name} the foreach could 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_block so that it's all comptime-checkable.)

    • For capturing stack variables, maybe we could go the opposite direction and allow usingnamespace on a stack-local struct instance. That way we could do this:

    var stackToCapture = struct{
      count: usize = 0;
      fn action(self: *@This(), key: anytype, value: anytype) !void {...}
    } {};
    usingnamespace stackToCapture;
    //refer to `count` as if it were a local variable directly, pass &stackToCapture as callback-with-data
  6. ikskuh commented on Nov 4, 2020

    @ikskuh
    Contributor

    The proposal as written would say no, but this is an interesting use case. I think I'm still inclined to say no though.

    Yeah that would be my opinion as well. It would get too messy quickly and most macros are unique anyways...

    No, but you can do this:

    const err_or_void = localTry(||{
        try foo();
        try bar();
    });

    I really like this, we created a new language feature out of macros already, killing two birds (#229, #5610) with one stone (#6965)

    If I understood this correctly (and maybe I overlooked something), we can already get 80% of same effect by

    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

    try :block <expression> syntax

    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

    For capturing stack variables, maybe we could go the opposite direction and allow usingnamespace on a stack-local struct instance. That way we could do this:

    This is confusing and usingnamespace is not a tool that should be used often. It's also just a syntactical trick.

  7. rohlem commented on Nov 4, 2020

    @rohlem
    Contributor

    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 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.

    usingnamespace is not a tool that should be used often.

    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.

    It's also just a syntactical trick.

    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.

  8. alexnask commented on Nov 4, 2020

    @alexnask
    Contributor

    I quite like this proposal personally.
    The one modification I would make would be to require macro arguments to be comptime

  9. SpexGuy commented on Nov 4, 2020

    @SpexGuy
    ContributorAuthor

    If a macro has no parameters, the capture list is omitted.

    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.

    return and try can now (with the same syntax) only apply to an anonymous block, if that block is contextually passed as a macro argument.

    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_label to yield a value. I could argue then that return from inside a macro marks invisible control flow, but you could make the same argument about nonlocal breaks.

    If I understood this correctly [...], we can already get 80% of same effect [with anon functions + state objects]

    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:

    • you are now taking the address of a local variable, so the optimizer needs to put it in memory and needs to consider aliasing possibilities
    • you are copying memory around and hoping that the optimizer will realize and remove it.

    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").

    require macro arguments to be comptime

    This brings up a problem that I didn't think about before. I think putting comptime on a macro implies that it can be executed in a comptime block, meaning that it does not capture runtime locals.

  10. ikskuh commented on Nov 4, 2020

    @ikskuh
    Contributor

    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").

    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

  11. rohlem commented on Nov 4, 2020

    @rohlem
    Contributor

    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.

    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:

    • The code is translated to an AST and then translated to IR, and the foreach function 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.)
    • The compiler has to analyze all three functions as one, because they have been inlined. (Also Zig compiles all code as one translation unit anyway, so cross-analysis of known functions is not very restricted.)
      It notices the pointer to the struct instance 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 struct case. I'd be interested to know which step of this is unrealistic.

    This problem is one of the main reasons that many game developers avoid C++ lambdas.

    Interesting, I was under the impression people liked to use templates because (just like comptime parameters) 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. by std::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.

  12. SpexGuy commented on Nov 4, 2020

    @SpexGuy
    ContributorAuthor

    I was under the impression people liked to use templates

    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.


    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.

    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:

    /// iterates the entries in a bucketed hash map
    inline fn foreach(self: *@This(), action: anytype) !void {
        for (self.buckets) |bucket| {
            var current = bucket.first;
            while (current) |node| : (current = node.next) {
                try action.each(&node.key, &node.value);
            }
        }
    }
    
    fn countValues(self: *@This(), value: Value) usize {
        var countValuesEach = struct{
            count: usize = 0,
            target: Value,
            pub inline fn each(self: *@This(), k: *Key, v: *Value) !void {
                if (v.* == self.target) self.count += 1;
            }
        } { .target = value };
        self.foreach(&countValuesEach) catch @compileError("no errors possible");
        return countValuesEach.count;
    }
    
    fn hasValue(self: *@This(), value: Value) bool {
        self.foreach(struct{
            target: Value,
            pub inline fn each(self: @This(), k: *Key, v: *Value) !void {
                if (v.* == self.target) return error.FoundTheValue;
            }
        } { .target = value }) catch return true;
        return false;
    }
    
    fn findKeysWithValue(self: *@This(), value: Value, allocator: *Allocator) ![]*Key {
        var findKeysEach = struct{
            list: std.ArrayList(*Key),
            target: Value,
            pub fn init(target: Value, alloc: *Allocator) @This() {
                return .{ .target = target, .list = std.ArrayList(*Key).init(alloc) };
            }
            pub fn deinit(self: *@This()) void {
                self.list.deinit();
            }
            pub inline fn each(self: *@This(), k: *Key, v: *Value) !void {
                if (v.* == self.target) {
                    try self.list.append(k);
                }
            }
        }.init(value, allocator);
        errdefer findKeysEach.deinit();
    
        try self.foreach(&findKeysEach);
    
        return findKeysEach.list.toOwnedSlice();
    }

    The mental gymnastics that I have to do to try to understand this code are too much. It's not worth making the foreach function 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 usingnamespace on instance idea helps any of these examples. The availability of the locals to the return statement is not the problem.

    I think I would implement "nonlocal break" by returning an enum {continue, break} to the foreach function.
    If we change that to union {continue: void, break: label_name} the foreach could 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 [optional value].
    (We could even limit the expressions to ConstantLiteral syntax .labeled_block so that it's all comptime-checkable.)

    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.

  13. tauoverpi commented on Nov 4, 2020

    @tauoverpi
    Contributor

    Making this an expression |x| x and |x| blk: { break :blk x } would be a bit nicer and not need to change what try and return mean 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 otherwise try would fit better outside of macro definitions.

    Other than that, sounds rather neat.

  14. rohlem commented on Nov 4, 2020

    @rohlem
    Contributor

    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.

    Valid point for unoptimized Debug mode and compilation time.

    Limiting this to comptime-known values doesn't actually work.

    I was a bit unclear. I meant that the syntax of breakto <expression> [optional value] could limit expression to be a ComptimeLiteral, but the compiler would end up creating an enum to represent all labels in the function. (Afaik automatic enumeration and a goto table is how suspend/resume point information is managed for async functions already, so basically expose an idea like that to userland.) EDIT: And I meant to switch on the result of self.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):

    //Introduce a new builtin: @currentScopeMixin(comptime Extension: type): anytype.
    //This magically makes all stack declarations visible at this point (including function parameters) be auto-wrapped into a struct instance, and adds all declarations of `Extension` to that struct type.
    //Downside: Would require adding `const` fields to the language for the semantics of this builtin type. (Then again, we already have `comptime` fields just for tuples...)
    
    fn findKeysWithValue(self: @This(), value: Value, allocator: *Allocator) ![]*Key {
        var list = std.ArrayList(*Key).init(allocator);
        errdefer list.deinit();
        try self.foreach(&@currentScopeMixin(struct{
            fn each(self: anytype, k: *Key, v: *Value) !void {
                if (v.* == self.value) {
                    try self.list.append(k);
                }
            }
        }));
        return list.toOwnedSlice();
    }

    EDIT: fixed passing the address of the instance constructed by @currentScopeMixin, not a copy.

  15. 129 remaining items

  16. added this to the 0.12.0 milestone on Jul 9, 2023
  17. moderatelyConfused commented on Mar 23, 2024

    @moderatelyConfused

    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 block would 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:

    // This will give you 4, not the block itself
    const b = blk: {
        break :blk 4;
    };

    And again it's already true in zig today that blocks with parameters are only allowed in certain places:

    // ok, the language allows this
    if (x) |y| {
        ...
    }
    // not allowed
    const z = |w| {
        ...
    };

    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, switch cases, 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):

    // any control flow statements like continue, break, return in this block...
    action({
        ...
    });
    
    
    // ...should have identical semantics to those in this block.
    {
        ...
    }

    (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 block instead of macro?

  18. rohlem commented on Mar 23, 2024

    @rohlem
    Contributor

    @moderatelyConfused I like your simplified specification, however here's a nitpick:

    So instead of creating a new "macro" feature [...] , this feature is just extending the list of places it's legal to put a block with parameters (and without) from [...], to include (inline) function parameters.
    ... example using syntax action({ ... }); ...

    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):

    const debugLog = @import("std").log.debug;
    fn action(x: anytype) void { //mark x inline once we have inline parameters #7772
      debugLog("x is {}", .{x});
    }
    
    pub fn main() void {
      action({
        debugLog("1", .{});
      }); //block is evaluated to {} (the void value)
      action(x: {
        debugLog("2", .{});
        break :x @as(u8, 44);
      }); //block is evaluated to @as(comptime_int, 44);
    }

    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 capabilities repeating myself).

  19. moderatelyConfused commented on Mar 23, 2024

    @moderatelyConfused

    @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 a block parameter 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.

    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

    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/macro action() is not a function call, but an inline done at compile time.

    which is very close to anonymous functions #1717 but with extra stack-unwinding capabilities

    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)

  20. tmccombs commented on Aug 24, 2024

    @tmccombs

    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.

  21. mlugg commented on Aug 24, 2024

    @mlugg
    Member

    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.

  22. tmccombs commented on Aug 24, 2024

    @tmccombs

    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?

  23. mlugg commented on Aug 25, 2024

    @mlugg
    Member

    If you don't like this proposal, just say so.
    [...]
    One new keyword and a fairly straight-forward generalization of existing capture syntax is "a significant amount"?

    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 ...).

    The context struct workaround is so ugly or this particular application that nobody would use it. Besides, one of the essential components making this actually attractive for the exterior iterator use case was breakout control flow, which the struct workaround certainly can't emulate in any reasonable way.

    Assuming you're referring to the foreach example 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.

  24. andrewrk commented on Aug 27, 2024

    @andrewrk
    Member

    It would be more honest to say that it was never seriously considered, because it's not how Andrew likes to program.

    100%. And I will be closing most of the other open proposals with no explanation offered for the same reason.

  25. jamii commented on Feb 17, 2025

    @jamii

    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.

  26. Yay295 commented on Feb 17, 2025

    @Yay295

    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?

  27. locked as resolved and limited conversation to collaborators on Feb 17, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    proposalThis issue suggests language modifications. If it also has the "accepted" label then it is planned.

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions