Repository navigation
[Draft proposal] Void branches #10402
Replies: 12 comments 26 replies
|
-- just reiterating some things I said on Discord The main problem I have with I'd think a discard would be "clearer" in that case; i.e. I also prefer Ruby's approach of This in general isn't supper common in languages outside of conditional null assignment, which we already have. |
|
(I like |
|
My problem with this is that
I also think that it is trying to solve a specific problem and maybe some kind of general lightweight symbol-macro options would be more valuable to explore? We could end up with something like P.S. I'm all up for sinking |
|
I like One reason I prefer |
|
Note: for completeness/consistency, I would like So having |
|
If the goal is to let callers set either a specific value or the default value, perhaps |
|
The problem is very real; what alternative syntaxes have been considered? Would a limited statement syntax inside an object initialiser work? E.g. var forecast = new WeatherForecast
{
if ( node["temperatureC"] is { } t)
{
TemperatureC = (int)t;
}
Summary = "..."
}; |
|
I don't like the skipping invocation syntax. If anything, I would expect that to select the optional parameter's default value. M(c ? a : void); // a or 42
void M(int x = 42) { } |
|
The idea that a void somewhere down the right of the line can just make the whole expression null and void (pun intended), I don't like. I am on board with the concept of conditional execution, but |
|
At first I thought this might be the mythical |
|
I think this proposal might also fix/enhance the throw issue. using System.Diagnostics.CodeAnalysis;
[DoesNotReturn]
public void ThrowNotFound(this ValidationMessage message) => throw new NotFoundException(message);
// somewhere else
var resource = await GetResource(id) ?? M.E12.ThrowNotFound(); // ERROR: Operator '??' cannot be applied to operands of type '...' and voidThe above won't work because the |
|
Could you add to Drawbacks how it might have confusion with "Switch expression as a statement expression"? A switch expression in the future might allow calling a method that returns void. That void "return" might get confused with the void_branch - for how people understand the language. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Summary
Let a
voidbranch of?:,??, or a switch expression skip the enclosing assignment, asa?.b = cdoes whenais null:Motivation
Code that creates an object with
newcan set the object's init-only properties only in an object initializer, and an object initializer has no way to assign a member conditionally. So users can't set an init-only property on some paths and keep its default on the others. The System.Text.Json source generator and community generators work around this limitation with code that requires<AllowUnsafeBlocks>true</AllowUnsafeBlocks>in every consuming project that opts into unsafe evolution. A partner team has asked for this feature urgently, because the rollout of unsafe evolution will force more and more of these projects to set it. The final initializers proposal is also waiting on this one, because final initializers put more pressure on generators to set members inside an object initializer.Users who write the initializer by hand copy the default into the other branch:
The copy goes stale when the type's author changes the default, and it can't follow a default that a constructor computes. Users have asked for a fix in conditionally set properties in object initializers and the other requests in Appendix B. LDM connected that discussion to conditional elements in collection expressions and expected "a feature to improve that for both collection literals and other types of initializers".
A generator usually can't copy the default, because it can be any expression and may be compiled into another assembly. So a generator that keeps defaults sets init-only properties after construction, through an
UnsafeAccessorextern for each init accessor. Here is the output of the community PolyType generator for a class with three init-only properties, reformatted and with namespaces removed:With this proposal, the generator writes an object initializer instead:
System.Text.Json first supported init-only properties by setting all of them in the initializer, so members missing from the payload lost their defaults. Setting only the members that are present takes one initializer per subset, or "$2^n$ constructor delegates" for$n$ init-only properties, according to the library's maintainer. System.Text.Json has since moved to
UnsafeAccessoror reflection. The author of another serializer summed it up: "C# simply lacks the syntax for deserializers to emit C# code that honors default values of init accessor properties."The
AllowUnsafeBlocksrequirement comes from the externs. Under unsafe evolution, everyexternmethod must be markedunsafeorsafe, and as proposed, both modifiers requireAllowUnsafeBlocks. LDM gave source-generated code no exemption, so a generator's only other choice is reflection.Final initializers affect every member that a generator sets, init-only or not. A final initializer runs at the end of the
newexpression, so in code that sets members afterward, it validates the defaults instead of the payload:A generator can still validate the payload by creating the object without
new, for example withActivator.CreateInstance, setting the members, and then running the final initializer itself. C# code can't invoke a final initializer, so the generator would call it through reflection or anotherUnsafeAccessorextern. With an extern, theAllowUnsafeBlocksrequirement then applies to every type with a final initializer, whether or not it has init-only properties. The current design of final initializers supports generators "as long as they set members in an object initializer rather than after the constructor returns". With this proposal, a generator can set every optional member conditionally, inside one initializer.Where a statement fits, an
ifstatement is the right tool. But object initializers,withexpressions, and field initializers have no statement position. The language already skips an assignment from inside an expression. In a null-conditional assignmenta?.b = c(),c()isn't evaluated whenais null.Detailed design
x = c ? a : void;has the same effect asif (c) x = a;, except that the target's receiver and index arguments are evaluated beforec:The compiler emits a branch around the store, so the feature needs no runtime support or new metadata.
Where
voidcan appearvoidcan be the second or third operand of?:, the second operand of??, or the expression of a switch expression arm. The conditional that contains it must be the right side of one of the following, directly, in parentheses, or through the branches of other conditionals:withinitializer, including an indexer initializer such asnew A { [1] = c ? b : void };We recommend limiting
voidto these positions (see How farvoidreaches) and requiring at least one branch with a value (see Assignments whose branches are allvoid). A lambda has its own body, so avoidbranch in a lambda needs an assignment inside that lambda.voidfollowed by(or*still begins a lambda with an explicit return type, as invoid () => { }. A parenthesizedvoidis an error, matching(throw e).Types
A
voidbranch has no type and converts implicitly to every type, through a new implicit void conversion modeled on the implicit throw conversion. So the other branches decide the type.n ?? voidhas typeintwhennis anint?, ands ?? voidis not null whensis astring?, the same as with?? throw e.The value of a skipped assignment
We recommend that a skipped assignment yield the target's current value, so
y = x = c ? a : void;leavesyequal toxon both paths (see Value of a skipped assignment). An assignment to a discard, or to a property or indexer without a get accessor, then has no value. It can still be a statement or a member initializer, as in_ = c ? Log() : void;, which callsLogonly whencis true.Compound, event, and ref assignments
In a compound assignment,
a += c ? b : voidmeansa = c ? a + b : void, except thatais evaluated once and its value is read beforec. On thevoidbranch, no operator is called, including a user-defined+=, and for an event, the add accessor isn't called.A ref assignment can use a ref conditional with a
voidbranch:A ref local declaration can't use one, because a ref local must be initialized.
Initializers
In an object or
withinitializer, a skipped member keeps the value that the constructor or the copy gave it. That makeswitha direct way to apply a partial update:A local, field, or property initializer that takes the
voidbranch leaves the variable unassigned. A field or property keeps its default value, and a local isn't definitely assigned afterward:A
usingdeclaration orusingstatement can't usevoid, because its variable could be unassigned when it's disposed.Places where
voidis an errora = void;, because the assignment never happens.requiredmember, because the member must be set during object initialization. LDM made the same choice for compound assignment in initializers.letclause or an anonymous type member. Neither has an earlier value to keep, andvoidcould suggest a type whose shape depends on a runtime condition.defaultcovers a missing value there.voidshould skip one element or the whole assignment. This can be allowed later.Definite assignment and nullable analysis
Both analyses treat the skipped path as though the assignment weren't there. So
x = c ? a : void;doesn't makexdefinitely assigned, and afterwardxmay be null if it may have been null before or ifamay be null.Expansions
The same idea could skip other operations that consume a value. We don't propose these now. The core rules make
voidan error in each of these positions, so each could be added later without a break.Skipping an invocation
This expansion would make
M(c ? a : void);equivalent toif (c) M(a);. Avoidbranch in an argument would skip the nearest enclosing invocation. The receiver and earlier arguments would be evaluated, but later arguments wouldn't, and the method wouldn't be called. The invocation would have no value, so it could only be a statement or the body of a lambda that returnsvoid.Users have asked for a way to skip a call when an argument is null, as
M(?a)and as short-circuiting arguments. With this expansion, they would writeM(a ?? void). It helps most in expression-bodied members and lambdas, and when several arguments are conditional, whereM(a ?? void, b ?? void)replacesif (a is { } x && b is { } y) M(x, y);:The objection raised on
M(?a)applies here as well: "some operator applied to an argument expression that alters the behavior of the parent expression". In its discussion ofbreakandcontinuein arguments, LDM noted the same cost, but also that deciding before the call "leads to more ceremony and less locality of the code." We think a keyword in the branch is easier to spot than a?prefix, but the objection is why invocations aren't part of the core. The expansion wouldn't apply to collection initializer elements, where a conditional spread is the better tool (see Conditional members and elements).Other statements
throw e ?? void;,yield return x ?? void;, andreturn c ? a : void;could skip their statement, matching requests forthrow?, null-conditionalyield return, andreturn?. As witha = void;,return void;would be an error. These are statements, so anifalready works there, andreturn c ? a : void;may read as if it always returns.For
await, this expansion would mean the language doesn't needawait?after all. LDM approved null-conditionalawaitfor code likeawait? GetX()?.DoSomethingAsync();, which users could write asawait (GetX()?.DoSomethingAsync() ?? void);instead.await?would be the first keyword made of a word and a symbol, and LDM's triage notes call it "an odd kind of keyword". A skippedawaitwould have no value, so this form works only where the result is discarded, as in LDM's example.Specification
Throughout this section,
strikethroughindicates text being removed from the existing specification, and bold indicates text being added.The baseline is the draft-v14 branch of the C# standard.
Expressions
§12.18 The null coalescing operator is updated as follows:
null_coalescing_expression : conditional_or_expression | conditional_or_expression '??' null_coalescing_expression | throw_expression + | void_branch ;A new subsection is added after §12.19 The throw expression operator:
§12.21 Conditional operator is updated as follows:
conditional_expression : null_coalescing_expression | null_coalescing_expression '?' expression ':' expression | null_coalescing_expression '?' 'ref' variable_reference ':' 'ref' variable_reference + | null_coalescing_expression '?' 'ref' variable_reference ':' void_branch + | null_coalescing_expression '?' void_branch ':' 'ref' variable_reference ;§12.2.1 General is updated as follows:
Conversions
§10.2.1 General is updated as follows:
A new subsection is added after §10.2.17 Implicit throw conversions:
Definite assignment
§9.4.4.25 Simple and deconstructing assignment expressions is updated as follows:
A new subsection is added after §9.4.4.32 Throw expressions:
Drawbacks
Readers must check every branch to know whether an assignment happens, and a
voidbranch can sit deep in a chain of conditionals. LDM weighed the same cost for null-conditional assignment, when a few members' "first read was thatc()would be executed regardless of whetherawas null", and accepted it. Avoidbranch is further from the target than?.is, so the skip is easier to miss. Users might possibly learn the forms that yield clarity nonetheless, as they do with?? throw, by organizing the code so that it doesn't bury the lede.voidgains a meaning as an expression. Users have also asked forvoidas a type with a value, for use in generics. If LDM sees value in both, potential conflicts between them may need to be investigated.The specification gains a second typeless operand beside throw expressions, with its own conversion and placement rules.
Alternatives
Do nothing
Generators keep setting members after construction. Once unsafe evolution and final initializers ship, their consumers need
AllowUnsafeBlocksfor every type that has init-only properties or a final initializer, unless the generator switches to reflection. Users who write initializers by hand keep copying defaults.Conditional members and elements
A conditional member initializer such as
new X { InitMember = if (cond) expr }has the same effect in initializers. It's special-cased to initializers and less widely useful, because it doesn't help assignments in statements,??, or switch arms. The same syntax would allow[a, if (cond) b, c]ornew List<T> { a, if (cond) b, c }, as Dart's collectionifdoes. But the condition must be repeated for each element added under it, which[a, .. cond ? [b, c] : [], d]avoids. That spread is listed as future work for immediately enumerated collection expressions, and today it needsnew[] { b, c }.A
whenclauseA suggestion on the initializer discussion was
B = b when readB, which resembles statement modifiers in Ruby and Perl. The value comes before the condition, so a pattern can't declare the variable that the value uses, asis { } t ? t : voiddoes. We prefervoid.A keyword for "no change"
Requests have used
_,value, andnonein place of thevoidbranch._is a discard and an identifier,valueis a contextual keyword and a common identifier, andnonewould be a new keyword.voidis already reserved and already means "no value".Open questions
How far
voidreachesA narrow rule allows
voidonly in branch positions: a branch of a?:,??, or switch expression that is the assignment's right side, or that is itself in a branch position. Such expressions nest to any depth, butvoidcan't reach the assignment through a condition, a switch expression's input, the left side of??, or any other operand. A broader rule would let avoidbranch anywhere in the right side skip the whole assignment, as an earlier "poisonous null" idea did for arguments:Shall
voidbe limited to branch positions, or allowed anywhere in the right side within the same body?voidin an argument would skip the assignment instead of the call, which rules out the invocation expansion.Recommendation: Branch positions only, because the skip stays next to the code that causes it.
Value of a skipped assignment
What is the value of an assignment when its
voidbranch is taken?yequalsxon both paths, and the type is the target's type. A discard, or a property or indexer without a get accessor, makes the assignment valueless. When the value is used, a simple assignment calls the get accessor on the skipped path, while the assigned path yields the right side without calling it.defaultof the nullable target type, which matchesa?.b = cwhenais null. The assignment's type becomesT?, soycan't holdx's value on the skipped path.Recommendation: The target's current value, because chained assignments keep the target's type and value on both paths.
Assignments whose branches are all
voidShall an assignment whose branches are all
voidbe an error?Recommendation: Error, because the code can only be a mistake or a long way to write
_ = c;.Appendix A: Precedent in other languages
We found no language in which a branch of a conditional cancels the enclosing assignment. The closest constructs fall into three groups.
Assignments skipped when the receiver is null:
Members or elements omitted from a literal by a condition:
[if c then "name"]: value....(c && { name: value }).ifelements and null-aware elements:key: ?valuein a map literal omits the entry whenvalueis null.ifwithoutelseinside a builder body.Statements with a trailing condition:
ifand Perl statement modifiers:x = a if c.Appendix B: Requests
Counts are current totals across each row's threads. Upvotes include each discussion author's own, and reactions are on the opening post. The same user may have both upvoted and reacted.
elseyield return,throw, orreturnAll reactions