SOLID Programmier Pattern

SOLID mit TypeScript

TypeScript hat bereits einen großen Einfluss darauf, dass man dem Clean-Code-Gedanken besser erfüllt. Doch man kann mit dem SOLID Prinzip noch viel besseren Programmcode schreiben. In diesem Artikel erkläre ich anhand von Beispielen SOLID in TypeScript.

SOLID ist ein Akronym für fünf Entwicklungsprinzipien, die dazu dienen, Software wartbarer und skalierbarer zu machen. Die SOLID-Prinzipien sind:

Diese Prinzipien wurden erstmals von Robert C. Martin eingeführt und sind in der objektorientierten Softwareentwicklung weit verbreitet, um einen flexibleren und wartbaren Code zu schreiben.

Single Responsibility Principle (SRP)

„Eine Klasse sollte nur einen Grund haben, sich zu ändern“

class User {
  private _username: string;
  private _password: string;

  constructor(username: string, password: string) {
    this._username = username;
    this._password = password;
  }

  public setUsername(username: string): void {
    this._username = username;
  }

  public setPassword(password: string): void {
    this._password = password;
  }

  public getUsername(): string {
    return this._username;
  }

  public getPassword(): string {
    return this._password;
  }
}

class UserValidator {
  public isUsernameValid(username: string): boolean {
    // implementation
  }

  public isPasswordValid(password: string): boolean {
    // implementation
  }
}

class UserService {
  public register(username: string, password: string): void {
    const user = new User(username, password);
    const validator = new UserValidator();

    if (validator.isUsernameValid(user.getUsername()) &&
        validator.isPasswordValid(user.getPassword())) {
      // register user
    } else {
      // return error
    }
  }
}

In diesem Beispiel hat die Klasse User die Aufgabe, Benutzerdaten zu speichern, und die Klasse UserValidator hat die Aufgabe, Benutzerdaten zu validieren. Die Klasse UserService hat die Aufgabe, einen Benutzer zu registrieren, und verwendet dazu die Klassen User und UserValidator.

Beachten Sie, dass es sich hierbei um einfache Beispiele handelt und Sie bei der tatsächlichen Implementierung komplexere Szenarien und Randfälle berücksichtigen sollten. Außerdem können die SOLID-Grundsätze auf verschiedenen Granularitätsebenen angewandt werden, z. B. auf funktionaler Ebene oder auf Modulebene.

Open-Closed-Prinzip (OCP)

„Eine Klasse sollte offen für Erweiterungen, aber geschlossen für Änderungen sein“

abstract class Shape {
  abstract getArea(): number;
}

class Circle extends Shape {
  private _radius: number;

  constructor(radius: number) {
    super();
    this._radius = radius;
  }

  getArea(): number {
    return Math.PI * this._radius ** 2;
  }
}

class Rectangle extends Shape {
  private _width: number;
  private _height: number;

  constructor(width: number, height: number) {
    super();
    this._width = width;
    this._height = height;
  }

  getArea(): number {
    return this._width * this._height;
  }
}

class AreaCalculator {
  constructor(private _shapes: Shape[]) {}

  sum(): number {
    let area = 0;
    this._shapes.forEach(shape => {
      area += shape.getArea();
    });
    return area;
  }
}

const shapes = [new Circle(5), new Rectangle(5, 10)];
const calculator = new AreaCalculator(shapes);
console.log(calculator.sum()); // prints 78.53981633974483

In diesem Beispiel ist die Klasse Shape offen für Erweiterungen (Sie können neue Formen hinzufügen, indem Sie neue Klassen erstellen, die sie erweitern), aber geschlossen für Änderungen (Sie müssen die Klasse Shape nicht ändern, um neue Formen hinzuzufügen). Die Klasse AreaCalculator verwendet die Klasse Shape und ihre Unterklassen, um die Summe der Flächen der Shapes zu berechnen, und sie muss nicht geändert werden, wenn Sie neue Shapes hinzufügen.

So kann die Anwendung neue Shapes hinzufügen, ohne den bestehenden Code zu ändern, und der AreaCalculator funktioniert auch mit den neuen Shapes ohne Änderung.

Liskov-Substitutionsprinzip (LSP)

„Subtypen sollten durch ihre Basistypen ersetzbar sein“

class Bird {
  fly(): void {
    console.log("I am flying");
  }
}

class Ostrich extends Bird {
  fly(): void {
    console.log("I can't fly but I can run very fast");
  }
}

function flyBird(bird: Bird) {
  bird.fly();
}

const bird = new Bird();
const ostrich = new Ostrich();

flyBird(bird); // prints "I am flying"
flyBird(ostrich); // prints "I can't fly but I can run very fast"

In diesem Beispiel hat die Klasse Bird eine fly-Methode, und die Klasse Ostrich erweitert Bird, hat aber ihre eigene Implementierung der fly-Methode. Die Funktion flyBird nimmt ein Bird als Argument, aber wir können ihr auch ein Ostrich-Objekt übergeben, da Ostrich eine Unterklasse von Bird ist und die gleichen Methoden implementiert, kann es die Bird-Klasse ersetzen.

Dies ermöglicht Flexibilität bei der Gestaltung der Klassen und der Funktionen, die sie verwenden. Das Liskov-Substitutionsprinzip besagt, dass Objekte einer Oberklasse durch Objekte einer Unterklasse ersetzt werden können sollten, ohne die Korrektheit des Programms zu beeinträchtigen.

In diesem Beispiel kann die Klasse Ostrich nicht fliegen, aber sie kann sehr schnell laufen, und sie ist ein gültiger Ersatz für die Klasse Bird, man muss die Funktion flyBird nicht ändern, um sie zu verwenden, und sie funktioniert trotzdem korrekt.

Interface Segregation Principle (ISP)

„Viele spezifische Schnittstellen sind besser als eine allgemeine“

interface Flyable {
  fly(): void;
}

interface Swimable {
  swim(): void;
}

interface Runnable {
  run(): void;
}

class Airplane implements Flyable {
  fly(): void {
    console.log("I am flying in the sky");
  }
}

class Fish implements Swimable {
  swim(): void {
    console.log("I am swimming in the water");
  }
}

class Human implements Runnable, Swimable {
  run(): void {
    console.log("I am running on the ground");
  }
  swim(): void {
    console.log("I am swimming in the water");
  }
}

In diesem Beispiel haben wir drei Schnittstellen: Flyable, Swimable und Runnable. Jede der Schnittstellen definiert ein einzelnes Verhalten. Die Klasse Airplane implementiert die Schnittstelle Flyable und kann fliegen, die Klasse Fish implementiert die Schnittstelle Swimable und kann schwimmen, und die Klasse Human implementiert die Schnittstellen Runnable und Swimable und kann laufen und schwimmen.

Indem wir die Schnittstellen in kleinere, spezialisiertere Schnittstellen aufteilen, können wir Klassen erstellen, die nur die für sie relevanten Methoden implementieren müssen. Dadurch wird der Code flexibler und leichter zu pflegen, da sich Änderungen an einer Schnittstelle nicht auf Klassen auswirken, die andere Schnittstellen implementieren.

Auf diese Weise können die Objekte nur die Methoden implementieren, die für sie relevant sind, und nicht die unnötigen. Dies macht den Code flexibler und weniger anfällig für Änderungen – das ist die Idee hinter dem Prinzip der Schnittstellentrennung.

Dependency Inversion Principle (DIP)

„Abhängigkeit von Abstraktionen, nicht von Konkretionen“

interface IWriter {
  write(data: string): void;
}

class FileWriter implements IWriter {
  write(data: string): void {
    // write data to a file
  }
}

class ConsoleWriter implements IWriter {
  write(data: string): void {
    console.log(data);
  }
}

class Logger {
  private _writer: IWriter;

  constructor(writer: IWriter) {
    this._writer = writer;
  }

  log(data: string): void {
    this._writer.write(data);
  }
}

const fileWriter = new FileWriter();
const consoleWriter = new ConsoleWriter();
const logger = new Logger(fileWriter);
logger.log("this will be written to a file");
logger = new Logger(consoleWriter);
logger.log("this will be written to the console");

In diesem Beispiel hängt die Logger-Klasse von einer IWriter-Schnittstelle und nicht von einer konkreten Implementierung eines Writers ab. Durch die Abhängigkeit von einer Schnittstelle ist die Logger-Klasse nicht eng an eine bestimmte Implementierung eines Writers gekoppelt. Die Klassen FileWriter und ConsoleWriter implementieren die IWriter-Schnittstelle und können austauschbar mit der Klasse Logger verwendet werden. Dies ermöglicht eine größere Flexibilität und eine einfachere Änderung des Codes.

Das Prinzip der Abhängigkeitsinversion besagt, dass High-Level-Module nicht von Low-Level-Modulen abhängen sollten, sondern dass beide von Abstraktionen abhängen sollten. In diesem Beispiel hängt die Klasse Logger von der Schnittstelle IWriter ab, die eine Abstraktion ist, und nicht von konkreten Klassen wie FileWriter oder ConsoleWriter. Dadurch ist die Klasse Logger flexibler und leichter zu ändern, da sie mit jeder Klasse arbeiten kann, die die IWriter-Schnittstelle implementiert.

Auf diese Weise hängen die High-Level-Module von der Abstraktion (Schnittstellen) und nicht von spezifischen Low-Level-Modulen ab, was den Code flexibler und einfacher zu pflegen macht und auch weniger anfällig für Änderungen.

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.

Warenkorb
WordPress Cookie Notice by Real Cookie Banner