Programmiererpraxis

Spaß mit C#, Teil 2

Ted Neward

Ted NewardWillkommen zurück. In meiner letzten Kolumne "Spaß mit C#" (msdn.microsoft.com/magazine/dn754595) bin ich kurz darauf eingegangen, wie sich ein scheinbar unlösbares Entwicklungsproblem durch die Vertrautheit mit anderen Programmiersprachen in den Griff bekommen lässt. Als Beispiel habe ich ein Problem aufgegriffen, das mir vor Jahren in meiner Consulting-Berufspraxis begegnet ist. Der Auftrag war, eine lokal gespeicherte Transaktionsliste mit einer – vermeintlich gleichen – remote gespeicherten Transaktionsliste abzugleichen. Die einzig zuverlässigen Daten, die ich vergleichen konnte, waren die Transaktionsbeträge. Ich musste also entweder einen identischen Betrag finden oder nicht zugeordnete Beträge in der Ergebnisliste aufführen.

So machte ich mich mit F# an die Arbeit, einer mir vertrauten Programmiersprache. Genauso hätten es aber auch Scala, Clojure oder Haskell sein können, jede funktionale Sprache hätte diese Aufgabe gleich gut gemeistert. Es geht also nicht um die Programmiersprache oder die zugrunde liegende Plattform, sondern vielmehr um das Konzept einer funktionalen Sprache. Ich hatte es also mit einem ziemlich funktionslastigen Problem zu tun.

Die F#-Lösung

Schauen wir uns zur Erinnerung die F#-Lösung in Abbildung 1 an, bevor wir das Konzept auf C# übertragen.

Wenn Sie nicht mit F# vertraut sind, lesen Sie meine frühere Kolumne mit einer Kurzzusammenfassung der in Abbildung 1 verwendeten F#-Syntax. Bei der Übertragung auf C# werde ich mich ebenfalls darauf beziehen, sodass Sie sich jetzt schon in das Beispiel vertiefen können.

Abbildung 1 – die F# -Lösung zur Abstimmung von Transaktionen

type Transaction =
  {
    amount : float32;
    date : DateTime;
    comment : string
  }
type Register =
  | RegEntry of Transaction * Transaction
  | MissingRemote of Transaction
  | MissingLocal of Transaction
let reconcile (local : Transaction list) 
  (remote : Transaction list) : Register list =
  let rec reconcileInternal outputSoFar local remote =
    match (local, remote) with
    | [], _
    | _, [] -> outputSoFar
    | loc :: locTail, rem :: remTail ->
      match (loc.amount, rem.amount) with
      | (locAmt, remAmt) when locAmt = remAmt ->
        reconcileInternal (RegEntry(loc, rem) :: 
          outputSoFar) locTail remTail
      | (locAmt, remAmt) when locAmt < remAmt ->
        reconcileInternal (MissingRemote(loc) :: 
          outputSoFar) locTail remote
      | (locAmt, remAmt) when locAmt > remAmt ->
        reconcileInternal (MissingLocal(rem) :: 
          outputSoFar) local remTail
      | _ ->
        failwith "How is this possible?"
  reconcileInternal [] local remote

Die C#-Lösung

Zunächst benötigen wir die Typen "Transaction" und "Register". Der Transaction-Typ ist leicht erklärt. Es handelt sich um einen einfachen Strukturtyp mit drei benannten Elementen, die die Modellierung als C#-Klasse vereinfachen:

class Transaction
{
  public float Amount { get; set; }
  public DateTime Date { get; set; }
  public String Comment { get; set; }
}

Diese automatischen Eigenschaften sorgen dafür, dass die Klasse fast genauso kurz wie die verwandte F#-Klasse ist. Ginge es mir hier um eine 1:1-Übertragung der F#-Version, so sollte ich an dieser Stelle wohl die überschriebenen Methoden "Equals" und "GetHashCode" erwähnen. Für unsere Zwecke geht es aber auch ohne.

Kompliziert wird es, wenn der Discriminated Union-Typ "Register" (unterscheidbare Vereinigung) ins Spiel kommt. Analog zu einer Aufzählung in C# kann eine Instanz eines Register-Typs nur einen von drei möglichen Werten aufweisen ("RegEntry", "MissingLocal" oder "Missing­Remote"). Anders als bei einer C#-Enumeration kann jeder dieser Werte wiederum Daten enthalten (entweder die beiden Transaktionen, die eine Übereinstimmung für "RegEntry" ergaben, oder die fehlende Transaktion für "MissingLocal" bzw. "Missing­Remote"). Auch wenn die Erstellung drei abgegrenzter Klassen in C# keine Schwierigkeiten bereitet, müssen sie dennoch in Beziehung zueinander stehen. Wir brauchen also eine Liste, die aus diesen drei Werten einen beliebigen zurückgeben kann, wie aus Abbildung 2 deutlich wird. Vererbung lässt grüßen!

Abbildung 2 – Verwenden der Vererbung zum Einschließen drei verschiedener Klassen

class Register { }
  class RegEntry : Register
  {
    public Transaction Local { get; set; }
    public Transaction Remote { get; set; }
  }
  class MissingLocal : Register
  {
    public Transaction Transaction { get; set; }
  }
  class MissingRemote : Register
  {
    public Transaction Transaction { get; set; }
  }

Unser Beispiel ist nicht etwa hoffungslos überfrachtet, sondern nur etwas ausführlicher. Hätten wir es hier mit einem produktionstauglichen Code zu tun, müsste ich der Vollständigkeit halber Methoden wie "Equals", "GetHashCode" und vor allem "ToString" anführen. Auch wenn es Möglichkeiten gibt, C# etwas eleganter zu schreiben, werde ich mich mit der Reconcile-Methode nahe an der F#-Referenzmethode halten. Später werden wir uns dann um die Optimierung kümmern.

Die F#-Version verfügt über eine "äußere", öffentlich zugängliche Funktion, die rekursiv eine innere, gekapselte Funktion aufruft. C# hat jedoch kein Äquivalent für geschachtelte Methoden. Annäherungsweise lässt sich dies durch zwei Methoden darstellen – eine deklarierte öffentliche und eine private Methode. Trotzdem ist es nicht das Gleiche. In der F#-Version wird die geschachtelte Funktion von allen Programmierelementen gekapselt, auch von anderen Funktionen im selben Modul. Wie aus Abbildung 3 ersichtlich, ist dies jedoch die bestmögliche Lösung.

Abbildung 3 – Kapselung einer geschachtelten Funktion

class Program
{
  static List<Register> ReconcileInternal(List<Register> Output,
             List<Transaction> local,
             List<Transaction> remote)
  {
    // . . .
  }
  static List<Register> Reconcile(List<Transaction> local,
             List<Transaction> remote)
  {
    return ReconcileInternal(new List<Register>(), local, remote);
  }
}

Nebenbei bemerkt, kann ich meinen rekursiven Ansatz "versteckt vor allen" jetzt zu Ende bringen, indem ich die interne Funktion vollständig als Lambda-Ausdruck schreibe, der von "Reconcile" aus als lokale Variable referenziert wird. Andererseits halten wir uns dadurch sklavisch an das Original und rücken ganz von idiomatischem C#-Code ab.

Auch wenn die meisten C#-Entwickler so nicht vorgehen würden, erzielen wir doch eine ziemliche Übereinstimmung mit der F#-Version. In "Reconcile­Internal" muss ich die verwendeten Datenelemente explizit extrahieren. Anschließend schreibe ich sie – anders als beim kurzen und prägnanten F#-Vorbild – explizit in eine "If/Else-If"-Struktur. So erhalten wir einen funktionsgleichen Code. Erst wenn die Liste der lokalen oder Remotetransaktionen leer ist, hat die Rekursion ihren Auftrag erfüllt. Jetzt brauche ich nur noch das Ergebnis auszugeben und kann mich entspannt zurücklehnen. Etwa so:

static List<Register> ReconcileInternal(List<Register> Output,
              List<Transaction> local,
              List<Transaction> remote)
{
  if (local.Count == 0)
    return Output;
  if (remote.Count == 0)
    return Output;

Anschließend muss ich noch den Anfang jeder Liste extrahieren, wobei ich jedoch einen Verweis auf den Rest der Liste (den "Tail") erhalten muss:

Transaction loc = local.First();
List<Transaction> locTail = local.GetRange(1, local.Count - 1);
Transaction rem = remote.First();
List<Transaction> remTail = remote.GetRange(1, remote.Count - 1);

Wenn ich an dieser Stelle nicht aufpasse, kann ich leicht in eine Leistungsfalle tappen. Listen in F# sind unveränderlich. Mit dem "Tail" verweise ich also auf das zweite Element in der Liste. Es werden keine Kopien erstellt.

C# bietet diese Sicherheit nicht. Das könnte dazu führen, dass ich jedes Mal eine vollständige Kopie der Liste erzeuge. Die GetRange-Methode erstellt "flache Kopien", d. h. eine neue Liste mit den Verweisen auf die ursprünglichen Transaction-Elemente. Mehr kann ich wahrscheinlich nicht tun, wenn mein Code nicht zu kompliziert werden soll. Wenn der Code allerdings zu einem Engpass wird, sollten Sie so erfindungsreich werden wie nötig.

Aber nun zurück zur F#-Version. Was ich im zweiten Musterabgleich wirklich untersuche, sind die Beträge der lokalen und Remotetransaktionen (siehe Abbildung 4). Auch diese Werte werde ich extrahieren und vergleichen.

Abbildung 4 – Untersuchung der Beträge von lokalen und Remotetransaktionen mit der F#-Version

 

float locAmt = loc.Amount;
  float remAmt = rem.Amount;
  if (locAmt == remAmt)
  {
    Output.Add(new RegEntry() { Local = loc, Remote = rem });
    return ReconcileInternal(Output, locTail, remTail);
  }
  else if (locAmt < remAmt)
  {
    Output.Add(new MissingRemote() { Transaction = loc });
    return ReconcileInternal(Output, locTail, remote);
  }
  else if (locAmt > remAmt)
  {
    Output.Add(new MissingLocal() { Transaction = rem });
    return ReconcileInternal(Output, local, remTail);
  }
  else
    throw new Exception("How is this possible?");
}

