Whatever happened to 'functions as expressions'?

Similar syntax in other languages usually implies a closure (eg. Java, Python, Javascript), which could be confusing if it’s just a regular non-closure function declaration scoped inside a parent function. In your example, it does make it clearer that bar only makes sense in the context of foo, but it makes scope behave unexpectedly.

Normally, I’d expect a block to be able to access anything in its parent scope, but in this case:

fn foo() void {
    const i: u8 = 3;
    fn bar() void {
        ...
    }
    fnWithCallback(bar);
}

bar can’t access i, which seems strange.

I’d probably just name bar to foo_bar and add a comment to indicate what it’s for, which is messier, but less syntactically confusing. You’re right, the status quo is ugly, but I think ugly but clear is better than neat but ambiguous.

Implementing closures in zig, where bar does inherit foo’s scope, is a related but different can of worms.

If you need to declare something that is only used once at one specific location, I would argue that it benefits readibility to declare it near the use site, instead of possibly tens or even hundreds of lines away.

Types/containers in functions, and by extension their functions, are allowed to capture comptime-known constants (including comptime parameters). The following compiles just fine:

fn foo() void {
    const i: u8 = 3;
    const s = struct {
        fn bar() void {
            std.debug.print("{}\n", .{i});
        }
    };
    s.bar();
}

I don’t agree that it would be confusing or unexpected if something like this was legal and worked the same way:

fn foo() void {
    const i: u8 = 3;
    fn bar() void {
        std.debug.print("{}\n", .{i});
    }
    bar();
}

I could see some argument that declaring fns in a function might break a mental model that “symbols declared in a function live on the stack”, but that idea is already arguably violated by comptime parameters and comptime-known consts which nested types can freely capture (and nested types themselves also don’t live on the stack).

7 Likes

I don’t have strong feelings about this, but I also fail to see the benefit, if it’s a one shot function, I would just write that one shot function, Out of all the things I’d like to see improved (anytype for example) nested function isn’t on my list, I also have no idea how this would translate in assembly, and it would probably break my mental model of how it’s supposed to flow.

How does it break your mental model? It can currently be written like

fn foo() void {
    const bar = struct { 
        fn bar() void {
            ...
        }
    }.bar;
    fnWithCallback(bar);
}

which is fairly common from what I’ve seen and feels straightforward to me.

2 Likes

There is value in being able to write functions within functions, and it’s a fairly common pattern in Zig, including the standard library. What I’ve proposed is nearly identical to what’s already possible, just without the extra noise. It also makes possible what is, in my opinion, the obvious syntax for nested functions for most people. I’m not sure what part of your mental model it’s breaking?

2 Likes

I don’t know it doesn’t feel right, I mean if it already is supported than I can see the argument for removing the noise, but I don’t understand what it enables that a function doesn’t is what I’m trying to say. Also where does it live as a function when you call it ?

1 Like

I’ve had the use for this thing exactly, and had to get some help here.
In summary, I needed to deserialize unions a bit differently than the std.json module does it, and I needed it to be the same across multiple unions. The solution was to write a function that returns a function that I could then assign to the jsonParse* methods.

Afaict it needed to be a second-order function because while the structure of each function is the same it needed to know the names of the union fields to deserialize the json object correctly. There might be better ways to do this but it’s the best way I could think of.

Regarding the mental model and where the function object lives, I think for functions that do not create a closure it’s simplest to just think of it as a namespaced function that lives in the text part of the object file (or the corresponding part of a windows executable). For a function that closes over (is that how you say it?) the outer scope I cannot say for sure, I don’t know how zig currently implements closures. I think someone earlier in this thread or the linked github issue mentioned that structs can only use comptime-known variables, so keeping that restriction would make sense here too.

1 Like

One thing I would like to point out: in Rust, at least, closures/lambdas and named functions are not strictly equivalent. Due to move and lifetime semantics, there are things that you can do with closures in Rust that you cannot do with a named function. That’s different from most GC languages where lambdas are simply equivalent to unnamed functions.

That’s not a small thing. And I have seen it trip up lots and lots of people. It also means that refactoring that code is really, really difficult. It also constrains your designs (it infects the Rust GUI implmentations heavily).

Doing lambdas/closures in a non-GC language has a LOT of implications, and that needs to be thought out far more thoroughly than I think anybody is really doing. (pay special note to how C++ adds syntax to close over the variables and capture by value or reference).

3 Likes

I love this discussion, because it illustrates so well what I love about Zig.

While learning Zig, I kept missing language features I am used to from other environments.

The more I worked in Zig, the less I missed these features. But that’s not because the features are bad or Zig has better concepts, it’s simply because my mind set adapted to the scope in which Zig operates. For example, Zig’s scope does not cover dynamic dispatch, so you don’t have interfaces. So you only use dynamic dispatch when you really needed or in other words when it’s a user or system requirement (one example would be a plugin interface).

Some things I tried with Zig were awkward. I concluded that these are things I would do in other languages, because they also don’t really benefit much from Zigs advantage, or Zig’s ecosystem (available Zig libraries) don’t support such a use case well.

I would love to completely switch to Zig, mostly because of personal preferences. But for some tasks it’s just not practically feasible for me.

Code generation is no longer fashionable. For me however it looks really attractive. I see Zig as an ideal target language for code generation, because it is so readable and offers so much control while at the same time it has this awesome cross compiling functionality. Last not least, this approach benefits a lot from compile time functionality, because the results can be highly optimized allowing a front-end language to create zero cost abstractions rather easily.

During my learning phase, I caught myself trying to come up with ways to make Zig more higher level. But the only thing I got out of this was higher complexity.

There seems to be a real benefit from Zig being a low level language refusing to add sugar. What I miss is not a more comfortable or feature rich Zig language, I would like the languages providing higher level abstractions to have more of the benefits of Zig. To me it looks like you can’t have both in one language.

Bun is an interesting project. It got a lot of attention, though in recent times I didn’t hear much about it. I think Bun could be much more interesting if instead of implementing a runtime for a language in Zig for performance sake, it would transpile code into Zig. JS is not my favorite higher language, but it has most of the features often requested for Zig. If you can implement JS using a runtime, you can trivially transpile it (with a modest quality). Once that works, the transpilation can gradually be improved. So this is at least possible. If it’s good depends.

I was disappointed by the removal of async at first. Now I wonder if that is really bad at all. There are so many ways to implement asynchronicity with hugely different characteristics, that it would be incredible if Zig managed to find “the best” solution. Implementing it on the application level is no good, because it’s too complex to easily get right. Implementing it on the library level is difficult, because libraries tempted to implement it are likely to have a broader scope (f.e. associating asynchronicity with HTTP or slightly more general with networking).

Finding a good solution for this is hard. If you have a layer between the Zig level and a higher level language used for application development, you can isolate this problem, offer several different implementations for different use cases and ideally replace one solution transparently with another.

When my task is to satisfy user requirements and I’m payed by the hour, I don’t want to be bothered by constraints imposed by Zig purism. When my task is to develop high quality system components, I don’t need high level language constructs, I might even not want them.

2 Likes