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

[Proposal]: Records with sealed base ToString override #4174

Open
3 of 4 tasks
Tracked by #829
jnm2 opened this issue Nov 25, 2020 · 15 comments · Fixed by dotnet/roslyn#52029
Open
3 of 4 tasks
Tracked by #829

[Proposal]: Records with sealed base ToString override #4174

jnm2 opened this issue Nov 25, 2020 · 15 comments · Fixed by dotnet/roslyn#52029
Assignees
Labels
Implemented Needs ECMA Spec This feature has been implemented in C#, but still needs to be merged into the ECMA specification Proposal champion Proposal Smallish Feature
Milestone

Comments

@jnm2
Copy link
Contributor

jnm2 commented Nov 25, 2020

Records with sealed base ToString override

  • Proposed
  • Prototype: Done
  • Implementation: Done
  • Specification: Not Started

Summary

This proposal would lift the restriction on declaring sealed ToString overrides in non-sealed records and lift the restriction on a record inheriting from a base type that has a sealed ToString override. The semantics of sealed override are clear, they are appropriate for closed hierarchies where ToString is controlled by the base, and the current errors seem to deny its otherwise consistent functioning out of an overabundance of caution.

Motivation

There's currently no way to specify a ToString implementation once for all derived records. Because of this, the need to override ToString can have a jarring result. Consider this hand-rolled discriminated union based on records:

public abstract record SomeDiscriminatedUnion(string Name)
{
    public sealed record FirstKind(string Name, int A) : SomeDiscriminatedUnion(Name);
    public sealed record SecondKind(string Name, int B) : SomeDiscriminatedUnion(Name);
    public sealed record ThirdKind(string Name, bool C) : SomeDiscriminatedUnion(Name);
    public sealed record FourthKind(string Name) : SomeDiscriminatedUnion(Name);
}

Overriding ToString has no effect on derived records unless each derived record is given a manual override that returns base.ToString(). Sealing the override would perfectly describe the intended result, but this is currently blocked by the compiler with errors CS0239 and CS8870.

The simplest option currently available is much less pleasing. One factor is its repetition. Another factor is that the base record no longer contains a readable list of single-line members.

public abstract record SomeDiscriminatedUnion(string Name)
{
    public sealed record FirstKind(string Name, int A) : SomeDiscriminatedUnion(Name)
    {
        public override string ToString() => Name;
    }

    public sealed record SecondKind(string Name, int B) : SomeDiscriminatedUnion(Name)
    {
        public override string ToString() => Name;
    }

    public sealed record ThirdKind(string Name, bool C) : SomeDiscriminatedUnion(Name)
    {
        public override string ToString() => Name;
    }

    public sealed record FourthKind(string Name) : SomeDiscriminatedUnion(Name)
    {
        public override string ToString() => Name;
    }
}

Detailed design

Non-sealed records would support sealed overrides of ToString the same way classes do rather than producing error "CS8870 'BaseRecord.ToString()' cannot be sealed because containing record is not sealed."

Derived records would support inheriting when the base ToString is sealed by skipping the synthesized ToString override rather than producing error "CS0239 'DerivedRecord.ToString()': cannot override inherited member 'BaseRecord.ToString()' because it is sealed."

Drawbacks

Alternatives

A community source generator could be developed that would look for an attribute on the base record or the base record's ToString override and add public override string ToString() => base.ToString(); to each derived record that doesn't already declare a ToString member. This feels like a workaround at best. The semantics of sealed override are clear, they are appropriate for closed hierarchies where ToString is controlled by the base, and the current errors seem to deny its otherwise consistent functioning out of an overabundance of caution.

Unresolved questions

Design meetings

https://github.com/dotnet/csharplang/blob/main/meetings/2021/LDM-2021-02-10.md#sealed-record-ToString

@thomaslevesque
Copy link
Member

thomaslevesque commented Nov 26, 2020

I was about to open an issue for the the exact same problem, and discovered that you did it yesterday... Good timing!

Apparently this restriction was carefully specified:

It is an error if the explicit declaration does not match the expected signature or accessibility, or if the explicit declaration doesn't allow overriding it in a derived type and the record type is not sealed.

But in some scenarios, it's killing one of the biggest benefits of records: conciseness.

Until this is fixed, I'm working around the issue using source generators, but it's annoying, because writing source generators is time-consuming.

@thomaslevesque
Copy link
Member

@333fred what's the status of this proposal? Has it been discussed in LDM yet? Is there any chance it could be included in C#10?

@333fred
Copy link
Member

333fred commented Mar 18, 2021

@333fred what's the status of this proposal? Has it been discussed in LDM yet? Is there any chance it could be included in C#10?

