Bewährte Methoden für C#

Gefahren des Verstoßes gegen SOLID-Prinzipien in C#

Brannon King

Während sich der Prozess des Schreibens von Software aus einem theoretischen Fachgebiet in eine echte technische Disziplin umwandelte, hat sich eine Reihe von Prinzipien entwickelt. Und wenn ich Prinzipien sage, meine ich die Merkmale des Computercodes, die helfen, den Wert eines Codes zu erhalten. Muster beziehen sich auf ein allgemein verwendetes Codeszenario, ob dies nun gut oder schlecht ist.

Sie schätzen möglicherweise Computercode, der in einer Multithread-Umgebung zuverlässig arbeitet. Oder Sie schätzen Computercode, der nicht abstürzt, wenn Sie den Code an einem anderen Ort ändern. Zweifelsohne schätzen Sie alle hilfreichen Eigenschaften eines Computercodes, treffen aber im Alltag immer wieder auf das Gegenteil.

Es gab einige fantastische Softwareentwicklungs-Prinzipien, die unter der Abkürzung SOLID zusammengefasst wurden – das Prinzip der einzigen Verantwortung, offen für Erweiterungen, aber geschlossen für Änderungen, Liskovsche Substitution, Schnittstellentrennung und Abhängigkeitsinjektion. Sie sollten mit diesen Prinzipien vertraut sein, da ich eine Auswahl von C#-spezifischen Mustern vorführen werde, die gegen diese Prinzipien verstoßen. Falls Sie nicht mit den SOLID-Prinzipien vertraut sind, sollten Sie sich darüber informieren, bevor Sie fortfahren. Außerdem setze ich voraus, dass die Architekturbegriffe Modell und ViewModel bekannt sind.

Die Abkürzung SOLID und die darin enthaltenen Prinzipien stammen nicht von mir. Besten Dank unter anderem an Robert C. Martin, Michael Feathers, Bertrand Meyer und James Coplie, dass ihr euer Wissen mit uns geteilt habt. Diese Prinzipien wurden in vielen anderen Büchern und Blogs erläutert und verfeinert. Ich hoffe, dass ich die Anwendung dieser Prinzipien vertiefen kann.

Durch die Arbeit mit und Schulungen für Softwareentwickler-Einsteiger habe ich festgestellt, dass es eine große Lücke zwischen den ersten Codierungsbemühungen und nachhaltigem Code gibt. In diesem Artikel werde ich versuchen, diese Lücke leichtfüßig zu überwinden. Die Beispiele sind etwas albern, Sie sollen aber damit erkennen, dass Sie die SOLID-Prinzipien auf alle Arten von Software anwenden können.

Eine professionelle Entwicklungsumgebung birgt viele Herausforderungen für angehende Softwareentwickler. In der Ausbildung wurde die Problemlösung aus einer Top-Down-Perspektive gelehrt. Wenn Sie diese Top-Down-Vorgehensweise auf Ihre ersten Aufträge in der Welt der Software in Unternehmensgröße anwenden, finden Sie bald heraus, dass Ihre Funktion auf der obersten Ebene auf eine kaum zu bewältigende Größe angewachsen ist. Schon die kleinsten Änderungen erfordern umfassendes Arbeitswissen über das gesamte System, was schwierig unter Kontrolle zu halten ist. Leitlinien zu Softwareprinzipien (von denen hier nur einige erwähnt sind) helfen dabei, die Struktur im Zaum zu halten.

Das Prinzip der einzigen Verantwortung

Das Prinzip der einzigen Verantwortung wird häufig wie folgt definiert: Ein Objekt sollte nur einen Grund zur Änderung haben; je länger die Datei oder die Klasse, desto schwieriger ist es, dies zu erreichen. Sehen Sie sich vor diesem Hintergrund den folgenden Code an:

public IList<IList<Nerd>> ComputeNerdClusters(
  List<Nerd> nerds,
  IPlotter plotter = null) {
  ...
  foreach (var nerd in nerds) {
    ...
    if (plotter != null)
      plotter.Draw(nerd.Location, 
      Brushes.PeachPuff, radius: 10);
    ...
  }
  ...
}

Was ist falsch an diesem Code? Wird die Software geschrieben oder dient sie zur Fehlerbehebung? Es kann sein, dass dieser spezielle Zeichnungscode nur zur Fehlerbehebung dient. Es ist schön, dass dieser Service nur der Schnittstelle bekannt ist, aber das gehört hier nicht her. „Brush“ ist ein guter Hinweis. So schön und verbreitet PeachPuff auch ist, es ist plattformspezifisch. Dies liegt außerhalb der Hierarchie dieses Rechenmodells. Es gibt viele Möglichkeiten, die eigentliche Berechnung und die zugehörigen Debugging-Dienstprogramme zu trennen. Zumindest können Sie die erforderlichen Daten durch Vererbung oder Ereignisse zur Verfügung stellen. Lassen Sie die Tests und die Testansichten getrennt.

Hier ist ein weiteres zweifelhaftes Beispiel:

class Nerd {
  public int IQ { get; protected set; }
  public double SuspenderTension { get; set; }
  public double Radius { get; protected set; }
  /// <summary>Get books for growing IQ</summary>
  public event Func<Nerd, IBook> InTheMoodForBook;
  /// <summary>Get recommendations for growing Radius</summary>
  public event Func<Nerd, ISweet> InTheMoodForTwink;
  public IList<Nerd> FitNerdsIntoPaddedRoom(
    IList<Nerd> nerds, IList<Point> boundary)
  {
    ...
  }
}

