Share via


C# object comparisons basics

 

Introduction

When working with various types in code comparisons are performed often ranging from simple string comparisons to various containers e.g. project class instances will be covered along with working with comparing objects with Interfaces to assist with common operations were duplicate data is undesirable.

This article will not dive too deep into all aspects for topics covered as the Microsoft documentation provides this information, instead practical samples for real life usage are shown with caveats.  

Comparing strings

When comparing two or more string variables the comparison is case sensitive. In the following example the result is false.

if ("karen" == "Karen")
{
    Console.WriteLine("Yes");
}
else
{
    Console.WriteLine("No");
}

When this is encountered developer tend to use code as shown below to do a case insensitive compare.

if ("karen".ToLower() == "Karen".ToLower())
{
    Console.WriteLine("Yes");
}
else
{
    Console.WriteLine("No");
}

This time the result is true as they are both the same casing. Another option

if (string.Equals("Karen", "karen", StringComparison.OrdinalIgnoreCase))
{
    Console.WriteLine("Yes");
}
else
{
    Console.WriteLine("No");
}

In the code above String.Equals provides an enumeration parameter StringComparison where StringComparison.IgnoreCase in an overloaded method for Equals. If this compare operation is done frequently a language extension can make the comparing method based, written once, used in all projects which reference the class. 

public static  class Extensions
{
    /// <summary>
    /// Perform case insensitive equal on two strings
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="item"></param>
    /// <returns>true if both strings are a match, false if not a match</returns>
    public static  bool AreEqual(this string  sender, string  item) => 
        string.Equals(sender, item, StringComparison.OrdinalIgnoreCase);
}

Revised code sample

if ("Karen".AreEqual("karen"))
{
    Console.WriteLine("Yes");
}
else
{
    Console.WriteLine("No");
}

The same issue for comparing string is casing with String.Contains as in the following example where the same chars are in both strings but one has a uppercase A while the other a lowercase a. The result is false back from the Contains method.

if ("Karen".Contains("Aren"))
{
    Console.WriteLine("Yes");
}
else
{
    Console.WriteLine("No");
}

Rather use Contains use IndexOf.

if ("Karen".IndexOf("Aren", StringComparison.OrdinalIgnoreCase) >=0)
{
    Console.WriteLine("Yes");
}
else
{
    Console.WriteLine("No");
}

As with Equals this code is best suited in a language extension.

public static  class StringExtensions
{
    /// <summary>
    /// Determines if a string is within another string with Comparison options
    /// </summary>
    /// <param name="source"></param>
    /// <param name="compareToken">string to see if it exists in source string</param>
    /// <param name="comparer">StringComparison</param>
    /// <returns></returns>
    public static  bool Contains(this string  source, string  compareToken, StringComparison comparer) => 
        source?.IndexOf(compareToken, comparer) >= 0;
}

If ignore case is what's need most set the comparison to default to StringComparison.OrdinalIgnoreCase which allows other options to be used and eliminates the need to pass OrdinalIgnoreCase to be passed on each call to the method.

public static  bool Contains(this string  source, 
    string compareToken, StringComparison comparer = StringComparison.OrdinalIgnoreCase) => 
    source?.IndexOf(compareToken, comparer) >= 0;

There is an issue with the above, using a default for the last parameter will mean the standard Contains method will be called. We could rename Contains as follows which is a better choice,

public static  bool ContainsWithOptions(this string  source,
    string compareToken, StringComparison comparer = StringComparison.OrdinalIgnoreCase) =>
    source?.IndexOf(compareToken, comparer) >= 0;

Important
Although this should be obvious, it does not matter where these compares happen e.g. in a class, model, web or windows forms container. 

Comparing objects

When comparing object equality there several options.

IEquatable<T> Interface

IEquatable<T> Interface Interface requires the Equals method and GetHashCode property to be overridden. Looking at where this would be useful, in this case data is read from an external source such as a Text file were for some reason data in the incoming file if added to a internal data source would violate current data.

  • One option might be to write database table constraints and then run incoming data to the internal data source, on error record the issue.
  • Another option is to write assertive statements to test if an object exists presently in the internal data source directly in the code reading in external data,
  • Using logic in bullet two implement IEquatable<T> Interface.

Here Employee data is read in from an external data source were duplicates are checked on primary key, first and last name and department. The class below represents a container for employee data which is prior to implementing IEquatable<T> Interface

public class  Employee 
{
    public int  EmployeeIdentifier { get;set;}
    public string  FirstName {get;set;}
    public string  LastName {get;set;} 
    public string  DepartmentName {get;set;}
}