@thomaslevesque this proposal is in the Any Time milestone. What that means is that it is not a priority for the compiler team at the moment, but we'd accept a PR implementing the feature from the community.

@thomaslevesque
Copy link
Member

@333fred thanks.
It seems like a relatively simple change, so I might give it a try. However I've never worked on the compiler before, so I don't know if I'd be able to implement this... Any pointers to get me started?

@jnm2
Copy link
Contributor Author

jnm2 commented Mar 19, 2021

@thomaslevesque https://aka.ms/dotnet-discord has a #roslyn contribution chat where @333fred and others are active. I'm really interested in seeing this happen for C# 10 too, and I might be able to be helpful with some of my memories from implementing the module initializers feature. It might be the case that a test already exists demonstrating that ToString can't be overridden which you can flip around. Once there's a test, then it's a matter of debugging the test and looking for likely places where logic should be modified. One tactic that sped me up was searching for all references of the enum member representing the CS error that was being reported so I could jump to the place in the code that was originating the error.

@jnm2
Copy link
Contributor Author

jnm2 commented Mar 19, 2021

Grabbing the error IDs from SharpLab, it looks like there will be two errors that will need to stop being emitted in this situation:

error CS8870: 'SomeDiscriminatedUnion.ToString()' cannot be sealed because containing record is not sealed.

error CS0239: 'SomeDiscriminatedUnion.FirstKind.ToString()': cannot override inherited member 'SomeDiscriminatedUnion.ToString()' because it is sealed

The Roslyn codebase (excluding tests) only has two references to the enum member representing CS8870, one clearly producing the diagnostic for ToString and the other clearly for GetHashCode: http://sourceroslyn.io/#Microsoft.CodeAnalysis.CSharp/Errors/ErrorCode.cs,7a7a4a52c24276bc,references

The other error will need to be fixed not by skipping emitting a diagnostic but by skipping emitting synthesized ToString overrides.

A test will probably be needed that shows that a ToString was actually generated as sealed in the base class.

Not sure how much help this is, but here's a PR demonstrating emit changes and emit tests: dotnet/roslyn#43281
Probably smarter to search for the PR that implemented records, or search for types that contain the words 'record' and 'synthesized' maybe.

@thomaslevesque
Copy link
Member

@jnm2 thanks, that's helpful.
No promise (not much free time these days), but I'll try to take a look.

@thomaslevesque
Copy link
Member

I'm wondering if this change should be applied just to ToString, or to GetHashCode as well.
Trying to seal the GetHashCode override produces CS8870, just like ToString. However, what bothers me is that we can override GetHashCode in a record (just not seal it), but we can't override Equals at all. This seems inconsistent, since Equals and GetHashCode must agree... Shouldn't it be illegal to override GetHashCode if we can't override Equals?

@jnm2
Copy link
Contributor Author

jnm2 commented Mar 20, 2021

Either way, why would that mean this change should also be applied to allow sealing of GetHashCode? What's the motivation?

@333fred
Copy link
Member

333fred commented Mar 20, 2021

LDM has only approved ToString. And I question the motivation of the others. ToString is a nicer representation. Equals and GetHashcode are fundamental pieces of how records work.

@thomaslevesque
Copy link
Member

You're right, there's no motivation for sealing GetHashCode. I'm just trying to get my head around why Equals and GetHashCode are handled differently.

Equals and GetHashcode are fundamental pieces of how records work.

Indeed. Which is why I wonder why they don't have the same restrictions. I assume the reason Equals can't be overridden has to do with EqualityContract, but then, why allow GetHashCode to be overridden, since it's so closely related to Equals?

LDM has only approved ToString

Good enough for me!

@333fred
Copy link
Member

333fred commented Mar 21, 2021

I assume the reason Equals can't be overridden

Equals(object) can't be overridden. Equals(MyType) can be overridden, which again is fundamental to how record equality works. You can override GetHashCode because you can also override that second equals, and they need to behave correctly wrt each other.

@thomaslevesque
Copy link
Member

I submitted a PR: dotnet/roslyn#52029
There might be an easier way, I'm open to suggestions.
I think the tests I modified cover the new behavior, but I can add more if requested.

@thomaslevesque
Copy link
Member

Equals(object) can't be overridden. Equals(MyType) can be overridden

Ah, I see. I didn't realize that before.

@jcouv
Copy link
Member

jcouv commented Apr 10, 2021

The feature is merged and will be in preview for a while before shipping for C# 10 (.NET 6). Thanks @thomaslevesque

@333fred 333fred added the Implemented Needs ECMA Spec This feature has been implemented in C#, but still needs to be merged into the ECMA specification label Jan 11, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Implemented Needs ECMA Spec This feature has been implemented in C#, but still needs to be merged into the ECMA specification Proposal champion Proposal Smallish Feature
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants