Vorherige Teile:
Wir denken weiterhin über Ansätze nach, wie wir unseren Code sauber, flexibel und wartbar machen können. Beginnen wir nun mit der Untersuchung sauberer architektonischer Lösungen.
Die SOLID-Prinzipien wurden von Robert C. Martin eingeführt und gelten weithin als Best Practices in der objektorientierten Programmierung und im Softwaredesign.
In diesem Artikel werfen wir einen Blick auf die drei ersten SOLID-Prinzipien: Das Single-Responsibility-Prinzip, das Open/Closed-Prinzip und das Liskov-Substitutionsprinzip anhand praktischer Beispiele.
Eine Klasse sollte nur einen Grund haben, sich zu ändern
Mit anderen Worten: Eine Klasse sollte eine einzige Verantwortung oder Aufgabe haben. Dies ist wichtig, da eine Klasse mit einer einzigen Verantwortung leichter zu verstehen, zu ändern und zu warten ist. Änderungen in einem Bereich des Codes wirken sich nicht auf das gesamte System aus, wodurch das Risiko der Einführung von Fehlern verringert wird.
Werfen wir einen Blick auf ein praktisches Beispiel und Möglichkeiten, dem Prinzip der Einzelverantwortung zu folgen:
// Bad class UserSettingsService { constructor(user: IUser) { this.user = user; } changeSettings(settings: IUserSettings): void { if (this.isUserValidated()) { // ... } } getUserInfo(): Promise<IUserSettings> { // ... } async isUserValidated(): Promise<boolean> { const userInfo = await this.getUserInfo(); // ... } }
In diesem Beispiel führt unsere Klasse Aktionen in verschiedene Richtungen aus: Sie richtet den Kontext ein, ändert ihn und validiert ihn.
Um SRP zu befolgen, müssen wir diese unterschiedlichen Verantwortlichkeiten aufteilen.
// Better class UserAuth { constructor(user: IUser) { this.user = user; } getUserInfo(): Promise<IUserSettings> { // ... } async isUserValidated(): boolean { const userInfo = await this.getUserInfo(); // ... } } class UserSettings { constructor(user: IUser) { this.user = user; this.auth = new UserAuth(user); } changeSettings(settings: IUserSettings): void { if (this.auth.isUserValidated()) { // ... } } }
SRP zielt darauf ab, die Codemodularität zu verbessern und die Herausforderungen, die sich aus gegenseitigen Abhängigkeiten ergeben, zu minimieren. Durch die Organisation des Codes in Funktionen, getrennte Klassen und die Förderung der Modularität wird er wiederverwendbar und spart Zeit, die andernfalls für die Neucodierung vorhandener Funktionen aufgewendet werden müsste.
Softwareeinheiten (Klassen, Module, Funktionen) sollten für Erweiterungen offen, für Änderungen jedoch geschlossen sein
Sie sollten in der Lage sein, neue Funktionen hinzuzufügen, ohne den vorhandenen Code zu ändern. Dies ist wichtig, da Sie durch die Einhaltung dieses Prinzips neue Funktionen oder Komponenten in Ihr System einführen können, ohne die Stabilität des vorhandenen Codes zu gefährden.
Es fördert die Wiederverwendbarkeit von Code und reduziert die Notwendigkeit umfangreicher Änderungen bei der Erweiterung der Funktionalität.
// Bad class Product { id: number; name: string[]; price: number; protected constructor(id: number, name: string[], price: number) { this.id = id; this.name = name; this.price = price; } } class Ananas extends Product { constructor(id: number, name: string[], price: number) { super(id, name, price); } } class Banana extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } } class HttpRequestCost { constructor(product: Product) { this.product = product; } getDeliveryCost(): number { if (product instanceOf Ananas) { return requestAnanas(url).then(...); } if (product instanceOf Banana) { return requestBanana(url).then(...); } } } function requestAnanas(url: string): Promise<ICost> { // logic for ananas } function requestBanana(url: string): Promise<ICost> { // logic for bananas }
In diesem Beispiel liegt das Problem in der Klasse HttpRequestCost
, die in der Methode getDeliveryCost
Bedingungen für die Berechnung verschiedener Produkttypen enthält, und wir verwenden für jeden Produkttyp separate Methoden. Wenn wir also einen neuen Produkttyp hinzufügen müssen, sollten wir die HttpRequestCost
Klasse ändern. Dies ist nicht sicher. wir könnten unerwartete Ergebnisse erzielen.
Um dies zu vermeiden, sollten wir eine abstrakte request
in der Product
ohne Realisierungen erstellen. Die besondere Erkenntnis wird die Klassen geerbt haben: Ananas und Banane. Sie werden die Bitte für sich selbst realisieren.
HttpRequestCost
übernimmt den product
, der der Product
folgt, und wenn wir bestimmte Abhängigkeiten in HttpRequestCost
übergeben, wird die request
bereits für sich selbst realisiert.
// Better abstract class Product { id: number; name: string[]; price: string; constructor(id: number, name: string[], price: string) { this.id = id; this.name = name; this.price = price; } abstract request(url: string): void; } class Ananas extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } request(url: string): void { // logic for ananas } } class Banana extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } request(url: string): void { // logic for bananas } } class HttpRequestCost { constructor(product: Product) { this.product = product; } request(): Promise<void> { return this.product.request(url).then(...); } }
Wenn Sie diesem Prinzip folgen, reduzieren Sie die Codekopplung und bewahren das System vor unvorhersehbarem Verhalten.
Objekte einer Oberklasse sollten durch Objekte ihrer Unterklassen ersetzbar sein, ohne die Anwendung zu beeinträchtigen.
Um dieses Prinzip zu verstehen, schauen wir uns dieses Beispiel an:
// Bad class Worker { work(): void {/../} access(): void { console.log('Have an access to closed perimeter'); } } class Programmer extends Worker { createDatabase(): void {/../} } class Seller extends Worker { sale(): void {/../} } class Designer extends Worker { access(): void { throwError('No access'); } }
In diesem Beispiel haben wir ein Problem mit der Contractor
Klasse. Designer
, Programmer
und Seller
sind alle Worker und haben von der übergeordneten Klasse Worker
geerbt. Gleichzeitig haben Designer jedoch keinen Zugriff auf den geschlossenen Bereich, da sie Auftragnehmer und keine Mitarbeiter sind. Und wir haben die access
außer Kraft gesetzt und das Liskov-Substitutionsprinzip gebrochen.
Dieses Prinzip besagt, dass die Funktionalität nicht beeinträchtigt werden sollte, wenn wir die Oberklasse Worker
durch ihre Unterklasse, beispielsweise Designer
Klasse, ersetzen. Wenn wir dies jedoch tun, wird die Funktionalität der Programmer
beeinträchtigt – die access
erhält unerwartete Erkenntnisse aus der Designer
Klasse.
Gemäß dem Liskov-Substitutionsprinzip sollten wir die Realisierungen nicht in Unterklassen umschreiben, sondern müssen neue Abstraktionsebenen erstellen, in denen wir für jeden Abstraktionstyp bestimmte Realisierungen definieren.
Korrigieren wir es:
// Better class Worker { work(): void {/../} } class Employee extends Worker { access(): void { console.log('Have an access to closed perimeter'); } } class Contractor extends Worker { addNewContract(): void {/../} } class Programmer extends Employee { createDatabase(): void {/../} } class Saler extends Employee { sale(): void {/../} } class Designer extends Contractor { makeDesign(): void {/../} }
Wir haben neue Ebenen der Abstraktionen Employee
und Contractor
erstellt, die access
in die Klasse „ Employee
verschoben und eine spezifische Realisierung definiert. Wenn wir Worker
Klasse durch die Unterklasse Contractor
ersetzen, wird die Worker
Funktionalität nicht beeinträchtigt.
Wenn Sie sich an LSP halten, können Sie jede Unterklasseninstanz überall dort ersetzen, wo eine Basisklasseninstanz erwartet wird, und das Programm sollte weiterhin wie vorgesehen funktionieren. Dies fördert die Wiederverwendung von Code und die Modularität und macht Ihren Code widerstandsfähiger gegenüber Änderungen.
Im nächsten Artikel werfen wir einen Blick auf die SOLID-Prinzipien der Schnittstellentrennung und der Abhängigkeitsinversion.