Implementing IEquatable<T> Interface

using System;
 
namespace Operations.Classes
{
    public class  Employee : IEquatable<Employee>
    {
        public int  EmployeeIdentifier { get;set;}
        public string  FirstName {get;set;}
        public string  LastName {get;set;} 
        public string  DepartmentName {get;set;}
        public bool  Equals(Employee other)
        {
            return other.EmployeeIdentifier == EmployeeIdentifier &&  
                   other.FirstName == FirstName && 
                   LastName == other.LastName && 
                   other.DepartmentName == DepartmentName;
 
        }
        public override  int GetHashCode() =>
            new { EmployeeIdentifier, FirstName, LastName, DepartmentName }.GetHashCode();
 
    }
}

Using mocked data to test if the class above will not allow duplicates.

protected HashSet<Employee> EmployeeHashSet()
{
    return new  HashSet<Employee>
    {
        {new Employee {EmployeeIdentifier = 1, FirstName =  "Karen", LastName =  "Payne", DepartmentName = "Finance"}},
        {new Employee {EmployeeIdentifier = 3, FirstName =  "Mary", LastName =  "Jones",   DepartmentName = "IT"}},
        {new Employee {EmployeeIdentifier = 1, FirstName =  "Karen", LastName =  "Payne",  DepartmentName = "Finance"}},
        {new Employee {EmployeeIdentifier = 4, FirstName =  "Frank", LastName =  "Anderson",  DepartmentName = "IT"}}
    };
}

Test method which should return three employee records (a HashSet is returned, tag on .ToList for a list of employee).

[TestMethod]
public void  HashSetIEquatableTest()
{
    var employees = EmployeeHashSet();
 
    Assert.IsTrue(employees.Count  == 3);
}

Going back to comparing strings, in the above example if the first instance has Payne for last name and the third last name is payne the compare fails. To correct this the GetHashCode must use StringComparer.GetHashCode. Revised class which will now perform proper comparisons.

using System;
 
namespace Operations.Classes
{
    public class  Employee : IEquatable<Employee>
    {
        public int  EmployeeIdentifier { get;set;}
        public string  FirstName {get;set;}
        public string  LastName {get;set;} 
        public string  DepartmentName {get;set;}
        public bool  Equals(Employee other)
        {
            return other.EmployeeIdentifier == EmployeeIdentifier &&  
                   other.FirstName == FirstName && 
                   other.DepartmentName == DepartmentName;
 
        }
 
        public override  int GetHashCode()
        {
            return StringComparer.OrdinalIgnoreCase.GetHashCode(FirstName) ^
                   StringComparer.OrdinalIgnoreCase.GetHashCode(LastName) ^
                   StringComparer.OrdinalIgnoreCase.GetHashCode(DepartmentName);
        }
 
    }
}

Note: In some cases (using the above example) there may be no primary key, if not simply exclude it from the overridden Equals method and GetHashCode property.

IEqualityComparer Interface

IEqualityComparer Interface can be used to return distinct items in a collection.  Working against string, numerics are straightforward along with dates. In the following example there are duplicates.

protected List<DateTime> DateTimeList()
{
    return new  List<DateTime>()
    {
        new DateTime(2019,9,12),
        new DateTime(2019,9,13),
        new DateTime(2019,9,12),
        new DateTime(2019,9,11)
    };
}

To get distinct dates.

var datesTimes = DateTimeList();
var test = datesTimes.Distinct();

Suppose there is an empty DateTime list and the developer wants only unique dates the following language extension will work (and work for any ICollection).

/// <summary>
/// Adds a value uniquely to to a collection and returns
/// a value whether the value was added or not.
/// </summary>
/// <typeparam name = "T">The generic collection value type</typeparam>
/// <param name = "sender">The collection.</param>
/// <param name = "pValue">The value to be added.</param>
/// <returns>Indicates whether the value was added or not</returns>
/// <remarks>Naming done to not conflict with extension method above</remarks>
public static  bool AddUniqueNoInterface<T>(this ICollection<T> sender, T pValue)
{
    var alreadyHasValue = sender.Contains(pValue);
 
    if (!alreadyHasValue)
    {
        sender.Add(pValue);
    }
 
    return alreadyHasValue;
}

Working with the predefined list above and the language extension.

