Revisiting 'break :blk value'

My only remaining gripe with the otherwise lovely Zig syntax is the requirement we name a block in order to ‘break’ a value out of it. Andrew himself called it awkward back in 2018. Given all of the proposals and discussion around this, I’m surprised this quirk appears here to stay. I’ve read through the Github threads on this, and I haven’t been able to understand what is preventing something like the following proposal.

There has been some hesitation regarding possible confusion given the traditional role of break in languages like C. However, the very concept of assigning a variable to a block of code is itself new compared to C. It seems to me this would provide ample justification for defining some additional semantics in service to this new assignment block™. As a C developer myself, the usage and behavior of the break value described below feels intuitive even with non-trivial nesting.

In short:

  • From a function block, we can return
  • From a loop block, we can break and continue
  • So, from an assignment block, why can’t we break value?

In the same way return cares only about functions, and (unlabeled) break cares only about loops, break value would care only about assignments. They would all remain separate.

By default, break refers to the innermost loop. Similarly, break value would by default refer to the innermost assignment. Note that it’s possible for the innermost assignment block to also be a loop, in which case, this feature happens to already be supported as described.

If needed, you may provide a :name to choose a different block. That is in fact exactly what we are currently forced to do. With a formal specification for this new assignment block, I see no reason to continue requiring this.

Here’s a basic example of what I’m describing.

fn exitFromEachBlockType(value: i32) !void { // start of a 'function' block
    while (true) { // start of a 'loop' block
        const x = { // start of an 'assignment' block
            if (value < 0) { // start of a regular ol 'scope' block
                return error.Negative; // exit 'function' block
            } else if (value > 100) {
                break; // exit 'loop' block (no value, traditional break behavior)
            } else {
                break value; // exit 'assignment' block (value included, which imo is just begging to get assigned to x)
            }
        }
        // ...
    }
}

This change would technically be breaking in situations where you had an assignment block inside of an assignment loop and were using an unlabeled break value, but that feels quite unlikely.

10 Likes

i support this, but why not a new name for it to not confuse it with the other break? maybe assign?

7 Likes

They sure do look the same, but they are, in fact, not the same.

Functions have bodies, you can’t label and break them;

curiously, loops do have blocks, which you can label and break, and that is equivalent to a continue. I’m guessing this is because loop bodies can also be single line expressions, breaking the block is just the body expression ending, no different from not breaking the block.

Probably just because blocks are expressions, so this is just simpler to not special case it.

But loops are expressions, they can return values via break(and an else branch).

You moved the annoyance to loop expressions, and also, labelled blocks that don’t return values.

Your idea is more complex to reason about.

My opinion is: for small blocks conciseness is nice, but for more complex cases labels are better.

What I want is for blocks (and others) to automatically be labelled when assigned.

const x = {
    break :x foo;
};

most of the annoyance was navigating to the block start and choosing a name, and the label is obvious, so this removes more than half the annoyance; it also keeps the nice property that unlabelled break always refers to the inner most, surrounding, loop.

14 Likes

I think that this seems nicer than it is and it would likely lead to a lot of opaque bugs when adding/removing blocks or loops.

It’s also hard to guess what a single break would do because it could refer to the innermost block or the second innermost block depending on whether a value was given and in what order the surrounding block and loop are.

This seems like a better proposal and also, at least for me, is similar to the proposal, that was flying around some time ago, to be able to refer to the return value of the function somehow.

I’m with you but for this specific case. Here the first break should be a compile error, and user should name the loop to be able to break like this. Having two meaning for break is confusing

5 Likes

no, the equivalent would be to be able to refer to &x in my example, that’s useful if you need init in place/be self referential.

breaking from a block is more equivalent to a normal function return

2 Likes

The proposed example is exactly why a solution like this is a no-go. While reading, it would have you chase down the exact place where you’ll end up by using break – one more thing to worry about when reasoning about code. The cognitive burden adds up. If I were a purist, I would even be in favour of disallowing unlabeled loop breaks, but let’s say single loops are common enough and people are used to these semantics from other languages so they get a pass.


*taps the sign*:

$ zig zen
...
 * Favor reading code over writing code.
...
10 Likes

I still think blocks should just implicitly return the last expression in them like in Rust, but I know that’s never going to happen.

This definitely seems like the best idea moving forward.

4 Likes

I considered mentioning other keywords when writing this up. I’ve seen yield, return, give, =>, result, and assign proposed. I think any of those have merits. But a new keyword would be a much bigger breaking change, and I personally think break works just as well, so may as well not break (ha) everyone’s code.

