Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

salvois
Copy link
Contributor

@salvois salvois commented Oct 10, 2022

Fixed #1808

There appears to be no objective way to detect whether a type is indeed a record struct. This pull requests tries to make a best guess by looking for a value type having at least a pair of compiler-generated equality and inequality operators.

Copy link
Member

@dennisdoomen dennisdoomen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 You also need to update the releases.md file to mention this change
🤔 Anything to add to the objectgraph.md file about support for record structs?

.GetMethod?
.GetCustomAttribute(typeof(CompilerGeneratedAttribute)) is not null);
{
bool isRecord = t.GetMethod("<Clone>$") is not null &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 For readability I would prefer to clean-up the layout a little bit and keep an empty line between the two boolean statements
🤔 Any links to background material that we can add here for future reference?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No real background material actually, besides the link already posted in the linked issue (https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-10.0/record-structs#open-questions) where it is stated that recognizing record structs is an open point.
This pull request is based on common sense and heuristic testing, apparently giving good results but not supported by official documentation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would mention exactly using in-line comments

@coveralls
Copy link

coveralls commented Oct 14, 2022

Pull Request Test Coverage Report for Build 3857283151

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 15 of 15 (100.0%) changed or added relevant lines in 1 file are covered.
  • 1 unchanged line in 1 file lost coverage.
  • Overall coverage increased (+0.9%) to 96.924%

Files with Coverage Reduction New Missed Lines %
Src/FluentAssertions/Common/TypeExtensions.cs 1 97.52%
Totals Coverage Status
Change from base Build 3853478629: 0.9%
Covered Lines: 12458
Relevant Lines: 12701

💛 - Coveralls

@dennisdoomen dennisdoomen changed the title Treat record structs as records (issue #1808) Treat record structs as records Oct 14, 2022
@salvois
Copy link
Contributor Author

salvois commented Oct 16, 2022

🤔 Anything to add to the equivalency.md file about support for record structs?

I guess you mean objectgraphs.md. I'm unsure about it, because it depends on how broad the meaning of "record" is in that context. If "record" is an umbrella term to mean both reference-type-based records and value-type-based record structs (either readonly or not), nothing has to be changed, otherwise we could be more explicit. For example, I was quite surprised discovering that record structs were not treated as records.

@dennisdoomen
Copy link
Member

I guess you mean objectgraphs.md. I'm unsure about it, because it depends on how broad the meaning of "record" is in that context. If "record" is an umbrella term to mean both reference-type-based records and value-type-based record structs (either readonly or not), nothing has to be changed, otherwise we could be more explicit. For example, I was quite surprised discovering that record structs were not treated as records.

Yes, sorry, that's what I meant. I think it wouldn't hurt to mention all of them.

@dennisdoomen dennisdoomen requested a review from jnyrup October 16, 2022 17:37
Comment on lines 598 to 606
var isRecordStruct = t.BaseType == typeof(ValueType) &&
t.GetMethods()
.Where(m => m.Name == "op_Inequality")
.SelectMany(m => m.GetCustomAttributes(typeof(CompilerGeneratedAttribute)))
.Any() &&
t.GetMethods()
.Where(m => m.Name == "op_Equality")
.SelectMany(m => m.GetCustomAttributes(typeof(CompilerGeneratedAttribute)))
.Any();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this is probably the best we can currently do, I wonder a bit if this is still too broad a check?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid of this too. This is backed by MSDN too (see comment). I've tried my best to "crack" this test and was not able to, but please feel free to add counter-examples.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking for one operator should be enough.

var isRecordStruct = t.BaseType == typeof(ValueType) &&
                     t.GetMethod("op_Equality", BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly, null, new[] { t, t }, null)?
                        .GetCustomAttribute(typeof(CompilerGeneratedAttribute)) is not null;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had another look at creating a heuristic to identify record structs with few false positives.

https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-10.0/record-structs#printing-members-printmembers-and-tostring-methods

specifies that a record struct contains a synthesized (read has [CompilerGenerated])

private bool PrintMembers(System.Text.StringBuilder builder)

The name PrintMembers is also a public part of Roslyn.

var isRecordStruct = t.BaseType == typeof(ValueType) &&
                     t.GetMethod("PrintMembers", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, null, new[] { typeof(StringBuilder) }, null)?
                        .GetCustomAttribute(typeof(CompilerGeneratedAttribute)) is { };

What do you think about this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That may be a good catch. I'll look into it!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand you here 🤔
As a record struct currently doesn't have any unspeakable parts, it's always possible to write a struct in plain C# that looks like a record struct.

E.g. this is valid C# code, SharpLab

public struct A
{
    [CompilerGenerated]
    public static bool operator ==(A x, A y) => true;
    
    [CompilerGenerated]
    public static bool operator !=(A x, A y) => false 
}

I tried another thing.

I made a record struct Auto {} and copied its output back into regular C# code.
The only thing I wasn't allowed to was using [IsReadOnly] as that gave a compiler error.

error CS8335: Do not use 'System.Runtime.CompilerServices.IsReadOnlyAttribute'. This is reserved for compiler usage.

PrintMembers is annotated with [IsReadOnly]

So what do you think about checking for this?

var isRecordStruct = t.BaseType == typeof(ValueType) &&
                     t.GetMethod("PrintMembers", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, null, new[] { typeof(StringBuilder) }, null)?
                        .GetCustomAttribute(typeof(IsReadOnlyAttribute)) is { };

I don't know if an arbitrary source generator is allowed to use [IsReadOnly].
IL weaving would most likely be able to circumvent CS8335, but we aren't looking for a bullet proof solution.

SharpLab

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting! I didn't know one could annotate with [CompilerGenerated] in source code; it looks a little silly to me that you can, but it is what it is I suppose :)
In this case, I definitely like your proposal with PrintMembers and IsReadOnly more. Let me try to give a couple of final touches and I'll be back with a new commit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jnyrup I have just tried the last proposal checking for a PrintMembers annotated with IsReadOnly. IsReadOnly is not available on NET47 and NETSTANDARD2_0, so it does not compile on those target, but here the problem is just my ignorance on properly handling multi-target.
The major problem for me is that the check fails for the MyReadOnlyRecordStruct and MyRecordStructWithCustomPrintMembers tests, for which PrintMembers has not the IsReadOnly attribute.
For this reason, at the end of the day (literally, for me 😄 ) I believe the lesser evil for now is checking for compiler generated equality, knowing that we can have a false positive if someone uses the [CompilerGenerated] attribute in source code (I have added a test to document this).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha!
I hadn't thought about the case in MyRecordStructWithCustomPrintMembers where one implements PrintMembers themselves such that the compiler doesn't create one.
Well found 👍

So it seems there will always be a PrintMembers, but we don't know who wrote it.

This code handles all your test cases and the false positive.

public static bool IsRecord(this Type type)
{
    return TypeIsRecordCache.GetOrAdd(type, static t => t.IsRecordClass() || t.IsRecordStruct());
}

private static bool IsRecordClass(this Type type)
{
    return type.GetMethod("<Clone>$", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) is { } &&
            type.GetProperty("EqualityContract", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)?
                .GetMethod?.IsDecoratedWith<CompilerGeneratedAttribute>() == true;
}

private static bool IsRecordStruct(this Type type)
{
    // As noted here: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-10.0/record-structs#open-questions
    // recognizing record structs from metadata is an open point. The following check is based on common sense
    // and heuristic testing, apparently giving good results but not supported by official documentation.
    return type.BaseType == typeof(ValueType) &&
            type.GetMethod("PrintMembers", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, null, new[] { typeof(StringBuilder) }, null) is { } &&
            type.GetMethod("op_Equality", BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly, null, new[] { type, type }, null)?
                .IsDecoratedWith<CompilerGeneratedAttribute>() == true;
}

I would be confident enough with this one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea: this way we rely on the two documented behaviors of record structs!
There is still a small possibility of a false positive, where one both implements a PrintMember and annotates equality with [CompilerGenerated] (I have added a test for reference), but I'd consider such code as deliberate sabotage 😄

@dennisdoomen
Copy link
Member

@salvois any follow-up?

Copy link
Member

@jnyrup jnyrup left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was probably waiting for me.

My concerns boils down to how many false positives the heuristic will have and if we're willing to take that risk, or we want to wait until C# eventually resolves the open point about recognizing record structs.

```

For records, this works like this:
For records, record structs and readonly record structs this works like this:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
For records, record structs and readonly record structs this works like this:
For records and record structs this works like this:

Comment on lines 598 to 606
var isRecordStruct = t.BaseType == typeof(ValueType) &&
t.GetMethods()
.Where(m => m.Name == "op_Inequality")
.SelectMany(m => m.GetCustomAttributes(typeof(CompilerGeneratedAttribute)))
.Any() &&
t.GetMethods()
.Where(m => m.Name == "op_Equality")
.SelectMany(m => m.GetCustomAttributes(typeof(CompilerGeneratedAttribute)))
.Any();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking for one operator should be enough.

var isRecordStruct = t.BaseType == typeof(ValueType) &&
                     t.GetMethod("op_Equality", BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly, null, new[] { t, t }, null)?
                        .GetCustomAttribute(typeof(CompilerGeneratedAttribute)) is not null;

@dennisdoomen
Copy link
Member

My concerns boils down to how many false positives the heuristic will have and if we're willing to take that risk, or we want to wait until C# eventually resolves the open point about recognizing record structs.

I think it's fair for people to expect FA to properly handle record structs the way we treat other records. So although we're not fully confident we can cover all edge cases, I think it's worth the risk. Whatever comes after that, we'll fix.

@salvois
Copy link
Contributor Author

salvois commented Jan 1, 2023

Hi @dennisdoomen , @jnyrup , I hope the new year finds you well.
I was under the impression we were in a kind of Mexican standoff, so I did not insist on it :)
Personally, I agree with @dennisdoomen on the benefit-cost ratio of a best guess, but I'd be glad to study the last @jnyrup 's proposal.

@dennisdoomen
Copy link
Member

@salvois any progress?

@salvois
Copy link
Contributor Author

salvois commented Jan 6, 2023

Hi @dennisdoomen , I should be able to find some spare time today. Thanks

@jnyrup jnyrup added the bug label Jan 10, 2023
@dennisdoomen
Copy link
Member

Hah, the spell check target fails on struct ;-)

@IT-VBFK
Copy link
Contributor

IT-VBFK commented Jan 10, 2023

:D

@salvois wrapping structs into ``

But cool to prove it works 🎉

@IT-VBFK
Copy link
Contributor

IT-VBFK commented Jan 11, 2023

Try to finish #2093 today.. stay tuned 🙃

@jnyrup jnyrup merged commit ebee22a into fluentassertions:develop Jan 11, 2023
@salvois
Copy link
Contributor Author

salvois commented Jan 14, 2023

Thanks @dennisdoomen , @IT-VBFK and @jnyrup , I really enjoyed the constructive exchange and I also got the opportunity to learn something new!

@IT-VBFK
Copy link
Contributor

IT-VBFK commented Jan 14, 2023

You should never stop to improve yourself :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

record struct with List or IEnumerable property fails Should BeEquivalentTo
6 participants