Practical .NET

Creating Sortable Objects with IComparable and Planning Your Interface Strategy

The IComparable interface lets you create objects that know how to sort themselves correctly. This interface also provides an example of a high-level strategy for building and extending your classes.

In my last column, I discussed how the Microsoft .NET Framework (and its compilers) use interfaces to implement behavior that crosses many different kinds of objects. My example in that column was using the IEnumerable interface in one of your classes to allow your class to work with a For...Each loop. I showed how you could use that knowledge to create a read-only collection. But I also pointed out that creating your own read-only collection was sort of pointless: The .NET Framework already provides the ReadOnlyCollectionBase class to let you create custom read-only collections, and it includes the AsReadOnly extension method for creating read-only collections from existing read-write collections.

This month, I thought I'd do something more useful: show you how to use the ICompare interface to create a class that supports sorting. However, a class that supports sorting can only support sorting one way -- the "typical" sort. I'll also show you how to support common, but not typical, sorts with your classes (and even how to apply any sort that you want to any collection), again by leveraging another .NET Framework interface.

All that will lead up to suggesting a strategy for building functionality into multiple classes in a flexible, maintainable way.

I'll start by sorting something simple: a List of strings. This code adds some strings to a List and then sorts them by calling the List's Sort method:

Dim sList As New List(of String)
sList.Add("Pat")
sList.Add("Terry")
sList.Add("Lou")
sList.Sort()

If I loop through the collection after the List's Sort method is called, I'd find "Lou" before "Pat" and "Terry" following "Pat." But there are limits to what the Sort method can do. I might have a Customer object with a constructor and three properties, like this:

Public Class Customer 

  Public Property ID As String
  Public Property FirstName As String
  Public Property LastName As String

  Public Sub New()
    '...code omitted...
  End Sub

  Public Sub New(ID As String)
    '...code omitted...
  End Sub

End Class

If I add multiple copies of my Customer Object to a List and try to sort it, I'll get an error:

Dim custList As New List(of Customer)
Dim cust As Customer
cust = New Customer("Q456")
custList.Add(cust)
cust = New Customer("A123")
custList.Add(cust)
cust = New Customer("0789")
custList.Add(cust)
custList.Sort()

It turns out that the Sort method requires the objects that will be sorted to have implemented the IComparable interface. The string class apparently implements this interface and my Customer class does not.

Adding Sort Support
To enhance my Customer object to support sorting, I first need to have my class implement the IComparable interface. Adding that interface will cause Visual Studio to add the one method required by that interface (called CompareTo) to my class. As part of implementing this interface, I'm going to promise that I'll only compare Customer objects to other Customer objects by using the generic version of IComparable, which forces me to specify what kind of objects I'll be comparing to. I'll end up with a class like this:

Public Class Customer 
  Implements IComparable(of Customer)

  '...code omitted...

  Public Function CompareTo(other As Customer) As Integer _
    Implements IComparable(Of Customer).CompareTo

  End Function
End Class

Reading the documentation on the IComparable interface tells me that the Sort method will select a Customer object from the List and call its CompareTo method, passing some other Customer object from the List. In my CompareTo method I have to indicate whether the Customer object passed to the method is to appear before or after the selected Customer object (or indicate that I don't care which Customer appears first). If the Customer passed to the method should appear before the selected Customer, then I should return 1; if the Customer passed in should appear after, I should return -1; if I don't care, I return 0.

If I assume I'll normally want to sort Customers by their ID property, I should create a CompareTo method:

Public Function CompareTo(other As Customer) As Integer _
  Implements IComparable(Of Customer).CompareTo

  If other.ID < Me.ID Then
    Return 1
  ElseIf other.ID > Me.ID Then
    Return -1
  Else
    Return 0
  End If

End Function
I can simplify this code, though. As I said earlier, the String class implements the IComparable interface, which means that any property of type String must have a CompareTo method that will return the correct value. Because my Customer's ID property is of type string, I can simplify my CompareTo method:

Public Function CompareTo(other As Customer) As Integer _
  Implements IComparable(Of Customer).CompareTo

  Return other.ID.CompareTo(Me.ID)

End Function

Supporting Other Sorts
This CompareTo method will work as long as users only want to sort the Customer objects by their ID property. But what if a user wants to sort Customer objects by their FirstName and LastName properties? If that request occurs often enough, I'd probably be willing to support that "common but not typical" sort by writing some code.

If you look at the Sort method on a List (or any other collection), you'll see that it will accept any object that implements not the IComparable interface, but the IComparer interface. If you pass a class that implements IComparer to the Sort method, the method will ignore the CompareTo method of the objects in the List (in fact, the objects in the List don't even have to implement the IComparable interface). One way to think of this is to consider the CompareTo method built into your class as the comparer for the "default sort" and support other sorts by creating "sorting classes" that implement IComparer.

