Vier Säulen der OOP

Die objektorientierte Programmierung zeichnet sich durch vier Merkmale aus, die oftmals als die vier Säulen der objektorientierten Programmierung bezeichnet werden:

Die vier Säulen der objektorientierten Programmierung Die vier Säulen der objektorientierten Programmierung

Diese vier Säulen sind (englische Übersetzung in Klammern):

  1. Kapselung (Encapsulation): Daten und Logik werden in einen gemeinsamen Gültigkeitsbereich zusammengenommen und zu einem neuen Datentyp kombiniert, der die einzelnen Bestandteile unter einem Ganzen kapselt.
  2. Abstraktion (Abstraction): Programmkonstrukte definieren Operationen auf einer höheren Bedeutungsebene, wodurch Zusammenhänge der realen Welt in einer klareren und präziseren Sprache ausgedrückt werden können.
  3. Vererbung (Inheritance): Neue Datentypen können die Funktionalität von bestehenden Datentypen übernehmen und dabei erweitern oder verändern. Bei der Vererbung erweitert ein neuer Datentyp einen bestehenden.
  4. Polymorphie (Polymorphism): Datentypen mit einer gemeinsamen Schnittstelle können spezifisches Verhalten definieren, wodurch sie sich einfacher austauschen lassen. Ein abstrakter Datentyp kann in verschiedenen Gestalten («Polymorphie» besteutet «Vielgestaltigkeit») als konkreter Datentyp umgesetzt werden.

Diese vier Konzepte sollen durch konkrete Beispiele veranschaulicht werden:

Kapselung

Betrachten wir das Beispiel einer Person, die über einen Vor- und Nachnamen verfügt. Diese beiden Eigenschaften – firstName und lastName können in einer Klasse FullName zusammengefasst werden. Die Klasse verfügt über zwei Methoden: formatDefault und formatSwiss, welche den vollen Namen in der Form [Vorname] [Nachname] bzw. schweizerisch als [Nachname], [Vorname] zurückgeben:

class FullName {
  private firstName: string;
  private lastName: string;

  constructor(firstName: string, lastName: string) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  formatDefault(): string {
    return `${this.firstName} ${this.lastName}`;
  }

  formatSwiss(): string {
    return `${this.lastName}, ${this.firstName}`;
  }
}

Die Klasse kann folgendermassen verwendet werden:

const joe = new FullName("Joe", "Doe");
const jane = new FullName("Jane", "Done");
console.log(joe.formatDefault()); // Joe Doe
console.log(jane.formatSwiss()); // Done, Jane

Die beiden Eigenschaften haben die Sichtbarkeit private, d.h. es kann nur innerhalb der Klasse auf sie zugegriffen werden. Die beiden Methoden haben jedoch (implizit, da nichts anderes definiert ist) die Sichtbarkeit public, wodurch man sie auch von aussen verwenden kann. Dadurch sind die Daten gekapselt: Ein direkter Zugriff darauf ist nicht möglich, sondern muss über die beiden Methoden erfolgen.

Man kann die Kapselung somit als ein Verpacken und Verstecken verstehen, so wie ein elektronisches Gerät komplizierte Vorgänge vor dem Anwender versteckt, diesem aber eine vereinfachte Benutzerschnittstelle anbietet.

Abstraktion

Beim Programmieren agiert man auf verschiedenen Bedeutungsebenen oder Abstraktionsebenen. Jede Programmiersprache bietet Grundoperationen wie z.B. die Addition und Multiplikation für Zahlen an. Programme werden aber zumeist erst dann nützlich, wenn sie Funktionen der realen Welt abbilden können.

Betrachten wir als Beispiel einen Zähler. Dieser ist sehr ähnlich wie eine Zahl, hat aber einige Einschränkungen:

Der Zähler…

  1. … beginnt immer bei 0.
  2. … kann schrittweise um 1 erhöht werden.
  3. … kann niemals gesenkt werden.

Trotz oder vielmehr wegen dieser Einschränkungen ist ein Zähler für bestimmte Anwendungsgebiete besser geeignet als eine einfache Zahl. Denn diese könnte beliebig von einem anderen Programmteil verändert werden.

Ein Zähler mit solchen Einschränkungen lässt sich folgendermassen umsetzen:

class Counter {
  private count: number;

  constructor() {
    this.count = 0;
  }

  increase() {
    this.count++;
  }

  getCount(): number {
    return this.count;
  }
}