Just to reinforce the point about reading code - Consider the following modification:

fn exitFromEachBlockType(value: i32) !void { // start of a 'function' block
    const z = while (true) { // start of a 'loop' block
        const x = { // start of an 'assignment' block
            const y = if (value < 0) { // start of a regular ol 'scope' block
                return error.Negative; // exit 'function' block
            } else if (value > 100) {
                break; // exit 'loop' block (no value, traditional break behavior)
            } else {
                break value; // exit 'assignment' block (value included, which imo is just begging to get assigned to x)
            }
        }
        // ...
    }
}

Will break value set z, y or x? Sure, the compiler can figure it out, but for the reader there’s an ambiguity there.

In these two examples, how much of the “placeholder for more code” do you need to read in order to determine where the break statement will go?

const x = blk: {
    //
    // [placehoder for more code]
    //
            break :blk 0;
    //
    // [placehoder for more code]
    //
};
const x = {
    //
    // [placehoder for more code]
    //
            break 0;
    //
    // [placehoder for more code]
    //
};

In the second example the answer is, “all the code” in the placeholder. In the first example, we already have all the code we need to determine the answer.

13 Likes

What I want is for blocks (and others) to automatically be labelled when assigned.

I would be in favor of this proposal as well.

it also keeps the nice property that unlabelled break always refers to the inner most, surrounding, loop.

In my proposal, this is also still the case.

You moved the annoyance to loop expressions, and also, labelled blocks that don’t return values.

I don’t see how I’ve done this. As described, both are intended to remain unchanged. Could you expand on what you mean?

1 Like

I once suggested:

that contradicts what you described:

break value is also unlabelled

you’re right, the rest of the paragraph just didnt register for me :sweat_smile:

but you do still have a different problem now:

differentiating loop body blocks and non loop body blocks, since they are both technically blocks, and which you break from affects control flow.

but that can trivially be solved by just checking if a blocks parent is a loop, and if you want the “continue with extra steps” behaviour then you’re forced to use a label, which seems fine.

And void returning blocks are still excluded, well, not if you do break {} ({} is the void value), syntax oddities like this are annoying.

1 Like

Value would always get assigned to the innermost assignment, which in this case is y. No ambiguity there.

And I would have the break; in this case be a compile error. You’re attempting to break from the loop without a value, but z needs a value.

only if you know the rules, which is fair, but if you can understand it without knowing the rules that means it is objectively easier to understand.

One of the reasons I was drawn to zig was, in a talk, Andrew got the audience to understand and debug meta-programming without any prior knowledge. That is the readability zig strives for.

5 Likes

The problem isn’t that their is an ambiguity; because there wouldn’t be any. But the problem is that it’s harder to read for humans because some non-local changes could change what the break value does in some quite “spooky action at a distance” ways. You save typing 4-10 characters and spend a possibly long time debugging some minor syntax variation.

That’s exactly what put me of C++.

4 Likes

I see what you mean. In this proposal, break and break value are entirely separate constructs.

So as you say, unlabelled break always refers to the innermost loop.

While unlabeled break value would always refer to the innermost assignment.

Side note, when blk: and :blk are too long (fair enough feeling for many cases), I think for short/simple enough blocks it’s completely reasonable to give them just a single letter identifier. (I liked D: and :D in @Cloudef’s codebases for the playfulness of looking like emoticons :grin:)

5 Likes

I think it having break and break value jump out of different blocks is confusing. Keywords mean control flow in Zig, so the keyword break alone should determine the control flow, and this should not depend on whether you add a value after it.

Also, whether a block is an “assignment block” (and is thus relevant for control flow by “catching” the break) should not be implicit by using its value, but made explicit with a keyword, e.g. block { } (or whatever).

So my suggestion would instead be:

  • A keyword like block is needed to use blocks as values, label them and break out of them. Plain { ... } blocks cannot be labeled anymore.
  • break jumps out of the innermost for, while of block block (whichever it is), with or without value. Using break without value means the value of the whole construct has type void.
  • for, while of block can be labeled, but this is only needed for breaking out of non-innermost of these.

Instead of current labeled blocks, you would have e.g. const x = block { y += 1; break y; }; This is of course a big language change, especially with the addition of a keyword, but I think block expressions/statement expressions are an important enough language feature that deserves its own keyword, and should not be “hidden” in the language behind labels.

1 Like