ziglang / zig

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

`errdefer` should be a compile error when used inside a function that doesn't return an error #2654

Open ghost opened 5 years ago

ghost commented 5 years ago

This code is currently allowed, but from reading the docs I don't think it's ever possible for that errdefer to be hit.

pub fn main() void {
    var x = false;

    errdefer x = true;
}
daurnimator commented 5 years ago

I once had the same thought, but it came in useful when coding defensively:

fn myfunc() Foo {
    const foo = Foo.init();
    errdefer foo.deinit()
    foo.bar = Bar.init();
    return foo;
}

And then at some point later, Bar.init is changed to sometimes throw an error: a developer only needs to add ! to the return signature: the errdefer is already there. -> I see this as helping the "good practice" that every .init() is paired with a .deinit()

ghost commented 5 years ago

On the other hand, I ran into this when I was refactoring and ended up with this stale code (the errdefers) that I needed to change to regular defers.

(My refactor was something like taking a large init function, which returned errors, and which was called by my main function - and inlining it into my main function which doesn't return errors. The errdefers were previously in the init function)

CurtisFenner commented 5 years ago

What about functions that do return errors, but don't in the paths following the errdefer, like

fn dothing() !void {
    if (badthing()) {
        return error.AProblemHappened;
    }
    errdefer cleanup();
}

I think if you wanted to complain about errdefer in functions that don't return errors at all you'd also want to complain about the above example.

Though, I do think the point @daurnimator is important. Because Zig doesn't have any "automatic" resource cleanup (such as RAII), it seems like it would be good style to always pair resource acquisition with an errdefer wherever appropriate; however, if no-op errdefer is not allowed, then you would be forced to sometimes omit them:

var r1 = try R1.acquire();
errdefer r1.cleanup();

var r2 = try R2.acquire();
errdefer r2.cleanup();

var r3 = try R3.acquire();
// conspicuously missing errdefer

return consume(r1, r2, r3);

If someone were to modify this code to acquire a fourth resource, they probably wouldn't know whether or not r3 is intentionally missing cleanup (without a comment to the effect of "errdefer is missing because the compiler is annoying"), and in large/complex enough functions, the modifier might not even notice.

nmichaels commented 4 years ago

I see the arguments of the people saying that it can be a clean style to have dead code, but it's still dead code. You may as well have

fn dothing() !void {
    if (badthing()) {
        return error.AProblemHappened;
    }
    errdefer unreachable;
}

This relates to the Zig compiler's overall lazy approach to evaluation, where if it doesn't know that an expression is needed in this build it won't try to compile it. Untested code paths are buggy, and if you've got an errdefer block that can't ever be reached, it's untestable. Personally, I'd rather write the code with unreachable and a comment explaining that cleanup() should be called if this changes.

Anyway, I'll try to sum up the debate with examples of bugs from both sides. Either untested code is silently enabled or cleanup isn't done properly.

In the case where a function with no error in its type errdefers:

fn myFunc() Foo {
    const foo = Foo.init();
    errdefer foo.deinit()
    foo.bar = Bar.init();
    errdefer foo.deinit() // <-- See the bug? Here it is.
    const foo.baz = Baz.init();
    return foo;
}

This is not an unlikely scenario (by my estimation) and when myfunc is changed to return !Foo because the programmer changes Bar.init()'s return type to !Bar, there's one chance to catch it when Bar.init() becomes try Bar.init().

In the proposed case:

fn myFunc() Foo {
    const foo = Foo.init();
    foo.bar = Bar.init();
    return foo;
}

When Bar is changed to return !Bar, myfunc has to be changed to return !Foo and Bar.init() gets its try added. The programmer fails to inspect the rest of myfunc and doesn't add the errdefer Foo.init() and now there's a resource leak.

There's another situation, which is how I ended up here: incorrect use of errdefer.

fn myFunc() ?Foo {
    const foo = Foo.init();
    errdefer foo.deinit();
    // We're going to pretend it makes sense for Bar.init() to return null.
    if (Bar.init()) |bar| {
        foo.bar = bar;
    } else {
        return null;
    }
    return foo;
}

Whoops, leaked some memory because Bar.init() returned null and null is not an error. This is just a bug, and it was found with valgrind. I was surprised, though.

All of the above scenarios are bad, and none are unlikely. If I'm already running valgrind on my tests, it will catch all of these situations, but that's only true if the leaked resource is memory. On principle, I prefer the proposal, since I don't like dead code in my codebase.

I totally forgot @dbandstra's case of refactoring that needs to change a return type from !Foo to Foo. It puts some weight on the pro-proposal side.

hryx commented 4 years ago

See also: #335, #282, #952

andrewrk commented 4 years ago

Thank you @nmichaels for your well-considered and detailed response here. I agree with your reasoning, as well as your suggestion for what idiomatic zig code would look like when "coding defensively":

    errdefer unreachable;

This will be allowed, even when there is no possible error returned from the function, for the same reason that unreachable is allowed in unreachable code paths.

The idea here is when the programmer gets the compile error for returning a compile error through errdefer unreachable (possibly related: #4094), the programmer should understand that resource handling for errors was not taken into account previously, and should inspect the entire function, adding resource management where appropriate.

Code reviewers can look for the simple pattern of:

-    errdefer unreachable;

And understand that the entire function's resource management with respect to errors, needs to be double-checked.


I also want to note that much like the other issues @hryx noted, multibuilds (#3028) is required to enable this compile error, because the return type and reachability in general could depend on build options.

GMWolf commented 4 days ago

I fail to see any material upsides to this proposal, which seems to me like it is inspired by a dogmatic view against dead code without considering why dead code is harmful.

I first want to point out that errdefer unreachable; is deadcode already. and worse, errdefer unreachable; // deinit the object here is commented out dead code, written in English.

I don't see any value in telling a future human to add needed defer statements if anything changes, when we already have a perfectly functional way to tell the compiler to add the defer statement if needed. Why involve error prone humans more than necessary?

I am not convinced by the example cases given by @nmichaels. The errors made in the dead code examples are just as likely to be made after this proposal, except they would be made when dealing with code that you didn't write, making the error more likely. In fact, after this proposal, these errdefer statements will likely need to be introduced many at a time, further increasing the odds of making such an error when copy/pasting.

So not only does this proposal increase the odds of forgetting a defer, it does not reduce the chances of making that error, all while keeping the dead code around, just commented out and written in English instead of code.

I also want to point out reliance on tools like valgrind to catch missing errdefers only works in a limited number of cases. Not all errdefers manage resources monitored by valgrid, and in real time apps, such as games, valgrind is not a option (even debug builds are often not used either, even during development). nor will valgrind catch coding errors that did not happen during prod or QA. Live issues are very common and retail crash dumps are opaque at best.