Wiederum kommt Kapselung zum Einsatz, wodurch der Wert des Zählers (count) nur um Einzelschritte erhöht (increase) und herausgelesen werden kann (getCount). Es ist nicht möglich, den Zähler zurückzusetzen.

Damit lassen sich etwa Besucher auf einer Webseite zählen:

const visitors = new Counter();
visitors.increase();
visitors.increase();
visitors.increase();
console.log(visitors.getCount()); // 3

Abstraktionen können zu neuen und höheren Abstraktionen kombiniert werden. Nur durch das Einführen sinnvoller Abstraktionsschichten lässt sich die Komplexität realistischer Programme überhaupt noch bewältigen.

Vererbung

Die Vererbung ist ein Mechanismus, mit der die Funktionalität einer bestehenden Klasse von zusätzlichen Klassen erweitert werden kann. Hierbei hat man es mit einer Vererbungshierarchie zu tun: Eine Klasse ganz oben an der Hierarchie ist sehr allgemein. Die Klassen weiter unten in der Hierarchie, die von den Klassen weiter oben in der Hierarchie erben, fügen auf jeder Stufe Erweiterungen hinzu.

Betrachten wir hierzu ein Beispiel als UML-Klassendiagramm:

---
title: "Vererbung: Angestellte und Kunden sind Spezialfälle von Personen"
---
classDiagram
    class Person {
        -firstName : string
        -lastName : string
        +constructor(firstName : string, lastName : string)
        +fullName() string
        +describe() string
    }
    class Employee {
        -company : string
        -boss : Employee
        +constructor(firstName : string, lastName : string, company : string, boss : Employee)
        +describe() string
    }
    class Customer {
        -revenue : number
        +constructor(firstName : string, lastName : string)
        +bookSell(amount : number)
        +describe() string
    }

    Person <|-- Employee
    Person <|-- Customer
    Employee o-- Employee

Die Vererbungshierarchie besteht aus drei Klassen:

  1. Person: Eine Person mit Vor- (firstName) und Nachnamen (lastName)
  2. Employee: Ein Angestellter mit Firma (company) und Vorgesetztem (boss)
  3. Customer: Ein Kunde mit Umsatz (amount)

Der Pfeil besagt: die Klasse mit dem ausgehenden Pfeil erbt von bzw. erweitert die Klasse mit dem eingehenden Pfeil. (Die Verbindungslinie bei Employee mit der Raute hat eine andere Bedeutung.)

Sowohl Employee und Customer sind gleichzeitig auch eine Person und verfügen damit über einen Vor- und Nachnamen. Auch die Methoden fullName und swissName lassen sich auf Instanzen aller drei Klassen aufrufen.

Die Implementierung sieht folgendermassen aus. Zuerst die Klasse Person:

class Person {
  private firstName: string;
  private lastName: string;

  constructor(firstName: string, lastName: string) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  fullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }

  describe(): string {
    return `Person ${this.fullName()}`;
  }
}

Die Klasse ist praktisch identisch zur Klasse FullName weiter oben.

Ein Angestellter (Employee) ist folgendermassen implementiert:

class Employee extends Person {
  private company: string;
  private boss?: Employee;

  constructor(
    firstName: string,
    lastName: string,
    company: string,
    boss?: Employee,
  ) {
    super(firstName, lastName);
    this.company = company;
    this.boss = boss;
  }

  describe(): string {
    const description = `${super.fullName()} working at ${this.company}`;
    if (this.boss != null) {
      return `${description} (under ${this.boss.describe()})`;
    } else {
      return description;
    }
  }
}

Der Vorgesetzte ist eine optionale Eigenschaft. Je nachdem, ob ein Angestellter über einen Vorgesetzten verfügt, gibt die Methode describe() eine andere Beschreibung der Person zurück.

Der Kunde (Customer) ist folgendermassen implementiert:

class Customer extends Person {
  private revenue: number;

  constructor(firstName: string, lastName: string) {
    super(firstName, lastName);
    this.revenue = 0.0;
  }

  bookSell(amount: number) {
    this.revenue += amount;
  }

  describe(): string {
    return `${super.fullName()} worth ${this.revenue}`;
  }
}

Mit der Methode bookSell kann ein Verkauf verbucht werden, der dem Umsatz zugeschlagen wird. Dieser Umsatz wird dann von der Methode describe ausgegeben.

Die Klassen lassen sich folgendermassen verwenden:

