ziglang / zig

General-purpose programming language and toolchain for maintaining robust, optimal, and reusable software.
https://ziglang.org
MIT License
34.6k stars 2.53k forks source link

Make assignment operations expressions #11805

Open ifreund opened 2 years ago

ifreund commented 2 years ago

Currently the following code snippets which look valid do not parse:

test {
    var x: u32 = 0;
    while (true) if (true) x += 1;

    var y = false;
    switch (y) {
        true => if (true) y = false,
        false => if (true) y = true,
    }

    defer if (true) y = false;
}

Some of these examples are mentioned in #5731 and #3749.

This is because the grammar does not treat assignment operations such as x = 1 or y += 5 as expressions and only allows them in specific contexts.

I propose changing the grammar to make assignment operations normal expressions, example patch here. I think this may unlock some further cleanups to the grammar as well not included in that example patch.

Assignment expressions would be defined to always evaluate to type/value void. In effect, we already allow expressions with exactly these semantics such as { x = 1; }. This proposal merely makes the grammar a bit more consistent and allows writing more intuitive code as the examples above demonstrate.

Original additional proposal to disallow void expressions in most contexts That change alone would however bring some downsides. For example code like this would become valid: ```zig test { var y: u32 = 0; var x = (y = 1); // declares variable x with type/value void, assigns 1 to y } ``` This is obviously undesirable, therefore I propose disallowing expressions of type void in all contexts except: 1. As a statement in a block. 2. As the operand of `defer`/`errdefer`/`suspend`. 4. In the continue expression of a while loop. 5. In the body and else branch of `if`/`while`/`for` if the expression as a whole evaluates to type void. 6. In `switch` cases if the switch as a whole evaluates to type void. 7. As the operand of `comptime`/`nosuspend` if the expression as a whole evaluates to type void. Note that rules 4, 5, and 6 are transitive, the outer `if`/`while`/`for`/`switch`/`comptime`/`nosuspend` must also appear in a context where void expressions are permitted. I also propose that an exception be made for the empty block `{}` which is permitted as an expression in all contexts as the canonical void value. These proposed rules would make the `var x = (y = 1);` snippet a compile error while allowing the motivating examples above. These rules would also effectively implement #9059. As discussed there, there are already cases in the language where allowing void expressions causes confusion and this proposal would help eliminate those.
rohlem commented 2 years ago

Am I interpreting correctly that this proposal would similarly disallow function calls resulting in void in these contexts? For instance,

fn wrapper(target_fn: anytype) @typeInfo(target_fn).Fn.return_type.? {
  return target_fn();
}

would now only be valid for a target_fn with non-void return type? (Though still possible to implement status-quo behaviour by special casing void to work around this.)

ifreund commented 2 years ago

Am I interpreting correctly that this proposal would similarly disallow function calls resulting in void in these contexts?

Yes that's correct, this would disallow all expressions that evaluate to a void type including calls to functions with return type void. You're right that this will require some more special casing of void in generic code than the status quo requires. I think this is an acceptable tradeoff in your example but this brings more problematic cases to mind such as std.AutoHashMap(u32, void).

In implementing such a generic data structure, there might be a function like this:

fn put(self: *Self, key: K, value: V) void {
    self.items[i].key = key;
    self.items[i].value = value; // if V is void then the expression `value` has type void.
}

Adding another exception alongside {} for plain identifier expressions such as value in that example would solve that use-case. I'm trying to think of other particularly problematic cases.

Hejsil commented 2 years ago

I wounder if it is really worth the effort preventing people from using assignments in expressions. Currently, we allow function that return void everywhere, and you don't really see people using that for anything spicy, because it does not have much use.

rohlem commented 2 years ago

Just an observation, all motivating examples listed here make use of if expressions (grammar element IfExpr). From the problem description that assignment statements aren't expressions, to me it sounds like we could instead allow if statements (with block or statement bodies) in those places. If that results in ambiguities, ban if expressions from the top level of expressions where if statements are also accepted.

I assume you found some downside with this, compared to the proposed solution? EDIT: I guess that it doesn't solve #9059 in one fell swoop? And maybe the grammar grows by doing this? Fwict errors earlier in the compiler pipeline are preferred, and checking types happens later, though I don't know how much complexity increase is reasonable for offsetting this.

ifreund commented 2 years ago

I wounder if it is really worth the effort preventing people from using assignments in expressions. Currently, we allow function that return void everywhere, and you don't really see people using that for anything spicy, because it does not have much use.

This is a good point. We already allow { x = 42; } as an expression, which has the same semantics I'm proposing for the expression x = 42.

I may have gotten too hung up on trying to keep the language as strict or stricter than status quo at the cost of significantly increasing complexity. I think that the examples in #9059 show that there are some weird edge cases with the status quo of void expressions, but that is perhaps orthogonal to allowing usage of assignments as expressions.

I think I'll move the "disallow void expressions in most contexts" part of this proposal over to #9059 and reduce the scope of this to allowing assignment operations in expressions.

abvee commented 1 month ago

Hey,

Has there been any work done on this proposal ? As of zig 0.13.0, you still cannot do this:

var x: usize = 4;
for (0..10) |i|
    if (true) x = i;

You have to make the assignment an expression:

var x: usize = 4;
for (0..10) |i|
    if (true) {x = i;};

Are there any plans to make assignments the same as normal expressions ?

InKryption commented 1 month ago

I think it's interesting to note that the main benefit of making assignments act as statements rather than as expressions is avoiding the pitfall seen in C where x == y can by typo'd as x = y, causing the condition to simply evaluate to the value of y, and by extension its truthiness or nonzero-ness.

However in zig this is already avoided/avoidable in two ways:

In effect, status quo assignments-as-statements acts as a redundant protection against a solved problem, and incur an extra unnecessary cost in complexity in the grammar, and adds special cases to remember in general usage, ie while (cond) : (expr_or_assignment) {} vs (while (cond) : (expr) {}, plus the other special cases mentioned in this and related issues.

rohlem commented 1 month ago
  • Assignments (under this proposal, and simply as a sane choice in general) would evaluate to void, meaning the typo isn't a runtime bug, but a compile error which at worst will make the programmer feel a little silly.

@InKryption While I generally agree with your point, the single exception I'll nitpick is generic contexts that allow both bool and void expressions. For example an anytype function argument would allow both a == b (bool result) and a = b (immediate assignment, void result). I do think it's good the second isn't allowed. In contrast, both a function call f() and a block {a = b;} may yield void, but look intentional and make sense to allow.

abvee commented 1 month ago

For example an anytype function argument would allow both a == b (bool result) and a = b (immediate assignment, void result).

I'm still a student, so I might be ignorant on the topic. However, I cannot think of a function that is evaluated at compile time doing something that will not result in an error if passed a void type variable.

At least, from my understanding, anytype is generally followed by @hasDecl or @typeInfo, which should both give compile errors if it has to evaluate a void expression.

rohlem commented 1 month ago

@abvee I mostly expect usage of void in generic code. For example, a union(error) may have some fields with additional payload data, and some fields without - it's up to the implementation whether that is represented via an 0-bit empty struct{} or as void. Status-quo allows a union field of void payload to coerce from enum literal syntax: .foo can be written instead of .{.foo = {}}, but not instead of .{.foo = .{}}.

You could also write a function to handle a function result, f.e. repackage an error union E!T into a different userland structure. To me it seems sensible for such a function to accept both E!T as well as plain T, which could include both void and bool.