Startseite / Artikel / SOLID-Prinzipien mit Code: Verstöße und Korrekturen für jede Regel

SOLID-Prinzipien mit Code: Verstöße und Korrekturen für jede Regel

Ein praktischer Gang durch die fünf SOLID-Prinzipien im Low-Level-Design, mit einem schlechten und einem guten Codebeispiel für SRP, OCP, LSP, ISP und DIP sowie einer kurzen Zusammenfassung.

2125 Wörter

Low-Level-Design ist die Phase, in der Anforderungen zu Klassen, Interfaces und Beziehungen werden. Unter den Ideen, die es leiten, tauchen die SOLID-Prinzipien häufiger auf als alle anderen.

SOLID steht für fünf Faustregeln, die objektorientierten Code sauber, flexibel und leicht wartbar halten:

  1. S: Single-Responsibility-Prinzip (SRP)
  2. O: Open/Closed-Prinzip (OCP)
  3. L: Liskovsches Substitutionsprinzip (LSP)
  4. I: Interface-Segregation-Prinzip (ISP)
  5. D: Dependency-Inversion-Prinzip (DIP)

Zu jedem Prinzip unten gibt es zwei Versionen desselben Codes, eine, die die Regel bricht, und eine, die ihr folgt, sodass der Unterschied leicht zu erkennen ist.

S: Single Responsibility

Eine Klasse sollte nur einen Grund haben, sich zu ändern. Anders gesagt: Sie sollte eine Aufgabe erfüllen.

Nehmen wir eine Klasse Car. Alles rund um Autos gehört hinein, und eine Funktion, die nichts mit Autos zu tun hat, gehört woandershin.

Zuerst die Version, die die Regel bricht:

package SingleResponsibilityWithViolation;
// Violates SRP: This class has multiple responsibilities
// (salary calculation & report generation)
class Employee {
    private String name;
    private double salary;    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }    public double calculateSalary() {
        return salary * 1.2; // Example salary calculation
    }    public void generateReport() {
        System.out.println("Generating employee report...");
    }
}public class SRPViolation {
    public static void main(String[] args) {
        Employee emp = new Employee("John", 50000);
        System.out.println("Salary: " + emp.calculateSalary());
        emp.generateReport();
    }
}

Hier jongliert die Klasse Employee mit drei Aufgaben: Sie hält Mitarbeiterdaten, berechnet das Gehalt und erstellt Berichte. Damit hat sie drei Gründe, sich zu ändern. Eine neue Gehaltsformel, ein neues Berichtslayout oder eine Änderung der Mitarbeiterfelder erzwingen allesamt Änderungen an derselben Klasse. Mit der Zeit bläht sich die Klasse auf und wird riskant anzufassen, denn eine Anpassung am Reporting könnte unbemerkt die Gehaltslogik brechen.

Jetzt die sauberere Version:

package SingleResponsibilityWithoutViolation;
// Now, each class has a single responsibility// Responsible only for holding employee details
class Employee {
    private String name;
    private double salary;    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }    public String getName() {
        return name;
    }    public double getSalary() {
        return salary;
    }
}// Responsible only for salary calculation
class SalaryCalculator {
    public double calculateSalary(Employee employee) {
        return employee.getSalary() * 1.2;
    }
}// Responsible only for report generation
class ReportGenerator {
    public void generateReport(Employee employee) {
        System.out.println("Generating employee report for " + employee.getName() + "...");
    }
}public class SRPExample {
    public static void main(String[] args) {
        Employee emp = new Employee("John", 10000);
        SalaryCalculator calculator = new SalaryCalculator();
        ReportGenerator reportGenerator = new ReportGenerator();        reportGenerator.generateReport(emp);
        System.out.println("Salary: " + calculator.calculateSalary(emp));
    }
}

Jede Klasse hat nun einen einzigen Grund, sich zu ändern. Employee speichert nur Daten, SalaryCalculator berechnet nur die Vergütung, und ReportGenerator erstellt nur Berichte. Ändert sich morgen die Gehaltsformel, wird nur SalaryCalculator angefasst, und nichts anderes ist gefährdet. Jede Klasse lässt sich außerdem viel leichter für sich testen.

O: Open/Closed