Was ist falsch an diesem Code? Er wirft quasi die „Schulfächer“ durcheinander. Erinnern Sie sich noch, wie Sie in verschiedenen Fächern in der Schule unterschiedliche Themen gelernt haben? Es ist wichtig, diese Trennung auch im Code beizubehalten – nicht, weil sie in keinem Zusammenhang stehen, sondern aufgrund des Organisationsaufwands. Im Allgemeinen sollten Sie folgende Objekte nicht in dieselbe Klasse stecken: Mathematik, Modelle, Grammatik, Ansichten, physische oder Plattformadapter, kundenspezifischer Code usw.

Sie können eine allgemeine Analogie zu Dingen feststellen, die Sie in der Schule als Skulpturen aus Holz oder Metall bauen. Diese benötigen Maße, eine Analyse, Anleitungen usw. Im oben angegebenen Beispiel werden Mathematik und Modell gemischt – FitNerdsIntoPaddedRoom gehört hier nicht her. Diese Methode kann ganz einfach in eine Dienstprogrammklasse verschoben werden, selbst eine statische. Sie sollten keine Modelle in Ihren Mathe-Testroutinen instanziieren.

Dies ist ein weiteres Beispiel mit mehreren Verantwortlichkeiten:

class AvatarBotPath
{
  public IReadOnlyList<ISegment> Segments { get; private set; }
  public double TargetVelocity { get; set; }
  public bool IsReverse { get { return TargetVelocity < 0; } }
  ...
}
public interface ISegment // Elsewhere
{
  Point Start { get; }
  Point End { get; }
  ...
}

Was ist hier falsch? Offensichtlich werden zwei verschiedenen Abstraktionen von einem einzigen Objekt dargestellt. Eine von ihnen bezieht sich auf die Übertragung einer Form, die andere stellt die eigentliche geometrische Form dar. Dies kommt häufig in Codes vor. Sie haben eine Darstellung und separate nutzungsspezifische Parameter, die zu dieser Darstellung gehören.

Hier kommt Ihnen die Vererbung gelegen. Sie können die Eigenschaften TargetVelocity und IsReverse an einen Erben übergeben und sie in einer präzisen IHasTravelInfo-Schnittstelle erfassen. Alternativ können Sie eine allgemeine Sammlung von Features an die Form übergeben. Wer die Geschwindigkeit benötigt, fragt die Feature-Sammlung ab, ob diese für eine bestimmte Form definiert ist. Außerdem könnten Sie auch eine andere Sammlungsmethode verwenden, um Darstellungen mit Reiseparametern zu koppeln.

Das Offen-Geschlossen-Prinzip.

Damit kommen wir zum nächsten Prinzip: offen für Erweiterungen, geschlossen für Änderungen. Wie funktioniert das? Vorzugsweise nicht so:

void DrawNerd(Nerd nerd) {
  if (nerd.IsSelected)
    DrawEllipseAroundNerd(nerd.Position, nerd.Radius);
  if (nerd.Image != null)
    DrawImageOfNerd(nerd.Image, nerd.Position, nerd.Heading);
  if (nerd is IHasBelt) // a rare occurrence
    DrawBelt(((IHasBelt)nerd).Belt);
  // Etc.
}

Was ist hier falsch? Nun, Sie müssen diese Methode jedes Mal ändern, wenn der Kunde neue Dinge anzeigen möchte – und er möchte ständig neue Dinge anzeigen! Fast jedes neue Software-Feature erfordert ein Benutzeroberflächenelement. Schließlich wurde die neue Feature-Anforderung dadurch ausgelöst, dass in der vorhandenen Oberfläche etwas fehlt. Das in dieser Methode angezeigte Muster ist ein guter Hinweis, aber Sie können die If-Anweisungen in die von ihnen bewachten Methoden verschieben, ohne dass das Problem gelöst wird.

Sie brauchen einen besseren Plan, aber welchen? Wie soll er aussehen? Nun, Sie haben einen Code, der weiß, wie er bestimmte Dinge zeichnen soll. Das ist in Ordnung. Sie brauchen nur noch ein allgemeines Verfahren, um diese Dinge mit dem Code zum Zeichnen zu koppeln. Im Grunde entsteht dabei ein Muster wie das Folgende:

readonly IList<IRenderer> _renderers = new List<IRenderer>();
void Draw(Nerd nerd)
{
  foreach (var renderer in _renderers)
    renderer.DrawIfPossible(_context, nerd);
}

Es gibt weitere Möglichkeiten, etwas zur Liste der Renderer hinzuzufügen. Der Zweck des Codes ist es jedoch, Zeichnungsklassen (oder Klassen über Zeichnungsklassen) zu schreiben, die eine bekannte Schnittstelle implementieren. Der Renderer muss die Intelligenz besitzen, um zu bestimmen, ob er etwas basierend auf der Eingabe zeichnen kann oder sollte. Beispielsweise kann der Zeichnungscode „Belt“ zu seinem eigenen „Belt-Renderer“ verschoben werden, der die Schnittstelle überprüft und (falls erforderlich) fortfährt.

Sie müssen möglicherweise die Methoden „CanDraw“ und „Draw“ trennen, aber dies verstößt nicht gegen das Offen-Geschlossen-Prinzip. Der Code, der die Renderer verwendet, sollte nicht geändert werden müssen, sobald Sie einen neuen Renderer hinzufügen. Im Grunde genommen also ganz einfach. Sie sollte außerdem in der Lage sein, den neuen Renderer in der richtigen Reihenfolge hinzuzufügen. Auch wenn ich Rendering als Beispiel verwende, gilt dies auch für die Verarbeitung von Eingaben und Daten sowie das Speichern von Daten. Dieses Prinzip hat in allen Arten von Software viele Anwendungsbereiche. Das Muster lässt sich in Windows Presentation Foundation (WPF) schwieriger emulieren, aber es ist möglich. In Abbildung 1 sehen Sie eine mögliche Option.

Abbildung 1: Beispiel für das Zusammenführen von Windows Presentation Foundation-Renderern in einer einzigen Quelle

public abstract class RenderDefinition : ViewModelBase
{
  public abstract DataTemplate Template { get; }
  public abstract Style TemplateStyle { get; }
  public abstract bool SourceContains(object o); // For selectors
  public abstract IEnumerable Source { get; }
}
public void LoadItemsControlFromRenderers(
    ItemsControl control,
    IEnumerable<RenderDefinition> defs) {
  control.ItemTemplateSelector = new DefTemplateSelector(defs);
  control.ItemContainerStyleSelector = new DefStyleSelector(defs);
  var compositeCollection = new CompositeCollection();
  foreach (var renderDefinition in defs)
  {
    var container = new CollectionContainer
    {
      Collection = renderDefinition.Source
    };
    compositeCollection.Add(container);
  }
  control.ItemsSource = compositeCollection;
}

Dies ist ein weiteres fehlerhaftes Beispiel:

class Nerd
{
  public void WriteName(string name)
  {
    var pocketProtector = new PocketProtector();
    WriteNameOnPaper(pocketProtector.Pen, name);
  }
  private void WriteNameOnPaper(Pen pen, string text)
  {
    ...
  }
}

Was ist hier falsch? Die Probleme dieses Codes sind enorm und vielschichtig. Das Hauptproblem, das ich herausgreifen möchte, ist, dass es keine Möglichkeit gibt, das Erstellen der PocketProtector-Instanz zu überschreiben. Codes wie dieser machen es schwierig, erbende Klassen zu schreiben. Sie haben einige Möglichkeiten zur Vermeidung dieses Problems. Sie können den Code wie folgt ändern:

  • Machen Sie die WriteName-Methode virtuell. Dazu ist außerdem der Schutz von WriteNameOnPaper erforderlich, um das Ziel der Instanziierung eines geänderten PocketProtector zu erreichen.
  • Machen Sie die WriteNameOnPaper-Methode öffentlich, dabei bleibt jedoch die beschädigte WriteName-Methode für die erbenden Klassen bestehen. Dies ist keine gute Option, es sei denn, Sie entfernen WriteName. In diesem Fall übergibt die Option eine Instanz von PocketProtector an die Methode.
  • Fügen Sie eine zusätzliche geschützte virtuelle Methode hinzu, deren einziger Zweck im Erstellen von PocketProtector besteht.
  • Weisen Sie der Klasse den allgemeinen Typ T zu, der ein Typ von PocketProtector ist, und erstellen Sie sie mit einer Objekt-Factory. Dann müssen Sie wiederum die Objekt-Factory ergänzen.
  • Übergeben Sie im Konstruktor oder über eine öffentliche Eigenschaft eine Instanz von PocketProtector an diese Klasse, statt sie innerhalb der Klasse zu erstellen.

Die letzte aufgelistete Option ist in der Regel der beste Plan, vorausgesetzt, dass Sie PocketProtector wiederverwenden möchten. Die virtuelle Erstellungsmethode ist ebenfalls eine gute, einfache Option.

Sie sollten sich überlegen, welche Methoden Sie virtuell machen möchten, um das Offen-Geschlossen-Prinzip zu berücksichtigen. Diese Entscheidung wird häufig bis zur letzten Minute gelassen: „Ich mache die Methoden virtuell, wenn ich sie von einer erbenden Klasse aufrufen muss, die ich aber im Moment nicht habe.“ Andere machen alle Methoden virtuell, in der Hoffnung, dass Extender die Möglichkeit haben, Flüchtigkeitsfehler im ursprünglichen Code zu umgehen.

Doch beide Vorgehensweisen sind falsch. Sie sind ein Beispiel für die Unfähigkeit, sich auf eine offene Schnittstelle festzulegen. Zu viele virtuelle Methoden beschränken Ihre Möglichkeiten, den Code später zu ändern. Ein Mangel an Methoden, die überschrieben werden können, schränkt die Erweiterbarkeit und Wiederverwendbarkeit des Codes ein. Dies begrenzt seinen Nutzen und die Lebensdauer.

Dies ist ein weiteres häufiges Beispiel für Verstöße gegen das Offen-Geschlossen-Prinzip:

class Nerd
{
  public void DanceTheDisco()
  {
    if (this is ChildOfNerd)
            throw new CoordinationException("Can't");
    ...
  }
}
class ChildOfNerd : Nerd { ... }

Was ist hier falsch? Nerd hat einen festen Bezug auf den untergeordneten Typ. Das ist ein trauriger Anblick und leider ein häufiger Fehler bei unerfahrenen Entwicklern. Sie können den Verstoß gegen das Offen-Geschlossen-Prinzip sehen. Um ChildOfNerd zu erweitern oder umzugestalten, müssten Sie mehrere Klassen ändern.

Basisklassen sollten niemals direkt auf ihre erbenden Klassen verweisen. Die Erbenfunktionalität ist dann unter den Erben nicht mehr einheitlich. Um diesen Konflikt zu vermeiden, gibt es die großartige Möglichkeit, die Erben einer Klasse in separate Projekte zu verteilen. Auf diese Weise lässt die Projektreferenzstruktur dieses unselige Szenario nicht zu.

Das Problem beschränkt sich jedoch nicht auf über- und untergeordnete Beziehungen. Es existiert auch in gleichberechtigten Klassen. Angenommen, Sie haben folgenden Code:

class NerdsInAnArc
{
  public bool Intersects(NerdsInAnLine line)
  {
    ...
  }
  ...
}

„Arc“ und „Line“ sind in der Regel gleichberechtigte Peers in der Objekthierarchie. Sie kennen keine Details über den anderen, die sie nicht selbst geerbt haben, da diese Details häufig für optimale Überschneidungsalgorithmen benötigt werden. Sie können den einen ändern, ohne den anderen anzupassen. Dies bringt jedoch wieder einen Verstoß gegen die einzige Verantwortung mit sich. Wollen Sie „Arc“ speichern oder analysieren? Packen Sie Analysevorgänge in eine eigene Dienstprogrammklasse.

Wenn Sie diese bestimmte Peer-übergreifende Fähigkeit benötigen, müssen Sie die entsprechende Schnittstelle hinzufügen. Befolgen Sie diese Regel, um Verwechslungen zwischen den Elementen zu vermeiden: Verwenden Sie das Schlüsselwort „is“ mit einer Abstraktion statt mit einer konkreten Klasse. Sie könnten potenziell eine IIntersectable- oder INerdsInAPattern-Schnittstelle für das Beispiel erzeugen, obwohl Sie wahrscheinlicher auf eine andere Überschneidungs-Dienstprogrammklasse zur Analyse der Daten an dieser Schnittstelle ausweichen würden.

Das Liskovsche Substitutionsprinzip

Das Liskovsche Substitutionsprinzip definiert einige Richtlinien zur Verwaltung der Erbenersetzung. Wenn Sie die erbende Klasse eines Objekts statt der Basisklasse übergeben, sollte keine vorhandene Funktionalität in der aufgerufenen Methode beschädigt werden. Sie sollten in der Lage sein, alle Implementierungen einer bestimmten Schnittstelle gegenseitig auszutauschen.

C# lässt keine Änderung der Rückgabetypen oder Parametertypen in Überschreibungsmethoden zu (selbst wenn der Rückgabetyp ein Erbe des Rückgabetyps in der Basisklasse ist). Deshalb hat es nicht mit den häufigsten Ersetzungsverstößen zu kämpfen: Kontravarianz der Methodenargumente (der Überschreiber muss denselben oder Basistyp der übergeordneten Methoden haben) und Kovarianz der Rückgabetypen (Rückgabetypen in Überschreibungsmethoden müssen dieselbe oder eine erbende Klasse der Rückgabetypen in der Basisklasse haben). Es wird jedoch häufig versucht, diese Einschränkung zu umgehen:

class Nerd : Mammal {
  public double Diopter { get; protected set; }
  public Nerd(int vertebrae, double diopter)
    : base(vertebrae) { Diopter = diopter; }
  protected Nerd(Nerd toBeCloned)
    : base (toBeCloned) { Diopter = toBeCloned.Diopter; }
  // Would prefer to return Nerd instead:
  // public override Mammal Clone() { return new Nerd(this); }
  public new Nerd Clone() { return new Nerd(this); }
}

Was ist hier falsch? Das Verhalten des Objekts ändert sich, wenn es mit einer Abstraktionsreferenz aufgerufen wird. Die Klonmethode „new“ ist nicht virtuell und wird daher bei Verwendung einer Mammal-Referenz nicht ausgeführt. Das Schlüsselwort „new“ im Methodendeklarationskontext ist wahrscheinlich ein Feature. Wenn Sie jedoch die Basisklasse nicht kontrollieren, wie können Sie dann die gewünschte Ausführung garantieren?

C# bietet einige brauchbare Alternativen, aber diese sind trotzdem etwas widerspenstig. Sie können eine allgemeine Schnittstelle (etwa wie IComparable<T>) verwenden, die explizit in jeder erbenden Klasse implementiert wird. Sie benötigen jedoch eine virtuelle Methode, welche den eigentlichen Klonvorgang durchführt. Dies ist notwendig, damit der Klon dem abgeleiteten Typ entspricht. C# unterstützt außerdem den Liskov-Standard zur Kontravarianz von Rückgabetypen und Kovarianz der Methodenargumente bei der Verwendung von Ereignissen, das hilft jedoch nicht beim Ändern der verfügbaren Schnittstelle durch Klassenvererbung.

Aufgrund des Codes könnte man denken, dass C# den Rückgabetyp in der Methode einschließt, welche der Klassenmethoden-Auflösungsdienst verwendet. Das ist falsch – Sie können nicht mehrere Überschreibungen für verschiedene Rückgabetypen mit denselben Namen und Eingabetypen verwenden. Methodenbeschränkungen werden für die Methodenauflösung ebenfalls ignoriert. Abbildung 2 enthält ein Beispiel für einen syntaktisch korrekten Code, der aufgrund der Methodenzweideutigkeit nicht kompiliert wird.

Abbildung 2: Mehrdeutige Methode

interface INerd {
  public int Smartness { get; set; }
}
static class Program
{
  public static string RecallSomeDigitsOfPi<T>(
    this IList<T> nerdSmartnesses) where T : int
  {
    var smartest = nerdSmartnesses.Max();
    return Math.PI.ToString("F" + Math.Min(14, smartest));
  }
  public static string RecallSomeDigitsOfPi<T>(
    this IList<T> nerds) where T : INerd
  {
    var smartest = nerds.OrderByDescending(n => n.Smartness).First();
    return Math.PI.ToString("F" + Math.Min(14, smartest.Smartness));
  }
  static void Main(string[] args)
  {
    IList<int> list = new List<int> { 2, 3, 4 };
    var digits = list.RecallSomeDigitsOfPi();
    Console.WriteLine("Digits: " + digits);
  }
}

Der Code in Abbildung 3 zeigt, wie die Fähigkeit zur Ersetzung beschädigt werden kann. Betrachten Sie die erbenden Klassen. Eine von ihnen könnte das Feld „isMoonWalking“ nach dem Zufallsprinzip ändern. Wenn das passiert, läuft die Basisklasse Gefahr, einen wichtigen Bereinigungsabschnitt zu überspringen. Das Feld „isMoonWalking“ sollte privat sein. Falls die erbenden Klassen es kennen müssen, kann es eine geschützte getter-Eigenschaft geben, die den Zugriff ermöglicht, aber keine Änderung.

Abbildung 3: Beispiel, wie die Fähigkeit zur Ersetzung beschädigt werden kann

class GrooveControl: Control {
  protected bool isMoonWalking;
  protected override void OnMouseDown(MouseButtonEventArgs e) {
    isMoonWalking = CaptureMouse();
    base.OnMouseDown(e);
  }
  protected override void OnMouseUp(MouseButtonEventArgs e) {
    base.OnMouseUp(e);
    if (isMoonWalking) {
      ReleaseMouseCapture();
      isMoonWalking = false;
    }
  }
}

Kluge, gelegentlich pedantische Programmierer gehen noch einen Schritt weiter. Versiegeln Sie die Maushandler (oder eine andere Methode, die den privaten Status erfordert oder ändert), und lassen Sie die erbenden Klassen Ereignisse oder andere virtuelle Methoden verwenden, die keine notwendigen Aufrufmethoden sind. Das Muster des erforderlichen Basisaufrufs ist zulässig, aber nicht ideal. Wir haben alle schon gelegentlich vergessen, erwartete Basismethoden aufzurufen. Lassen Sie die erbenden Klassen nicht den Kapselstatus beschädigen.

Liskov-Substitution erfordert außerdem, dass die erbenden Klassen keine neuen Ausnahmetypen veranlassen (auch wenn Erben von Ausnahmen, die bereits in der Basisklasse ausgegeben wurden, zulässig sind). C# hat keine Möglichkeit, dies durchzusetzen.

Das Schnittstellentrennungsprinzip

Jede Schnittstelle sollte einen bestimmten Zweck haben. Sie sollten nicht gezwungen sein, eine Schnittstelle zu implementieren, wenn das Objekt den Zweck nicht teilt. Durch Hochrechnung gilt, je größer die Schnittstelle, desto wahrscheinlicher umfasst sie Methoden, die nicht alle Implementierer erreichen können. Dies ist die Essenz des Schnittstellentrennungsprinzips. Betrachten Sie ein altes, häufig verwendetes Schnittstellenpaar aus Microsoft .NET Framework:

public interface ICollection<T> : IEnumerable<T> {
  void Add(T item);
  void Clear();
  bool Contains(T item);
  void CopyTo(T[] array, int arrayIndex);
  bool Remove(T item);
}
public interface IList<T> : ICollection<T> {
  T this[int index] { get; set; }
  int IndexOf(T item);
  void Insert(int index, T item);
  void RemoveAt(int index);
}

Die Schnittstellen sind immer noch einigermaßen nützlich, aber es wird implizit vorausgesetzt, wenn Sie diese Schnittstellen verwenden, dass Sie die Auflistungen verändern möchten. Häufig ist es jedoch so, dass der Ersteller dieser Datenauflistungen verhindern möchte, dass jemand die Daten ändert. Es ist genau genommen sehr nützlich, die Schnittstellen in Quelle und Verbraucher zu unterteilen.

Viele Datenspeicher würden gern eine gemeinsame indizierbare, schreibgeschützte Schnittstelle verwenden. Beispiele sind Datenanalyse- oder Datensuchanwendungen. Diese lesen in der Regel große Protokolldateien oder Datenbanktabellen zur Analyse. Das Ändern der Daten ist dabei nicht geplant.

Zugegeben, die IEnumerable-Schnittstelle war als minimale, schreibgeschützte Schnittstelle vorgesehen. Mit dem Hinzufügen der LINQ-Erweiterungsmethoden wurde dieses Ziel endlich erfüllt. Microsoft hat ebenfalls die Lücke bei indizierbaren Sammelschnittstellen erkannt. Das Unternehmen hat dies in Version 4.5 von .NET Framework durch den Zusatz von IReadOnlyList<T> behoben, das nun von vielen Framework-Auflistungen implementiert wird.

Sie erinnern sich an diese Prachtstücke in der alten ICollection-Schnittstelle:

public interface ICollection : IEnumerable {
  ...
  object SyncRoot { get; }
  bool IsSynchronized { get; }
  ...
}

Anders ausgedrückt, bevor Sie die Auflistung iterieren können, müssen Sie potenziell zuerst SyncRoot sperren. Einige Erben haben diese speziellen Objekte sogar explizit implementiert, um ihre Scham darüber zu verbergen, dass sie sie implementieren müssen. In Multithread-Szenarien wurde erwartet, dass Sie die Auflistung überall dort sperren, wo Sie sie verwenden (statt in SyncRoot).

Die meisten von Ihnen wollen die Auflistungen kapseln, damit der Zugriff threadsicher ist. Statt „foreach“ zu verwenden, müssen Sie den Multithread-Datenspeicher kapseln und nur eine ForEach-Methode zur Verfügung stellen, die stattdessen einen Delegaten verwendet. Glücklicherweise sind neuere Auflistungsklassen wie die gleichzeitigen Auflistungen in .NET Framework 4 oder die unveränderlichen Auflistungen jetzt für .NET Framework 4.5 (durch NuGet) verfügbar und haben einen Großteil dieser Verrücktheiten eliminiert.

Die .NET-Stream-Abstraktion hat denselben Fehler, dass sie viel zu groß ist, einschließlich lesbare und beschreibbare Elemente sowie Synchronisierungskennzeichen. Sie enthält jedoch Eigenschaften, welche die Beschreibbarkeit bestimmen: CanRead, CanWrite, CanSeek usw. Vergleichen Sie „if (stream.CanWrite)“ mit „if (stream is IWritableStream)“. Falls Sie Streams erstellen, die nicht beschreibbar sind, wird Letzteres sicher geschätzt.

Betrachten Sie nun den Code in Abbildung 4.

Abbildung 4: Beispiel für unnötige Initialisierung und Bereinigung

// Up a level in the project hierarchy
public interface INerdService {
  Type[] Dependencies { get; }
  void Initialize(IEnumerable<INerdService> dependencies);
  void Cleanup();
}
public class SocialIntroductionsService: INerdService
{
  public Type[] Dependencies { get { return Type.EmptyTypes; } }
  public void Initialize(IEnumerable<INerdService> dependencies)
  { ... }
  public void Cleanup() { ... }
  ...
}

Wo liegt hier das Problem? Die Dienstinitialisierung und Bereinigung sollte durch einen der fantastischen IoC (Inversion of Control)-Container erfolgen, die allgemein für .NET Framework verfügbar sind, statt neu erfunden zu werden. In diesem Beispiel kümmert sich niemand außer dem Dienst „manager/­container/boostrapper“ um Initialisierung und Bereinigung – welcher Code auch immer diese Dienste lädt. Das ist der Code, der zählt. Sie möchten nicht, dass jemand sonst vorzeitig die Bereinigung aufruft. C# verfügt über einen Mechanismus namens explizite Implementierung, um dies zu unterstützen. Sie können den Dienst übersichtlicher wie folgt implementieren:

public class SocialIntroductionsService: INerdService
{
  Type[] INerdService.Dependencies { 
    get { return Type.EmptyTypes; } }
  void INerdService.Initialize(IEnumerable<INerdService> dependencies)
  { ... }
  void INerdService.Cleanup() {       ... }
  ...
}

Im Allgemeinen sollten Sie Ihre Schnittstellen mit einem anderen Zweck als der reinen Abstraktion einer einzigen konkreten Klasse entwerfen. Dies gibt Ihnen die Möglichkeiten, zu organisieren und zu erweitern. Dabei gibt es jedoch mindestens zwei wesentliche Ausnahmen.

Erstens tendieren Schnittstellen dazu, sich weniger häufig zu ändern als ihre konkreten Implementierungen. Sie können dies zu Ihrem Vorteil nutzen. Verschieben Sie Schnittstellen in eine separate Assembly. Lassen Sie die Nutzer nur auf die Schnittstellen-Assembly verweisen. Dies unterstützt die Kompilierungsgeschwindigkeit. Auf diese Weise weisen Sie der Schnittstelle keine Eigenschaften zu, die dort nicht hingehören (da ungeeignete Eigenschaftentypen in einer korrekten Projekthierarchie nicht verfügbar sind). Falls sich die zugehörigen Abstraktionen und Schnittstellen in derselben Datei befinden, ist etwas schief gelaufen. Schnittstellen sind in der Projekthierarchie ihren Implementierungen übergeordnet und außerdem Peers der Dienste (oder Abstraktionen der Dienste), die sie verwenden.

Zweitens besitzen Schnittstellen laut Definition keine Abhängigkeiten. Daher sind sie für einfache Komponententests durch Objektmocks/Proxy-Frameworks geeignet. Damit kommen wir zum nächsten und letzten Prinzip.

Das Abhängigkeitsumkehr-Prinzip

Abhängigkeitsumkehr bedeutet die Abhängigkeit von Abstraktionen statt konkreten Typen. Es gibt viele Überschneidungen zwischen diesem Prinzip und den bereits erläuterten. Viele der vorherigen Beispiele enthalten Fehlerpunkte bei Abhängigkeiten von Abstraktionen.

In seinem Buch „Domain-Driven Design“ (Addison-Wesley Professional, 2003) beschreibt Eric Evans einige Objektklassifizierungen, die zum Erläutern der Abhängigkeitsumkehr nützlich sind. Für eine Zusammenfassung des Buches ist es hilfreich, Ihre Objekte in diesen drei Gruppen zu klassifizieren: Werte, Entitäten oder Dienste.

Werte beziehen sich auf Objekte ohne Abhängigkeiten, die in der Regel vorübergehend und unveränderlich sind. Diese sind im Allgemeinen nicht abstrahiert, und Sie können sie nach Belieben instanziieren. Es ist jedoch nichts falsch daran, sie zu abstrahieren, besonders wenn Sie alle Vorteile von Abstraktionen erhalten können. Einige Werte können im Laufe der Zeit zu Entitäten anwachsen. Entitäten sind Ihre Geschäftsmodelle und ViewModels. Sie werden aus Werttypen und anderen Entitäten erstellt. Es ist nützlich, Abstraktionen für diese Elemente zu haben, besonders wenn Sie über ein ViewModel verfügen, das mehrere verschiedene Varianten eines Modells oder umgekehrt darstellt. Dienste sind Klassen, die diese Entitäten enthalten, organisieren, bedienen und verwenden.

Mit dieser Klassifizierung im Sinn beschäftigt sich die Abhängigkeitsumkehrung hauptsächlich mit Diensten und den Objekten, die diese benötigen. Dienstspezifische Methoden sollten immer in einer Schnittstelle erfasst werden. Egal von wo aus Sie auf den Dienst zugreifen, Sie gehen immer über die Schnittstelle. Verwenden Sie keinen konkreten Diensttyp im Code, außer dort, wo der Dienst erstellt wird.

Dienste sind normalerweise von anderen Diensten abhängig. Einige ViewModels hängen von Diensten ab, besonders Container- und Factory-Dienste. Deshalb lassen sich Dienste zu Testzwecken schwer instanziieren, da Sie die vollständige Dienststruktur benötigen. Abstrahieren Sie das Wesentliche in eine Schnittstelle. Anschließend sollten alle Verweise auf die Dienste über die Schnittstelle erfolgen, sodass sie zu Testzwecken ganz einfach modelliert werden können.

Sie können auf jeder Ebene im Code Abstraktionen erzeugen. Wenn Sie denken: „Oh, das wird schwierig für A, die Schnittstelle von B zu unterstützen, und für B, die Schnittstelle von A zu unterstützen“, ist es höchste Zeit, eine neue Abstraktion in der Mitte einzuführen. Erstellen Sie brauchbare Schnittstellen, und verlassen Sie sich auf sie.

Die Adapter- und Mediator-Muster können Ihnen helfen, die bevorzugte Schnittstelle zu vereinheitlichen. Es klingt so, als ob zusätzliche Abstraktionen für zusätzlichen Code sorgen, aber normalerweise ist das nicht so. Wenn Sie die Schritte zur Interoperabilität durchführen, hilft Ihnen das, den Code zu organisieren, der sowieso vorhanden sein muss, damit A und B miteinander kommunizieren können.

Vor Jahren habe ich gelesen, dass ein Entwickler seinen Code immer wiederverwenden sollte. Es schien damals zu einfach. Ich konnte nicht glauben, dass so ein einfaches Mantra den auf meinem Bildschirm verteilten Spaghetticode sortieren sollte. Doch im Laufe der Zeit habe ich dazugelernt. Sehen Sie hier:

private readonly IRamenContainer _ramenContainer; // A dependency
public bool Recharge()
{
  if (_ramenContainer != null)
  {
    var toBeConsumed = _ramenContainer.Prepare();
    return Consume(toBeConsumed);
  }
  return false;
}

Sehen Sie eine Codewiederholung? Okay, „_ramenContainer“ wird zweimal gelesen. Aus technischer Sicht wird der Compiler dies durch eine Optimierung namens „allgemeine Unterausdruckeliminierung“ eliminieren. Zu Erläuterungszwecken nehmen wir an, dass Sie eine Multithread-Situation haben und der Compiler die Klassenfeldlesungen in der Methode tatsächlich wiederholt hat. Damit riskieren Sie, dass Ihre Klassenvariable noch vor der Verwendung in Null geändert wird.

Wie lösen Sie dieses Problem? Führen Sie eine lokale Referenz vor der If-Anweisung ein. Diese Neuanordnung erfordert, dass Sie ein neues Element im oder über dem äußeren Bereich hinzufügen. Das Prinzip in Ihrer Projektorganisation ist dasselbe! Wenn Sie Codes oder Abstraktionen wiederverwenden, erreichen Sie letztendlich einen brauchbaren Bereich in der Projekthierarchie. Lassen Sie die projektübergreifende Referenzarchitektur durch die Abhängigkeiten steuern.

Sehen Sie sich jetzt diesen Code an:

