Ein generischer »Comparer« mit Hilfe von Lambda-Ausdrücken

Von Matthias Loerke

FASTER Software – 18. Januar 2011
Typsicherheit und die Notwendigkeit des Vergleiches

Hamburg/Neu Wulmstorf  – Basierend auf verschiedenen Ideen und inspiriert von LINQ und Lambda Ausdrücken haben wir kürzlich einen generischen »EqualityComparer« erstellt. Auch wenn wir nicht empfehlen würden, diesen zu häufig in kritischem Code zu verwenden, so kann er doch in Situationen hilfreich sein, in denen Flexibilität wichtiger ist als Lesbarkeit (z.B. Test-Applikationen, T4-Skripte oder Entwicklungswerkzeuge).

Wenn man ein komplexes Projekt erstellt, so hat man oftmals eine Reihe verschiedener eigener Klassen, um mit den Anwendungsdaten zu arbeiten. Normalerweise wird ein Teil der Sortierungs- und Filtervorgänge in der Applikation selbst durchgeführt, auch wenn man ein externes System (wie z.B. ein SQL-Server) zur Datenspeicherung und -abfrage verwendet. Typische Probleme bei der Behandlung von Listen mit Datenobjekten sind Typsicherheit und objektspezifische Vergleiche. In diesem Artikel werde ich die Probleme der Vergleiche und Listenoperationen auf die Gleichheitsprüfung reduzieren. Diese ist ein relativ häufiges Problem und bildet zudem die Basis für viele andere Operationen. Andere Vergleichsarten können leicht mit Hilfe des Beispiels abgeleitet werden.

Das .NET-Framework wurde von jeher erweitert, um die Entwickler bei einfachen Aufgaben besser zu unterstützen. Während zur Zeit von .NET 1.X die meisten Collection-Klassen nicht einmal typsicher waren (der Typ der Listenelemente war »object«), wurden mit .NET 2.0 generische Datentypen, die Generics eingeführt. Dies erlaubte typsichere Varianten der Standard-Collections (z.B. »List<T>«, »Dictionary<T>«) zu verwenden, ohne neue Klassen zu schreiben und sie boten sogar einige Methode für Filter- und Sortieroperationen. Ein Schwachpunkt blieb jedoch der Vergleich eigener Klassen. Während das Framework für die meisten Standardtypen »Comparer« bereits mitlieferte gab es kaum Unterstützung für eigene Klassen. Wie auch, woher sollte Microsoft denn auch wissen welche Eigenschaften Ihre eigene Klassen hat und welche davon in welcher Weise verglichen werden müssen um eine Gleichheitsprüfung durchzuführen? Ja, es gibt »Predicate«-Klassen und man kann auch einen »Comparer« für jeden beliebigen Typ registrieren. Allerdings funktioniert keine dieser Lösungen ohne eine klassenspezifische Lösung (d.h. zusätzlicher Code für jede eigene Klasse). Selbst mit der Verwendung von Generics muss eine gemeinsame Basisklasse oder ein gemeinsames Interface definiert werden, dass die zu vergleichenden Eigenschaften enthält. Es wird sogar noch »lustiger« wenn man verschiedene Vergleiche für die gleichen Klassen benötigt.

Mit der Version 3.5 wurde das Framework um LINQ erweitert, was Funktionalität zu vielen bestehenden Komponenten des Frameworks hinzufügte (LINQ-to-entity, LINQ-to SQL, etc), darunter auch die Collection-Klassen. Neben den komplexen LINQ-Komponenten führte Microsoft ebenfalls eine Reihe von »Extensions« (ebenfalls ein neues Konzept) im Namespace »System.Linq« ein welche Lambda-Ausdrücke verwenden. Soweit ich weiß, wurden Lambda-Ausdrücke bereits mit Visual Studio 2008 eingeführt, aber ich habe sie erst relativ spät für mich entdeckt. Mein erster wirklicher Kontakt fand erst mit der Verwendung der LINQ-Extensions statt. Wie können nun diese Ausdrücke bei unseren Listenproblemen helfen? Sie ersparen uns Assemblies voller Copy-Paste-Code, machen langweilige Standard-Probleme zu Einzeilern … und können einem das Leben zur Hölle machen. Zumindest der letzte Teil ist jedoch eine andere Geschichte …

Implementierung eines generischen »Comparers«

Um einen wiederverwendbaren »Comparer« zu erstellen, werden wir die Möglichkeiten der Generics mit der Flexibilität der Lambda-Ausdrücke verbinden. Die Basis ist eine generische Klasse mit dem Typparameter »T« der ein Platzhalter für den tatsächlich zu vergleichenden Typ ist. Das .NET-Framework enthält ein Interface »IEqualityComparer« dass die erforderlichen Klassen-Member definiert die eine »Comparer«-Implementierung benötigt. Neben der »klassischen« Version gibt es noch eine neuere, generische Version “IEqualityComparer<T>” die aufgrund der Typsicherheit vorzuziehen ist. Das Interface definiert zwei Methoden: »Equals« and »GetHashCode«. Die »Equals«-Methode benötigt keine weitere Erklärung and liefert einen boolschen Wert als Rückgabe bei Eingabe von zwei zu vergleichenden Instanzen. Die »GetHashCode«-Methode ist ein wenig abstrakter: Sie liefert einen Integer-Wert bei Eingabe einer einzelnen Instanz. Wie der Name bereits vermuten lässt, wird erwartet, dass sich die Implementierung wie eine Hash-Funktion verhält. D.h. sie gibt jeweils einen eindeutigen Wert für unterschiedliche Objekte (unterschiedlich im Sinne der Wertigkeit, nicht der Referenz) aber den gleichen Wert für »gleiche« Objekte. Die meisten der Standard-Collections verwenden die »GetHashCode«-Methode bei der Verwaltung ihrer Elemente.

