Skip to content

Latest commit

 

History

History
234 lines (154 loc) · 20.2 KB

File metadata and controls

234 lines (154 loc) · 20.2 KB

Compound assignment in object initializer and with expression

Champion issue: #9896

Summary

Allow compound assignments in an object initializer:

var timer = new DispatcherTimer
{
    Interval = TimeSpan.FromSeconds(1d),
    Tick += (_, _) => { /*actual work*/ },
};

Or a with expression:

var newCounter = counter with { Value -= 1 };

Motivation

It is not uncommon, especially in UI frameworks, to create objects that both have values assigned and need events hooked up as part of initialization. While object initializers addressed the first part with a nice shorthand syntax, the latter still requires additional statements. This makes it impossible to create these sorts of objects as a simple declaration expression, preventing their use in expression-bodied members or in nested constructs like collection initializers or switch expressions. Spilling the object creation expression out to a variable declaration statement makes things more verbose for such a simple concept.

The declarative UI story can be made much more complete with a small change to the language. Windows Forms in particular can immediately gain a more appetizing story for dynamic or manual creation of UI controls, both in vanilla form and when using third-party vendor frameworks that build on Windows Forms.

The same reasoning applies to more than just events. Newly created objects (and especially objects produced via with) may want their initialized values to be relative to a prior or default state, which is exactly what a compound operator expresses.

Detailed design

The following updates are presented as a diff against the corresponding sections of the C# 7 standard (expressions.md), and against the with expression subsection of the C# 9 records proposal.

Throughout this section, strikethrough indicates text being removed from the existing specification, and bold indicates text being added. Unchanged prose is quoted verbatim for context.

Object initializer

The following diff is applied to the grammar in §12.8.16.3:

 object_initializer
     : '{' member_initializer_list? '}'
     | '{' member_initializer_list ',' '}'
     ;

 member_initializer_list
     : member_initializer (',' member_initializer)*
     ;

 member_initializer
-    : initializer_target '=' initializer_value
+    : initializer_target '=' object_or_collection_initializer
+    | initializer_target assignment_operator expression
     ;

 initializer_target
     : identifier
     | '[' argument_list ']'
     ;

- initializer_value
-    : expression
-    | object_or_collection_initializer
-    ;

The prose in §12.8.16.3 is updated as follows. The terms compound assignment operator and compound assignment below are used as defined in §12.21.1.

An object initializer consists of a sequence of member initializers, enclosed by { and } tokens and separated by commas. Each member_initializer shall designate a target for the initialization. An identifier shall name an accessible field or property or event of the object being initialized, whereas an argument_list enclosed in square brackets shall specify arguments for an accessible indexer on the object being initialized. It is an error for an object initializer to include more than one member initializer for the same field or property. For any given field, property, or event target, any number of member initializers using a compound assignment operator are permitted for the same target. If present, an = member initializer shall appear in lexical order before any other member initializer for that target. No such restriction applies to indexer targets.

A member_initializer of the first form (initializer_target '=' object_or_collection_initializer) is exclusive: it must be the only member_initializer in the enclosing member_initializer_list whose initializer_target designates the same field, property, or event.

Note: While an object initializer is not permitted to set the same field or property more than once with =, the existing "same indexer arguments multiple times" allowance is preserved for plain assignment and compound forms. The relaxation above for compound operators supports, among other things, subscribing multiple handlers to the same event in a single initializer (Click += h1, Click += h2) and accumulating into the same property (Value = 10, Value += 5). The target = { … } form is exclusive because it configures the nested instance the target already references; combining it with a slot-overwriting = or a read-modify-write compound assignment operator would overwrite that configuration. end note

Each initializer_target is followed by an equals sign and either an expression, an object initializer or a collection initializer. It is not possible for expressions within the object initializer to refer to the newly created object it is initializing. The single exception is the target of a compound member initializer (a member_initializer of the second form whose operator is a compound assignment operator): both the read and the write of the target occur on the newly created object. Member initializers are processed in lexical order, so the read performed by a compound member initializer takes place after every preceding member initializer has executed; the read accordingly reflects whatever state that target's get accessor reports at that point.

A member initializer that specifies an expression after the equals sign is processed in the same way as an assignment (§12.21.2) to the target. A member_initializer of the second form target op value in an object initializer is semantically equivalent to the statement_expression x.target op value;, where x is the otherwise invisible and inaccessible temporary variable holding the instance being initialized, and where x.target is the field, property, event, or indexer access designated by initializer_target. The meaning of that statement_expression is given by §12.21: simple assignment (§12.21.2) when op is =, compound assignment (§12.21.4) when op is a compound assignment operator and the target is not an event, and event assignment (§12.21.5) when op is += or -= and the target is an event. In particular, the statement_expression context required by §12.21.5 is satisfied by this lowering, and the get-and-set requirement on property and indexer targets imposed by §12.21.4 applies to the target of a compound member initializer.

The existing §12.8.16.3 paragraphs about nested object initializer and nested collection initializer are unchanged; they apply only to the first-form member_initializer, which is the only form that admits a nested object_or_collection_initializer.

Example: The following class combines properties and an event:

public class Counter
{
    public int Value { get; set; }
    public event EventHandler Changed;
}

An instance of Counter can be created and initialized using a mixture of = and compound member initializers:

Counter c = new Counter
{
    Value = 10,
    Value += 5,
    Changed += OnChanged,
    Changed += OnChanged2,
};

which has the same effect as

Counter __c = new Counter();
__c.Value = 10;
__c.Value += 5;
__c.Changed += OnChanged;
__c.Changed += OnChanged2;
Counter c = __c;

where __c is an otherwise invisible and inaccessible temporary variable. Each generated line is a statement_expression; the first two are a simple assignment and a compound assignment on a property, and the last two are event assignments on an event.

end example

with expression

The following diff is applied to the grammar of the with expression:

 with_expression
     : switch_expression
     | switch_expression 'with' '{' member_initializer_list? '}'
     ;

 member_initializer_list
     : member_initializer (',' member_initializer)*
     ;

 member_initializer
-    : identifier '=' expression
+    : identifier assignment_operator expression
     ;

The prose of the with expression subsection is updated as follows.

On the right hand side of the with expression is a member_initializer_list with a sequence of assignments to identifier, which must be an accessible instance field or property or event of the receiver's type.

For any given field, property, or event target, any number of member initializers using a compound assignment operator are permitted for the same target. If present, an = member initializer shall appear in lexical order before any other member initializer for that target.

First, receiver's "clone" method (specified above) is invoked and its result is converted to the receiver's type. Then, each member_initializer is processed the same way as an assignment to a field or property access of the result of the conversion. Assignments are processed in lexical order. Then, for each member_initializer target op value in lexical order, the statement_expression x.target op value; is executed, where x is the otherwise invisible and inaccessible temporary variable holding the converted clone. The meaning of that statement_expression is given by §12.21, as for an object initializer. When op is a compound assignment operator, both the read and the write of target are performed on the clone x; the original receiver of the with expression is not read again after its clone method has returned. Member initializers are processed in lexical order, so the read performed by a compound member initializer takes place after every preceding member initializer has executed; the read accordingly reflects whatever state that target's get accessor reports on the clone at that point.

Example: Given

public record Counter(int Value)
{
    public event EventHandler Changed;
}

Counter original = ...;

the expression

Counter c = original with { Value -= 1, Changed += OnChanged };

has the same effect as

Counter __c = (Counter)original.<Clone>();
__c.Value -= 1;           // both the read and the write are on __c
__c.Changed += OnChanged;
Counter c = __c;

where __c is an otherwise invisible and inaccessible temporary variable.

end example

