Variable declaration at if-expression scope

This is about a feature in Odin which I think would be nice in Zig. Odin lets you declare variables in an if statement, which makes the variable available to the adjacent else if and else branches and inner compound statements.

I think this might be quite useful, as very often you declare a variable just before an if statement but after your decision is made, you don’t need it anymore.

What are the community’s thoughts on this?

I think this is a pretty cool feature, but I don’t think that it’s going to be implemented, simply for the fact that this would probably add complexity to the compiler which would slow it down. And the main priority seems to be compilation speed right now. Also I don’t see a strong use cases for it, since Zig already has capturing syntax in the if/else statements. But I’d like to see example where it really improves the code quality/readability if you can provide some :slight_smile:

4 Likes

Just to give credit where credit is due, this is actually one of the Go features adopted by Odin. Not sure if Go adopted it from a prior language though.

As @pierrelgol says, I think the capture feature of Zig’s if can match this functionality in the most useful cases like unwrapping optionals and handling errors. For example in Go, it’s often used for error handling:

if result, ok := foo(); ok { ... } else { ... }

In Zig

if (foo()) |result| { ... } else |err| { ... }
5 Likes

An Odin syntax example:

if x := foo(); x < 0 {
	fmt.println("x is negative")
} else if x == 0 {
	fmt.println("x is zero")
} else {
	fmt.println("x is positive")
}

I find no reason to write this in zig:

if const x = foo(); x < 0 {
	fmt.println("x is negative")
} else if x == 0 {
	fmt.println("x is zero")
} else {
	fmt.println("x is positive")
}
1 Like

If it’s style people want, why not just write it like:

const x = foo(); if (x < 0) {
	fmt.println("x is negative")
} else if (x == 0) {
	fmt.println("x is zero")
} else {
	fmt.println("x is positive")
}

This seems like a case of reaching for a one-liner, imo. You can add extra scope around x if you want to keep it local, too.

I’d say loop variables are closer to what I think will be helpful (like counters), but I’m managing fine without them.

3 Likes

Well the intention wouldn’t be aesthetic, but rather scope minimisation.

I am thinking of something parallel to the following:

if (const x : i8 = foo(); x < 0) {
    // Print "x is too small."
} else if (x > 10) {
    // Print "x is too large."
} else if (const y : i8 = bar(); y < 0) {
    // Print "y is too small."
} else if (y > 10) {
    // Print "y is too large."
} else if (x + y > 20) {
    // Print "Sum of x and y is too large."
} else {
    // Print "x and y are ok."
}

(I’m not sure this is correct syntax, as I’m writing on my phone without syntax highlighting and also I haven’t learnt Zig yet.)

Can Zig’s capture syntax be harnessed here?

Minimizing scopes seems like an aesthetic thing to me.

Without this, you would write:

const x : i8 = foo();
if (x < 0) {
    // Print "x is too small."
} else if (x > 10) {
    // Print "x is too large."
} else {
    const y : i8 = bar();
    if (y < 0) {
        // Print "y is too small."
    } else if (y > 10) {
        // Print "y is too large."
    } else if (x + y > 20) {
        // Print "Sum of x and y is too large."
    } else {
        // Print "x and y are ok."
    }
}

Personally I find this preferable, the introduction of new variables is clearly and quickly identifiable without having to scan through the preceding ifs to see whether they introduce new variables. And the scopes and indentation matches the lifetime of the variables.

I think with long chains of if-else I would find it annoying having to search upwards to other cases to see where a new variable was introduced, especially if the bodies are many lines long. Without this feature indentation naturally guides you towards where it was introduced.

If the short circuit behavior isn’t important for the call to bar you also can just choose to do both calls upfront.

const x : i8 = foo();
const y : i8 = bar();
if (x < 0) {
    // Print "x is too small."
} else if (x > 10) {
    // Print "x is too large."
} else if (y < 0) {
    // Print "y is too small."
} else if (y > 10) {
    // Print "y is too large."
} else if (x + y > 20) {
    // Print "Sum of x and y is too large."
} else {
    // Print "x and y are ok."
}
2 Likes