Beide Methoden sind im Grunde einfache Funktionen, die sich in den meisten Fällen nur durch die zum Vergleich verwendete Objekt-Eigenschaft des spezifischen Typs unterscheiden. Um einen generischen »Comparer« zu erhalten, ist es daher lediglich notwendig die Funktionen generisch zu definieren. Eine einfache Möglichkeit um dies zu erreichen, ist die Verwendung von Delegates, die unter dem Namen »Func« ebenfalls als Generic verfügbar sind. Eine Funktion, die als »Equals«-Methode verwendet werden kann, lässt sich als »Func<T, T, bool>« definieren, was »bool F(T x, T y)« entspricht. Das Äquivalent für »GetHashCode« ist »Func<T, int>«, was »int F(T x)« entspricht. Im Prinzip kann jede Methode mit passender Signatur übergeben werden.

Als Letztes benötigen wir jetzt noch eine Funktion ohne sie statisch zu deklarieren. Eine Möglichkeit sind anonyme Funktionen, die andere sind Lamda-Ausdrücke! Während anonyme Funktionen immer noch eine vollständige Deklarierung und Implementierung benötigen, definieren Lambda-Ausdrücke Typen implizit durch ihre Verwendung und bieten zusätzlich eine kurze, praktische Syntax. Lassen Sie uns einmal eine Funktion anschauen welche die »Equals«-Implementierung für einen »Comparer« für Strings sein könnte:

Func<string, string, bool> f = (a, b) => a.Equals(b);

Die Typen für »a« und »b« werden beim Aufruf durch die verwendeten Variablen festgelegt und der Typ des Rückgabewertes wird durch den Rückgabewert der Transformation definiert.

Eine beispielhafte Implementierung

Die Implementierung eines generischen »Comparers« ist recht gradlinig. Die generische Klasse selbst muss das generische “IEqualityComparer<T>”-Interface implementieren und verwendet zwei anonyme Funktionen, die im Konstruktor übergeben werden. Die Funktionen sind ebefalls generisch und verwenden den Typ-Parameter der Klasse.

/// <summary>
/// Generic equality comparer that compares items based on provided functions
/// </summary>
/// <typeparam name="T">A class type</typeparam>
public class DynamicEqualityComparer<T> : IEqualityComparer<T>
{
    private Func<T, T, bool> _equalsFunction;
    private Func<T, int> _hashFunction;

    #region Constructor

    /// <summary>
    /// Creates a new instance of the class
    /// </summary>
    /// <param name="equalsFunction">A compare function <see cref="IEqualityComparer{T}.Equals"/></param>
    /// <param name="hashFunction">A hash function <see cref="IEqualityComparer{T}.GetHashCode"/></param>
    public DynamicEqualityComparer(Func<T, T, bool> equalsFunction,
                                   Func<T, int> hashFunction)
    {
        // Store functions
        this._equalsFunction = equalsFunction;
        this._hashFunction = hashFunction;
    }

    #endregion

    #region Public

    /// <summary>
    /// <see cref="IEqualityComparer{T}.Equals"/>
    /// </summary>
    /// <param name="x"><see cref="IEqualityComparer{T}.Equals"/></param>
    /// <param name="y"><see cref="IEqualityComparer{T}.Equals"/></param>
    /// <returns><see cref="IEqualityComparer{T}.Equals"/></returns>
    public bool Equals(T x, T y)
    {
        return this._equalsFunction(x, y);
    }

    /// <summary>
    /// <see cref="IEqualityComparer{T}.GetHashCode"/>
    /// </summary>
    /// <param name="obj"><see cref="IEqualityComparer{T}.GetHashCode"/></param>
    /// <returns><see cref="IEqualityComparer{T}.GetHashCode"/></returns>
    public int GetHashCode(T obj)
    {
        return this._hashFunction(obj);
    }

    #endregion
}

Das Instanzieren des »DynamicEqualityComparers« um Strings zu vergleichen würde z.B. so aussehen:

DynamicEqualityComparer<string> comparer
 = new DynamicEqualityComparer<string>((a, b) => a.Equals(b),
                                       a => a.GetHashCode());

Von dieser Beispielimplementierung gibt es sicherlich eine Menge Möglichkeiten für Variationen.

Nachteile

Wie bereits erwähnt, würde ich nicht empfehlen dieses Konstrukt übermäßig in kritischem Code zu verwenden. Der Grund hierfür ist, dass selbst Funktionen mit geringer Komplexität sehr schnell unleserlich und übersichtlich werden. Dadurch steigt die Anfälligkeit für Fehler. Da man sogar zwei Funktionen im Konstruktor übergeben muss, ist das Ergebnis zwar cool, State of the art, aber sicher kein Muster an Wartbarkeit.