Jeder angehende Softwareentwickler stößt beim ersten Kontakt mit dem Thema „Design Patterns“ unweigerlich auf die Entwurfsmuster der „Gang of Four“ (GoF). Das dazugehörige Werk „Design Patterns – Elements of Reusable Object-Oriented Software“ aus dem Jahr 1994 (!) enthält Ansätze, die auch heute noch relevant sind.
Allerdings sind viele der darin vorgestellten Konzepte aus meiner Sicht nicht nur für Einsteiger, sondern auch oft für Fortgeschrittene schwer verständlich. Zudem haben einige dieser Muster in der Praxis an Bedeutung verloren.
In diesem Blog möchte ich mich auf simple Design Patterns konzentrieren, die in der Praxis immer wieder Verwendung finden.
Einleitung
Design Patterns sind entstanden, um wiederkehrende Probleme in der Softwareentwicklung auf bewährte Weise zu lösen. Sie bieten Entwicklern klare Strukturen, um sauberen, wartbaren und wieder verwendbaren Code zu schreiben. Besonders in der objektorientierten Programmierung helfen sie dabei, Komplexität zu beherrschen und effiziente Lösungen zu finden.
Dieser Artikel basiert auf meinen persönlichen Erfahrungen als Java-Entwickler und stellt eine Auswahl von Design Patterns vor, die ich als besonders relevant und idealerweise als einfach umzusetzen empfinde. Letzteres ist besonders wichtig, da die oberste Priorität immer sein muss möglichst verständlichen und wartbaren Code, anstatt möglichst cleveren Code zu schreiben, den keiner versteht.
Builder Pattern
Einsatzzweck
Das klassische GOF Builder Pattern ist ein bewährtes Entwurfsmuster, das zur Gruppe der konstruktiven Design Patterns gehört. Sein Hauptziel ist es, die Erstellung von Objekten zu strukturieren, insbesondere wenn diese Objekte viele Parameter oder optionale Konfigurationen benötigen.
Anstatt alle erforderlichen Parameter über einen Konstruktor oder eine Methode zu übergeben, ermöglicht das Builder Pattern, die Attribute schrittweise mithilfe von sogenannten „Builder“-Methoden zu setzen. Dieser Ansatz fördert die Lesbarkeit und Verständlichkeit des Codes, da die einzelnen Schritte zur Objektkonstruktion klar sichtbar sind.
Beim abschließenden „Bauen“ des Objekts können zudem zusätzliche Überprüfungen durchgeführt werden. Dies stellt sicher, dass alle erforderlichen Attribute korrekt gesetzt sind, bevor das Objekt instanziiert wird, und erhöht somit die Robustheit der Anwendung.
Beispiel
Hier ein Beispiel für das Builder Pattern zum erzeugen eines „Userprofile“ Objekts.
public class UserProfile {
private String name;
private String email;
private String phoneNumber; // Optional
private String address; // Optional
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public String getPhoneNumber() {
return phoneNumber;
}
public String getAddress() {
return address;
}
public static UserProfileBuilder builder() {
return new UserProfileBuilder();
}
@Override
public String toString() {
return "UserProfile{" +
"name='" + name + '\'' +
", email='" + email + '\'' +
", phoneNumber='" + phoneNumber + '\'' +
", address='" + address + '\'' +
'}';
}
public static class UserProfileBuilder {
private String name; // Erforderlich
private String email; // Erforderlich
private String phoneNumber; // Optional
private String address; // Optional
public UserProfileBuilder setName(String name) {
this.name = name;
return this;
}
public UserProfileBuilder setEmail(String email) {
this.email = email;
return this;
}
public UserProfileBuilder setPhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
return this;
}
public UserProfileBuilder setAddress(String address) {
this.address = address;
return this;
}
public UserProfile build() {
// Validierungen
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("Name darf nicht leer sein.");
}
if (email == null || email.isEmpty()) {
throw new IllegalArgumentException("E-Mail darf nicht leer sein.");
}
// Erstellen des UserProfile-Objekts
UserProfile userProfile = new UserProfile();
userProfile.name = this.name;
userProfile.email = this.email;
userProfile.phoneNumber = this.phoneNumber;
userProfile.address = this.address;
return userProfile;
}
}
}
Für das Erstellen eines Userprofile Objekts wird die statische Methode „builder“ aufgerufen, welche das Objekt „UserProfileBuilder“ zurückliefert. Über Methodchaining können nun dem Builder die gewünschten Attribute übergeben werden.
Mit der Methode „build“ wird zuletzt das Userprofile Objekt erzeugt. Beim Ausführen dieser Methode wird überprüft, ob erforderliche Attribute gesetzt wurden (hier: Name und Email). Sollte ein erforderliches Attribut nicht gesetzt worden sein, wird eine IllegalArgumentException geworfen.
Aufruf:
public class Main {
public static void main(String[] args) {
try {
UserProfile userProfile = UserProfile.builder()
.setName("Max Mustermann")
.setEmail("max.mustermann@example.com")
.setPhoneNumber("01234-56789")
.setAddress("Musterstraße 1, 12345 Musterstadt")
.build();
System.out.println(userProfile);
} catch (IllegalArgumentException e) {
System.out.println("Fehler: " + e.getMessage());
}
// Beispiel mit leerem Namen
try {
UserProfile invalidProfile = UserProfile.builder()
.setName("")
.setEmail("invalid@example.com")
.build();
} catch (IllegalArgumentException e) {
System.out.println("Fehler: " + e.getMessage());
}
}
}
Während das Userprofil für „Max Mustermann“ erfolgreich angelegt wurde, wird bei Anlage des zweiten Objekts eine Exception geworfen. Der Grund liegt darin, dass der Name weder „null“ noch ein leerer String sein darf.
Factory Pattern
Einsatzzweck
Das klassische GOF Factory Pattern erlaubt, dass der Code, der die Objekte benötigt, nicht direkt für die Instanziierung verantwortlich ist. Stattdessen wird dies durch eine spezielle Factory-Klasse übernommen. Dies hat mehrere Vorteile:
- Kapselung der Objekterstellung: Die Factory versteckt die Details der Objekterstellung und sorgt für eine saubere Trennung von der Verwendung der Objekte.
- Flexibilität: Änderungen in der Art und Weise, wie Objekte erstellt werden, können innerhalb der Factory vorgenommen werden, ohne dass der Code, der diese Objekte verwendet, angepasst werden muss.
- Erweiterbarkeit: Neue Objekttypen können problemlos zur Factory hinzugefügt werden, ohne dass Änderungen im bestehenden Code erforderlich sind.
- Vermeidung von komplexen Instanziierungslogiken: Wenn die Objekterstellung komplex ist, beispielsweise aufgrund von mehreren Konstruktorparametern, ermöglicht das Factory Pattern eine einfachere Handhabung und reduziert die Komplexität an anderen Stellen im Code.
Das Factory Pattern eignet sich hervorragend, wenn eine oder mehrere Arten von Objekten je nach Kontext instanziiert werden müssen und dies an einer zentralen Stelle verwaltet werden soll.
Beispiel
// Enum für Fahrzeugtypen
public enum VehicleType {
CAR,
TRUCK;
}
// Interface für Fahrzeuge
public interface Vehicle {
void drive();
}
// Car-Implementierung
public class Car implements Vehicle {
@Override
public void drive() {
System.out.println("Driving a car");
}
}
// Truck-Implementierung
public class Truck implements Vehicle {
@Override
public void drive() {
System.out.println("Driving a truck");
}
}
// Die Factory-Klasse
public class VehicleFactory {
// EnumMap für die Zuordnung von VehicleType zu Fahrzeugobjekten
private static final EnumMap<VehicleType, Vehicle> vehicleMap = new EnumMap<>(VehicleType.class);
static {
// Initialisieren der EnumMap mit den Fahrzeugtypen und den entsprechenden Instanzen
vehicleMap.put(VehicleType.CAR, new Car());
vehicleMap.put(VehicleType.TRUCK, new Truck());
}
public static Vehicle createVehicle(VehicleType type) {
Vehicle vehicle = vehicleMap.get(type);
if (vehicle == null) {
throw new IllegalArgumentException("Unknown vehicle type: " + type);
}
return vehicle;
}
}
Im Beispiel sollen Objekte vom Typ „Vehicle“ erstellt werden. Es gibt zwei Implementierungen: „Car“ und „Truck“. Um keine Magic-Strings verwenden zu müssen, wird für die Typen zusätzlich eine Enumeration erzeugt.
In der Factory werden diese an die „createVehicle“ Methode übergeben, welche die entsprechende Vehicle Implementierung als Objekt zurückliefert. Welche Enumeration zu welcher Implementierung gehört, wird im static Block der Factory festgelegt. Das bedeutet, sollte es zukünftig neu Implementierungen geben, müssen diese lediglich hier hinzugefügt werden.
Aufruf:
public class Main {
public static void main(String[] args) {
// Fahrzeug erstellen
Vehicle car = VehicleFactory.createVehicle(VehicleType.CAR);
car.drive(); // Ausgabe: Driving a car
Vehicle truck = VehicleFactory.createVehicle(VehicleType.TRUCK);
truck.drive(); // Ausgabe: Driving a truck
}
}
Observer Pattern
Einsatzzweck
Das klassiche GOF Observer Pattern wird verwendet, um eine lose Kopplung zwischen einem Subjekt und mehreren Beobachtern zu erreichen. Wenn sich der Zustand des Subjekts ändert, werden alle registrierten Beobachter darüber benachrichtigt.
Beispiel
import java.util.ArrayList;
import java.util.List;
interface Observer {
void update(String message);
}
class Subscriber implements Observer {
private String name;
public Subscriber(String name) {
this.name = name;
}
@Override
public void update(String message) {
System.out.println(name + " received news: " + message);
}
}
class NewsPublisher {
private List<Observer> subscribers = new ArrayList<>();
public void addSubscriber(Observer observer) {
subscribers.add(observer);
}
public void removeSubscriber(Observer observer) {
subscribers.remove(observer);
}
public void publishNews(String message) {
for (Observer subscriber : subscribers) {
subscriber.update(message);
}
}
}
public class Main {
public static void main(String[] args) {
NewsPublisher publisher = new NewsPublisher();
Subscriber john = new Subscriber("John");
Subscriber jane = new Subscriber("Jane");
publisher.addSubscriber(john);
publisher.addSubscriber(jane);
publisher.publishNews("New Java release!");
}
}
Zuerst wird ein Interface „Observer“ und eine dazugehörige Implementierung „Subscriber“ erstellt. Der Subscriber stellt eine Methode bereits, die der Publisher im Falle einer Änderung ausführen soll.
Der Publisher (NewsPublisher) erlaubt für Aufrufer einerseits das Registrieren und Entfernen von Subscribern als auch eine zentrale Methode, um die Subscriber über Änderungen zu informieren (durch das Senden einer Nachricht).
Null Object Pattern
Einsatzzweck
Das Null Object Pattern ist kein GOF Pattern. Es ersetzt null
-Referenzen durch „leere“ Objekte, die eine Standardimplementierung bereitstellen. Es adressiert das Problem, dass null
in modernen Anwendungen immer noch eine häufige Fehlerquelle ist.
Beispiel
public interface Logger {
void log(String message);
}
public class ConsoleLogger implements Logger {
@Override
public void log(String message) {
System.out.println("Log: " + message);
}
}
public class NullLogger implements Logger {
@Override
public void log(String message) {
// Tut nichts
}
}
// Nutzung
public class Application {
private Logger logger;
public Application(Logger logger) {
this.logger = logger != null ? logger : new NullLogger();
}
public void run() {
logger.log("Application is running...");
}
}
In obenstehendem Beispiel definieren wir das Interface „Logger“ zum loggen einer Message. Während die ConsoleLogger-Implementierung die Message in der Console ausgibt, macht die NullLogger Implementierung nichts.
Auf diese Weise wird vermieden, dass es zu einer NullpointerException kommt, wenn beispielsweise der Aufrufer der Klasse „Application“ im Konstruktor „null“ als Logger übergibt.