A class that implements a version of IComparer that works with Customer objects, though without the actual sort code, looks like this (I've called this class SortByName):

Public Class SortByName
  Implements IComparer(Of Customer)

  Public Function Compare(x As Customer, y As Customer) As Integer _
    Implements IComparer(Of Customer).Compare
  
  End Function

End Class
The Compare method required by the IComparer interface is passed two Customer objects and must return -1 if the first object being passed should appear first, 1 if the second object should appear first, and 0 if you don't care what order the objects appear in. To support sorting Customer objects by first and last name, I should compare the LastName properties of the two objects, and when I have two Customer objects with the same LastName, compare their FirstName properties. The code in Listing 1 will do the trick (and could be simplified further, but I wanted to make the first version as obvious as possible).

Listing 1. Obvious code for sorting on FirstName and LastName.
Public Function Compare(x As Customer, y As Customer) As Integer _
  Implements IComparer(Of Customer).Compare
  If x.LastName < y.LastName Then
    Return -1
  ElseIf x.LastName > y.LastName Then
    Return 1
  ElseIf x.FirstName < y.FirstName Then
    Return -1
  ElseIf x.FirstName > y.FirstName Then
    Return 1
  Else
    Return 0
  End If
End Function

To use this new sort, when it comes time to call the Sort method, you would pass an instance of my SortByName class:

custList.Sort(New SortByName)

Beyond Predefined Sorts
There are additional benefits to using the IComparable interface. For instance, the List collection also has several methods for locating objects within a List, one of which (BinarySearch) requires the objects in the collection to have implemented the IComparable interface. Because I've implemented the IComparable interface with a method that tests the ID property, a developer using my Customer object could find the closest matching Customer by ID by passing a Customer object with an ID property holding the value to search for (provided that the List has been sorted):

custList.Sort()
Dim pos As Integer = 
  custList.BinarySearch(New Customer With {.ID = "A123"})

I can also use my IComparer object with the BinarySearch method if I want to find objects that match on the properties I used in my IComparer. This example finds Customers with a specific FirstName and LastName (again, provided the List has been sorted using those properties):

custList.Sort(New SortByName)
Dim pos As Integer = custList.BinarySearch(
  New Customer With {.FirstName = "Peter", 
                     .LastName = "Vogel"}, 
  New SortByName)

The Sort and BinarySearch methods are defined in the List class itself. However, they could just as easily be extension methods that attach themselves to any class that implements IComparable (I discussed extension methods in last month's column).

And that brings me to my point: Using interfaces and extension methods provides a process for creating classes that can be extended in a maintainable way. First, you define an interface that's easy for developers to implement; second, you add functionality by defining extension methods that work with that interface.

For instance, this company embeds quite a lot of information in its CustomerID: as an example, customers with an ID that begins with a letter are premium customers, while the second digit in the Customer ID specifies what division supplies support for the customer ("1" for the Eastern division, "2" for the Western division and so on). Support for decoding information from the Customer ID would be easy to implement in the Customer object through methods called IsPremium, GetDivision and so on. However, the CustomerID appears in many different objects: the SalesOrder object also has a CustomerID property, as does the CustomerInvoice object. It would be useful to provide those methods on all of those objects. The process I'm recommending here would do that.

I'll begin that process by defining an interface that's easy to implement because it contains just a single property:

Public Interface ICustomerID

  ReadOnly Property CustID As String

End Interface

A developer that implements this interface only has to return the Customer ID from this property:

Public Class SalesOrder
  Implements ICustomerID

  Public ReadOnly Property CustID As String _
    Implements ICustomerID.ID
    Get
      Return Me.ID
    End Get
  End Property

Now you can incrementally add functionality to any object that implements the interface by writing extension methods that work with any class that implements the ICustomerID interface. The following code implements the IsPremium method that checks to see if the first character of the ID is a letter:

Public Module ICustIDExtensions
  <Extension>
  Public Function IsPremium(Cust As ICustomerID) As Boolean
    Return Char.IsLetter(Cust.CustID.Substring(0, 1))
  End Function
  
  '...more methods

End Module
From the developer's point of view, adding the ICustomerID interface to their class (and implementing its one member) also adds all of the related extension methods to their class. And when a new requirement appears, you just add another extension method -- which also adds that new method to every class that implements the interface. This process also allows you to limit the potential damage of any change, because extension methods can only modify the external properties of a particular interface.

It would probably be a good idea to establish some conventions around using this process. Keeping all the extension methods in the same file (or at least in the same project) with their related interface would be a good idea. This would also simplify using the extension methods, because adding the reference that supports the interface would also add the extension method. Keeping the interface and extension methods in the same namespace -- which is easier if they're in the same file or project -- would also make life easier for developers using your interface.

One last note: The only reason that this column got written was because I was lucky enough to sit in on a class on design patterns with Greg Adams. Thanks, Greg!

About the Author

Peter Vogel is a system architect and principal in PH&V Information Services. PH&V provides full-stack consulting from UX design through object modeling to database design. Peter tweets about his VSM columns with the hashtag #vogelarticles. His blog posts on user experience design can be found at http://blog.learningtree.com/tag/ui/.

comments powered by Disqus

Featured

  • Compare New GitHub Copilot Free Plan for Visual Studio/VS Code to Paid Plans

    The free plan restricts the number of completions, chat requests and access to AI models, being suitable for occasional users and small projects.

  • Diving Deep into .NET MAUI

    Ever since someone figured out that fiddling bits results in source code, developers have sought one codebase for all types of apps on all platforms, with Microsoft's latest attempt to further that effort being .NET MAUI.

  • Copilot AI Boosts Abound in New VS Code v1.96

    Microsoft improved on its new "Copilot Edit" functionality in the latest release of Visual Studio Code, v1.96, its open-source based code editor that has become the most popular in the world according to many surveys.

  • AdaBoost Regression Using C#

    Dr. James McCaffrey from Microsoft Research presents a complete end-to-end demonstration of the AdaBoost.R2 algorithm for regression problems (where the goal is to predict a single numeric value). The implementation follows the original source research paper closely, so you can use it as a guide for customization for specific scenarios.

  • Versioning and Documenting ASP.NET Core Services

    Building an API with ASP.NET Core is only half the job. If your API is going to live more than one release cycle, you're going to need to version it. If you have other people building clients for it, you're going to need to document it.

Subscribe on YouTube