Software-Einheiten sollten offen für Erweiterung, aber geschlossen für Modifikation sein. In der Praxis sollte neues Verhalten hinzukommen, ohne bereits funktionierenden Code zu bearbeiten. Jede Änderung an stabilem Code riskiert, etwas kaputtzumachen, das in Ordnung war, daher sollten neue Funktionen durch Erweiterung des Bestehenden entstehen.

Der Verstoß:

package OpenClosedWithViolation;
// Violates OCP: Every time a new shape is added,
// we need to modify the existing class.
class AreaCalculator {
    public double calculateArea(String shape, double radius, double length, double breadth) {
        if (shape.equals("circle")) {
            return Math.PI * radius * radius;
        } else if (shape.equals("rectangle")) {
            return length * breadth;
        }
        return 0;
    }
}public class OCPViolation {
    public static void main(String[] args) {
        AreaCalculator calculator = new AreaCalculator();
        System.out.println("Circle Area: " + calculator.calculateArea("circle", 5, 0, 0));
    }
}

Um eine weitere Form zu unterstützen, ob Dreieck, Quadrat oder Sechseck, muss jemand zu AreaCalculator zurückkehren und einen weiteren else-if-Zweig hinzufügen. Die Klasse wird nie fertig. Auch die Methodensignatur ist unhandlich: radius, length und breadth müssen jedes Mal übergeben werden, selbst wenn die meisten irrelevant sind, weshalb der Aufruf diese Nullen enthält. Jede neue Form macht die Methode länger, schwerer lesbar und schwerer testbar.

Die Korrektur:

package OpenClosedWithoutViolation;
// Now, new shapes can be added without modifying the existing code
interface Shape {
    double calculateArea();
}class Circle implements Shape {
    private double radius;    public Circle(double radius) {
        this.radius = radius;
    }    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}class Rectangle implements Shape {
    private double length, breadth;    public Rectangle(double length, double breadth) {
        this.length = length;
        this.breadth = breadth;
    }    public double calculateArea() {
        return length * breadth;
    }
}class AreaCalculator {
    public double calculateArea(Shape shape) {
        return shape.calculateArea();
    }
}public class OCPExample {
    public static void main(String[] args) {
        Shape circle = new Circle(5);
        Shape rectangle = new Rectangle(4, 6);
        AreaCalculator calculator = new AreaCalculator();        System.out.println("Circle Area: " + calculator.calculateArea(circle));
        System.out.println("Rectangle Area: " + calculator.calculateArea(rectangle));
    }
}

Jetzt weiß jede Form selbst, wie sie ihre Fläche berechnet, und AreaCalculator ist egal, welche Form es bekommt. Es ruft einfach calculateArea() auf. Ein Triangle hinzuzufügen bedeutet, eine neue Klasse zu schreiben, die Shape implementiert, ohne Änderungen an AreaCalculator, Circle oder Rectangle. Das Design ist offen für Erweiterung, weil sich neue Formen frei hinzufügen lassen, und geschlossen für Modifikation, weil bestehende Klassen bleiben, wie sie sind.

L: Liskovsche Substitution

Objekte einer Oberklasse sollten durch Objekte einer Unterklasse ersetzt werden können, ohne die Korrektheit des Programms zu beeinträchtigen.

Dieses Prinzip hängt an der Vererbung. Eine Elternklasse hat mehrere Kindklassen, die ihre Eigenschaften erben. Das Prinzip besagt, dass überall dort, wo Code den Elterntyp erwartet, jeder Kindtyp an seiner Stelle korrekt funktionieren sollte.

Ein Beispiel für den Verstoß:

package LiskovSubstitutionWithViolation;
// Violates LSP: Penguin cannot fly, but it inherits a fly() method.
class Bird {
    public void fly() {
        System.out.println("Flying...");
    }
}class Sparrow extends Bird {
    @Override
    public void fly() {
        System.out.println("Sparrow can fly");
    }
}class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins can't fly!");
    }
}public class LSPViolation {
    public static void main(String[] args) {
        Bird penguin = new Penguin();
        Bird sparrow = new Sparrow();
        sparrow.fly();
        penguin.fly(); // This will break at runtime
    }
}

