Discarding hides type errors

If you discard the result of two or more branches, even if they return different types, the code will still compile (unless an error set or union is among them).

It makes sense that the type doesn’t matter as the value wont be used. My issue is that this allows silent resource leakage, ignoring DebugAllocator or similar mechanisms as they are optional.

Case in point (and how I just found out about this behaviour)

var accept = io.async(net.Server.accept, .{ &server, io });
defer _ = accept.cancel(io) catch {};

silently leaks the stream, if the async task finished before the cancel occured.

a clearer example, though you probably wouldnt forget to close the stream if you wrote it like this:

var accept = io.async(net.Server.accept, .{ &server, io });
defer _ = if (accept.cancel(io)) |stream| stream else |_| {};

With the new Io being merged this kind of code, and mistake by extension, become more common.

It is possible that this was a regression, but it would be an old one as I went back several versions and the behaviour persisted.

It isn’t specific to catch, it occurs with all ways to do branching that I tested, though I probably missed some.

So why does this behaviour exist? and is there an argument to it remaining?

4 Likes

It took me a little bit to parse what you mean (in my defence I’m sleep deprived and it’s after 01:00), I think it’d be clearer if you rewrote it as

defer if (accept.cancel(io)) |stream| {
    _ = stream;
} else |_|  {};

That makes the “branch” aspect a little bit easier to understand IMO.

I’m not quite sure how this is a regression from existing behaviour though?

3 Likes

Yes, that is what you would do to handle the success as well.

My point was how easy it is to miss, but I will add a second, clearer example, I don’t need my point to be missable too :smiley:

It isn’t, as far as I have tested, but it’s possible it is an old regression that was just missed, that’s what I was saying. As I see an argument for why it could exist, but I don’t think it’s a very convincing argument, which is why I questioned if it was a regression in the first place.

just realised, your suggestion isnt the same as both branches are void, but I got what you meant and fixed it in my update.

1 Like

I guess me pointing out that I struggled to understand the issue works in your favour quite a bit.

I’ll try have a think about this tomorrow when I can… actually think.

1 Like

Do you mean peer type resolution? I’m not sure. I’ve seen discussions about removing it.

Nope, peer type resolution is coercing two different types into a common type, if possible.

I am talking about when two branches have a type conflict that isn’t resolved, yet it is hidden if you discard the value, removing peer type resolution will only increase such cases.

1 Like

I see.
Perhaps I’m digressing a bit, but if discarding values ​​is considered a less desirable solution, I can accept some of its oddities.
But it seems the standard Io.cancel solution encourages discarding return values ​​and catch {} statements that might throw errors but not handle them? This smells a bit strange to me. If discarding return values ​​is encouraged, then there will certainly be many cases like forgetting to close the stream.

it does not, no different from calling any other function with a return value.

However, cancel is encouraged to be deferred right after async or concurrent calls (depending on your code ofc), and in defers you cannot propagate errors, but since it’s cleanup code you often dont need to worry about them either so catch {} will be somewhat common, depending on the task.

You will get an error if you don’t discard, but if you are aware it returns a success value but overlook it needs to be cleaned up you will discard it, as far as such a person is aware that is correct, which hides this error and successfully compiles.

I think that’s a common enough occurrence, especially if you are less familiar with the API you are using (net.Server.connect for my example).

The type error is secondary, it’s the fact that it alerts you to incorrect code that I care about.

2 Likes

After rethinking, I feel the problem still lies in the overconfident discarding of values.

Even in a positive example: the user is confident that a successful value that needs to be discarded is being returned, and this successful value doesn’t require resource release. In this example, which aligns with the intended requirement of “correctly discarding,” a type error still occurs if type resolution is strictly enforced. I think allowing branches of different types to discard values ​​is precisely the kind of “correctly discarding” usage that fits this need.

If discarding is not used, but something like const a = func() catch {}, the resolution error here is understandable: the symbol a does not exist in the branch. But with discarding, there is no such problem.

If the discarded object is not the return value of Io.cancel, but the return value of another function that doesn’t return an error, then even without a branch or type resolution issue, overconfidently discarding a return value can still lead to the leakage of results that require resource release.

Therefore, I think the key is that discarding values ​​is an operation that needs to be handled with caution. On the surface, _ allows assignment to any type, and type resolution conflicts stem from type resolution contradictions within the target symbol itself. For a symbol that allows any type, this contradiction doesn’t exist. The compiler error for type resolution conflicts is not intended to alert the user to resource release issues, and it cannot resolve such problems. When a user uses _ for discarding, resource release contradictions can occur regardless of whether there is a branch or type resolution issue. Programmers have a responsibility to know exactly what the returned resource is when discarding a return value.

I don’t deny that it is a mistake from the programmer, but that doesn’t mean this is good behaviour. It doesn’t mean there is not any better behaviour.

zig’s explicit discarding instead of silent ignoring of values comes from this line of thinking, human error will always occur, let’s make it easier to catch when it does.

‘Just be careful’ works until it doesn’t, we already passed that point, the example I showed came from someone’s real project.

C devs that have been deving for most of c’s existence still make simple mistakes, you can’t dismiss them for not being careful enough, when they know from experience how careful they need to be.

1 Like

Avoiding such footgun is possible by requiring explicit declaration of the value’s type when discarding it.

The footgun not only occur during branch type resolution but can happen whenever a programmer confidently discards a function return value without confirming its return type. If you want to force programmers to be responsible and understand what they’re discarding, then have them repeatedly declare the type of the values ​​they’re discarding.

However, this feature would be incredibly unpopular, as you can imagine. XD

2 Likes

They would need to allow it, if not require it, once PTR is removed. Unless, discarding gets special treatment.

An issue is that you can discard void values, and when you see foo() catch {}, it’s reasonable to assume foo returns void on success, and you’d be none the wiser as unlike a normal assignment, the type mismatch is hidden.

In that respect I think it’s more of a problem when errors are involved, so I am fine with discarding being otherwise the same, as long as this mistake isn’t silent.

But I do agree with you, specifying the type would give the best chance of catching these mistakes.

It’s a balancing act between annoying and explicit.

1 Like