-
Notifications
You must be signed in to change notification settings - Fork 5.9k
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
null-forgiving operator *can have* effect at run time #17988
Comments
@springy76 thanks! I've simplified your example a bit: using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable
class Program
{
private static void Main()
{
try
{
T1(null);
}
catch (Exception)
{
Console.WriteLine("T1 throws");
}
try
{
T2(null);
}
catch (Exception)
{
Console.WriteLine("T2 throws");
}
}
private static IEnumerable<int> T1(int[]? numbers)
{
return numbers?.ToArray().ToArray();
}
private static IEnumerable<int> T2(int[]? numbers)
{
return numbers?.ToArray()!.ToArray();
}
} The above program produces the following output:
Note that private static IEnumerable<int> T1([System.Runtime.CompilerServices.Nullable(2)] int[] numbers)
{
if (numbers == null)
{
return null;
}
return Enumerable.ToArray(Enumerable.ToArray(numbers));
} while private static IEnumerable<int> T2([System.Runtime.CompilerServices.Nullable(2)] int[] numbers)
{
return Enumerable.ToArray((numbers != null) ? Enumerable.ToArray(numbers) : null);
} @BillWagner is that expected behavior? |
Let's ask @jcouv for some thoughts on this, thank you @pkulikov and @springy76. |
Yeah, I see it as: private static IEnumerable<int> T1(int[]? numbers)
{
return numbers?.ToArray().ToArray();
}
private static IEnumerable<int> T2(int[]? numbers)
{
return (numbers?.ToArray()).ToArray();
} It is a really odd use case, it's literally like expressing that something could be |
I can't resist a good puzzle. I think the behavior is correct, and is covered in operator precedence and in the nullable reference type spec on the null forgiving operator, and finally in the C# 6.0 spec on the null conditional operator:
I think that means the left operand of What's equally interesting is that the statement "The null forgiving operator has no runtime effect" is still strictly true (and was taken directly from the feature spec referenced above). It isn't very helpful, though. The code generated is based on the precedence of the operators, and the fact that they are left-associative. I'm still not sure how best to fix this in the docs, but you've found a super interesting edge case @springy76 We'll keep noodling. |
Precedence. Does that mean that |
Sort of. It's both precedence and associativity. The It took my a few reads of the sections I cited above, and I'm still not completely certain I'm analyzing it correctly. (I first thought it might be a compiler bug. Now I don't think so.) I'd like to wait for @jcouv to weigh in as well before we suggest a fix. |
ok, then we need to write such code (it compiles!) :) numbers?.ToArray()!?.ToArray(); |
Thanks @BillWagner for the analysis (which I think is correct). @gafter, Would this warrant changing the precedence of post-fix |
I don't see what other precedence it could be... |
@BillWagner thank you for the analysis; it helped. However, it's hard to think in terms of precedence because the spec on that doesn't mention null-conditional operators. And it's possible to think that the precedence of Given (I assume the following two statements are true):
null_conditional_expression
: primary_expression null_conditional_operations
;
null_conditional_operations
: null_conditional_operations? '?' '.' identifier type_argument_list?
| null_conditional_operations? '?' '[' argument_list ']'
| null_conditional_operations '.' identifier type_argument_list?
| null_conditional_operations '[' argument_list ']'
| null_conditional_operations '(' argument_list? ')'
;
We can't do the similar split for Furthermore, So, if this issue to be fixed, the definition of a null-conditional expression should be updated, not only the precedence of null-forgiving (to unary??). @BillWagner @jcouv is that correct analysis? |
Let me go into detail how I (we) spotted this problem yesterday, because it's exactly the other way round than the example I gave in the original post: It began with a collegue who claimed that inserting a "!" somewhere fixed a NRE. I protested and bet a staple of crates of beer that the null-forgiving operator won't fix any code but only give the code analyzer a hint: "I know it better, just be calm and stop warning me". In our case it was exactly the other way round (code without "!" threw NRE) because the LINQ extension method in our code (I used var data = mightBeNullObject?.ArrayTypeProperty.EmptyIfNull().Select(x => x.Prop);
hashset.UnionWith(data); // ArgumentNullException when mightBeNullObject is null (data is then null, too) So in our case the "?." stripped away the call to var data = mightBeNullObject?.ArrayTypeProperty!.EmptyIfNull().Select(x => x.Prop);
hashset.UnionWith(data); // data is never null now equals var data = (mightBeNullObject?.ArrayTypeProperty).EmptyIfNull().Select(x => x.Prop);
hashset.UnionWith(data); // data is never null now |
It has no effect at run time; however, it may affect how the expression is parsed at compile time: int[] a = null;
string s1 = a?.First().ToString(); // null: ToString() is not called
string s2 = a?.First()!.ToString(); // Empty string: ToString() is called on default(int?) |
Yes, if you decompile it (using ILSpy for example) you'll see that the compiler emitted different code (by adding parenthesis or maybe an intermediary variable): I just did not expect the compiler to behave different in any way because I only expected reducing of false-positive-warnings. As already stated in the original post: I don't think the compiler is doing something wrong, it just came unexpected due to the lack of explanation or any mentioning in all the samples. |
@BillWagner @IEvangelist @springy76 what about adding the following note to the article about the null-forgiving operator: Note The compiler interprets expression |
I think that note would be appropriate, I'm curious what @BillWagner would think? I'd probably reword it a little (let me know your thoughts): Note The compiler interprets the |
@IEvangelist I would omit the words "the operator precedence": the parentheses in the first sentence are enough. Also to me, "operator precedence" here is more confusing than explaining (however, I don't know how it is to the remaining audience). By the way, "runtime" is the noun that is in "Common Language Runtime", while "at run time/at compile time". |
Have we clarified with the compiler team that it is indeed the intended behavior here? If not, I would create a Roslyn issue asking for clarification. The present behavior is counterintuitive. We might consider changing the grammar to allow null-forgiving operators in |
@BillWagner @IEvangelist I've thought more about it and can explain why mentioning precedence confuses me (and can confuse others). We say that However, I don't think we need to mention precedence here at all. Precedence, for a given operand, decides to which operator the operand binds: to the one with a higher precedence. For example, in That said, in expression There are two solutions: (1) to allow As for the docs, currently they say:
Maybe we should say something like that:
|
@pkulikov I am not sure how you came to this conclusion. Expressions Having said that, I do not think it is strictly necessary to distinguish priorities of those special operators (implied by the grammar) in the table in question. Its main idea is that primary expressions are evaluated first (they have the highest precedence), then all other 'normal' operators are evaluated according to their priorities. For that purpose, it seems OK to extend primary expressions with null-conditional operators even if the outdated formal spec is worded slightly differently.
The words "that changes" imply it is the same effect; however, those are two separate effects:
|
@AntonLapounov this is how I understand the language spec. According to it, a null-conditional expression and a member access expression (as it's a primary expression) are unary expressions. That means they have one operand. Indeed, they require right-hand part, but it's not operand (it can be a member identifier or a list of member and indexer access operations). For example, the spec on null-conditional says:
So, there is a distinction between "operand" and "rhs". This distinction is important, because precedence concerns operands. |
@AntonLapounov thanks for that remark. I agree my suggestion can be improved like that:
@springy76 @IEvangelist @BillWagner what do you think? |
@pkulikov I agree with your analysis, and I like the updated text you're proposing. The last sentence was tripping me up a bit (this is a minor).
Does this read a bit better written this way?
I am thoroughly enjoying this thread, being new to this team - I'm learning a lot from it. Thank you |
@IEvangelist I would make the intro even shorter (is that grammatically correct?):
|
BTW: Not to oversee what happens on nullable structs while talking about nullable reference types: static System.TimeSpan T1(DateTime? dt)
=> dt?.TimeOfDay!.GetValueOrDefault(Timeout.InfiniteTimeSpan); Without |
I'm good with that, I think the "That happens because" was throwing me off... |
That is not correct interpretation of the spec. While every
While you might claim that
And that list of operations works as the second argument/operand of the operator. That is commonly used terminology. Look, for example, at Safe navigation operator Wiki page:
Operator precedence rules dictate how an expression is interpreted by the compiler and translated into the expression tree. That includes determining arguments of each operator. It does not matter whether you call them operands or lhs/rhs — you still have to determine their textual boundaries. If some part of the grammar is more complicated and cannot be expressed as an operator-precedence grammar, that is fine, but then you should avoid assigning precedence to operators in that part of the grammar. That is what you did in #18001 and I came here to explain why it was wrong:
|
Thanks for all the discussion. I'll make the following proposal: In light of dotnet/csharplang#3393, dotnet/csharplang#3297 and dotnet/roslyn#43659 which open possible changes o the spec and implementation, I'd like to re-focus this on explaining the current behavior. (Thanks @AntonLapounov @agocke and @jcouv for driving those discussions). In the short term, I think the best solution is Option 1 in https://github.com/dotnet/docs/pull/18037/files#r414319212 as @AntonLapounov says. In addition, we should add a note as previously discussed. I suggest the following edit to the previous proposal: Note In C# 8, The expression I specifically mention C# 8 to take into account change that might come from dotnet/csharplang#3393. In the longer term, as the standardization effort catches up, we should remove this table from the language reference section, and refer to the corresponding table in the spec. Can we give this is thumbs up or down as a proposed solution? |
Nitpicking, it may throw, but not necessary if int[] a = null;
// s2 is the empty string: ToString() is called on default(int?)
string s2 = a?.First()!.ToString(); What's about the following?
|
BTW: What is inside LINQ expressions? |
Fixes dotnet#17988. See dotnet#17988 (comment) The null conditional operators and null forgiving operators interact in interesting ways. Add notes on both pages to explain those interactions.
I opened #18124 to address this. Thanks for all the discussion. @springy76 Can you open a new issue for the question on LINQ expressions? I'm not sure what you mean, and I'd like to start a new discussion for that concern. |
I just wanted to note that
does not compile at all (CS8072 An expression tree lambda may not contain a null propagating operator) but
is no problem, seems to be ignored by (or transparent to) the expression code builder. |
Thanks @springy76 That's a different issue. (And we don't document it well.) There are a number of new syntax elements that aren't representable in LINQ expression trees. I'll create an issue and we'll address that as well. |
* Add note about interactions in operators Fixes #17988. See #17988 (comment) The null conditional operators and null forgiving operators interact in interesting ways. Add notes on both pages to explain those interactions. * respond to feedback * typos * rework note based on feedback. * one final set of changes
Quote from the current doc:
The document currently focuses primarily on the usage of suppressing warnings of code flow analysis.
But when combining the null-forgiving operator with a leading null-conditional member-access operator the compiler will indeed emit different code and this will have effect at run time -- which comes unexpected.
Example:
This happens because in
T2()
the code actually compiles toreturn (fi?.Directory?.EnumerateFileSystemInfos()).Select(fsi => fsi.Name);
(notice the additional parentheses).I'm sure this is by design and is covered by the sentence "expression x! evaluates to the result of the underlying expression x". But since any article or documentation I have read about the null-forgiving operator so far, focused only about getting rid of warnings, this (change in) behavior was very unexpected, at least to me.
Document Details
⚠ Do not edit this section. It is required for docs.microsoft.com ➟ GitHub issue linking.
The text was updated successfully, but these errors were encountered: