Skip to content
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

Add detailed design to clarify the field keyword spec and incorporate all LDM decisions #8069

Merged
merged 30 commits into from
Jul 3, 2024
Merged
Changes from 2 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8a49b97
Add detailed design to clarify the field keyword spec
jnm2 Apr 23, 2024
c2bf687
Tweaks
jnm2 Apr 23, 2024
299b07f
Respond to review feedback
jnm2 Apr 24, 2024
e507eb4
Further thought on nullability design
jnm2 May 9, 2024
7ae7db0
Further clarifications
jnm2 May 9, 2024
21fedfb
Fix typo
jnm2 May 9, 2024
70104d2
Add spec link for auto-property declaration conditions
jnm2 May 9, 2024
5ae6b7f
Stop quoting just part of the list of restrictions from the linked sp…
jnm2 May 9, 2024
b6a2e75
Clarify that not all instances of nameof(field) are disallowed
jnm2 May 9, 2024
62c6f9c
Bring back explicit example of abstract/interface properties
jnm2 May 25, 2024
a85baec
Add glossary
jnm2 May 25, 2024
de6d277
Reword to remove the term 'semi-auto' since the feature covers proper…
jnm2 May 25, 2024
37191a3
Add clarification that auto properties are properties without accesso…
jnm2 May 25, 2024
abc4532
Call out missing warning and add as open question
jnm2 May 25, 2024
3573c8f
Clarify wording
jnm2 May 25, 2024
45b0a51
Resolve merge conflicts
jnm2 May 25, 2024
01f8cc8
Update spec for breaking changes decisions
jnm2 May 25, 2024
825e403
Use simpler explanation of LDM decisions on definite assignment in st…
jnm2 May 29, 2024
0a3ac75
Add examples to each glossary entry
jnm2 Jun 7, 2024
87cd574
Save principles for later
jnm2 Jun 7, 2024
c4d42bd
Copy the nullability example to its open question
jnm2 Jun 7, 2024
e020a0c
Make warning samples less confusing
jnm2 Jun 12, 2024
a08339f
Clarify that these diagnostics are not warnings by default
jnm2 Jun 12, 2024
6f707cc
Grammar fix
jnm2 Jun 12, 2024
b2782ce
Add nullability and semi-auto as open questions
jnm2 Jun 12, 2024
05d1836
Use headers instead of bullets for open questions
jnm2 Jun 22, 2024
1711adf
Bring in updates from main
jnm2 Jun 24, 2024
9edd99d
Open question was resolved in LDM
jnm2 Jul 3, 2024
416971f
Add LDM decision about keywordness of `field`
jnm2 Jul 3, 2024
5263bc4
Responding to review
jnm2 Jul 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 239 additions & 50 deletions proposals/semi-auto-properties.md
jnm2 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,228 @@ Two common scenarios are that you want to apply a constraint on the setter, ensu

In these cases by now you always have to create an instance field and write the whole property yourself. This not only adds a fair amount of code, but it also leaks the `field` into the rest of the type's scope, when it is often desirable to only have it be available to the bodies of the accessors.

## Detailed design

For properties with an `init` accessor, everything that applies below to `set` would apply instead to the `init` accessor.

**Principle 1:** Every property can be thought of as having a backing field by default, which is elided when not used. The field is referenced using the keyword `field` and its visibility is scoped to the accessor bodies.
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

**Principle 2:** `get;` will now be considered syntactic sugar for `get => field;`, and `set;` will now be considered syntactic sugar for `set => field = value;`.
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

This means that properties may now mix and match auto accessors with full accessors. For example:
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

```cs
{ get; set => Set(ref field, value); }
```

```cs
{ get => field ?? GetDefault(); set; }
jnm2 marked this conversation as resolved.
Show resolved Hide resolved
```

Both accessors may be full accessors with either one or both making use of `field`:
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

```cs
{ get => field; set => field = value; }
```

```cs
{ get => field; set => throw new InvalidOperationException(); }
```

```cs
{ get => overriddenValue; set => field = value; }
```

Expression-bodied properties and properties with only a `get` accessor may also use `field`:

```cs
public string LazilyComputed => field ??= Compute();
```

```cs
public string LazilyComputed { get => field ??= Compute(); }
```

As with regular auto-properties, a setter that uses a backing field is disallowed when there is no getter. This restriction could be loosened in the future to allow the setter to do something only in response to changes, by comparing `value` to `field` (see open questions).

```cs
// ❌ Error, will not compile
{ set => field = value; }
```

### Field-targeted attributes

As with regular auto-properties, any property that uses a backing field in one of its accessors will be able to use field-targeted attributes:
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

```cs
[field: Xyz]
public string Name => field ??= Compute();

[field: Xyz]
public string Name { get => field; set => field = value; }
```

