What does Zig mean by "No hidden control flow"?

Hi, first of all, I don’t know any zig, I’m interested in what it can offer and want to learn more about it.

One of the core principles about zig seems to be “No hidden control flow”, but it’s not clear to me what this exactly means. The In-depth overview section of the webpage states:

There is no hidden control flow, no hidden memory allocations, no preprocessor, and no macros. If Zig code doesn’t look like it’s jumping away to call a function, then it isn’t.

Which seems like a sensible explanation, but browsing through the zig examples, I noticed the defer and errdefer keywords, which not only can be used to call a function in a “hidden” way, but they also seem to be part of the standard way of handling memory allocation. How is this not considered “hidden control flow”? Is it just a small concession in the name of convenience, which I totally understand, or I am missing something?

Hi @videbar, welcome to ziggit :slight_smile:

No hidden control flow means that “what you see is what you get”, there is no operator overloading, no fields that call functions, no destructors, no exceptions.
Example:

{
    auto x = doit();
    stdout << x.foo;
}

x destructor is called on } (you must know that the object that doit returns have a destructor, but the code of the destructor is elsewhere and you don’t know what it calls), << is a shift operator (that does not shift anything but is overridden and used as printf alternative), in x.foo you expect that there are no side effects but foo actually is a block of code that changes variables and then returns something.

In zig defer the enclosed code is executed on }. The code is near its execution point and is visible.

{
    const x = init();
    defer {
        x.deinit();
    }
}

Hope it helped.
A good starting point is:

Since you are seeking for the philosophy of the language, start from the first two links in Zig Learning Resources. (both from Andrew Kelley)

2 Likes

Let’s do another take on what @dimdin has already presented here and look at another common overload: +

const z = x + y;

First, in an overloaded language, + can do anything. It can make syscalls, allocate memory, you name it. It’s worse though - all of those overloads can fail and throw. This is part of the reason that many large software companies outright prohibit writing exceptions in their code bases. In Zig, + means addition in the typical sense.


Now, I think it’s very important to be honest about what things mean, even though I genuinely prefer this language. You’re asking a very pertinent question and I think it deserves careful consideration.

You can absolutely write software that hides control flow in any systems programming language.

In a current version of a project I’m working on, if you run out of device memory, the program closes. In this sense, functions do have “side-effects” - it could return something or shut down the program. I think it’s fair to say that’s a form of hidden control flow - it’s not obvious from the call site that could happen.

Now… the question is, what does that say about Zig?

Zig gave me the tools to make my own decisions about that and they are very apparent. I’ve had to make that decision consistently knowing full well that I’ll either continue that way or come up with an auxiliary solution in the future. That said, Zig makes it extremely apparent where I am making this decision.

Let’s go back to that plus example from above:

auto z = x + y;

Can this throw? Maybe, I have no clue what plus means in this context because it could be overloaded. It could throw the kitchen sink for all I know. There’s nothing here that says “I can be a problem”. I like to contrast things to C++ in cases because I regularly come across this issue at work. You’ll see this happen alot:

auto p = new int(42);

assert(p != nullptr); 

They’re trying to be a good citizen and put some debugging around nullptrs. The problem is that new throws. If it fails, we’ll never even hit that assert. This goes really deep though. For example, std::variant throws if you try to access the wrong member. Just like in the last example, you’d never know that from the call site.

Again, it’s possible to write software that hides control flow - if we couldn’t, I don’t see how libraries would even be possible considering that they expose an interface and do things on the backend for you. The question though is does the language itself (and I’ll add standard utilities) present you with this information forthrightly?

2 Likes

I know I’m going on at length here, but I’d like to give you a real world situation where throw became a problem.

There was a database application I was helping develop in the financial industry. This thing was massive (10 million lines of code) and had hundreds of tables and even more stored procedures. If you ran tests locally, your computer would run out of memory (literally talking daily transactions in terabytes). It was already hard to test. Anyhow…

At some point in the past, it was decided that if you throw an exception to the user, you could catch it and present it to them at the UI level. This was used to communicate things like “invalid Loan ID” back to the user. Remember, this project was started in the 90’s… malloc was cool and so were cassette tapes. So, would you imagine that people wanted to use this? They certainly did.

We were tasked to write a work queue to manage information for the end user. Different teams wrote different parts and everyone tried to write professional software. In isolation, each piece looked good and used the error handling that SQL Server offered - lots and lots of throws. Oh boy.

Here’s the problem - if I wanted to use another stored procedure written by a different user on the team, their throws could mess with my catches. I’m supposed to undo a transaction and reset the state of the database while they’re trying to tell the user that they entered their birthdate wrong. It was incredibly frustrating and for the sake of time, I had to rewrite multiple things to remove their throws so I could have a consistent picture of what was happening. Code duplication… awesome… Remember, they’re updating their software in real time as I’m updating mine so I have to go read their software to figure out what they could throw next.

You could argue (and be certainly right) that it was a bad idea to abuse this utility from the start. I’d argue that it’s a bad utility that gave clever people a reason to use what they had. That’s what I’m saying here - does the language offer you tools that actually help you make bad decisions that you’ll pay for in the long run?

Alright, rant over - I hope there was something interesting in there.

I think the point with defer/errdefer is not that it’s “hidden” - you have a sort of explicit goto - but it’s not quite clear where to we are jumping, and the order of execution of deferred parts is semi-hidden, so to say. There was a discussion about goto vs defer a couple of months ago.

IMHO this is just the behavior of current languages that use operator overloading.

In Zig, as an example, we can just require that errors are not allowed.
Unfortunately declaring a function pure is not possible in Zig.

A possible design for operator overload without hidden control flow is:

var z = x @+ y;
1 Like

Yeah, no argument there - that is in fact the intended design of operator overloading. So we agree there. Not to go off topic, but in this case we’re actually not overloading + because we’re overloading @+ so + and remains unchanged. What I’m saying here is that your proposal is actually better because it leaves + alone. I personally do not miss operator overloading, but we can open another thread on that.

1 Like