Die Basisklasse Bird geht davon aus, dass jeder Vogel fliegt, daher sitzt fly() direkt in ihr. Pinguine können nicht fliegen, also muss Penguin fly() überschreiben, nur um eine Exception zu werfen. Jeder Code, der einen Bird entgegennimmt und fly() aufruft, wie hier main(), stürzt zur Laufzeit ab, wenn er einen Penguin bekommt. Die Unterklasse kann ihre Elternklasse nicht mehr gefahrlos vertreten, und genau das verbietet LSP. Die eigentliche Ursache ist schlechtes Vererbungsdesign: Fliegen wurde in die Basisklasse eingebaut, als könnte es jeder Vogel.

Das korrigierte Design:

package LiskovSubstitutionWithoutViolation;
abstract class Bird {
    abstract void makeSound();
}interface FlyingBird {
    void fly();
}class Sparrow extends Bird implements FlyingBird {
    public void makeSound() {
        System.out.println("Chirp Chirp!");
    }    public void fly() {
        System.out.println("Sparrow is flying...");
    }
}class Penguin extends Bird {
    public void makeSound() {
        System.out.println("Penguin sound...");
    }
}public class LSPExample {
    public static void main(String[] args) {
        Bird sparrow = new Sparrow();
        Bird penguin = new Penguin();        sparrow.makeSound();
        penguin.makeSound();        FlyingBird flyingBird = new Sparrow();
        flyingBird.fly();  // Works without breaking substitution
    }
}

Das Fliegen wanderte aus der Basisklasse in ein eigenes Interface FlyingBird. Bird garantiert jetzt nur, was wirklich jeder Vogel kann, nämlich einen Laut von sich geben, und die Arten, die fliegen, schließen sich an, indem sie FlyingBird implementieren. Penguin bleibt ein legitimer Bird, überall verwendbar, wo ein Bird erwartet wird, ohne dass zur Laufzeit ein Absturz lauert. Das Design entspricht nun der Realität, statt gegen sie anzukämpfen.

I: Interface Segregation

Clients sollten nicht gezwungen werden, von Interfaces abzuhängen, die sie nicht nutzen. Das Prinzip bevorzugt kleinere, spezifischere Interfaces gegenüber einem großen Allzweck-Interface.

Ein Beispiel für das Problem:

package InterfaceSegregationWithViolation;
// Violates ISP: Robot is forced to implement eat()
// even though it doesn't need it.
interface Worker {
    void work();
    void eat();
}class Human implements Worker {
    public void work() {
        System.out.println("Human working...");
    }    public void eat() {
        System.out.println("Human eating...");
    }
}class Robot implements Worker {
    public void work() {
        System.out.println("Robot working...");
    }    public void eat() {
        throw new UnsupportedOperationException("Robots do not eat!");
    }
}public class ISPViolation {
    public static void main(String[] args) {
        Human human = new Human();
        human.work();
        human.eat();        Robot robot = new Robot();
        robot.work();
        robot.eat(); // Causes error
    }
}

Das Interface Worker packt zwei unzusammenhängende Fähigkeiten, Arbeiten und Essen, in einen Vertrag. Ein Roboter kann arbeiten, aber nicht essen, und muss trotzdem eat() bereitstellen. Eine Exception zu werfen ist der einzige Ausweg, und dadurch gibt die Klasse ihre Fähigkeiten falsch an. Ein Aufruf von eat() auf etwas vom Typ Worker kann dann abstürzen, wenn sich das Objekt als Robot entpuppt. Für manche Implementierungen verlangt dieses Interface zu viel.

Die aufgeteilte Version:

package InterfaceSegregationWithoutViolation;
interface Worker {
    void work();
}interface Eatable {
    void eat();
}class Human implements Worker, Eatable {
    public void work() {
        System.out.println("Developer is coding...");
    }    public void eat() {
        System.out.println("Developer is eating...");
    }
}class Robot implements Worker {
    public void work() {
        System.out.println("Robot is working...");
    }
}public class ISPExample {
    public static void main(String[] args) {
        Human human = new Human();
        human.work();
        human.eat();        Robot robot = new Robot();
        robot.work();
    }
}