When I’m programming in C, I always run into the situation when a for() statement would get way too wide.

Minimizing scope allows for variable name reuse, which can be useful for repetitive tasks that can’t be lumped into a loop or function. Personally, I’d say that’s a little higher than aesthetic but it’s certainly subjective.

More to @wolterhv’s question, I think a block surrounding the if statement is the closest Zig has (as @AndrewCodeDev said earlier). So the equivalent would be roughly:

{ const x : i8 = foo(); if (x < 0) {
    // Print "x is too small."
} else if (x > 10) {
    // Print "x is too large."
} else if (const y : i8 = bar(); y < 0) {
    // Print "y is too small."
} else if (y > 10) {
    // Print "y is too large."
} else if (x + y > 20) {
    // Print "Sum of x and y is too large."
} else {
    // Print "x and y are ok."
} }

(That’s assuming you want the one-line style, but most people writing Zig would likely separate the const declaration and outer block to their own lines).

3 Likes

Minimising scope has other implications. Aside @00JCIV00’s example, it’s more generally about hiding things from where they’re not needed, which removes footguns. Minimisation of scope has been baked into rules for developing safety-critical software, see MISRA C 2012 and The Power of Ten rules.

What I’m not a fan of, in your proposed code where y is declared at a new indentation level, is that it can lead to an undesirable amount of nesting. if-else if-else blocks are nice (IMO) to execute sequences where any step can fail in a particular way. If the introduction of new variables requires introducing nesting, the linearity is broken.

IMO finding the declarations in the conditional expressions is trivial, but this is evidently subjective.

In the absence of such a feature, I would also favour not declaring the variable in the same line as the if-expression.

2 Likes

Then I would always prefer the following:

    {  // Evaluate foo() and bar()
        const x : i8 = foo();
        const y : i8 = bar();
        if (x < 0) {
            // Print "x is too small."
        } else if (x > 10) {
            // Print "x is too large."
        } else if (y < 0) {
            // Print "y is too small."
        } else if (y > 10) {
            // Print "y is too large."
        } else if (x + y > 20) {
            // Print "Sum of x and y is too large."
        } else {
            // Print "x and y are ok."
        }
    }

It’s much clearer and easier to reed.

3 Likes

Yea, that’s what I was referring to at the end of my message.

1 Like

Which, if you want to minimise scope and skip calling bar if x is no good, would become

{
    const x : i8 = foo();
    if (x < 0) {
        // Print "x is too small."
    } else if (x > 10) {
        // Print "x is too large."
    } else {
        const y : i8 = bar();
        if (y < 0) {
            // Print "y is too small."
        } else if (y > 10) {
            // Print "y is too large."
        } else if (x + y > 20) {
            // Print "Sum of x and y is too large."
        } else {
            // Print "x and y are ok."
        }
    }
}

And then, any additional operations you might want to execute on the happy path, whose outcome you’d want to test in multiple ways, might require additional nesting, or sacrificing const-ness for some objects (in this example, for y).

In the code snippet above, the discussed feature would save us 2 indentation levels, thus letting us keep everything in one chain of events, all the while letting us achieve scope-minimalism. This would come potentially at the expense of the ease of finding variable declarations and possibly at the expense of ease of parsing for the compiler. The drawbacks might not be objective or easily verified, but might be valid nonetheless.

