.NET / [Из песочницы] Сравнение объектов в C#.NET

в 13:29, , рубрики: .net, метки: ,

C#.NET предлагает множество способов сравнить объекты, как экземпляры классов, так и структур. Способов так много, что без упорядочения этих способов и понимания их грамотного использования и имплементации (при наличии возможности переопределения), в голове, неминуемо, образуется каша.
Итак, класс System.Object предлагает следующие методы:

public static bool ReferenceEquals(object objA, object objB)
{
return objA == objB;
}

public static bool Equals(object objA, object objB)
{
return objA == objB || (objA != null && objB != null && objA.Equals(objB));
}

public virtual bool Equals(object obj)
{
return RuntimeHelpers.Equals(this, obj);
}

Ну и, конечно:
public static bool operator == (Foo left, Foo right);

Также имеется возможность наследования IEquatable, IStructuralEquatable.
ReferenceEquals

Метод ReferenceEquals сравнивает две ссылки. Если ссылки на объекты идентичны, то возвращает true. Это значит, что данный метод проверяет экземпляры не на равенство, а на тождество. В случае передачи этому методу экземпляров значимого типа (даже если передать один и тот же экземпляр) всегда будет возвращать false. Так произойдёт потому, что при передаче произойдёт упаковка значимых типов и ссылки на них будут разные.
Здесь также хотелось бы упомянуть о сравнение двух строк этим методом. Например:
class Program
{
static void Main(string[] args)
{
string a = "Hello";
string b = "Hello";

if(object.ReferenceEquals(a,b))
Console.WriteLine("Same objects");
else
Console.WriteLine("Not the same objects");

Console.ReadLine();
}
}

Такая программа, запросто может вывести «Same objects». Не стоит переживать, это связано с интернированием строк. Но это совсем другая история и здесь об этом речи идти не будет.
public static bool Equals(object objA, object objB)

Сначала этот метод проверяет экземпляры на тождество и если объекты не тождественны, то проверяет их на null и делегирует ответственность за компарацию переопределяемому экземплярному методу Equals.
public virtual bool Equals(object obj)

По умолчанию, этот метод ведёт себя точно также как ReferenceEquals. Однако для значимых типов он переопределён и в System.ValueType выглядит следующим образом:
public override bool Equals(object obj)
{
if (obj == null)
{
return false;
}
RuntimeType runtimeType = (RuntimeType)base.GetType();
RuntimeType left = (RuntimeType)obj.GetType();
if (left != runtimeType)
{
return false;
}
if (ValueType.CanCompareBits(this))
{
return ValueType.FastEqualsCheck(this, obj);
}
FieldInfo[] fields = runtimeType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
for (int i = 0; i < fields.Length; i++)
{
object obj2 = ((RtFieldInfo)fields[i]).InternalGetValue(this, false);
object obj3 = ((RtFieldInfo)fields[i]).InternalGetValue(obj, false);
if (obj2 == null)
{
if (obj3 != null)
{
return false;
}
}
else
{
if (!obj2.Equals(obj3))
{
return false;
}
}
}
return true;
}

Не дай бог никому пользоваться такой имплементацией на больших множествах. Разработчики BCL не могут знать какие значимые типы мы будем определять и проводят сравнение экземпляров значимых типов по их полям, используя рефлексию, заранее ничего не зная об этих полях. Разумеется, это не очень производительный способ сравнения. Поэтому, при использовании значимых типов, известных на этапе компиляции, необходимо переопределить этот метод, ибо кто лучше вас может знать как сравнить два разработанных вами объекта? Для ссылочных типов, без нужды сравнения двух экземпляров на манер значимых типов, переопределять этот метод необязательно.
Посмотрим на пример грамотного переопределения этого метода и сразу реализуем IEquatable:
class Vehicle:IEquatable
{
protected int speed;
public int Speed
{
get { return this.speed; }
set { this.speed = value; }
}

protected string name;
public string Name
{
get { return this.name; }
set { this.name = value; }
}

public Vehicle(){}
public Vehicle(int speed, string name)
{
this.speed = speed;
this.name = name;
}

public override bool Equals(object other)
{
//Последовательность проверки должна быть именно такой.
//Если не проверить на null объект other, то other.GetType() может выбросить //NullReferenceException.
if (other == null)
return false;

//Если ссылки указывают на один и тот же адрес, то их идентичность гарантирована.
if (object.ReferenceEquals(this, other))
return true;

//Если класс находится на вершине иерархии или просто не имеет наследников, то можно просто
//сделать Vehicle tmp = other as Vehicle; if(tmp==null) return false;
//Затем вызвать экземплярный метод, сразу передав ему объект tmp.
if (this.GetType() != other.GetType())
return false;

return this.Equals(other as Vehicle);
}
public bool Equals(Vehicle other)
{
if (other == null)
return false;

//Здесь сравнение по ссылкам необязательно.
//Если вы уверены, что многие проверки на идентичность будут отсекаться на проверке по ссылке - //можно имплементировать.
if (object.ReferenceEquals(this, other))
return true;

//Если по логике проверки, экземпляры родительского класса и класса потомка могут считаться равными,
//то проверять на идентичность необязательно и можно переходить сразу к сравниванию полей.
if (this.GetType() != other.GetType())
return false;

if (string.Compare(this.Name, other.Name, StringComparison.CurrentCulture) == 0 && this.speed.Equals(other.speed))
return true;
else
return false;
}
}