Aus dem fetten Interface wurden zwei fokussierte, Worker und Eatable. Jede Klasse implementiert nur, was sie wirklich kann: Human implementiert beide, Robot nur Worker. Es bleiben keine nicht unterstützten Methoden, keine zu werfenden Exceptions und keine irreführenden Verträge. Jede Klasse spiegelt ehrlich ihre eigenen Fähigkeiten wider.

D: Dependency Inversion

High-Level-Module sollten nicht von Low-Level-Modulen abhängen. Beide sollten von Abstraktionen abhängen statt von konkreten Implementierungen.

Hier die Version, die das Prinzip bricht:

package DependencyInversionWithViolation;
class WiredKeyboard {
    public void connect() {
        System.out.println("Wired keyboard connected");
    }
}// Violates DIP: Computer is tightly coupled to WiredKeyboard.
class Computer {
    private WiredKeyboard keyboard = new WiredKeyboard(); // Tight coupling    public void start() {
        keyboard.connect();
    }
}public class DIPViolation {
    public static void main(String[] args) {
        Computer pc = new Computer();
        pc.start();
    }
}

In diesem Aufbau sitzt Computer auf der oberen Ebene, und WiredKeyboard ist ein Low-Level-Detail. Da Computer selbst ein WiredKeyboard erzeugt und in einem Feld hält, ist es dauerhaft an diesen einen Tastaturtyp gebunden. Eine kabellose Tastatur zu unterstützen hieße, Computer selbst zu bearbeiten. Auch Unit-Tests sind nahezu unmöglich, da sich weder eine Fake- noch eine Mock-Tastatur einsetzen lässt, wenn die Abhängigkeit intern erzeugt statt übergeben wird.

Und hier die Version, die ihm folgt:

package DependencyInversionWithoutViolation;
interface Keyboard {
    void connect();
}class WiredKeyboard implements Keyboard {
    public void connect() {
        System.out.println("Wired keyboard connected");
    }
}class WirelessKeyboard implements Keyboard {
    public void connect() {
        System.out.println("Wireless keyboard connected");
    }
}class Computer {
    private Keyboard keyboard;    public Computer(Keyboard keyboard) {
        this.keyboard = keyboard;
    }    public void start() {
        keyboard.connect();
    }
}public class DIPExample {
    public static void main(String[] args) {
        Keyboard wired = new WiredKeyboard();
        Computer pc1 = new Computer(wired);
        pc1.start();        Keyboard wireless = new WirelessKeyboard();
        Computer pc2 = new Computer(wireless);
        pc2.start();
    }
}

Computer hängt jetzt vom Interface Keyboard ab, einer Abstraktion, statt von einer bestimmten Tastaturklasse. Die tatsächliche Tastatur kommt von außen über den Konstruktor, eine Technik namens Dependency Injection. Eine kabelgebundene, eine kabellose oder jede künftige Tastatur lässt sich anschließen, ohne Computer jemals zu ändern. Auch das Testen wird einfach, weil sich eine Mock-Tastatur injizieren lässt.

Alle fünf auf einen Blick

  • SRP: Geben Sie jeder Klasse eine einzige Aufgabe und einen einzigen Grund, sich zu ändern.
  • OCP: Fügen Sie Verhalten durch Erweiterung hinzu und lassen Sie funktionierenden Code unangetastet.
  • LSP: Eine Kindklasse muss überall funktionieren, wo ihre Elternklasse funktioniert.
  • ISP: Lieber mehrere schlanke Interfaces als ein aufgeblähtes.
  • DIP: Stützen Sie sich auf Abstraktionen, nie auf konkrete Klassen.

Ein roter Faden zieht sich durch alle fünf Beispiele: Abstraktion und Trennung. Fast jeder Verstoß ließ sich auf eine Klasse zurückführen, die zu viel tat oder fest mit einem konkreten Typ verschweißt war, und fast jede Korrektur trennte Zuständigkeiten und programmierte gegen ein Interface.

Wer mit diesen fünf Prinzipien vertraut ist, schreibt bereits spürbar saubereren und wartbareren Code, und es ist eine gute Vorbereitung auf LLD-Interviewrunden. Entwurfsmuster sind das natürliche nächste Thema.