Das Open-Closed Principle (OCP)

Modules should be open for extension and closed for modification.

Software sollte lesbar, modular und wartbar sein. Leider entsteht im Laufe der Entwicklung, vor allem aber auch bei Weiterentwicklungen, immer wieder schlechtes Software-Design. Im schlimmsten Fall treten Programmfehler bei bereits längst getesteten Funktionalitäten auf. Die Beachtung der SOLID-Prinzipien objektorientierter Softwareentwicklung kann helfen, diese Fehler zu vermeiden und Programmcode wartbar zu halten. Insbesondere die Wartbarkeit ist von großer Bedeutung, da man selten Softwareentwicklung auf der grünen Wiese betreibt, sondern in den allermeisten Fällen auf vorhandenem Programmcode aufbaut.

In diesem Beitrag wollen wir das "Open/Closed Principle" näher betrachten: Programmcode sollte offen für Erweiterung, aber geschlossen gegen Änderungen sein. Wie ist das aber umzusetzen und wie kann es tatsächlich helfen, Fehler zu vermeiden und die Wartbarkeit zu erhöhen? Jedes Prinzip ist nur soviel wie seine praktische Relevanz wert – die schönsten Prinzipien sind nur hübsche Gedankenkonstrukte, solange sie keinen praktisch belegbaren Nutzen haben.

Codebeispiel

Schauen wir uns dazu folgendes Codebeispiel als Erweiterung zum letzten Blogbeitrag an, in dem ein Kundenscoring umgesetzt werden soll:

public class Customer implements Serializable {
   private Integer id;
   private String name;
   private int type;
   private int orderCount;
   private int orderedAmountSum;
   private CustomerScoring customerScoring;
    
   /*
    * getter und setter Methoden wurden zugunsten der Lesbarkeit entfernt
    */

   public Customer(CustomerScoring customerScoring) {
      this.customerScoring = customerScoring;
   }

   public double calculateScoring() {
      return customerScoring.calculateScoring(this);
   }
}

public class CustomerScoring {

   public double calculateScoring(Customer customer) {
      if (customer.getType() == 1) {
         // Endkunde
         return 100 * customer.getOrderedAmountSum() / Accounting.getTotalOrderedRetailAmount();
      } else {
         // Geschäftskunde
         return 100 * customer.getOrderCount() / Accounting.getTotalBusinessOrderCount();
      }
   }    
}


Durch Berücksichtigung des "Single Responsibility Principles" aus dem letzten Blogbeitrag wird die Verantwortung für das Kundenscoring an die separate Klasse CustomerScoring übertragen. Der entsprechende Test sieht folgendermaßen aus:

public class CustomerScoringTest {
   CustomerScoring sut;
   Customer customer;

   @SuppressWarnings("unchecked")
   @Before
   public void init()  {
      sut = new CustomerScoring();
      customer = new Customer(sut);
   }

   @Test
   public void calculateScoringRetail() {
      customer.setType(1);
      customer.setOrderedAmountSum(30000);
      double scoring = sut.calculateScoring (customer);
      assertTrue(scoring == 2.5);
   }

   @Test
   public void calculateScoringBusiness() {
      customer.setType(2);
      customer.setOrderCount(22);
      double scoring = sut.calculateScoring (customer);
      assertTrue(scoring == 0.55);
   }
}

Neue Anforderungen

Alles ist also im grünen Bereich, unser Code erfüllt die Tests und beachtet das SRP aus dem letzten Blogbeitrag. Jetzt wird eine neue Anforderung gestellt, dass Privatkunden mit mehr als 20 Bestellungen als Vip-Kunden zu behandeln sind und daher das Kundenscoring anders berechnet werden soll. Für die Umsetzung wird nun die eigens erstellte Klasse CustomerScoring angepaßt:

public class CustomerScoring {

   public double calculateScoring(Customer customer) {
      if (customer.getOrderCount() > 20) {
         // VIP Kunde
         return Math.min(20+ customer.getOrderCount(), 100);
      } else if (customer.getType() == 1) {
         // Endkunde
         return 100 * customer.getOrderedAmountSum() / Accounting.getTotalOrderedRetailAmount();
      } else {
         // Geschäftskunde
         return 100 * customer.getOrderCount() / Accounting.getTotalBusinessOrderCount();
      }
   }    
}


Zudem wird ein neuer Testfall hinzugefügt:

public class CustomerScoringTest {
   @Test
   public void calculateScoringVip() {
      customer.setType(1);
      customer.setOrderCount(22);
      double scoring = sut.calculateScoring (customer);
      assertTrue(scoring == 42.0);
   }
}


Leider bricht jetzt ein alter Testfall, obwohl dieser weiterhin valide ist:

Vermeiden des Fehlers durch das "Open/Closed Principle"

Unsere Anpassung hat jetzt also den Nebeneffekt, dass nun auch manche Geschäftskunden als Vip-Kunden betrachtet werden, obwohl dies so nicht gewünscht ist. Das "Open/Closed Principle" hilft, solche Nebenwirkungen zu vermeiden: Bestehender Programmcode sollte offen für neues Verhalten sein, aber geschlossen gegen Änderungen.