A field-targeted attribute will remain invalid unless an accessor uses a backing field:

```cs
// ❌ Error, will not compile
[field: Xyz]
public string Name => Compute();
```

### Property initializers
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

Properties with initializers may use `field`. The backing field is directly initialized rather than the setter being called ([LDM decision](https://github.com/dotnet/csharplang/blob/main/meetings/2022/LDM-2022-03-02.md#open-questions-in-field)).

Calling a setter for an initializer is not an option; initializers are processed before calling base constructors, and it is illegal to call any instance method before the base constructor is called. This is also important for default initialization/definite assignment of structs.
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

This luckily gives control over whether or not you want to initialize the backing field directly or call the property setter: if you want to initialize without calling the setter, you use a property initializer. If you want to initialize by calling the setter, you use assign the property an initial value in the constructor.
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

Here's an example of where this is useful. The `field` keyword will find a lot of its use with view models because of the neat solution it brings for the `INotifyPropertyChanged` pattern. View model property setters are likely to be databound to UI and likely to cause change tracking or trigger other behaviors. The following code needs to initialize the default value of `IsActive` without setting `HasPendingChanges` to `true`:
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

```cs
using System.Runtime.CompilerServices;
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

class SomeViewModel
{
public bool HasPendingChanges { get; private set; }

public bool IsActive { get; set => Set(ref field, value); } = true;

private bool Set<T>(ref T location, T value)
{
if (RuntimeHelpers.Equals(location, value)) return false;
jnm2 marked this conversation as resolved.
Show resolved Hide resolved
location = value;
HasPendingChanges = true;
return true;
}
}
```

This difference in behavior between a property initializer and assigning from the constructor can also be seen with virtual auto-properties in previous versions of the language:

```cs
using System;

// Nothing is printed; the property initializer is not
// equivalent to `this.IsActive = true`.
_ = new Derived();

class Base
{
public virtual bool IsActive { get; set; } = true;
}

class Derived : Base
{
public override bool IsActive
{
get => base.IsActive;
set
{
base.IsActive = value;
Console.WriteLine("This will not be reached");
}
}
}
```

### Constructor assignment

As with existing auto-properties, assignment in the constructor calls the setter if it exists, and if there is no setter it falls back to directly assigning to the backing field.

```cs
class C
{
public C()
{
P1 = 1; // Assigns P1's backing field directly
P2 = 2; // Assigns P2's backing field directly
P3 = 3; // Calls P3's setter
}

public int P1 => field;
public int P2 { get => field; }
public int P3 { get => field; set => field = value; }
}
```

### Definite assignment in structs

A semi-auto property will be treated as a regular auto property for the purposes of calculating default backing field initialization if its setter is automatically implemented, or if it does not have a setter ([LDM decision](https://github.com/dotnet/csharplang/blob/main/meetings/2022/LDM-2022-03-02.md#property-assignment-in-structs)).
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

Default initialize a struct when calling a manually implemented semi-auto property setter, and issue a warning when doing so, like a regular property setter ([LDM decision](https://github.com/dotnet/csharplang/blob/main/meetings/2022/LDM-2022-05-02.md#definite-assignment-of-manually-implemented-setters)).
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

### Nullability
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

When `{ get; }` is written as `{ get => field; }`, or `{ get; set; }` is written as `{ get => field; set => field = value; }`, a similar warning should be produced when a non-nullable property is not initialized:

```cs
class C
{
// ⚠️ CS8618: Non-nullable property 'P' must contain a
// non-null value when exiting constructor.
public string P { get => field; set => field = value; }
}
```

No warning should be produced if the property is initialized to a non-null value via constructor assignment or property initializer:
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

```cs
class C
{
public C() { P = ""; }

public string P { get => field; set => field = value; }
}
```

```cs
class C
{
public string P { get => field; set => field = value; } = "";
}
```

For reference-typed fields, the `field` type should be nullable. No warning should be produced in the following example:
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

```cs
class C
{
public string P => field ??= "test";
}
```

### `nameof`

`nameof(field)` will be disallowed. It is not like `nameof(value)`, which is the thing to use when property setters throw ArgumentException as some do in the .NET core libraries. In contrast, `nameof(field)` has no expected use cases. If it did anything, it would return the string `"field"`, consistent with how `nameof` behaves in other circumstances by returning the C# name or alias, rather than the metadata name.
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

### Overrides

Like with regular auto properties, semi-auto properties that override a base property must override all accessors ([LDM decision](https://github.com/dotnet/csharplang/blob/main/meetings/2022/LDM-2022-05-02.md#partial-overrides-of-virtual-properties)).
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

### Shadowing

`field` can be shadowed by parameters or locals in a nested scope ([LDM decision](https://github.com/dotnet/csharplang/blob/main/meetings/2022/LDM-2022-02-16.md#open-questions-in-field)). Since `field` represents a field in the type, even if anonymously, the shadowing rules of regular fields should apply.
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

### Captures

`field` should be able to be captured in local functions and lambdas, and references to `field` from inside local functions and lambdas should be allowed even if there are no other references ([LDM decision](https://github.com/dotnet/csharplang/blob/main/meetings/2022/LDM-2022-03-21.md#open-question-in-semi-auto-properties)):
jnm2 marked this conversation as resolved.
Show resolved Hide resolved

```cs
public class C
{
public static int P
{
get
{
Func<int> f = static () => field;
return f();
}
}
}
```

## Specification changes

The following changes are to be made to [§14.7.4](https://github.com/dotnet/csharpstandard/blob/draft-v6/standard/classes.md#1474-automatically-implemented-properties):
Expand Down Expand Up @@ -120,71 +342,38 @@ The following changes are to be made to [§14.7.4](https://github.com/dotnet/csh
+```
````

## Open LDM questions:
## Open LDM questions

1. If a type does have an existing accessible `field` symbol in scope (like a field called `field`) should there be any way for an auto-prop to still use `field` internally to both create and refer to an auto-prop field. Under the current rules there is no way to do that. This is certainly unfortunate for those users, however this is ideally not a significant enough issue to warrant extra dispensation. The user, after all, can always still write out their properties like they do today, they just lose out from the convenience here in that small case.

2. Should initializers use the backing field or the property setter? If the latter, what about `public int P { get => field; } = 5;`?

* Calling a setter for an initializer is not an option because initializers are processed before calling base constructor and it is illegal to call any instance method before the base constructor is called.

* If the initializer assigns directly to the backing field when there is a setter, then the initializer does one thing and an assignment to the property within constructors does a different thing (calls the setter). Today there is already a semantic difference between an initializer and an assignment in constructors in such cases. This difference can be observed with virtual auto properties:

```cs
using System;

// Nothing is printed; the property initializer is not
// equivalent to `this.IsActive = true`.
_ = new Derived();
1. Which of these scenarios should be allowed to compile? Assume that the "field is never read" warning would apply just like with a manually declared field.

class Base
1. `{ set; }` - Disallowed today, continue disallowing
1. `{ set => field = value; }`
1. `{ get => unrelated; set => field = value; }`
1. `{ get => unrelated; set; }`
1. ```cs
{
public virtual bool IsActive { get; set; } = true;
}

class Derived : Base
{
public override bool IsActive
set
{
get => base.IsActive;
set
{
base.IsActive = value;
Console.WriteLine("This will not be called");
}
if (field == value) return;
field = value;
SendEvent(nameof(Prop), value);
}
}
```

* There is a practical benefit when initializers skip calling the setter. It allows users of the language to choose whether to invoke the setter or not (constructor assignment or property initializer). If property initializers call the setter, this choice is taken away. Allowing language users to make this choice means that the `field` feature would be able to be used in more scenarios.

One example of this is view models. The `field` keyword will find a lot of its use with view models because of the neat solution it brings for the `INotifyPropertyChanged` pattern. View model property setters are likely to be databound to UI and likely to cause change tracking or trigger other behaviors. Consider the following example which needs to initialize the default value of `Foo` without setting `HasPendingChanges` to `true`. If initializers call the setter, using the `field` keyword would not be an option or would require setting `HasPendingChanges` back to false in the constructor(s) which feels like a workaround: unnecessary work is being done which also leaves behind a "mess" which needs to be manually reversed, if the property initializer calls the setter.

```cs
using System.Runtime.CompilerServices;

class SomeViewModel
1. ```cs
{
public bool HasPendingChanges { get; private set; }

public int Foo { get; set => Set(ref field, value); } = 1;

private bool Set<T>(ref T location, T value)
get => unrelated;
set
{
if (RuntimeHelpers.Equals(location, value)) return false;
location = value;
HasPendingChanges = true;
return true;
if (field == value) return;
field = value;
SendEvent(nameof(Prop), value);
}
}
```

* A preexisting expectation may exist as a result of refactoring to and from auto properties. Some language users turn field initializers into property initializers and vice versa rather than moving the initialization far away from the field declaration into the constructor(s). This forms a mental model that is consistent with how the language already behaves with virtual auto properties. (See the virtual auto property example above.)

3. Definite assignment related questions:
- https://github.com/dotnet/csharplang/issues/5563
- https://github.com/dotnet/csharplang/pull/5573#issuecomment-1002110830

## LDM history:
- https://github.com/dotnet/csharplang/blob/main/meetings/2021/LDM-2021-03-10.md#field-keyword
- https://github.com/dotnet/csharplang/blob/main/meetings/2021/LDM-2021-04-14.md#field-keyword
Expand Down