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

ShouldBe does a sequence comparison for ReadOnlyMemory #610

Merged
merged 10 commits into from
Jul 5, 2020
Merged

ShouldBe does a sequence comparison for ReadOnlyMemory #610

merged 10 commits into from
Jul 5, 2020

Conversation

hrai
Copy link
Contributor

@hrai hrai commented Mar 27, 2020

Closes #562

@jnm2 I've created this as there was no update on the linked issue so I assumed no one was working on the fix.


public static bool IsMemory(this Type type, out Type elementType)
{
if (type.IsGenericType() && type.GetGenericTypeDefinition()?.FullName == "System.Memory`1")
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (type.IsGenericType() && type.GetGenericTypeDefinition()?.FullName == "System.Memory`1")
if (type.IsGenericType() && type.GetGenericTypeDefinition().FullName == "System.Memory`1")

@sungam3r
Copy link
Contributor

A couple of questions.

  1. Is reflection really required here?
  2. Is it possible to do more simple way for handling [Readonly]Memory<byte> as the most frequent case?

@hrai
Copy link
Contributor Author

hrai commented Mar 27, 2020

A couple of questions.

1. Is reflection really required here?

2. Is it possible to do more simple way for handling `[Readonly]Memory<byte>` as the most frequent case?

There's an extension method that we could have used but it's only available in .NET Core and .NET Standard. As shouldly targets .NET Framework, I don't think there's another way.

https://docs.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.memorymarshal.toenumerable?view=netcore-3.1

The code blocks were added by @jnm2 as it shows in #562

Perhaps he will have more info.

@jnm2
Copy link
Collaborator

jnm2 commented Mar 28, 2020

Shouldly used to build for .NET Framework 4.0 which meant it couldn't reference https://www.nuget.org/packages/System.Memory in its net40 compile output. Reflection seemed easy and simpler than splitting Shouldly's behavior. (Remember, net40 builds can run on net48 and even netcoreapp3.1.)

Since then, Shouldly has dropped support for net40. That means you can add a package reference rather than reflection.

@hrai
Copy link
Contributor Author

hrai commented Mar 28, 2020

As Memory<> and ReadOnlyMemory<> are generic types, reflection seems to be the only way to cast to these types at runtime. When the equality method is invoked, there's no way to determine what the generic parameter's type is going to be.

This is what I found.
https://stackoverflow.com/questions/7676989/how-to-cast-a-generic-type-at-runtime-in-c-sharp

@sungam3r
Copy link
Contributor

I talked about explicit check for [Readonly]Memory<byte> and then cast to IEnumerable for the most frequent case. Otherwise fallback to reflection.

@jnm2
Copy link
Collaborator

jnm2 commented Mar 28, 2020

Ah, and I forgot generics just now, quite right.

@sungam3r It would be indistinguishable to first check for IEnumerable, then fall back to using reflection to check for Memory/ReadOnlyMemory. Those are the essential checks. Before we add additional checks on top of that, we need hard numbers on which scenarios it speeds up and which it slows down.

@sungam3r
Copy link
Contributor

I think that all scenarios will get a slowdown due to the fact that IEnumerable is used. All elements of value types are boxed to be used in if (!equalityComparer.Equals(enumeratorX.Current, enumeratorY.Current)).

@jnm2
Copy link
Collaborator

jnm2 commented Mar 28, 2020

@sungam3r With regard to that, this PR doesn't change anything. It's good to keep changes small and focused. Because of how much more complex it makes the code to use generic IEnumerable, I'd also prefer to see hard measurements of real-world projects demonstrating the problem so that we know we're doing something meaningful before starting a PR to avoid boxing via IEnumerable.Current.

@jnm2
Copy link
Collaborator

jnm2 commented Mar 28, 2020

Also, if we were to go the direction of avoiding boxing, it would be both simpler and more general to first have a stage where a non-generic IEnumerable is obtained and then a second stage where generic IEnumerable is detected with a specialized iteration implementation that we invoke using MakeGenericMethod. That way not just Memory<byte>/ReadOnlyMemory<byte> but any memory/enumerable of any value type gets the benefit. And that way, this PR doesn't need to be concerned about it one way or the other.

@sungam3r
Copy link
Contributor

I understand all this and do not insist.

@jnm2
Copy link
Collaborator

jnm2 commented Mar 28, 2020

@sungam3r I appreciate that. Thanks for your input. Would you be interested in starting a new issue and doing a benchmark to help us understand how big of an effect we're talking about, or do you want to wait and see if someone starts noticing some pain over it?

@sungam3r
Copy link
Contributor

No, I don’t think it matters much.

@hrai
Copy link
Contributor Author

hrai commented Mar 29, 2020

It doesn't look like any more changes are expected in this pr. Can I get the approval then @sungam3r ?

should be
System.ReadOnlyMemory<Byte>[2]
but was
System.ReadOnlyMemory<Byte>[3]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Normally the elements are listed in the error message. Could you see what it would take to enumerate here too?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The method that generates the error method takes in the original and expected objects and passes it to this method -

internal static void AssertAwesomely<T>(

We can convert actual and expected Memory items into Enumerables in

  1. ShouldBe() and work with Enumerables
  2. this line while constructing ExpectedActualShouldlyMessage
    throw new ShouldAssertException(new ExpectedActualShouldlyMessage(originalExpected, originalActual, customMessage, shouldlyMethod).ToString());

Copy link
Collaborator

@jnm2 jnm2 Mar 29, 2020

Choose a reason for hiding this comment

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

Thanks, that helps. It looks like those values are used here:

var expected = context.Expected.ToStringAwesomely();
var actualValue = context.Actual.ToStringAwesomely();

And the string is built here:

if (value is IEnumerable)
{
var objects = value.As<IEnumerable>().Cast<object>();
var inspect = "[" + objects.Select(o => o.ToStringAwesomely()).CommaDelimited() + "]";
if (inspect == "[]" && value.ToString() != type.FullName)
{
inspect += " (" + value + ")";
}
return inspect;
}

I'm thinking we should check for the memory types and convert to IEnumerable in ToStringAwesomely for consistency. It sometimes uses the type full name and ToString of the original instance, and we wouldn't want to use the instance returned from MemoryMarshal.ToEnumerable for that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Pushed a change

private static IEnumerable ToEnumerable(object readOnlyMemory, Type elementType)
{
return (IEnumerable)Type.GetType("System.Runtime.InteropServices.MemoryMarshal, System.Memory")
?.GetMethod("ToEnumerable", BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's best to specify parameter types so that this doesn't crash if an overload is added.

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 wanted to use this overload of GetMethod() but it only support .NET Standard 2.0 and above - https://docs.microsoft.com/en-us/dotnet/api/system.type.getmethod?view=netframework-4.8#System_Type_GetMethod_System_String_System_Reflection_BindingFlags_System_Reflection_Binder_System_Type___System_Reflection_ParameterModifier___

Below is the modified code which fails to compile.

            return (IEnumerable)Type.GetType("System.Runtime.InteropServices.MemoryMarshal, System.Memory")
                ?.GetMethod("ToEnumerable", BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly, null, new[] { typeof(ReadOnlyMemory<T>) }, null)
                ?.MakeGenericMethod(elementType)
                .Invoke(null, new[] { readOnlyMemory });

Copy link
Contributor Author

Choose a reason for hiding this comment

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

image

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah, that's too bad. We could cross our fingers and hope that an overload of MemoryMarshal.ToEnumerable is never added, or we could use GetMethods+SingleOrDefault like you did above, or we could replace netstandard1.3 with netstandard2.0.

@josephwoodward What are your thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

GetMethods+SingleOrDefault sounds like a small enough change as opposed to upgrading to netstandard2.0

The library may stop supporting older .NET versions which may have unintended consequences.

{
var readOnlyMemory = type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.SingleOrDefault(method =>
method.Name == "op_Implicit"
Copy link
Contributor

Choose a reason for hiding this comment

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

Also you can check method.IsSpecialName for operators.

<PropertyGroup>
Copy link
Collaborator

Choose a reason for hiding this comment

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

Any chance you could reformat back to the original (standard) 2-space csproj indentation, and squash it so the most recent line history for every line in the file doesn't point back to this PR?

else if (objectType.IsReadOnlyMemory(out genericParameterType))
{
value = value.ToEnumerable(genericParameterType);
}

if (value is IEnumerable)
Copy link
Collaborator

Choose a reason for hiding this comment

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

What are your thoughts on using TryGetEnumerable from here?

@SimonCropp SimonCropp added this to the 4.0.0 milestone Jul 5, 2020
@SimonCropp
Copy link
Contributor

thanks peoples

@SimonCropp SimonCropp merged commit 2d725cb into shouldly:master Jul 5, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

ShouldBe does not do a sequence comparison for ReadOnlyMemory
4 participants