This is off-topic, but } else if (x + y > 20) { is dead code x and y are both at most 10 so the sum will never be greater than 20.


Personally I don’t feel like I often write very long chains of if-else, that is why I am fine with them adding a few indentation levels. If the chain is at the end of the function there is also no need for the surrounding scope.

Often times you also can use early exit instead of chains of if-else, I think the following should be recognizable by the compiler to be essentially the same?
(I don’t know, I haven’t done extensive comparisons of the resulting assembly, it is a genuine question)

const x : i8 = foo();
if (x < 0) {
    // Print "x is too small."
    return;
}
if (x > 10) {
    // Print "x is too large."
    return;
}

const y : i8 = bar();
if (y < 0) {
    // Print "y is too small."
    return;
}
if (y > 10) {
    // Print "y is too large."
    return;
}
if (x + y > 18) {
    // Print "Sum of x and y is too large."
    return;
}

// Print "x and y are ok."
return;

If there isn’t some big performance drawback to the early exit way I think I would prefer using that, in many situations.

If the program deals with error conditions, instead of printing stuff you might even change it to something like this:

const x : i8 = foo();
try checkRange(x, 0, 10, error.XTooSmall, error.XTooBig);
const y : i8 = bar();
try checkRange(y, 0, 10, error.YTooSmall, error.YTooBig);
if(x + y > 18) return error.SumTooBig;
return [_]i8{x, y};

Overall I think the only thing that would really convince me that this feature is a big benefit, was if there was a piece of code that would become way easier to write, that you can’t rewrite in a different style.

But using early exit instead, seems like it would be possible in most (or maybe even all) cases, and you also could use a scope and break from the scope instead, if you want to avoid the function for some reason.

I think that this is a nice to have syntax sugar, that isn’t really needed, because there are already other ways to do something that are similar enough. Because of that it may be against the "Only one obvious way to do things." from the Zig zen. Although I wouldn’t say that it is obvious how to structure these if-else cases, I think it may be more obvious if we talked about real code instead of made up examples.

2 Likes

+1 on that one; also kind of off-topic but that chain of if-elif-etc. makes me miss go’s “condition-less” switch… really like it. But, personal preference-thing I guess.

3 Likes

Good catch! The dead condition wasn’t intentional.

I’m not opposed to early exit and I used to default to it, but some programming standards (e.g. the aforementioned MISRA C) discourage multiple exit points from functions, so now I use if-else chains instead. I also don’t see why a compiler would produce the different code for early exit vs. for if-else chain with single exit.

I agree that it’s syntactic sugar in that you can achieve the same with similar code, i.e. introduction of new scopes and nesting in if-else chains. It’s interesting to hear opinions about it. It doesn’t seem like something most other people would actively push for.

I’ll see if I can dig up a useful example where this would go beyond nice-to-have.

1 Like

There are parallels to draw between declarations in if statements and declarations in for statements. Unlike the former, the latter are widespread, but the rationale is largely the same: Limit scope and allow for optimizations.

Why do we value declarations in for statements more than declarations in if statements?

1 Like

I don’t have an answer to your question, but I want to point out that Zig doesn’t allow declarations other than captures in for or while loops either.

3 Likes

Reading through the thread there does not seem to be a short answer, but I would like to add to the case. Something that I find a drag is not only a stronger if, but the added ability to if and switch based on error, inline. If there is a way then please do tell. (I am very new to Zig)

– The returns in the code is purely for demonstrative purposes.

    const new_age = if (get_switched(a)) |age| {
        std.debug.print("New Age {}", .{age});
        return age;
    } else |err| switch (err) {
        MyError.err1 => return 77,  
        MyError.err2 => return 88,
        MyError.err3 => return 99,
    };

I would not like to create an additional function for this. If Zig supported nested functions then I would definitely entertain putting it in there, but a function on its own is too much, as these are single use cases.

The issue for me is in handling methods with a safe fallback result - which is obviously dependent on the function being called, with possibility of error, in the context of where it is called from.

    var new_age : u32 = undefined;
    if (get_switched(a)) |age| {
        std.debug.print("New Age {}", .{age});
        new_age = age;
    } else |err| switch (err) {
        MyError.err1 => new_age = 77,
        MyError.err2 => new_age = 88,
        MyError.err3 => new_age = 99,
        else => unreachable
    }

Code that works, but semantically not the same as new_age is now var.

const new_age = get_switched(a) catch |err| switch (err) {
    MyError.err1 => return 77,  
    MyError.err2 => return 88,
    MyError.err3 => return 99,
};
std.debug.print("New Age {}", .{new_age});

You also could have a surrounding block scope and use break :blk 77 to break from the block if you don’t want to return from the entire function.

That said it doesn’t look to me like this has much to do with the original topic, maybe create a new topic and describe your specific situation?