-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Proposal: Conditional Access operator for fields on nullable types #8392
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
👍 I have a lot of places where I've wanted this, it's incredibly tedious to have ifs and they don't work well with nested field accesses, e.g.: foo?.bar?.baz Good luck writing that in any sane manner. It's worth having one for array accesses too -- I'm not sure how the syntax could work with Prior art: JavaScript/TypeScript, C#, etc. It's already widely implemented. (FWIW, C# also names the postfix non-null assertion Zig's |
Duplicate of #2816. |
@Vexu Ah I see, I tried to search for duplicates but didn't get the name right. Do you know why that issue was originally closed? It doesn't seem like there was much disagreement on the original proposal. |
Likely the usual "don't add syntax sugar unless it prevents footguns/encourages better coding styles" reason since the goal is to keep Zig as simple as possible. |
I totally understand the desire to keep the amount of symbols/operators in use to a minimum, but I really think that this is a case where the possible benefits for both writing and reading code outweigh the mental overhead of adding new syntax. We could expand on BinaryWarlock's example to see how it reduces the complexity of arguably simple operations: // With this proposal
const x = foo?.bar?.baz;
// Current method
const x = if (foo) |f|
if (f.bar) |b|
b.baz
else null
else null;
// Alternate current method
var x: ?u32 = null;
if (foo) |f| {
if (f.bar) |b| {
x = b.baz;
}
} |
It definitely will reduce the amount of code when dealing with control flow, but I believe simplifying control flow with the syntax proposed is likely to increase the chance that developers footgun themselves (by either making it easier for developers to introduce logic errors in their code, or by making it easier to write less performant code with unnecessary branching). Here's an example of how I do conditional branching with a disk-backed trie implementation I wrote a month back. While a lot of the code is quite lengthy, scoping conditional accesses out in blocks made it easier to debug the code while I was implementing it (and lets me consider that for some of the code, I could encapsulate them out into functions when need be). Additionally, typing the whole plethora of more code needed also made me really think a whole lot harder about how I should be branching and how potentially-null variables should be dealt with when null. With this conditional access operator, as a tradeoff for having my code coming out "aesthetically less lengthy", most likely it would take a lot more work debugging such code while I was working on it. Perhaps additions to the syntax in Zig might be a good idea to simplify the amount of branching that goes on in a lot of Zig code, but the conditional access operator in my opinion wouldn't be the way to go. |
I think this sugar does encourage safer behavior. The "easy" way to get the value of an optional is with |
@lithdew Thanks for providing this example. I definitely agree that the bulk of code here would not benefit from a conditional access operator. Specifically, I think that the
I think that the only place in your code where I could see this proposal being used is here: const maybe_left_hash = if (maybe_left_node) |left_node| left_node.hash else null;
const maybe_right_hash = if (maybe_right_node) |right_node| right_node.hash else null;
// Becomes...
const maybe_left_hash = maybe_left_node?.hash;
const maybe_right_hash = maybe_right_node?.hash;
// Or even without assigning to a separate variable first
node.hash = Node.hash(key, node.value, maybe_left_node?.hash, maybe_right_node?.hash); If I understand your concern correctly, it's that giving developers easier ways to deal with nullability will make it more likely that they'll inappropriately shoehorn it into cases that require more complex solutions, like your |
You can't shoot yourself using the proposed Introducing // That's ok, value still needs to be unwrapped
var value = a?.b?.c;
// Really unsafe, can reference null without checking it
var value = a.?.b.?,c;
|
Wouldn't the fix otherwise be to discourage the use of
Apologies, I should have pasted the text of the code block to make it easier to copy-paste 😅. A specific case that I worry about is the chaining of multiple conditional access operators i.e. A counter-argument would be that developers could use the same conditional access operator to handle each case when unwrapping a, b, c, or d, but in that case, the syntax sugar does not help at all as you could just equivalently write: const b = a orelse return error.A;
const c = b orelse return error.B;
const d = c orelse return error.C;
const e = d.e; Citing back to my example of a disk-backed trie, here is a case of an obvious footgun where additional handling should've been done, but was not done because it was just so much more easier to chain multiple conditional access operators and handle all null conditions with just one case: const data = node?.next?.next?.data orelse return error.NodeNotFound;
// Was it really the case that we should have returned an error stating that the node was not found?
// Or was it an invariant that was broken, such that we should have put `unreachable` instead?
// Or was it that while we were writing code, we forgot to initialize `node?.next?.next`?
// Or, if `node?.next` is null where `node` is null, should we have a case where we initialize `node`?
// ???? Another case that I worry about is that such syntax sugar would incentivize a lot of redundant branching should multiple conditional access operators be chained i.e: const item_1 = a?.b?.c?.d?.item_1 orelse return error.A;
const item_2 = a?.b?.c?.d?.item_2 orelse return error.B; With the code above, nullability checks would be performed twice as much as needed. I see two counter-arguments here:
For the first point, when writing code free-style, most likely such a refactor would be something you would only consider after you finish writing your code and clarify your codes' correctness. With Zig's current syntax, I would not have performed any redundant branching at all in the first place given how verbose it is to unwrap and handle For the second point, I strongly doubt that optimizers are able to merge such a case of duplicate redundant branching in several many situations (especially if there are additional references to variables b, or c, etc.). You could see how much I fixate my arguments around redundant branching and code by the way, but that is solely because one of the nice properties I like about Zig's current syntax is how much it paves the right path for me to write more performant and correct code (the more verbose some code that I need to write is, Zig is pointing out to me that I should put more care into checking it while writing it) with footguns only to be used when I am sure I can justify its use in my code (i.e. unwrapping null with |
@lithdew I'll admit right off the bat that I have no idea how code optimizers work, so I'll take you word on this being difficult to optimize. I also see your concerns about this encouraging sloppy code with unnecessary branches, and I don't have a direct counter-argument to that either. You're bringing up really good points. I still believe that this idea - as originally proposed - has value, but let me propose an alternate version that may be closer to addressing the issues you brought up - only allow the conditional access operator inside // Original proposal:
const x = a?.b?.c?.d;
// Alternate:
const x = if (a?.b?.c) |c| c.d else null;
// This would not immediately solve the issue you brought up regarding duplicate branching, but I believe it would make
// it easier for a developer to spot instances that could be refactored. For example, let's restate your example:
const item_1 = a?.b?.c?.d?.item_1 orelse return error.A;
const item_2 = a?.b?.c?.d?.item_2 orelse return error.B;
// With this alternate proposal, the code would be:
const item_1 = if (a?.b?.c?.d) |d| d.item_1 else return error.A;
const item_2 = if (a?.b?.c?.d) |d| d.item_2 else return error.B;
// Here the nested access into d is still simplified, but the if statement segregates it into an
// easy-to-spot location, hinting to the developer that it could be made into its own variable.
// This also solves an issue that, as far as I'm aware, nobody has talked about yet
// Original:
const item: u32 = a?.b?.c?.d?.item orelse return error.A;
// Alternate:
const item: u32 = if (a?.b?.c?.d) |d| d.item else return error.A;
// The benefit here, in case it's not clear, is that someone reading this code can clearly see that d.item is not nullable,
// whereas in the original it wasn't clear.
// This can also make it easier to handle cases where you want to handle intermediate nullability separately:
const item = if (a?.b) |b| blk: {
if (b?.c?.d) |d| break :blk d.item else return error.B;
} else return error.A; Now this solution isn't perfect either, and in my opinion it's confusing to introduce syntax that can only be used in such a specific context (could be made to work in
Although I'm not sure that this is much better. PS I see that this issue is closed now, sorry if continuing to post this is violating a rule but since I started before it was closed I just wanted to get it out as a last word. Since it seems like this isn't something we want I'll leave it here. Thanks for the feedback. |
It seems like the options currently available to handle nullable values was more oriented towards situations like this:
However a common pattern for dealing with nullable values (in my experience at least) is this:
This is why I'm proposing the conditional access operator:
?.
.The basic idea is that you have a nullable struct and want to access the fields inside. If the struct itself is null, then you want to fallback on a default value. If you're dealing with a single nullable struct then it's easy enough to use the code above, but it gets annoying when dealing with 2 or more nullable structs:
My proposal is to add a conditional access operator similar to what exists already for nullable types in Typescript. The idea would be that using a conditional access operator on a nullable type immediately returns
null
if the value is null, or allows accessing fields if it is not. Here's some examples:For a less contrived example, here's the situation that prompted me to create this issue - the ODBC function
SQLForeignKeys
uses nullable pointers to make the function operate in three different ways:Here's an abbreviated version of this function's implementation if this proposal was accepted:
I think that having the option to write this function like this would be much nicer than having to create separate variables to hold each of the string lengths or wrapping everything in layers of
if
statements.The text was updated successfully, but these errors were encountered: