So habe ich das Open/Closed Prinzip endlich verstanden!

von Sergej Kukshaus vor 4 monaten

Das Open/Closed Prinzip, ist zugegebenermaßen etwas schwierig zu verstehen.

Der Grundgedanke klingt ziemlich cool und banal.

Die Definition stammt diesmal von Prof. Bertrand Meyer:

Wenn schon einmal versucht hat, durch eine geschlossene Tür zu gehen, wird schnell feststellen, dass es etwas weh tun kann.

Wie kann etwas geschlossenes gleichzeitig offen sein?

Bertrand Meyer empfiehlt hierzu Vererbung zu verwenden.

Ganz ehrlich? Das ist Nonsens! 🙂

Ein alltägliches Problem…

Zugegeben, so alltäglich ist es jetzt auch wieder nicht 🙂

Du hast eine Aufgabe bekommen, eine Applikation zu schreiben. In dieser App soll ein Logger eingebaut werden. Um kein Schwergewicht mit ins Boot zu holen, erstellst du dir mal eben einen eigenen Logger.

Und am Ende des Tages hast du diesen Quellcode:

class Client
{
    static void Main(string[] args)
    {
        var log = new Logger();
        var s = new System(log);
        s.Logik();
    }
}

class Logger
{
    public void Log(string eintrag)
    {
        Console.WriteLine("LOG: " + eintrag);
    }

    public void Fehler(string eintrag)
    {
        Console.WriteLine("ERR: " + eintrag);
    }
}

class System
{
    private Logger _logger;

    public System(Logger logger)
    {
        _logger = logger;
    }

    public void Logik()
    {
        _logger.Log("Einfache Log-Nachricht");
        _logger.Fehler("Fehlernachricht");
    }
}

Das Beispiel ist zwar nicht kreativ, verdeutlicht das Problem aber ziemlich gut.

Beachte: Das System hat eine direkte Abhängigkeit zum Logger.

Jetzt kommt der Chef um die Ecke, und sagt, dass die Logeinträge lieber noch zusätzlich in einer Datei gespeichert werden sollen anstelle nur auf der GUI.

Joa, und jetzt?

Man könnte einfach den Logger erweitern und folgendes hinzufügen:

class Logger
{
    public void Log(string eintrag)
    {
        Console.WriteLine("LOG: " + eintrag + " (GUI)");
        Console.WriteLine("LOG: " + eintrag + " (Datei)");
    }

    public void Fehler(string eintrag)
    {
        Console.WriteLine("ERR: " + eintrag + " (GUI)");
        Console.WriteLine("ERR: " + eintrag + " (Datei)");
    }
}

Das Problem ist jedoch, dass dieser Logger jetzt immer sowohl auf der GUI, als auch in eine Datei schreiben würde.

Des Weiteren, haben wir hier an dieser Stelle weitere Funktionalität in den Logger hinzugefügt und damit das Open/Closed Prinzip verletzt.

Außerdem hat der Logger plötzlich zwei Aufgaben (einmal GUI und einmal Datei) und somit haben wir auch noch zusätzlich das SRP verletzt.

Die Lösung nach OCP

In der objektorientierten Programmierung, haben wir ein sehr mächtiges Werkzeug.

Interfaces

Statt jetzt also die Klasse „Logger“ zu verwenden, erstellen wir uns daraus ein interface:

interface ILogger
{
    void Log(string eintrag);
    void Fehler(string eintrag);
}

Und im Anschluss wird dieser in den unseren normalen Logger eingebaut und noch weiter spezifiziert:

class GuiLogger : ILogger
{
    public void Log(string eintrag)
    {
        Console.WriteLine("LOG: " + eintrag + " (GUI)");
    }

    public void Fehler(string eintrag)
    {
        Console.WriteLine("ERR: " + eintrag + " (GUI)");
    }
}

Den eigentlichen Quellcode, ändern wir noch wie folgt ab:

class System
{
    private ILogger _logger;

    public System(ILogger logger)
    {
        _logger = logger;
    }

    public void Logik()
    {
        _logger.Log("Einfache Log-Nachricht");
        _logger.Fehler("Fehlernachricht");
    }
}

Und der Aufruf kann fast so bleiben wie der ist:

var log = new GuiLogger();
var s = new System(log);
s.Logik();

Durch diese Änderung, haben wir am Quellcode nichts kaputt gemacht. Der funktioniert noch genauso wie vorher auch.

Wir haben nur ein wenig Refactoring betrieben.

Und wenn jetzt der Chef um die Ecke kommt und einen neuen Logger möchte, sagen wir nur:

Ok Chef, kein Problem!

class DateiLogger : ILogger
{
    public void Log(string eintrag)
    {
        Console.WriteLine("LOG: " + eintrag + " (Datei)");
    }

    public void Fehler(string eintrag)
    {
        Console.WriteLine("ERR: " + eintrag + " (Datei)");
    }
}

Jetzt haben wir einen neuen Logger. Jedoch haben wir am eigentlichen System rein gar nichts verändert.

Besser noch, wir haben keine einzige Klasse geändert (höchstens den Aufrufer).

Wie du mit OCP Sonderwünsche berücksichtigen kannst

Der Chef kommt und sagt, dass er jetzt im Fehlerfall direkt per E-Mail benachrichtigt werden möchte und argumentiert damit, dass ihn jeder Fehlerfall 7 Millionen Euro kostet.

Okay, wir brauchen also einen E-Mail-Logger. Allerdings erwartet unser System nur ein Logger.

