Was sind SOLID-Prinzipien? (Die fünf SOLID-Prinzipien des objektorientierten Designs)
Die SOLID-Prinzipien sind eine Sammlung von fünf grundlegenden Prinzipien des objektorientierten Designs (OOD), die von Robert C. Martin eingeführt wurden.
Die SOLID-Prinzipien liefern Leitlinien für die Entwicklung von Software, die sich mit wachsendem Projekt leichter warten und skalieren lässt. Diese Prinzipien können helfen, Code Smells zu beseitigen, das Refactoring von Code zu vereinfachen und agile bzw. adaptive Entwicklungspraktiken zu unterstützen.
Dieser Leitfaden stellt jedes Prinzip einzeln vor und erklärt, wie es Ihre Entwicklungsfähigkeiten verbessern und Ihnen helfen kann, besseren und robusteren Java-Code zu schreiben.
Was sind SOLID-Prinzipien?
Das Akronym SOLID steht für:
- S: Single Responsibility Principle (Prinzip der eindeutigen Verantwortlichkeit)
- O: Open-Closed Principle (Offen-geschlossen-Prinzip)
- L: Liskov Substitution Principle (Liskovsches Substitutionsprinzip)
- I: Interface Segregation Principle (Prinzip der Schnittstellentrennung)
- D: Dependency Inversion Principle (Prinzip der Abhängigkeitsumkehr)
Single Responsibility Principle
Das Single Responsibility Principle bedeutet, dass sich jede Klasse auf nur eine bestimmte Aufgabe oder einen Zweck konzentrieren sollte, sodass es nur einen einzigen Grund gibt, sie zu ändern.
Technisch ausgedrückt sollte das Design einer Klasse nur von einer Art von Änderung beeinflusst werden, etwa von der Datenbanklogik oder der Logging-Logik. Repräsentiert eine Klasse beispielsweise eine Datenentität wie ein Buch (Book) oder einen Studenten (Student), sollte sie nur dann aktualisiert werden, wenn sich die Struktur oder die Attribute dieser Entität ändern.
Die Einhaltung dieses Prinzips bringt erhebliche Vorteile. Es verringert das Risiko, dass mehrere Teams für voneinander unabhängige Zwecke an derselben Klasse arbeiten, was andernfalls zu widersprüchlichen oder inkompatiblen Änderungen führen könnte.
Außerdem vereinfacht es die Versionskontrolle. Wird beispielsweise eine Klasse aktualisiert, die ausschließlich für Datenbankoperationen zuständig ist, lassen sich die Änderungen an der Datenbankfunktionalität leicht erkennen.
Dieses Prinzip minimiert zudem Merge-Konflikte bei der gemeinsamen Entwicklung. Wenn jede Klasse nur einen Zweck hat, überschneiden sich weniger Änderungen, sodass Konflikte seltener auftreten und leichter zu lösen sind.
Beispiel
Hier ist ein Beispiel für das Single Responsibility Principle. Betrachten wir eine Klasse Student:
Vor der Anwendung des SRP (Single Responsibility Principle) übernimmt die Klasse Student sowohl studentenspezifische Aufgaben als auch eine Aufgabe im Zusammenhang mit Benachrichtigungen, was gegen das SRP verstößt.
public class Student {
public String getDetails(int studentID) {
// Logic to fetch student details
}
public void updateGrade(int studentID, int grade) {
// Logic to update student grade
}
public void sendNotification(String message) {
// Logic to send notifications to the student
}
}Nach Anwendung des Single Responsibility Principle lagern wir die Zuständigkeit für Benachrichtigungen in eine neue Klasse NotificationService aus:
public class Student {
public String getDetails(int studentID) {
// Logic to fetch student details
}
public void updateGrade(int studentID, int grade) {
// Logic to update student grade
}
}
public class NotificationService {
public void sendNotification(String message) {
// Logic to send notifications to students
}
}Die Klasse Student konzentriert sich jetzt nur noch auf studentenbezogenes Verhalten.
Die Benachrichtigungslogik ist in der Klasse NotificationService gekapselt, wodurch der Code leichter zu verwalten und zu ändern ist.
Jede Klasse hat eine klare und einzige Verantwortlichkeit, was die Kopplung verringert und die Wartbarkeit verbessert.
Open-Closed Principle
Das Open/Closed Principle besagt, dass „Softwareeinheiten (wie Klassen, Module oder Funktionen) offen für Erweiterungen, aber geschlossen für Änderungen sein sollten“. Das bedeutet, dass Sie einem System neue Funktionen hinzufügen können, ohne den bestehenden Code zu ändern.
Sehen wir uns dieses Prinzip an einem Beispiel an
Angenommen, Sie haben eine Klasse Shape, mit der in einer Geometrieanwendung die Fläche verschiedener Formen berechnet wird. Anfangs unterstützt die Klasse nur die Flächenberechnung von Rechtecken. Später möchten Sie auch die Flächenberechnung von Kreisen unterstützen.
Anstatt die bestehende Klasse Shape um die Logik für Kreise zu ergänzen, können Sie eine neue Klasse wie Circle erstellen, die das Verhalten erweitert. So bleibt die Klasse Shape unverändert, und die neue Funktionalität kann trotzdem hinzugefügt werden.
So lässt sich das umsetzen:
// Base class
public abstract class Shape {
public abstract double calculateArea();
}
// Rectangle class
public class Rectangle extends Shape {
private double length;
private double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
public double calculateArea() {
return length * width;
}
}
// Circle class
public class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
Im obigen Code gilt gemäß dem Open/Closed Principle:
Die Klasse Shape wird nicht verändert, was die Stabilität des bestehenden Codes gewährleistet. Neue Formen (etwa Triangle oder Square) lassen sich einfach durch Erweiterung der Klasse Shape hinzufügen. Dieser Ansatz fördert Wiederverwendbarkeit, Flexibilität und Wartbarkeit des Codes.
Liskov Substitution Principle
Das Liskov Substitution Principle, das Barbara Liskov 1987 vorgestellt hat, besagt, dass „Subtypen ihre Basistypen ersetzen können müssen“. Das bedeutet, dass jede Kindklasse ihre Elternklasse ersetzen können sollte, ohne unerwartetes Verhalten zu verursachen.
Um dieses Prinzip zu verstehen, betrachten wir das Beispiel einer Klasse Bird. Ein Bird (Vogel) hat in der Regel Verhaltensweisen wie das Fliegen. Eine bestimmte Vogelart, etwa ein Pinguin, kann von der Klasse Bird erben. Pinguine können jedoch nicht fliegen, was zu einem Verstoß gegen das Liskov Substitution Principle führen würde, wenn das Flugverhalten direkt vererbt wird.
Beispiel
So lässt sich das anhand von Code erklären:
Im Folgenden sehen Sie den Verstoß gegen das Liskov Substitution Principle:
// Parent class
public class Bird {
public void fly() {
System.out.println("Flying...");
}
}
// Child class
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly");
}
}
In diesem Beispiel führt das Ersetzen von Bird durch Penguin zu unerwartetem Verhalten, weil die Klasse Penguin die Methode fly mit einer Exception überschreibt.
Um das Liskov Substitution Principle anzuwenden, können wir den Code umstrukturieren und das Flugverhalten in eine eigene Abstraktion auslagern:
// Base class
public abstract class Bird {
public abstract void eat();
}
// Flying behavior
public interface Flyable {
void fly();
}
// Subclass for birds that can fly
public class Sparrow extends Bird implements Flyable {
@Override
public void eat() {
System.out.println("Sparrow is eating...");
}
@Override
public void fly() {
System.out.println("Sparrow is flying...");
}
}
// Subclass for birds that cannot fly
public class Penguin extends Bird {
@Override
public void eat() {
System.out.println("Penguin is eating...");
}
}
Die Klasse Bird konzentriert sich jetzt nur noch auf gemeinsame Verhaltensweisen wie das Fressen.
Das Flugverhalten ist isoliert, und nur Vögel, die fliegen können, implementieren die Schnittstelle Flyable.
Das Ersetzen der Klasse Bird durch einen beliebigen Vogel funktioniert wie erwartet, wodurch die Konsistenz gewahrt bleibt und Laufzeitfehler vermieden werden.
Das obige Codebeispiel stellt sicher, dass Kindklassen ihre Elternklasse ersetzen können, ohne gegen das Liskov Substitution Principle zu verstoßen.
Interface Segregation Principle
Das Interface Segregation Principle bezieht sich auf Schnittstellen statt auf Klassen und ist eng mit dem Single Responsibility Principle verwandt. Es besagt: „Kein Client sollte gezwungen werden, eine Schnittstelle zu implementieren, die er nicht nutzt.“
Die Idee besteht darin, keine großen, allgemeinen Schnittstellen zu erstellen, sondern sie in kleinere, spezifischere Schnittstellen aufzuteilen, die auf die Bedürfnisse einzelner Clients zugeschnitten sind.
Einfacher ausgedrückt sollte eine Klasse oder Entität gemäß dem Interface Segregation Principle nur Methoden implementieren müssen, die für ihren konkreten Zweck relevant sind. Das reduziert unnötige Abhängigkeiten und sorgt für Flexibilität im Design.
Beispiel
Stellen Sie sich ein Bibliotheksverwaltungssystem vor. Es gibt eine allgemeine Schnittstelle LibraryServices mit Methoden zum Ausleihen von Büchern, zur Rückgabe von Büchern, zum Bezahlen von Säumnisgebühren und zum Reservieren von E-Books.
Nehmen wir nun an, es gibt zwei Arten von Benutzern: Besucher vor Ort und Online-Benutzer.
Besucher vor Ort benötigen nur Zugriff auf das Ausleihen und Zurückgeben von Büchern.
Online-Benutzer konzentrieren sich auf das Reservieren von E-Books und nutzen die Ausleih- oder Rückgabefunktionen möglicherweise nicht.
Eine einzige Schnittstelle LibraryServices für beide Benutzertypen zwingt beide dazu, Methoden zu implementieren, die sie nicht benötigen, und verstößt damit gegen das Interface Segregation Principle.
Verstoß gegen das Interface Segregation Principle
// Fat interface with unrelated methods
public interface LibraryServices {
void borrowBook();
void returnBook();
void payLateFee();
void reserveEBook();
}
// In-person visitor implements all methods but uses only a few
public class InPersonVisitor implements LibraryServices {
@Override
public void borrowBook() {
System.out.println("Borrowing a book...");
}
@Override
public void returnBook() {
System.out.println("Returning a book...");
}
@Override
public void payLateFee() {
System.out.println("Paying a late fee...");
}
@Override
public void reserveEBook() {
// Not applicable for in-person visitors
throw new UnsupportedOperationException("Not applicable for in-person visitors");
}
}
Durch Einhaltung des Interface Segregation Principle wird die einzelne große Schnittstelle in kleinere, clientspezifische Schnittstellen aufgeteilt.
// Smaller, focused interfaces
public interface BookBorrowing {
void borrowBook();
void returnBook();
}
public interface FeePayment {
void payLateFee();
}
public interface EBookReservation {
void reserveEBook();
}
// In-person visitors only implement relevant interfaces
public class InPersonVisitor implements BookBorrowing, FeePayment {
@Override
public void borrowBook() {
System.out.println("Borrowing a book...");
}
@Override
public void returnBook() {
System.out.println("Returning a book...");
}
@Override
public void payLateFee() {
System.out.println("Paying a late fee...");
}
}
// Online users implement only their relevant interface
public class OnlineUser implements EBookReservation {
@Override
public void reserveEBook() {
System.out.println("Reserving an e-book...");
}
}
Der obige Ansatz stellt sicher, dass das Design sauber bleibt und dem Interface Segregation Principle entspricht.
Die Vorteile der Einhaltung des Prinzips
- Jeder Client arbeitet nur mit Schnittstellen, die für ihn relevant sind.
- Reduziert unnötige Abhängigkeiten.
- Erleichtert die Wartung und Erweiterung des Systems.
- Vermeidet mögliche Laufzeitfehler, etwa durch nicht implementierte Methoden, die Exceptions auslösen.
Dependency Inversion Principle
Das Dependency Inversion Principle betont:
„Entitäten sollten von Abstraktionen statt von konkreten Implementierungen abhängen.“
Das bedeutet, dass High-Level-Module (zuständig für die Gesamtfunktionalität) nicht direkt von Low-Level-Modulen (zuständig für Implementierungsdetails) abhängen sollten. Stattdessen sollten beide auf Abstraktionen (wie Schnittstellen oder abstrakten Klassen) beruhen.
Durch die Einhaltung dieses Prinzips erreichen Sie eine Entkopplung, die das System flexibler und wartbarer macht und Änderungen erleichtert, ohne andere Komponenten zu beeinträchtigen.
Beispiel
Betrachten wir einen NotificationService, der Nachrichten über verschiedene Kanäle wie SMS und E-Mail versenden muss. Hängt der NotificationService direkt von konkreten Klassen für den SMS- oder E-Mail-Versand ab, ist er eng an diese Implementierungen gekoppelt. Jede Änderung an den Low-Level-Klassen (z. B. das Hinzufügen von WhatsApp-Benachrichtigungen) würde Änderungen am NotificationService erfordern und damit gegen das Prinzip verstoßen.
// High-level module directly depends on low-level modules
public class NotificationService {
private EmailSender emailSender;
private SMSSender smsSender;
public NotificationService() {
this.emailSender = new EmailSender(); // Tight coupling
this.smsSender = new SMSSender(); // Tight coupling
}
public void sendNotification(String message) {
emailSender.sendEmail(message);
smsSender.sendSMS(message);
}
}
class EmailSender {
public void sendEmail(String message) {
System.out.println("Sending Email: " + message);
}
}
class SMSSender {
public void sendSMS(String message) {
System.out.println("Sending SMS: " + message);
}
}
In diesem Beispiel ist NotificationService eng an EmailSender und SMSSender gekoppelt. Das Hinzufügen einer neuen Benachrichtigungsmethode (z. B. WhatsAppSender) würde eine Änderung der Klasse NotificationService erfordern und damit gegen das Dependency Inversion Principle verstoßen.
Um das Dependency Inversion Principle einzuhalten, führen Sie eine Abstraktion (eine Schnittstelle) ein, von der sowohl das High-Level-Modul (NotificationService) als auch die Low-Level-Module (EmailSender, SMSSender) abhängen.
// Abstraction for sending notifications
public interface NotificationSender {
void send(String message);
}
// Low-level module: EmailSender
public class EmailSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending Email: " + message);
}
}
// Low-level module: SMSSender
public class SMSSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}
// High-level module: NotificationService
public class NotificationService {
private final NotificationSender notificationSender;
// Dependency injection via constructor
public NotificationService(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
public void sendNotification(String message) {
notificationSender.send(message);
}
}
Anwendungsbeispiel
Jetzt kann der NotificationService mit jeder Implementierung der Schnittstelle NotificationSender arbeiten, ohne direkt von bestimmten Klassen abhängig zu sein.
public class Main {
public static void main(String[] args) {
NotificationSender emailSender = new EmailSender();
NotificationService emailService = new NotificationService(emailSender);
emailService.sendNotification("Your email message!");
NotificationSender smsSender = new SMSSender();
NotificationService smsService = new NotificationService(smsSender);
smsService.sendNotification("Your SMS message!");
}
}Vorteile des Dependency Inversion Principle
- Verringert die Kopplung zwischen High-Level- und Low-Level-Modulen.
- Erleichtert das Hinzufügen neuer Implementierungen (z. B. WhatsAppSender), ohne bestehenden Code zu ändern.
- Fördert Dependency Injection, was das Testen vereinfacht und die Flexibilität erhöht.
Indem Sie von Abstraktionen statt von konkreten Implementierungen abhängen, können Sie ein modulares und skalierbares System schaffen.
Fazit
In diesem Artikel haben wir die fünf SOLID-Prinzipien für sauberen und wartbaren Code vorgestellt. Wenn Sie diese Prinzipien befolgen, lassen sich Projekte leichter mit Teammitgliedern teilen, um neue Funktionen erweitern, ändern, testen und refaktorisieren, und mögliche Schwierigkeiten werden dabei minimiert.
Suchen Sie die besten VPS-Hosting-Dienste? Bei BlueVPS bieten wir leistungsstarke, skalierbare und anpassbare Hosting-Lösungen mit einer dedizierten Umgebung und unbegrenztem Traffic. Starten Sie noch heute und bringen Sie Ihr Hosting auf das nächste Level!
Blog