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:
- Single Responsibility Principle (SRP) – eine Klasse sollte nur einen Grund haben, sich zu ändern
- Open-Closed-Prinzip (OCP) – eine Klasse sollte offen für Erweiterungen, aber geschlossen für Änderungen sein
- Liskov-Substitutionsprinzip (LSP) – Subtypen sollten durch ihre Basistypen ersetzbar sein
- Interface Segregation Principle (ISP) – viele spezifische Schnittstellen sind besser als eine allgemeine
- Dependency Inversion Principle (DIP) – Abhängigkeit von Abstraktionen, nicht von Konkretionen
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.