Ändern wir das?

Nein. Ansonsten würden wir mal wieder das OCP verletzten.

Stattdessen machen wir folgendes:


class FehlerEmailLogger : ILogger
{
    public void Log(string eintrag)
    {
    }

    public void Fehler(string eintrag)
    {
        Console.WriteLine("Email an chef@acme.org: " + eintrag);
    }
}

class VerbundsLogger : ILogger
{
    private readonly ILogger[] _loggers;

    public VerbundsLogger(ILogger[] loggers)
    {
        _loggers = loggers;
    }

    public void Log(string eintrag)
    {
        foreach (ILogger logger in _loggers)
        {
            logger.Log(eintrag);
        }
    }

    public void Fehler(string eintrag)
    {
        foreach (ILogger logger in _loggers)
        {
            logger.Fehler(eintrag);
        }
    }
}

Und der Aufruf?

var alleLogger = new ILogger[3]
{
    new GuiLogger(),
    new DateiLogger(),
    new FehlerEmailLogger(),
};
var log = new VerbundsLogger(alleLogger);
var s = new System(log);

s.Logik();

Das war doch einfach, oder? 🙂

Durch den VerbundLogger (in einer realen Anwendung, würde ich das Teil CompositeLogger nennen), ist jetzt in der Lage beliebig viele Logger entgegenzunehmen und alle Logger gleichzeitig zu bedienen.

OCP strickt nach Meyer

Wie schon Eingangs erwähnt, empfiehlt Meyer Vererbung zu verwenden.

Vorteil ist, dass man bei einer Änderung einfach von einer Klasse ableiten kann und die Funktionalität in der abgeleiteten Klasse einfach überschreiben kann.

Somit hat man ebenfalls keinen bestehenden Code verändert, sondern neue Funktionalität erschaffen.

Ich vermute, dass dies einfach nur historische Gründe hat.

Das OCP stammt aus den 90er Jahren.

In dieser Zeit gab es noch keine Interfaces 🙂 wohl aber Vererbung.

Interfaces wurden erst mit Java Mitte der 90er eingeführt. Jedoch musste es sich erst durchsetzen. In den 90ern war C++ mit seiner Mehrfachvererbung am Drücker.

Langer rede kurzer Sinn:

Von Klassen abzuleiten, bringt meistens Probleme mit sich. Vor allem dann, wenn die Klasse nicht abstrakt ist.

In dem Logger Beispiel, müsste man alle Logger Methoden explizit überschreiben.

Doch das ist nicht ohne weiteres möglich. Man muss den Logger anpassen und die Methoden als virtual markieren. Hat man nicht diese Möglichkeit, hat man verloren. Weil man dann überall in seinem Quellcode den Verweis auf den Logger anpassen müsste, da sonst die falschen Methoden verwendet werden.

Nimmst du stattdessen Interfaces, bist du an dieser Stelle safe. Da weißt du jedenfalls, woher die Implementierung kommt.

Wie du dir Funktionalität zwischen den Varianten teilst

Wir erhalten eine brandneue Anforderung vom Chef.

Er möchte sowohl in der Datei, als auch in der E-Mail das Datum und die genaue Uhrzeit des Logeintrags sehen.

Jetzt könnten wir dahergehen, und in beiden Loggern die Uhrzeit anhängen.

Wenn wir das jedoch tun, müssen wir vier Stellen im Quellcode anpassen und immer wieder dasselbe schreiben.

Streng nach OCP, würden wir dafür auch zwei neue Logger benötigen. Das lassen wir jedoch mal weg, da unser Chef unmissverständlich zu verstehen gegeben hat, dass diese neue Anforderung Bestand hat.

Und um auf alle Eventualitäten vorbereitet zu sein, spendieren wir uns noch ein interface dazu 🙂

interface ILogNachrichtFormatierung
{
    string Formatiere(string nachricht);
}

class NachrichtMitDatum : ILogNachrichtFormatierung
{
    public string Formatiere(string nachricht)
    {
        return DateTime.UtcNow.ToString("s") + nachricht;
    }
}

class FehlerEmailLogger : ILogger
{
    ILogNachrichtFormatierung _formatierung = new NachrichtMitDatum();

    public void Log(string eintrag)
    {
    }

    public void Fehler(string eintrag)
    {
        string formatierteNachricht = _formatierung.Formatiere("Email an chef@acme.org: " + eintrag);
        Console.WriteLine(formatierteNachricht);
    }
}

Diese Vorgehensweise, nennt man übrigens Composite Reuse Principle (CRP) 🙂 (Bitte nicht mit dem Composite-Pattern verwechseln.)

Besonders mächtig wird es aber erst, wenn man die Objekte dynamisch erzeugt und später auch die Formatierung dynamisch ändern kann.

Fazit

Was zunächst paradox klingt, erweist sich nach reiflicher Überlegung als ziemlich nützlich.

Wenn man bestehenden Code nicht verändert, sondern nur neue Funktionalität hinzunimmt, läuft man weniger in die Gefahr, etwas kaputtzumachen.

Zusätzlich, sollst du dich mittels Unit-Tests sicherstellen, dass nach deiner Änderung noch alles so funktioniert wie es funktionieren soll oder du nicht doch aus verstehen irgendwas kaputt gemacht hast (man weiß ja nie ;-))

Den Quellcode zum Artikel gibt es wie immer bei github.

Sei der erste und teile deine Meinung mit der Welt!
... und was meinst du dazu?
Deine E-Mail-Adresse wird nicht veröffentlicht.