Cloudflare and `.?`

There is only one valid reason to not use if (optional) |x|.
When you want to exit early and not increase the nesting level of the normal code path:

if (optional == null)
    return;
const x = optional.?;
2 Likes

Even better:

const x = optional orelse return;
15 Likes

I believe that the ability of panic to clean up resources is often overestimated. In reality, panic can only clean up memory resources. A single error does not mean that we should doubt a programmer’s ability to correctly release resources, especially in Rust, which has strict memory management; it’s already rare for resources to not be released properly. As for a child module that stops executing due to failure, since it is no longer running, even if there is a resource leak when it fails, the impact is limited.

In production environments, real resource issues often come from external side effects. Panic is hard to use to properly handle file locks, temporary files, device handles, session protocols, and distributed consistency. A simple panic can create more problems in cleaning up these types of resources, with consequences exceeding those posed by memory issues within a module. …

1 Like

I may have miscommunicated. The approach I’m recommending is to panic and abort the process when an assertion fails that indicates a programming error. Releasing systems resources is then done by the OS of course. Making (backend) systems resilient in my experience requires making them resilient to process crashes, and crashing a process when it becomes unreliable is then the best choice when an illegal state is encountered. The difficulty in trying to recover is that an operation changing data structure is often partially complete, and undoing those changes is what is so difficult to implement and test.

Can you describe what types of sub-systems you’re referring to? We may be talking about completely different kinds of programs. I have almost never seen cases where an in-process subsystem is non-critical in the sense that it can just be disabled and no longer used, and it is beneficial for the process to continue running.

1 Like

Cloudflare is a prime example of this architectural difference. The panic pattern you described is typical of backend microservice architectures, while Cloudflare belongs to the performance-sensitive dataplane monolith.

In this architecture, submodules like bot management are intentionally integrated into the same binary for latency and cache locality considerations, and a process crash incurs a global traffic outage.

Rust has significantly reduced the pressure on memory safety, so “using process isolation to avoid resource leaks” no longer constitutes a valid justification for this type of architecture.

Ok. In that scenario, when using Rust you can panic and catch panics at sub-system boundaries. Rust web servers work that way to isolate web apps. Of course this doesn’t apply to Zig.

1 Like

The panic of the subsystem still has some differences compared to the error and exit. Modules with basic quality compliance will release external resources and some shared states when they exit due to an error, while panic does not guarantee the correct release of these resources except for memory resources at all.

1 Like

Can we please quit this nesting level stuff?

  1. It’s 2025, 80 character TTYs have been dead for longer than most of us have been alive.

  2. Zig and Rust and other modern languages make extra nesting a significant part of the language. From Zig you often do this kind of code to make a const variable after a C runtime function assigns to a reference:

const foovar = blk: {
    var foovar: SomeType = undefined;
    const rv = fnfoo(&foovar);
    break :blk foovar;
}

If that’s in an event loop that’s another level. And Zig doesn’t have do-while or compound for so you may need an indent level to isolate your loop variable since Zig flags shadowed variable names as errors. etc.

  1. Last I checked, MISRA guidelines specifically prohibit anything other than single-return exit and say nothing about indentation level other than “Have a style guide.” Perhaps that says something about the relative importance of single return vs indentation levels.

Nesting level in modern code is, at best, circumstantial evidence that the code might be too complicated.

Then, again, it might not be. Story time:

My favorite example of this was when I wrote a state machine based operating system daemon. The spec had the full state machine. I wrote the function so that it followed the spec and handled single exit so that the state transitions were properly guaranteed.

But it had one too many indents (mostly due to the fact that “state” was a struct and being written inline).

Now, I knew that the spec was still getting bug fixes so keeping the thing in “state machine” form was vital to allow that fluidity. But, no, everybody else knew better. So, they refactored it.

And failed the validation tests. And then refactored it again. And failed the validations tests again. …

Finally, one of the greybeards wrote a Perl script to run on my original code to pull the states out into mangled name functions because, yes, the code was structured so strongly that a mere regex could parse it. Performance went down 40% but, hey, that extra indent was GONE, baby. It only took 4 months of work for absolutely zero gain.

And then the spec changed as expected. So the validation tests were now failing. And because of the mangled function names nobody could figure out which states needed to be updated.

I updated my original code with the correct states configured again along with the new validation tests that I now passed. I then resubmitted it and asked the greybeard to run his Perl code for me.

He graciously refused and told everybody to just check the damn code in with the extra indent so we could all fix it when things changed in the future.

4 Likes
  1. Avoiding nesting is about readability of the code and nothing else.
  2. I prefer using a function instead of a label block:
const foovar = fnfoo();
  1. MISRA is about the C language and not zig. C does not have defer, and it is unavoidable to have a single return for exit point, and jump there using goto to clean up.
2 Likes

FWIW, I think this piece of advice is misguided, and personally strive for using early returns as much as possible. But I also pay attention to the purpose of the code; something like your state machine example could very well justify the extra effort to make sure there is a single exit point. Context matters.

4 Likes

Context matters.

Precisely.

The context behind the MISRA C warning has to do with the fact that a lot of time C passes back the error code via return value and all other things back via argument references. It’s really easy to forget to update all the required references with an early return.

With a more current language like Zig, you tend to pass back the result and error both via the return value and don’t tend to mutate argument references quite so much. In that context, you’re not as likely to miss mutating so the early returns are less of a hazard.