Also überspitzt ausgedrückt: Einmal geschriebener (und getesteter) Code sollte nie wieder verändert werden müssen. Bei Ausbringung neuer Funktionalität sollten nur zusätzliche Files (Jars etc) benötigt werden, bestehende Teile sollten unberührt bleiben. Erreicht werden kann dies z.B. durch Vererbung oder Nutzung von Interfaces: Das bisherige Verhalten wird dann nicht verändert, neues Verhalten wird nur durch neue Module (z.B. Klassen) umgesetzt.

Konkret bedeutet dies in unserem Beispiel, dass ein Interface CustomerScoring das abstrakte Verhalten eines beliebigen Scorings beschreibt, die konkreten Scoring-Berechnungen aber in separaten Implementationen dieses Interfaces gekapselt werden. Auf diese Weise kann jederzeit ein neues Scoring nur durch Hinzufügen einer neuen Interface-Implementation erstellt werden:

public interface CustomerScoring {
   public double calculateScoring(Customer customer);
}

public class CustomerScoringRetail implements CustomerScoring {
   public double calculateScoring(Customer customer) {
      return 100 * customer.getOrderedAmountSum()  / Accounting.getTotalOrderedRetailAmount();
   }    
}

public class CustomerScoringBusiness implements CustomerScoring {
   public double calculateScoring(Customer customer) {
      return 100 * customer.getOrderCount()  / Accounting.getTotalBusinessOrderCount();
   }    
}

public class CustomerScoringVip implements CustomerScoring {
   public double calculateScoring(Customer customer) {
      return Math.min(20 + customer.getOrderCount (), 100);
   }    
}


Beispielhaft sei hier nur ein Test ausführlicher dargestellt:

public class CustomerScoringBusinessTest {
   CustomerScoringBusiness sut;
   Customer customer;

   @SuppressWarnings("unchecked")
   @Before
   public void init()  {
      sut = new CustomerScoringBusiness();
      customer = new Customer(sut);
      customer.setType(1);
      customer.setOrderCount(22);
   }
    
   @Test
   public void calculateScoring() {
      double scoring = sut.calculateScoring (customer);
      assertTrue(scoring == 0.55);
   }
}


Durch diese Trennung der Implementierung wird jetzt die einzelne Fachlogik klar und übersichtlich gehalten, Seiteneffekte werden vermieden:

Fazit

Das "Open/Closed Principle" hilft also, Fehler in bestehenden Programmteilen durch neue Funktionalität zu vermeiden, indem neues Verhalten nur durch Hinzufügen neuer Module erreicht werden sollte, nicht durch Anpassung bestehenden Programmcodes – Maßnahmen zum Refactoring ausgenommen. Dadurch wird Software wartbarer, da existierende, bereits getestete Funktionalität unberührt bleibt. Zudem ist es als Softwareentwickler auch sehr viel einfacher, neuen Programmcode eigens für die geforderte neue Funktionalität zu entwickeln und hierfür Tests zu schreiben, als die neue Funktionalität in bestehenden Programmcode zusätzlich einzubauen mit entsprechend komplizierten Tests, die unterschiedliche Funktionalität abdecken müssen – wie wir gesehen haben ist in letzterem Fall die Fehleranfälligkeit sehr viel höher.

Durch die Beachtung des "Open/Closed Principles" mit den entsprechenden Codeanpassungen ist es in unserem Beispiel jetzt tatsächlich möglich, ein weiteres Scoring nur durch Hinzufügen weiterer Klassen einzuführen, ohne eine bestehende zu ändern.

Überall im Code Abstraktionen im Sinne des OCP einzuführen und im Extremfall jegliche "if" Statements zu vermeiden ist aber nicht immer sinnvoll: Die durch OCP eingeführten Abstraktionslevel durch Interfaces oder Vererbung machen den Programmcode komplexer. Man sollte also einen konkreten Grund für die zusätzliche Komplexität haben – Programmcode, der nur ein einziges Mal produktiv ausgeführt werden wird, unterscheidet sich hinsichtlich der Anforderungen an die Wartbarkeit mit Sicherheit von Programmcode, der das Herz einer Anwendung darstellt und regelmäßig neuen und veränderten Anforderungen unterworfen ist. Eine erste simple Implementierung kann also manchmal akzeptabel sein, um ggf. später ein Refactoring durchzuführen, wenn ein echter Bedarf hierfür vorliegt:

  • Gibt es mehr als ein aktuell gewünschtes Verhalten eines Moduls (Klasse)?
  • Ist zu erwarten, dass sich das gewünschte Verhalten häufig ändert?
  • Extraktion von verschiedenem Verhalten in separate Klassen, Einführung von Interfaces oder Vererbung
  • Extraktion von "if"-Code in separate Klassen

Wieder konnten unsere Klassen etwas verbessert werden, andere SOLID Prinzipien dieser Blog-Serie werden aber weitere Aspekte beleuchten, um Software wartbarer zu machen und Fehler zu vermeiden.