[TestMethod]
public void  AddUniqueDatesWithExtensionMethod()
{
    var datesTimes = DateTimeList();
    var dateTimesDistinct = new  List<DateTime>();
 
 
    foreach (var dateTime in datesTimes)
    {
        dateTimesDistinct.AddUniqueNoInterface(dateTime);
    }
 
    Assert.IsTrue(dateTimesDistinct.Count == 3,
        "Expected three unique dates");
 
}

For complex collections this is where IEqualityComparer Interface would be an options. Consider receiving records for the following class.

public class  Suppliers
{
    public int  SupplierIdentifier { get; set; }
    public string  CompanyName { get; set; }
    public string  ContactName { get; set; }
 
}

Once a list is populated which may have duplicate records the overload for Distinct accepts a IEqualityComparer<T> to compare values in the collection. For the Supplier class duplicates may come from CompanyName, ContactName, SupplierIdentifier or a combination of these properties. In this example two properties will define the equality.

Note that optionally include modifications for string comparing on string properties if needed as explained above.

using System.Collections.Generic;
 
namespace Operations.Classes
{
    public class  SuppliersComparer : IEqualityComparer<Suppliers>
    {
        public bool  Equals(Suppliers supplier1, Suppliers supplier2)
        {
            return supplier1.SupplierIdentifier == supplier2.SupplierIdentifier && 
                   supplier1.CompanyName == supplier2.CompanyName;
        }
 
        public int  GetHashCode(Suppliers obj)
        {
            return new  {obj.SupplierIdentifier, ComanyName = obj.CompanyName}.GetHashCode();
        }
    }
}

In the following usage there may be only one or more duplicates so in the first example the first duplicate is used using .First() extension while in the second example .Last is used to get the last instance for a duplicate. There are many twist here, this is simply a base example to start with.

[TestMethod]
public void  DictionaryTest()
{
    var dictionaryFirst = SupplierList().Distinct(new SuppliersComparer()).ToList()
        .GroupBy(supplier => supplier.SupplierIdentifier)
        .ToDictionary(grouping => grouping.Key, grouping => grouping.First());
 
    Assert.IsTrue(dictionaryFirst.Count == 2, 
        "Expected two for dictionary first");
 
 
    var dictionaryLast = SupplierList().Distinct(new SuppliersComparer()).ToList()
        .GroupBy(supplier => supplier.SupplierIdentifier)
        .ToDictionary(grouping => grouping.Key, grouping => grouping.Last());
 
    Assert.IsTrue(dictionaryLast.Count == 2,
        "Expected two for dictionary last");
 
}

Advantages, no extra code needed in the class to check for duplicates and that other implementations can be used. In the following code there are two Comparers for a Customer class.

using System;
using Operations.Interfaces;
 
namespace Operations.Classes
{
    public class  Customer : IBase
    {
        public int  Id { get; }
        public int  CustomerIdentifier { get; set; }
        public string  CompanyName { get; set; }
        public int? ContactIdentifier { get; set; }
        public int? ContactTypeIdentifier { get; set; }
        public string  Street { get; set; }
        public string  City { get; set; }
        public string  PostalCode { get; set; }
        public int? CountryIdentfier { get; set; }
        public string  Phone { get; set; }
        public DateTime? ModifiedDate { get; set; }
        public bool? InUse { get; set; }
 
    }
 
}

Equality on company name, street, city and country.

using System.Collections.Generic;
 
namespace Operations.Classes
{
    public class  CustomerNameStreetCityCountryComparer : IEqualityComparer<Customer>
    {
        public bool  Equals(Customer customer1, Customer customer2)
        {
            return customer1.CompanyName == customer2.CompanyName &&
                   customer1.Street == customer2.Street &&
                   customer1.City == customer2.City &&
                   customer1.CountryIdentfier == customer2.CountryIdentfier;
        }
 
        public int  GetHashCode(Customer obj)
        {
            return new  { obj.CompanyName, obj.CountryIdentfier }.GetHashCode();
        }
    }
}

While this one is on name and country.

using System.Collections.Generic;
 
namespace Operations.Classes
{
    public class  CustomerNameCountryComparer : IEqualityComparer<Customer>
    {
        public bool  Equals(Customer customer1, Customer customer2)
        {
            return customer1.CompanyName == customer2.CompanyName &&  
                   customer1.CountryIdentfier == customer2.CountryIdentfier;
        }
 
        public int  GetHashCode(Customer obj)
        {
            return new  { obj.CompanyName, obj.CountryIdentfier }.GetHashCode();
        }
    }
}

Usage for both in a unit test method.