Similarly for indentation, with more current languages like Zig and Rust, extra scopes often help quite a lot. In Rust, an extra scope can mean the difference between fighting the borrow checker or not. In Zig, an extra scope helps contain the loop variables for something like while so that you don’t have to have unique names for every single while loop control variable. By contrast, extra scopes in C have very little usage above and beyond introducing an actual control flow change so indentations reflect the underlying complexity more directly.

1 Like

Because I need a unified else not two elses for two ifs.

if (condition) {
  // do stuff
} else if (optional != null and optional.? > a and optional.? < b) {
  try array_list.append(allocator, optional.?);
} else if (next_condition) {
  // do stuff
} else if (chain_continues) {
  // do stuff
} else ...
1 Like

:index_pointing_up:Do you think a chain of ifs is invalid?

This is a matter of personal preference. In this scenario, I tend to use block and break:

complex_logic: {
    if (condition) {
        // do stuff
        break :complex_logic;
    }
    if (optional) |x| if (x > a and x < b) {
        try array_list.append(allocator, x);
        break :complex_logic;
    };
    if (chain_continues) {
        // do stuff
        break :complex_logic;
    }
    ...
}

The drawback is that some people may not like it, but the advantage is that this usage can indeed eliminate the usage requirements of .?

3 Likes
if (condition) {
  // do stuff
} else if (if(optional) |o| o > a and o > b else false)  {
  try array_list.append(allocator, optional.?);
} else if (next_condition) {
  // do stuff
} else if (chain_continues) {
  // do stuff
} else ...
1 Like

A small issue: a .? hasn’t been removed yet. But it’s okay, using a block can address this while keeping the if...else...if structure.

if (condition) {
  // do stuff
} else if (if(optional) |o| blk: { 
  const cond = o > a and o > b;
  if (cond) try array_list.append(allocator, o);
  break :blk cond;
} else false) {} else if (next_condition) {
  // do stuff
} else if (chain_continues) {
  // do stuff
} else ...

Block can handle almost everything, it’s really amazing. (But in terms of readability, I personally prefer the previous version that got rid of if...else...if)

edit: A relatively more comfortable way to write while keeping the if...else...if code structure (for me):

if (condition) {
  // do stuff
} else if (blk: {
  const o = optional orelse break :blk false;
  if (o > a and o > b) {
    try array_list.append(allocator, o);
    break :blk true;
  }
  break :blk false;
}) {} else if (next_condition) {
  // do stuff
} else if (chain_continues) {
  // do stuff
} else ...

Does this work too?

if (condition) {
  // do stuff
} else if (optional) |o| blk: {
  if (!(o > a and o < b)) break :blk;
  try array_list.append(allocator, o);
} else if (next_condition) {
  // do stuff
} else if (chain_continues) {
  // do stuff
} else ...

orelse

if (condition) {
  // do stuff
} else if (optional) |o| {
  if (o > a and o < b) try array_list.append(allocator, o);
} else if (next_condition) {
  // do stuff
} else if (chain_continues) {
  // do stuff
} else ...

Not work. As long as the option is not null, regardless of the judgment result of o, it will not enter the subsequent else if branch.

Edit:
I believe I have figured out this issue: In fact, the requirement here is a new value of the same type as option. The difference from the original option is that as long as option is not within the specified range, this new value is null

    if (condition) {
        // do stuff
    } else if (@as(@TypeOf(option), blk: {
        if (option) |o| {
            if (o > a and o < b) break :blk o else break :blk null;
        } else break :blk null;
    })) |o| {
        try array_list.append(allocator, o);
    } else if (next_condition) {
        // do stuff
    } else if (chain_continues) {
        // do stuff
    } else ...

blk can be eliminated here (but it will damage readability)

    if (condition) {
        // do stuff
    } else if (@as(@TypeOf(option), if (option) |o| if (o > a and o < b) o else null else null)) |o| {
        try array_list.append(allocator, o);
    } else if (next_condition) {
        // do stuff
    } else if (chain_continues) {
        // do stuff
    } else ...

There’s no way you can argue in good faith that this is cleaner or clearer than .?

I admit it’s not clearer. I still think it cleaner because the .? use case here is essentially

if (optional != null and if (optional) |o| o > a else unreachable and if (optional) |o| o < b else unreachable) {
  try array_list.append(allocator, if (optional) |o| o else unreachable);
}

When faced with syntactic sugar, I can’t help but wonder if they are still so clean after sugar removal. Therefore, I actually still prefer to use the initial block and break scheme. Notice that if... else if ... is also a syntactic sugar used to reduce code nesting. Syntactic sugar is always likely to encounter bottlenecks.

When using the block and break schemes, not only can the logic be clearly displayed, but also the logic branches here can be saved for reuse when needed in the future. It’s very convenient to do this if the requirements change!

const branch: union(enum) {
    condition: void,
    optional_meet_requirement: std.meta.Child(@TypeOf(optional)),
    next_condition: void,
    chain_condition: void,
    other_condition: void,
} = blk: {
    if (condition) {
        // do stuff
        break :blk .condition;
    }
    if (optional) |o| if (o > a and o < b) {
        try array_list.append(allocator, o);
        break :blk .{ .optional_meet_requirement = o};
    };
    if (next_condition) {
        // do stuff
        break :blk .next_condition;
    }
    if (chain_condition) {
        // do stuff
        break :blk .chain_condition;
    }
    break :blk .other_condition;
}
doPublicLogic();
switch (branch) {
    .optional_meet_requirement => |o| doSomethinWith(o),
    .next_condition => // do stuff,
    else => {},
}
···