Einführung
SOLID ist ein mnemonisches Akronym für fünf Prinzipien des objektorientierten Designs, die von Robert C. Martin eingeführt wurden, der im Volksmund als Uncle Bob bezeichnet wird. Diese Prinzipien sollen Softwaredesignern, Architekten, Ingenieuren und Entwicklern helfen, flexiblere, wartbarere und skalierbarere Software zu erstellen. Durch das Befolgen dieser Prinzipien können Sie Klassen entwerfen, die einfacher zu testen, zu refaktorieren, wiederzuverwenden und zu erweitern sind.
Das SOLID-Akronym steht für:
S – Single Responsibility Principle
O – Open-Closed Principle
L – Liskov Substitution Principle
I – Interface Segregation Principle
D – Dependency Inversion Principle
In diesem Artikel werden wir jedes Prinzip einzeln erklären, um zu verstehen, wie es Ihnen helfen kann, besseren Code zu schreiben. Darüber hinaus werden wir Code-Snippets für jedes Prinzip hinzufügen, um Ihnen zu zeigen, wie Sie diese in Ihrer Programmierpraxis anwenden können und was Sie in einer Clean-Code-Architektur vermeiden sollten. Um die Konzepte zu demonstrieren, verwenden wir die Kotlin-Programmiersprache, die von JetBrains und Open-Source-Mitwirkenden entwickelt wurde.
Single-Responsibility-Prinzip
Das Single-Responsibility-Prinzip (SRP) ist ein Softwaredesign-Prinzip, das besagt, dass jede Klasse oder jedes Modul in einem Programm eine einzige, klar definierte Verantwortung haben sollte. Das bedeutet, dass eine Klasse nur einen einzigen Grund für Änderungen haben sollte und nur für einen einzelnen Teil der Funktionalität des Programms verantwortlich sein sollte.
Betrachten Sie das folgende Code-Snippet als Beispiel dafür, wie dieses Prinzip in Kotlin:
|
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
Das data-Schlüsselwort in Kotlin zeigt an, dass es sich bei dieser Klasse um eine Datenklasse handelt, was bedeutet, dass sie dazu gedacht ist, Daten zu halten, und kein komplexes Verhalten aufweist. Mit dieser Datenklasse können wir eine UserService -Klasse zur Verwaltung von Benutzern erstellen, wie unten gezeigt:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // Code zum Erstellen eines Benutzers in der Datenbank } fun deleteUser(user: User) { // Code zum Löschen eines Benutzers aus der Datenbank } fun updateUser(user: User) { // Code zum Aktualisieren eines Benutzers in der Datenbank } fun getUser(id: Int): User { // Code zum Abrufen eines Benutzers aus der Datenbank } } |
In diesem Beispiel hat die UserService-Klasse eine einzige Verantwortung: die Verwaltung von Benutzern in einer Datenbank. Jeder Benutzer wird durch den zuvor geteilten Code der data class User repräsentiert. Alle Methoden in der UserService-Klasse hängen mit dieser Verantwortung zusammen und sind daher kohäsiv. Dies macht die Klasse einfacher zu verstehen und zu warten, da klar ist, dass alle Methoden in der Klasse mit einer einzigen, klar definierten Aufgabe zusammenhängen.
Eine Methode, die das Single-Responsibility-Prinzip (SRP) in der UserService-Klasse verletzt, wäre eine, die nicht mit der Hauptverantwortung der Klasse (der Verwaltung von Benutzern in einer Datenbank) zusammenhängt. Betrachten Sie beispielsweise die folgende Variante der UserService-Klasse:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // Code zum Erstellen eines Benutzers in der Datenbank } fun deleteUser(user: User) { // Code zum Löschen eines Benutzers aus der Datenbank } fun updateUser(user: User) { // Code zum Aktualisieren eines Benutzers in der Datenbank } fun getUser(id: Int): User { // Code zum Abrufen eines Benutzers aus der Datenbank } fun sendEmail(user: User, subject: String, message: String) { // Code zum Senden einer E-Mail an den Benutzer } } |
In diesem Beispiel bezieht sich die sendEmail -Methode nicht auf die Hauptverantwortung der UserService-Klasse, welche in der Verwaltung von Benutzern in einer Datenbank besteht. Diese Methode ist für das Senden von E-Mails verantwortlich, was ein separates Anliegen von der Verwaltung von Benutzern ist. Als Folge verletzt diese Methode das SRP, da sie einen zweiten Grund für eine Änderung der UserService-Klasse einführt.
Um das Single-Responsibility-Prinzip einzuhalten, wäre es besser, die E-Mail-Versandfunktionalität in eine separate Klasse auszulagern, wie beispielsweise eine EmailService -Klasse. Dies würde es der UserService-Klasse ermöglichen, sich auf ihre Hauptverantwortung der Benutzerverwaltung zu konzentrieren, und der EmailService-Klasse, sich auf ihre Verantwortung für den E-Mail-Versand zu konzentrieren.
Sie sollten beachten, dass es beim Single-Responsibility-Prinzip nicht um die Anzahl der Methoden geht, die eine Klasse hat, sondern vielmehr um den Zusammenhalt der Methoden und die klare Trennung der Verantwortlichkeiten innerhalb einer Klasse.
Open-Closed-Prinzip
Das Open-Closed-Prinzip (OCP) ist ein Software-Design-Prinzip, das besagt, dass Software-Entitäten (wie Klassen, Module oder Funktionen) offen für Erweiterungen, aber geschlossen für Modifikationen sein sollten. Dies bedeutet, dass es möglich sein sollte, einer Klasse oder einem Modul neue Funktionalität hinzuzufügen, ohne deren bestehenden Code zu ändern.
Betrachten Sie die UserService-Klasse aus dem vorherigen Beispiel. Angenommen, wir möchten der UserService-Klasse eine neue Funktion hinzufügen, mit der wir nach Benutzern anhand ihrer E-Mail-Adresse suchen können. Eine Möglichkeit, dies zu tun, bestünde darin, der UserService-Klasse eine neue Methode hinzuzufügen, wie im folgenden Code hervorgehoben:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // Code zum Erstellen eines Benutzers in der Datenbank } fun deleteUser(user: User) { // Code zum Löschen eines Benutzers aus der Datenbank } fun updateUser(user: User) { // Code zum Aktualisieren eines Benutzers in der Datenbank } fun getUser(id: Int): User { // Code zum Abrufen eines Benutzers aus der Datenbank } fun searchUsersByEmail(email: String): List<User> { // Code zum Suchen nach Benutzern per E-Mail-Adresse } } |
Dieser Ansatz funktioniert zwar, verletzt jedoch das Open-Closed-Prinzip, da wir die bestehende UserService-Klasse ändern mussten, um die neue Funktion hinzuzufügen. Ein besserer Ansatz wäre die Verwendung von Vererbung oder Komposition, um die Funktionalität der UserService-Klasse zu erweitern, ohne deren Code zu ändern.
Um dies zu erreichen, könnten wir eine neue Klasse namens UserSearchService erstellen, welche die UserService-Klasse erweitert und die E-Mail-Suchfunktionalität hinzufügt:
|
1 2 3 4 5 6 7 8 9 |
class UserSearchService(private val userService: UserService) : UserService() { fun searchUsersByEmail(email: String): List<User> { // Code zum Suchen nach Benutzern per E-Mail-Adresse } } |
In diesem Beispiel ist die UserSearchService-Klasse offen für Erweiterungen, da sie zusätzliche Funktionalität über das hinaus bietet, was von der UserService-Klasse. Gleichzeitig bleibt die UserService-Klasse für Änderungen geschlossen, da wir ihren Code nicht modifizieren mussten, um die E-Mail-Suchfunktion hinzuzufügen.
Liskovsches Substitutionsprinzip
Das Liskovsche Substitutionsprinzip (LSP) ist ein Software-Design-Prinzip, das besagt, dass Objekte einer Superklasse durch Objekte einer Subklasse ersetzt werden können sollten, ohne die Korrektheit des Programms zu beeinträchtigen. Dies bedeutet, dass eine Subklasse ein gültiger Ersatz für ihre Superklasse sein sollte und sich im selben Kontext genauso verhalten sollte wie die Superklasse.
Wir werden die Demonstration mit den Klassen User und UserService aus den vorherigen Beispielen fortsetzen. Um eine Erweiterung der User-Klasse zu ermöglichen, verwenden wir das Schlüsselwort open in Kotlin:
|
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
Hier ist die ursprüngliche UserService-Klasse, die die obige User-Datenklasse verwendet:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // Code zum Erstellen eines Benutzers in der Datenbank } fun deleteUser(user: User) { // Code zum Löschen eines Benutzers aus der Datenbank } fun updateUser(user: User) { // Code zum Aktualisieren eines Benutzers in der Datenbank } fun getUser(id: Int): User { // Code zum Abrufen eines Benutzers aus der Datenbank } } |
Angenommen, wir möchten eine Subklasse von User namens AdminUser erstellen, die Benutzer mit administrativen Rechten darstellt. Das könnten wir wie folgt tun:
|
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
In diesem Beispiel ist die AdminUser-Klasse ein gültiger Ersatz für die User-Klasse, da sie sich genauso verhält wie die User-Klasse und überall dort verwendet werden kann, wo ein User-Objekt erwartet wird. Beispielsweise können wir die AdminUser-Klasse mit der UserService-Klasse wie folgt verwenden:
|
1 2 3 4 5 6 7 8 9 |
fun main() { val userService = UserService() val adminUser = AdminUser("John", "Doe", "john.doe@example.com") userService.createUser(adminUser) } |
Dieser Code ist korrekt, da die AdminUser-Klasse ein gültiger Ersatz für die User-Klasse ist und auf dieselbe Weise wie ein User-Objekt verwendet werden kann.
Es’s wichtig zu beachten, dass es beim Liskovschen Substitutionsprinzip um mehr als nur Vererbung geht. Es geht darum sicherzustellen, dass sich Objekte einer Subklasse genauso verhalten wie Objekte der Superklasse, unabhängig davon, wie die Subklasse implementiert ist. Wenn sich beispielsweise die AdminUser-Klasse in irgendeiner Weise anders verhalten würde als die User-Klasse, würde dies gegen das Liskovsche Substitutionsprinzip verstoßen, da sie kein gültiger Ersatz für die User-Klasse wäre.
Interface-Segregations-Prinzip
Das Interface-Segregations-Prinzip (ISP) ist ein Software-Design-Prinzip, das besagt, dass Clients nicht dazu gezwungen werden sollten, von Schnittstellenabhängig zu sein, die sie nicht verwenden. Dies bedeutet, dass es im Allgemeinen eine gute Idee ist, kleine, fokussierte Schnittstellen zu erstellen, die eine Sache gut machen, anstatt große, universelle Schnittstellen zu erstellen, die versuchen, viele Dinge zu tun.
Hier’s ein Beispiel dafür, wie dieses Prinzip angewendet werden kann, indem die User- und UserService-Klassen aus den vorherigen Beispielen umgeschrieben werden:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User } class DatabaseUserService : UserService { override fun createUser(user: User) { // Code zum Erstellen eines Benutzers in der Datenbank } override fun deleteUser(user: User) { // Code zum Löschen eines Benutzers aus der Datenbank } override fun updateUser(user: User) { // Code zum Aktualisieren eines Benutzers in der Datenbank } override fun getUser(id: Int): User { // Code zum Abrufen eines Benutzers aus der Datenbank } } |
In diesem Beispiel definiert das UserService-Interface vier Methoden, die mit der Verwaltung von Benutzern in einer Datenbank zusammenhängen. Die Klasse DatabaseUserService implementiert dieses Interface und stellt konkrete Implementierungen für diese Methoden bereit.
Angenommen, wir möchten dem UserService-Interface eine neue Funktion hinzufügen, mit der wir nach Benutzern anhand ihrer E-Mail-Adresse suchen können. Eine Möglichkeit hierfür wäre, dem UserService-Interface eine neue Methode hinzuzufügen:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User fun searchUsersByEmail(email: String): List<User> } |
Ihr Code wird nicht ausgeführt, es sei denn, Sie implementieren diese Methode auch in allen Klassen, die das UserService-Interface implementieren:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService { override fun createUser(user: User) { // Code zum Erstellen eines Benutzers in der Datenbank } override fun deleteUser(user: User) { // Code zum Löschen eines Benutzers aus der Datenbank } override fun updateUser(user: User) { // Code zum Aktualisieren eines Benutzers in der Datenbank } override fun getUser(id: Int): User { // Code zum Abrufen eines Benutzers aus der Datenbank } override fun searchUsersByEmail(email: String): List<User> { // Code zum Suchen von Benutzern nach E-Mail-Adresse } } |
Dieser Ansatz funktioniert zwar, verletzt jedoch das Interface-Segregations-Prinzip, da er die Klasse DatabaseUserService dazu zwingt, eine Methode ( searchUsersByEmail ) zu implementieren, die sie möglicherweise nicht benötigt oder verwendet.
Ein besserer Ansatz wäre es, ein separates Interface für die E-Mail-Suchfunktionalität zu erstellen:
|
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
Jetzt haben wir separate, kleine und fokussierte Interfaces, d. h. UserService und UserSearchServicedie eine einzige Verantwortung haben. Eine Klasse, die alle Funktionalitäten dieser Schnittstellen benötigt, kann diese wie im folgenden Code-Snippet gezeigt implementieren:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService, UserSearchService { override fun createUser(user: User) { // Code zum Erstellen eines Benutzers in der Datenbank } override fun deleteUser(user: User) { // Code zum Löschen eines Benutzers aus der Datenbank } override fun updateUser(user: User) { // Code zum Aktualisieren eines Benutzers in der Datenbank } override fun getUser(id: Int): User { // Code zum Abrufen eines Benutzers aus der Datenbank } override fun searchUsersByEmail(email: String): List<User> { // Code zum Suchen von Benutzern nach E-Mail-Adresse } } |
Dies entspricht dem Interface-Segregations-Prinzip, da es sicherstellt, dass Clients (wie die DatabaseUserService Klasse) nicht gezwungen sind, von Schnittstellen abzuhängen, die sie nicht verwenden.
Um dieses Konzept besser zu verstehen, nehmen wir an, wir haben eine weitere Klasse namens MemoryUserService , die die Schnittstelle UserService implementiert, aber die E-Mail-Suchfunktionalität nicht benötigt. Wir können den Code wie folgt schreiben:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserService : UserService { private val users = mutableListOf<User>() override fun createUser(user: User) { users.add(user) } override fun deleteUser(user: User) { users.remove(user) } override fun updateUser(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun getUser(id: Int): User { return users[id] } } |
In diesem Beispiel muss die Klasse MemoryUserService nur die in der Schnittstelle UserService definierten Methoden implementieren und muss sich nicht um die E-Mail-Suchfunktionalität kümmern. Dies ermöglicht es der Klasse MemoryUserService, sich auf ihre Hauptverantwortung zu konzentrieren, nämlich die Verwaltung von Benutzern im Speicher, anstatt gezwungen zu sein, nicht damit zusammenhängende Funktionalitäten zu implementieren.
Dependency-Inversion-Prinzip
Das Dependency-Inversion-Prinzip (DIP) is ein Software-Entwurfsprinzip, das besagt, dass Module auf höherer Ebene nicht von Modulen auf niedrigerer Ebene abhängen sollten, sondern beide von Abstraktionen abhängen sollten. Das bedeutet, dass es im Allgemeinen eine gute Idee ist, Software so zu entwerfen, dass Komponenten auf höherer Ebene nicht an bestimmte Implementierungen von Komponenten auf niedrigerer Ebene gebunden sind, sondern von Abstraktionen (wie Schnittstellen oder abstrakten Klassen) abhängen, die auf unterschiedliche Weise implementiert werden können.
Sehen wir uns ein Beispiel an, wie dieses Prinzip auf die Klassen User und UserService angewendet werden kann, die in den vorherigen Code-Snippets verwendet wurden:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserRepository { fun create(user: User) fun delete(user: User) fun update(user: User) fun get(id: Int): User } class UserService(private val repository: UserRepository) { fun createUser(user: User) { repository.create(user) } fun deleteUser(user: User) { repository.delete(user) } fun updateUser(user: User) { repository.update(user) } fun getUser(id: Int): User { return repository.get(id) } } |
In diesem Beispiel hängt die UserService-Klasse von einer Abstraktion namens der UserRepository-Schnittstelle ab, anstatt von einer spezifischen Implementierung eines Benutzer-Repositorys abzuhängen. Dies ermöglicht es uns, die UserRepository-Schnittstelle auf verschiedene Weise zu implementieren, beispielsweise mit einer Datenbank oder einem In-Memory-Speicher, ohne die UserService-Klasse zu beeinflussen.
Hier ist beispielsweise eine Implementierung der UserRepository-Schnittstelle, die eine Datenbank verwendet:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class DatabaseUserRepository: UserRepository { override fun create(user: User) { // Code zum Erstellen eines Benutzers in der Datenbank } override fun delete(user: User) { // Code zum Löschen eines Benutzers aus der Datenbank } override fun update(user: User) { // Code zum Aktualisieren eines Benutzers in der Datenbank } override fun get(id: Int): User { // Code zum Abrufen eines Benutzers aus der Datenbank } } |
Hier ist eine weitere Implementierung der UserRepository-Schnittstelle, die einen In-Memory-Speicher verwendet:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserRepository : UserRepository { private val users = mutableListOf<User>() override fun create(user: User) { users.add(user) } override fun delete(user: User) { users.remove(user) } override fun update(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun get(id: Int): User { return users[id] } } |
Dies macht das System flexibler und wartbarer, da es uns ermöglicht, die Repository-Implementierung zu ändern, ohne den Rest des Systems zu beeinflussen. Es erleichtert auch das Testen der UserService-Klasse, da wir die UserRepository-Abhängigkeit in unseren Tests mocken können.
Fazit
In diesem Artikel besprachen wir die fünf Prinzipien von SOLID Code und freigegebene Code-Snippets, die jedes Prinzip erfüllen. Die Einhaltung dieser Prinzipien kann Ihnen helfen, Softwaresysteme zu entwerfen, die flexibler, wartbarer und skalierbarer sind. Es ist jedoch wichtig zu bedenken, dass diese Prinzipien Richtlinien und keine festen Regeln sind, und es liegt am Entwickler zu entscheiden, wann und wie er sie im Kontext seines spezifischen Projekts anwendet. Lernen Sie weiter, indem Sie unseren Blog besuchen, um ausführlichere und aktuellere Artikel und Tutorials über Cloud-Computing und DevOps, Softwaredesign und -entwicklung, Technologietrends, auf die Sie achten sollten, und mehr.
Viel Spaß beim Codieren!
Kommentare
Noch keine Kommentare. Schreiben Sie den ersten.