Jede Verzweigung der Struktur erklärt sich an dieser Stelle von selbst. Ich füge der Ausgabeliste das neue Element hinzu und verarbeite die nicht verarbeiteten Elemente der lokalen und Remoteliste rekursiv.

Zusammenfassung

Warum setzen wir uns überhaupt mit F# auseinander, wenn die C#-Lösung so elegant ist? Um das zu verstehen, muss man diesen Prozess selbst durchlaufen. Im Wesentlichen war der Zwischenstopp bei F# nötig, um den Algorithmus richtig kennenzulernen. Meine ersten Schritte auf diesem Gebiet waren ein ziemlicher Reinfall. Zunächst hatte ich doppelte "Foreach"-Schleifen verwendet, um die beiden Listen iterativ zu durchsuchen. Mein Plan war, den Status mitzuverfolgen, was mir schließlich einen Datenschlamassel bescherte, den ich in Millionen von Jahren nicht wieder in den Griff bekommen hätte.

"Think different" ist unser Ziel und nicht die Wahl der Programmiersprache (man möge mir die Anspielung auf den Marketing-Slogan eines uns allen bekannten Computerherstellers verzeihen). Zum selben Schluss wäre ich auch mit den Programmiersprachen Scala, Haskell oder Clojure gekommen. Es ging nicht um den Funktionsumfang, sondern um das Konzept der meisten funktionalen Sprachen – insbesondere die Rekursion. Damit lassen sich mentale Schranken überwinden.

Das ist auch ein Grund, warum Entwickler jedes Jahr eine neue Programmiersprache erlernen sollten, so wie es der pragmatische Programmierer Dave Thomas, ein exzellenter Kenner der Programmiersprache Ruby, als erster gefordert hat. Denken Sie nicht in Schubladen, sondern lassen Sie sich von neuen Ideen und Möglichkeiten inspirieren. Diese Erfahrung machen auch Programmierer, die sich mit Scheme, Lisp, einer stapelbasierten Programmiersprache wie Forth oder einer prototypbasierten Programmiersprache wie Io beschäftigen.

Für eine straffe Einführung in eine Reihe verschiedener Programmiersprachen, die alle auf der Microsoft .NET Framework-Plattform aufsetzen, kann ich "Seven Languages in Seven Weeks" (Pragmatic Bookshelf, 2010) von Bruce Tate wärmstens empfehlen. Bei einigen kämen Sie gar nicht auf die Idee, sie auf der .NET-Plattform einzusetzen. Manchmal liegt die Lösung eben darin, wie wir über ein Problem nachdenken und die Lösung konzeptionieren. Wiederverwendbarer Code ist nicht immer der Weißheit letzter Schluss. Viel Spaß beim Programmieren!


Ted Neward ist CTO bei iTrellis, einer Beratungsfirma. Er hat mehr als 100 Artikel geschrieben und als Autor und Mitautor ein Dutzend Bücher verfasst, darunter „Professional F# 2.0“ (Wrox 2010). Er ist ein C#-MVP und spricht auf Konferenzen in der ganzen Welt. Er berät und hilft regelmäßig – Sie können ihn unter ted@tedneward.com oder ted@itrellis.com erreichen, wenn Sie daran interessiert sind, dass er mit Ihrem Team zusammenarbeitet, oder Sie können seinen Blog unter blogs.tedneward.com lesen.

Unser Dank gilt dem folgenden technischen Experten von Microsoft für die Durchsicht dieses Artikels: Lincoln Atkinson