public IList<Nerd> RestoreNerds(string filename)
{
  if (File.Exists(filename))
  {
    var serializer = new XmlSerializer(typeof(List<Nerd>));
    using (var reader = new XmlTextReader(filename))
      return (List<Nerd>)serializer.Deserialize(reader);
  }
  return null;
}

Ist er von Abstraktionen abhängig?

Nein, ist er nicht. Er beginnt mit der statischen Referenz auf das Dateisystem. Er verwendet einen hartcodierten Deserializer mit hartcodierten Typreferenzen. Die Verarbeitung von Ausnahmen wird außerhalb der Klasse erwartet. Dieser Code kann unmöglich ohne den zugehörigen Speichercode getestet werden.

In der Regel verschieben Sie dies in zwei Abstraktionen: eine für das Speicherformat und eine für das Speichermedium. Einige Beispiele für Speicherformate sind XML, JSON und Protobuf-Binärdaten. Speichermedien umfassen direkte Dateien auf einem Datenträger sowie Datenbanken. Eine dritte Abstraktion ist ebenfalls typisch für diesen Systemtyp: eine Art selten geändertes Memento, das für das zu speichernde Objekt steht.

Nehmen Sie folgendes Beispiel:

class MonsterCardCollection
{
  private readonly IMsSqlDatabase _storage;
  public MonsterCardCollection(IMsSqlDatabase storage)
  {
    _storage = storage;
  }
  ...
}

Können Sie sehen, was mit diesen Abhängigkeiten nicht stimmt? Der Schlüssel ist der Abhängigkeitsname. Er ist plattformspezifisch. Der Dienst ist jedoch nicht plattformspezifisch (oder versucht zumindest durch die Verwendung eines externen Speichermoduls eine Plattformabhängigkeit zu vermeiden). Dies ist eine Situation, in der Sie das Adaptermuster anwenden sollten.

Wenn Abhängigkeiten plattformspezifisch sind, erhalten die Abhängigen ihren eigenen plattformspezifischen Code. Sie können dies durch eine zusätzliche Ebene vermeiden. Die zusätzliche Ebene hilft Ihnen, die Projekte so zu organisieren, dass die plattformspezifische Implementierung als eigenes Spezialprojekt existiert (mit allen seinen plattformspezifischen Referenzen). Sie müssen nur im Anwendungsstartprojekt auf das Projekt verweisen, das den gesamten plattformspezifischen Code enthält. Plattform-Wrapper sind meist groß; duplizieren Sie sie nicht häufiger als nötig.

Abhängigkeitsumkehrung fasst den gesamten Prinzipiensatz zusammen, der in diesem Artikel erläutert wurde. Sie verwendet saubere, zweckmäßige Abstraktionen, die Sie mit konkreten Implementierungen ausfüllen können, die den zugrunde liegenden Dienststatus nicht beschädigen. Das ist das Ziel.

Es stimmt, die SOLID-Prinzipien überschneiden sich bei ihren Bemühungen um nachhaltigen Computercode. Die große Welt des Zwischenversionscodes (der einfach dekompiliert werden kann) bietet fantastische Möglichkeiten zur Erkundung des vollen Umfangs, bis zu dem Sie ein Objekt erweitern können. Eine Reihe von .NET-Bibliotheksprojekten geht mit der Zeit verloren. Das liegt nicht daran, dass die Idee fehlerhaft war, sie konnten ganz einfach nicht auf die unvorhersehbaren, wechselhaften Anforderungen der Zukunft erweitert werden. Seien Sie stolz auf Ihren Code. Wenden Sie die SOLID-Prinzipien an, und Sie werden sehen, dass sich die Lebensdauer Ihres Codes erhöht.

Brannon B. King arbeitet seit 12 Jahren als Vollzeit-Softwareentwickler, acht davon speziell in C# und .NET Framework. Sein letztes Projekt war für Autonomous Solutions Inc. (ASI) in der Nähe von Logan, Utah, USA (asirobots.com). ASI hat die Fähigkeit, eine ansteckende Zuneigung zu C# zu erwecken. Das Team von ASI nutzt die Sprache leidenschaftlich gern und testet die Grenzen von .NET Framework aus. Kontaktmöglichkeit unter countprimes@gmail.com.

Unser Dank gilt den folgenden technischen Experten für die Durchsicht dieses Artikels: Max Barfuss (ASI) und Brian Pepin (Microsoft)
Brian Pepin ist seit 1994 Softwareentwickler bei der Microsoft Corporation, mit Schwerpunkt auf Entwickler-APIs und Tools. Er kennt sich mit Visual Basic, Java, .NET Framework, Windows Forms, WPF, Silverlight und dem Windows 8 XAML Designer in Visual Studio aus. Derzeit arbeitet er mit dem Xbox-Team an Xbox-Betriebssystemkomponenten und verbringt seine freie Zeit am liebsten in der Gegend von Seattle mit seiner Frau Danna und ihrem Sohn Cole.
Max Barfuss ist Softwarespezialist mit der festen Überzeugung, dass gute Codierungs-, Design- und Kommunikationsgewohnheiten die Dinge sind, die herausragende Softwareentwickler von den anderen unterscheiden. Er bringt sechzehn Jahre Erfahrung in der Softwareentwicklung ein, davon elf Jahre im .NET-Bereich.