Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Unexpected equality tests, Equals(a, a) evaluates to false

Tags:

c#

Given the struct S1:

unsafe readonly struct S1
{
    public readonly int A1, A2;
    public readonly int* B;

    public S1(int a1, int a2, int* b)
    {
        A1 = a1;
        A2 = a2;
        B = b;
    }
}

and an equality test:

int x = 10;
var a = new S1(1, 2, &x);
var b = new S1(1, 2, &x);

var areEqual = Equals(a, b);  // true

areEqual evaluates to true, as expected.

Now lets slightly change our struct to S2 (replacing the pointer with a string):

unsafe readonly struct S2
{
    public readonly int A1, A2;
    public readonly string C;

    public S2(int a1, int a2, string c)
    {
        A1 = a1;
        A2 = a2;
        C = c;
    }
}

with an analog test:

var a = new S2(1, 2, "ha");
var b = new S2(1, 2, "ha");

var areEqual = Equals(a, b);  // true

this evaluates to true as well.

Now the interesting part. If we combine both structs to S3:

unsafe readonly struct S3
{
    public readonly int A1, A2;
    public readonly int* B;
    public readonly string C;

    public S3(int a1, int a2, int* b, string c)
    {
        A1 = a1;
        A2 = a2;
        B = b;
        C = c;
    }
}

and test for equality:

int x = 10;
var a = new S3(1, 2, &x, "ha");
var b = new S3(1, 2, &x, "ha");

var areEqual = Equals(a, b);  // false

The equality test fails, unexpectedly. Even worse,

Equals(a, a); // false

does also fail the test.

Why do the last two equality tests evaluate to false?

Edit

Bugreport for reference. Fixed in .net 6.0.

like image 744
Timo Avatar asked Sep 18 '20 13:09

Timo


1 Answers

The actual comparison of the instances is performed by ValueType.Equals. Here is the implementation:

public override bool Equals(object? obj)
{
    if (null == obj)
    {
        return false;
    }
    Type thisType = this.GetType();
    Type thatType = obj.GetType();

    if (thatType != thisType)
    {
        return false;
    }

    object thisObj = (object)this;
    object? thisResult, thatResult;

    // if there are no GC references in this object we can avoid reflection
    // and do a fast memcmp
    if (CanCompareBits(this))
        return FastEqualsCheck(thisObj, obj);

    FieldInfo[] thisFields = thisType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);

    for (int i = 0; i < thisFields.Length; i++)
    {
        thisResult = thisFields[i].GetValue(thisObj);
        thatResult = thisFields[i].GetValue(obj);

        if (thisResult == null)
        {
            if (thatResult != null)
                return false;
        }
        else
        if (!thisResult.Equals(thatResult))
        {
            return false;
        }
    }

    return true;
}

We can see that basically it performs a bit level comparison if there are no GC references (classes referenced in your fields), otherwise it will invoke Equals on every field of your struct.

When you have a field of pointer type (int* in your case) and you use Reflection to get its value, then the value you get is boxed as a System.Reflection.Pointer.

We can see that it is a class, so no bit-level comparison will be performed.

So it will invoke Pointer.Equals, but unfortunately we can see by the Pointer class source code that it isn't overridden, so the check performed will be if the references of the object are the same:

public sealed unsafe class Pointer : ISerializable
{
    // CoreCLR: Do not add or remove fields without updating the ReflectionPointer class in runtimehandles.h
    private readonly void* _ptr;
    private readonly Type _ptrType;

    private Pointer(void* ptr, Type ptrType)
    {
        Debug.Assert(ptrType.IsRuntimeImplemented()); // CoreCLR: For CoreRT's sake, _ptrType has to be declared as "Type", but in fact, it is always a RuntimeType. Code on CoreCLR expects this.
        _ptr = ptr;
        _ptrType = ptrType;
    }

    public static object Box(void* ptr, Type type)
    {
        if (type == null)
            throw new ArgumentNullException(nameof(type));
        if (!type.IsPointer)
            throw new ArgumentException(SR.Arg_MustBePointer, nameof(ptr));
        if (!type.IsRuntimeImplemented())
            throw new ArgumentException(SR.Arg_MustBeType, nameof(ptr));

        return new Pointer(ptr, type);
    }

    public static void* Unbox(object ptr)
    {
        if (!(ptr is Pointer))
            throw new ArgumentException(SR.Arg_MustBePointer, nameof(ptr));
        return ((Pointer)ptr)._ptr;
    }

    void ISerializable.GetObjectData(SerializationInfo info, StreamingContext context)
    {
        throw new PlatformNotSupportedException();
    }

    internal Type GetPointerType() => _ptrType;
    internal IntPtr GetPointerValue() => (IntPtr)_ptr;
}

So the comparison will return false because you have a pointer.

like image 158
Matteo Umili Avatar answered Nov 20 '22 11:11

Matteo Umili