Interactions with other features

  • Accessor requirements on property and indexer targets. A compound member_initializer on a property or indexer target inherits the access requirements of §12.21.4 via the lowering above. In practice, a property or indexer target in either an object initializer or a with expression is valid with a compound assignment operator when it has a get accessor together with a set or init accessor, or when its get accessor returns a reference (a ref-returning property or indexer, which classifies the access as a variable per §12.8.7). An init accessor is accepted on the same terms as for a direct = member initializer. Plain = member initializers continue to use the accessor requirements already specified in their respective sections, unchanged by this proposal.

  • Event targets. On an event target, += and -= dispatch to the event's add and remove accessors respectively, per §12.21.5. A single object initializer or with expression may therefore subscribe or unsubscribe multiple handlers in lexical order, including on the same event; see the relaxed uniqueness rule above.

  • Indexer targets. The grammar of §12.8.16.3 continues to permit '[' argument_list ']' as an initializer_target for both member_initializer forms. The "arguments shall always be evaluated exactly once" rule already specified for indexer initializer targets in §12.8.16.3 is unchanged and applies equally to compound member initializers; the get-and-set requirement from §12.21.4 applies to the selected indexer.

  • Required members. In an object initializer, a required field or property is satisfied only by a = member_initializer; a compound member initializer does not on its own discharge the requirement. The required target may additionally appear under one or more compound member initializers in the same object initializer, subject to the "= before any compound" ordering above. In a with expression no such restriction applies — the receiver has already been constructed and its required members satisfied by that earlier construction, so the with clause admits compound-only member initializers on required targets. SetsRequiredMembersAttribute continues to discharge the obligation in both forms, as in the existing specification.

    Motivation: required exists to ensure each marked slot is in a valid state before any read of it. A compound assignment operator reads the slot before writing it; on a freshly constructed object the read sees the slot's default value, which is exactly the state required is meant to prevent the object from being observed in. A with clone has already been initialized once, so the read is safe.

  • Dynamic. When the target's instance expression (the temporary x) has an accessed member whose container has compile-time type dynamic, dynamic binding of the member initializer follows §12.21.2 (for =) or §12.21.4 (for compound), unchanged.

  • Collection initializers are unaffected. The grammar of §12.8.16.4 uses non_assignment_expression for the unbraced element form, which by definition excludes assignment (§12.22). A form such as a += b is an assignment and therefore remains ill-formed as an element initializer of a collection initializer. No change to §12.8.16.4 is required.

Back-compat analysis

This is a pure extension. The second-form member_initializer introduced into §12.8.16.3 (initializer_target assignment_operator expression) generalizes the existing initializer_target '=' expression form to the full assignment_operator, picking up target += value, target ??= value, etc.; the existing target = value form continues to be admitted by this same production with op equal to =. None of the newly admitted operator forms are valid member_initializers today, so no expression that compiles today changes meaning. The same reasoning applies to the with expression's member_initializer. Any program that compiled before this feature continues to compile with the same meaning.

Drawbacks

As with any language feature, the additional specification complexity must be weighed against the clarity and correctness improvements it offers users. The feature is localized to one subsection of §12.8.16.3 and one subsection of the with expression, and reuses §12.21's existing machinery at every step, so the marginal complexity is small.

Alternatives

  • Do nothing. Users continue to use patterns like an extension method that takes a receiver and a configuration lambda:

    var timer = new DispatcherTimer
    {
        Interval = TimeSpan.FromSeconds(1d),
    }.Init(t => t.Tick += (_, _) => { /*actual work*/ });

    This works for non-init members, but not for init-only properties, and loses the first-class expression shape.

  • Restrict to events only. A narrower feature that would permit Event += handler in an object initializer but not Property += 1. This addresses the headline UI scenario but excludes the accumulation-into-property and accumulation-into-cloned-value scenarios that motivate the with case.

Design decisions

Why allow multiple member initializers for the same target?

The single most compelling motivation for this feature is events, and events naturally chain: subscribing two handlers to the same event in one initializer is useful and unsurprising, or unsubscribing and resubscribing. Forbidding this would make the case (Click -= h1, Click += h2) illegal in exactly the contexts where the feature is most valuable. The same reasoning extends to compound operators on properties (Value = 10, Value += 5), where each step has an observable effect.

At the same time, = remains destructive: permitting a second = for the same target would make the first assignment dead code. The rule adopted here, "at most one = per target, any number of compound member initializers per target after it," keeps = as unambiguously initializing and lets compound operators compose on top.

Why specify the semantics as a lowering to statement_expression?

Phrasing a member_initializer as an equivalent statement_expression makes all of the necessary rules fall out of §12.21 with no further writing. In particular, §12.21.5 already requires event assignment to appear in a statement_expression context; the lowering satisfies that requirement by construction, without special-casing events at the initializer level. Each of simple assignment, compound assignment, and event assignment is invoked by the same uniform rule: "the meaning of x.target op value;."

Related discussions

Design meetings

TBD