[TestMethod]
public void  CustomersComparerTest() 
{
 
    var customers = CustomerList().Distinct(new CustomerNameStreetCityCountryComparer()).ToList();
    Assert.IsTrue(customers.Count == 2, 
        "Expected two customers");
 
    var secondAttempt = CustomerList();
    secondAttempt[0].CountryIdentfier = 1;
    customers = secondAttempt.Distinct(new CustomerNameStreetCityCountryComparer()).ToList();
    Assert.IsTrue(customers.Count == 3,
        "Expected three customers");
}

Equality by primary key

To compare objects for duplication by primary key keeping code open to work againsts more than one class. Create an Interface to implement with one property, Id.

namespace Operations.Interfaces
{
    public interface  IBase
    {
        int Id { get; }
    }
}

Implement in a class and assign Id to the current property for uniquely identifying an instance.

using Operations.Interfaces;
 
namespace Operations.Classes
{
    public class  Person : IBase
    {
        public int  Identifier { get; set; }
        public int  Id => Identifier;
        public string  FirstName { get; set; }
        public string  LastName { get; set; }
    }
 
}

Create an extension method that has a constraint that the type must implement IBase.

public static  void AddUnique<TType>(this ICollection<TType> self, TType item) where TType : IBase
{
    if (self.FirstOrDefault(data => data.Id == item.Id) ==  null)
    {
        self.Add(item);
    }
}

Here a list is iterated were there are duplicates by Identifier property.

[TestMethod]
public void  AddUniquePersonByPrimaryTest()
{
    var peopleList = new  List<Person>();
 
    foreach (var person in PeopleList())
    {
        peopleList.AddUnique(person);
    }
 
    Assert.IsTrue(peopleList.Count == 3,
        "People count incorrect for AddUnique");
}

Moving to extension method that is fluid, accepts a predicate. 

public static  void AddRangeUnique<TType>(this ICollection<TType> self, Func<TType, TType, bool> predicate, IEnumerable<TType> items)
{
    foreach (TType item in items)
    {
        if (!self.Any(data => predicate(data, item)))
        {
            self.Add(item);
        }
    }
}

A AddRange could had been done to compliment the AddUnique extension but options are good, define the condition in the caller rather than hard code by Interface.

public void  AddUniquePersonAfterTest()
{
    var peopleList = new  List<Person>();
 
    peopleList.AddRangeUnique((person1, person2) => 
        person1.Id == person2.Id, PeopleList());
 
    Assert.IsTrue(peopleList.Count == 3,
        "People count incorrect for AddUniqueBy");
}

Overriding == != operators

If == is needed to override != must be also and the reverse. The pattern is similar to both interfaces explained prior.

using System;
 
namespace Samples.Classes
{
    public class  Person 
    {
        public int  Identifier { get; set; }
        public string  FirstName { get; set; }
        public string  LastName { get; set; }
 
        public override  bool Equals(object sender)
        {
            if (sender == null)
                return false;
 
            if (!(sender is Person person))
                return false;
 
            /*
             * See abbreviated / extension AreEqual below
             */
            return Identifier == person.Identifier && 
                   string.Equals(FirstName, person.FirstName, StringComparison.OrdinalIgnoreCase) && 
                   string.Equals(LastName, person.LastName, StringComparison.OrdinalIgnoreCase);
 
        }
 
        public bool  Equals(Person person)
        {
            if (person == null)
                return false;
 
            return Identifier == person.Identifier && 
                   FirstName.AreEqual(person.FirstName) &&
                   LastName.AreEqual(person.LastName);
        }
 
        public override  int GetHashCode()
        {
            return Identifier.GetHashCode() ^ FirstName.GetHashCode() ^ LastName.GetHashCode();
        }
 
        public static  bool operator  ==(Person person1, Person person2)
        {
            if (ReferenceEquals(person1, person2))
                return true;
 
            if (person1 as object  == null  || person2 as  object == null)
                return false;
 
             
            return person1.Identifier == person2.Identifier && 
                   person1.FirstName.AreEqual(person2.FirstName) && 
                   person1.LastName.AreEqual(person2.LastName);
        }
 
        public static  bool operator  !=(Person person1, Person person2)
        {
            return !(person1 == person2);
        }
 
    }
}

See also

.NET: Understanding Equality 
.NET: Best Approach Implementing equality comparison

Summary

This article has presented basics for comparing common types and collections rather than write code that does similar result yet many times ends up fragile and only works in one method. The code samples are easy to follow and are easy to adapt to classes in various solutions ranging from Console projects to Web projects along with Windows Forms projects.

Source code

See the following GitHub repository where all code presented above and more can be downloaded to learn from.