Records
-
Builders vs init-only
-
Possible conflicts with discriminated unions
-
Value equality
We discussed more of the tradeoffs of using builders vs using an "init-only" feature for records,
and looked into requirements for other languages, including VB and F#. Based on our current
designs, the work needed to consume the new features for "init-only" seem fairly small.
Recognizing the modreq
and allowing access to init-only members, and calling any necessary
"validator" on construction, are pretty simple features for VB. VB already has the syntactic
requirements for the feature (object initializers), and we'd like to keep it possible for VB to
consume major changes in the API surface, if not write those new features. F# is undergoing
active development and changes are certainly viable there. Because the scope of changes is more
open-ended in F#, it's possible it could feature more implementation work.
Notably, most of the features associated with "init-only" and "validators" do not require a new runtime, only a new compiler version. The new compiler version is necessary to recognize safety rules (validators must always be called after constructors, if they exist), but they don't use any new features in the CLR. The only feature potentially requiring runtime features is overriding a record with another record, which could potentially require covariant returns.
The remaining differences seem to come down to whether you can "see" the whole object during
initial construction (as opposed to validation). If you can see the whole object immediately,
that makes writing a With
method that avoids a copy if all the values are identical very
straightforward. However, this could be done for "init-only" as well, by moving this semantic to
the with
expression, optionally comparing the arguments to the With
expression with the
values on the receiver object and avoiding calling With if they are identical. Therefore we don't
think we're actually ruling anything out by going down the "init-only" route.
There are advantages in going down the "init-only" route instead. The performance for structs is probably better and more optimizable, and the IL pattern seems clearer and less bloated.
Conclusion
We like the "init-only" IL better. Given the path forward for other languages and compatibility with many runtimes, we think it's a better future as an IL pattern.
There was a general question if these decisions could impact a future discriminated unions feature. We don't have a lot in mind, but if we do end up building a discriminated union made of the records feature, there is one component we may want to reserve. Discriminated unions often have a set of simple data members. For instance, if we wrote a Color enum as a discriminated union, it could look like.
enum class Color
{
Red,
Green,
Blue
}
If we reduce these to records, it may look like:
abstract class Color
{
public class Red() : Color;
public class Green() : Color;
public class Blue() : Color;
}
The problem is: since those classes are effectively singletons, we'd like for the instances to be cached by the compiler, to avoid allocations. However, we don't currently have a syntax in C# that means "singleton." Scala uses the "empty record" syntax to mean singleton. We need to decide if we'd like to reserve that syntax ourselves, or find some other solution.
We've decided that we want value equality by default for records. We need to settle on what that means. The primary proposal on the table is shallow-field-equality, namely comparing all fields in the type via EqualityComparer. This would match the semantic we have decided on for "Withers," where the shallow state of the object is copied, similar to MemberwiseClone.
There's a large segment of the LDM that thinks doing anything except for field comparison is problematic because it introduces far too many customization points for the record feature. Almost all custom equality would have to deal with sequence equality and string equality, which are already very different mechanisms. There's a proposal that we could provide further customization via source generators, which could allow almost any customization.
A follow-up to that is: why have value equality by default at all? Why not use source generators for all value equality? One problem with that is that we want the simplest records to be very short. The other problem is that we effectively have to pick an equality (C# will inherit one if you don't). We previously decided that value equality is a non-controversial default -- if it's wrong it's probably not worse than when reference equality is wrong, and it's often better. Struct-like field-based equality is a simple and familiar version of value equality.
We also need to decide on value-equality as a separable feature for regular classes. Some people like the idea of a separate feature and would be fine even with the constrained version that only compares fields. Others don't think this feature meets the hurdles for a new language feature. If we don't have customization options, it may be rarely used, and it seems possible that a source generator version could be the much more popular version. It's also worth noting that, as a separable feature, we don't need to add separable equality now.
Conclusion
No separable value equality for non-records, right now. Default record equality is defined as field-based shallow equality.