const ceo = new Employee("Biff", "Webster", "ACME Inc.", null);
const janitor = new Employee("Joe", "Doe", "ACME Inc.", ceo);
console.log(ceo.describe());
console.log(janitor.describe());

const customer = new Customer("Jane", "Done");
customer.bookSell(99.99);
customer.bookSell(49.99);
customer.bookSell(14.99);
console.log(customer.describe());

Die Ausgabe wird folgendermassen aussehen:

Biff Webster working at ACME Inc.
Joe Doe working at ACME Inc. (under Biff Webster working at ACME Inc.)
Jane Done worth 164.97

Polymorphie

Sowie die Kapselung die Abstraktion ermöglicht, ist auch die Vererbung die Grundlage für einen weiteren Mechanismus: die Polymorphie bzw. die Vielgestaltigkeit.

Bei der Polymorphie unterscheidet man zwischen einem abstrakten Datentyp einerseits und einem oder mehreren konkreten Datentypen andererseits. Der abstrakte Typ tritt durch konkrete Typen in verschiedenen Gestalten zur Erscheinung.

Betrachten wir als Beispiel die Grundrechenoperationen. Auf einer abstrakten Ebene kombinieren diese jeweils zwei Operanden mit einem Operator. Ein Wert kann von einer Reihe von Operationen verarbeitet werden. Dies lässt sich folgendermassen als UML-Klassendiagramm darstellen:

---
title: "Polymorphie: Eine abstrakte Operation kann verschiedene konkrete Formen annehmen"
---
classDiagram
    class Value {
        -state : number
        +constructor(value : number)
        +calculate(operations : Array~Operation~) Value
        +value() number
    }
    class Operation {
        -operand : number
        +constructor(operand : number)
        +perform(x : number) number
    }

    class Add {
    }
    class Sub {
    }
    class Mul {
    }
    class Div {
    }

    Operation <|-- Add
    Operation <|-- Sub
    Operation <|-- Mul
    Operation <|-- Div

    Value --> Value
    Value --> Operation

Es gibt eine Klasse Operation, welche mithilfe eines Operanden erzeugt wird, und die diesen als Eigenschaft operand abspeichert. Mit der Methode perform, welche einen weiteren Operanden als Parameter erhält, führt sie ihre Arbeit aus.

Die Klasse Value kapselt eine einfache Zahl als Zustand (state). Ihre Methode calculate erwartet eine Reihe von konkreten Operationen und gibt das Resultat als neue Instanz von Value zurück. Die Zahl erhält man durch den Aufruf der Methode value() zurück. Diese Klasse ist folgendermassen implementiert:

class Value {
  private state: number;

  constructor(value: number) {
    this.state = value;
  }

  calculate(operations: Array<Operation>): Value {
    let tmp: number = this.state;
    for (const operation of operations) {
      tmp = operation.perform(tmp);
    }
    return new Value(tmp);
  }

  value(): number {
    return this.state;
  }
}

Die Methode calculate arbeitet die gegebenen Operationen eine um die andere ab, merkt sich das Zwischenresultat und gibt schliesslich das Endresultat zurück.

Die Klasse Operation ist als abstrakte Klasse implementiert:

abstract class Operation {
  protected operand: number;

  constructor(operand: number) {
    this.operand = operand;
  }

  abstract perform(x: number): number;
}

Der Operand hat die Sichtbarkeit protected: Diese Eigenschaft kann nur innerhalb der Klasse Operation und innerhalb der Klassen verwendet werden, welche von Operation erben. Die folgenden vier Klassen implementieren je eine Rechenoperation, wozu sie auf operand zugreifen:

class Add extends Operation {
  perform(x: number): number {
    return x + this.operand;
  }
}

class Sub extends Operation {
  perform(x: number): number {
    return x - this.operand;
  }
}

class Mul extends Operation {
  perform(x: number): number {
    return x * this.operand;
  }
}

class Div extends Operation {
  perform(x: number): number {
    return x / this.operand;
  }
}

Im folgenden Beispiel wird, ausgehend von einem Wert von 7, eine Reihe von Rechenoperationen ausgeführt:

const x = new Value(7);
const result = x.calculate([new Add(1), new Div(2), new Sub(1), new Mul(2)]);
console.log(result.value());

Das Ergebnis:

$$ (((7 + 1) : 2) - 1) * 2 = 6 $$

Die abstrakte Klasse Operation tritt als verschiedene konkrete Klassen (Add, Sub, Mul, Div) in Erscheinung. Die konkreten Klassen sind dank der Polymorphie austauschbar.