Комментарий про вершину иерархии в переопределении виртуального метода сделан не просто так. Если создать наследник класса Vehicle (например, Bike), который также будет иметь переопределённый виртуальный метод Equals, в котором не будет сравнения типов по GetType, а будет попытка приведения типа Bike tmp = other as Bike; if(tmp!=null) this.Equals(tmp); то в таком случае, следующий код может вызывать проблемы:
Vehicle vehicle = new Vehicle();
Bike bike = new Bike();

object vehicleObj = vehicle;
object bikeObject = bike;

bike.Equals(vehicleObj); //Базовый тип не сможет привестись к наследнику. Таким образом, может быть //нарушено свойство симметричности сравнения объектов

public static bool operator == (Foo left, Foo right)

Для значимых типов всегда следует переопределять, как и виртуальный Equals(). Для ссылочных типов лучше не переопределять, ибо, по умолчанию, от == на ссылочных типах ожидается поведение как у метода ReferenceEquals(). Так что, здесь всё просто.
IStructuralEquatable

IStructuralEquatable идёт рука об руку с интерфейсом IEqualityComparer. Интерфейс IStructuralEquatable реализуют такие классы как System.Array или System.Tuple. Как пишет Билл Вагнер, IStructuralEquality декларирует то, что тип может составлять более крупные объекты, которые имплементируют семантику значимых типов и вряд ли когда-либо нам потребуется его самостоятельно реализовывать. Хотя, что сложного в его реализации? Достаточно посмотреть на его реализацию в System.Array:
bool IStructuralEquatable.Equals(object other, IEqualityComparer comparer)
{
if (other == null)
{
return false;
}
if (object.ReferenceEquals(this, other))
{
return true;
}
Array array = other as Array;
if (array == null || array.Length != this.Length)
{
return false;
}
for (int i = 0; i < array.Length; i++)
{
object value = this.GetValue(i);
object value2 = array.GetValue(i);
if (!comparer.Equals(value, value2))
{
return false;
}
}
return true;
}

Собственно, сначала проверяется тождественность объектов, затем производится приведение к одному типу и сравнение по длине. Если длинна равна, то тогда начинается поэлементное сравнение через делегирование ответственности за это сравнение интерфейсному (IEqualityComparer) методу Equals.
Вот, по сути, и всё, что можно сказать о сравнении объектов в C#.NET, но осталась ещё одна маленькая, но важная деталь: метод GetHashCode().
public virtual int GetHashCode()

В общем и целом, стандартная реализация этого метода ведёт себя как генератор уникального идентификатора. Минус такого подхода состоит в том, что одинаковые семантически объекты, могут возвращать разные hash-значения. Рихтер жалуется на то, что стандартная реализация ещё и низкопроизводительна. Грамотная реализация этого метода весьма проблематична. Необходимо высчитывать hash быстро и иметь большой разброс в результате, чтобы не случалось повторений на достаточно больших множествах. На самом деле, в большей части случаев, имплементации GetHashCode() донельзя простые. Везде производятся сдвиги, «побитовые или», или «исключающие или». Сам Рихтер приводит пример со структурой, имеющей два поля типа int. GetHashCode() он предлагает имплементировать примерно так:
internal sealed class Point
{
private int a;
private int b;

public override int GetHashCode()
{
return a ^ b;
}
}

А вот как переопределён GetHasCode() в System.Char:
public override int GetHashCode()
{
return (int)this | (int)this << 16;
}

Можно приводить много примеров и практически везде используются эвристические показатели для сдвигов, исключающих или и так далее.
При написании статьи были использованы небезызвестные источники:J.Richter, CLR via C#B. Wagner Effective C#
Также использовался свой опыт и источники в интернете, которые приводить особого смысла не имеет.


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js