Klassen & Objekte

In der objektorientierten Programmierung geht es um Objekte. Diese können in JavaScript direkt als sogenannte Objektliteralen erstellt werden. TypeScript ermöglicht es, die Typen dieser Objektliteralen genauer zu beschreiben. Mit diesem Mechanismus stösst man aber schnell an Grenzen, die mithilfe von Klassen überwunden werden können. Objekte lassen sich dann als Instanzen von Klassen erzeugen.

Typen in JavaScript und in TypeScript

JavaScript ist eine dynamisch typisierte Programmiersprache. Der Typ einer Variablen bzw. einer Konstanten lässt sich zur Laufzeit mit dem Operator typeof ermitteln:

const fullName = "Joe Doe";
const age = 42;
const married = false;

console.log(fullName, typeof fullName);
console.log(age, typeof age);
console.log(married, typeof married);

Ausgabe:

Joe Doe string
42 number
false boolean

Das obige JavaScript-Programm ist auch gleichzeitig ein TypeScript-Programm. Die Datentypen der drei Konstanten müssen hierzu nicht explizit angegeben werden, da diese anhand des initialen Werts mithilfe von Typinferenz vom Compiler ermittelt werden können.

Union Types

In den Übungen zu TypeScript ist die Funktion prompt zum Einsatz gekommen. Diese gibt grundsätzlich eine Zeichenkette zurück, welche die Eingabe des Benutzers enthält.

Betrachten wir folgendes Programm:

const input = prompt("say something:");
console.log(input, typeof input);

Führt man es aus und gibt einen String ein, wird dieser ausgegeben:

say something: Hello!
Hello! string

Wird die Eingabe aber abgebrochen, z.B. mit der Tastenkombination [Ctrl]-[D] in der Bash, sieht die Ausgabe folgendermassen aus:

null object

Die Funktion prompt kann also eine Zeichenkette oder null zurückgeben. Eine entsprechende Typannotation sieht folgendermassen aus:

const input: string | null = prompt("say something:");

Die Konstante input kann also eine Zeichenkette oder null sein. So kann input im weiteren Verlauf des Codes nicht einfach als ein string behandelt werden:

const input: string | null = prompt("say something:");
const capitalized = input.toUpperCase(); // ERROR: 'input' is possibly 'null'.

Dieses Konstrukt bezeichnet man als eine type union: als eine Vereinigungsmenge von Datentypen.

null behandeln

Zur Behandlung möglicher null-Werte gibt es zwei Varianten:

  1. Man verwendet den non-null assertion operator ! bei der Zuweisung.
  2. Man verwendet den optional chaining operator ?. beim Zugriff.

Bei der ersten Variante gibt der Programmierer an, dass der Rückgabewert von prompt sicherlich nicht null sein werde – und er bereit sei, andernfalls einen Programmabsturz in Kauf zu nehmen:

const input: string = prompt("say something:")!;

Dadurch hat der Rückgabewert nun den Datentyp string und nicht mehr string | null.

Bei der zweiten Variante behält der Ausdruck seinen Wert:

const input: string | null = prompt("say something:");
console.log(input?.toUpperCase());

In der weiteren Verwendung des Ausdrucks wird aber mit ?. statt mit . auf Eigenschaften/Methoden des Werts zugegriffen. Dieser Operator gibt undefined zurück, falls der Wert null ist. Andernfalls wird die angegebene Methode ausgeführt.

Typen definieren

In der objektorientierten Programmierung bildet man Sachverhalte aus der realen Welt ab, indem man neue Datentypen definiert. Bei TypeScript müssen dies aber nicht unbedingt gleich Klassen sein.

Betrachten wir Identifikationsnummern, die entweder als Zeichenkette oder als Zahl dargestellt werden können:

let idJoe: string | number = "123-456-789";
let idJane: string | number = 987654321;

console.log(idJoe, typeof idJoe);
console.log(idJane, typeof idJane);

Ausgabe:

123-456-789 string
987654321 number

Der Wert kann jeweils vom Typ string oder number sein. Es ist aber auf Dauer umständlich, diese Typinformation immer wieder aufs neue hinzuschreiben.

Mithilfe des type-Schlüsselworts lassen sich solche Definitionen unter einem neuen Typnamen abspeichern und wiederverwenden:

type Identifier = string | number;

let idJack: Identifier = "456-789-123";
let idJill: Identifier = 654987321;

console.log(idJack, typeof idJack);
console.log(idJill, typeof idJill);

Einem neuen Typbezeichner Identifier wird die Typdefinition string | number zugewiesen. Der Typbezeichner kann ab sofort als Datentyp verwendet werden.

Die Ausgabe mag überraschen:

456-789-123 string
654987321 number

Als Datentyp wird nicht etwa Identifier ausgegeben, sondern der jeweils tatsächlich verwendete Datentyp.

Dies liegt daran, dass die TypeScript-Typinformationen nur zur Kompilierzeit existieren. Zur Laufzeit sind diese in reale JavaScript-Typen aufgelöst.

Info

Merke:

  • typeof ist ein JavaScript-Schlüsselwort, das den Datentyp eines Ausdrucks zur Laufzeit zurückgibt.
  • type ist ein TypeScript-Schlüsselwort, das einen Datentyp für die Kompilierzeit definiert.

Objektliteralen und Objekttypen

Mehrere Werte können mit einem Bezeichner als Objekt abgespeichert werden. Der JavaScript-Datentyp dazu lautet object:

const person: object = {
  firstName: "Joe",
  lastName: "Doe",
  age: 42,
};
console.log(person, typeof person);

Ausgabe:

{ firstName: "Joe", lastName: "Doe", age: 42 } object

Doch woher wissen wir, auf welche Bezeichner wird auf dem Objekt zugreifen können?

console.log(person.firstName);  // ok
console.log(person.middleName); // ERROR
console.log(person.lastName);   // ok

Alle drei Zeilen lösen eine Warnung aus, da der Typ object die jeweiligen Eigenschaften nicht definiert. Die erste und dritte Zeile geben jeweils einen Wert ("Joe" bzw. "Doe") aus, während die zweite Zeile undefined ausgibt.

Um solche Zugriffsprobleme (und Warnungen) zu vermeiden, lässt sich die Struktur eines Objekt als Datentyp annotieren:

const joe: {
  fullName: string;
  age: number;
  married: boolean;
  spouse?: string;
} = {
  fullName: "Joe Doe",
  age: 42,
  married: true,
  spouse: "Jane Done",
};

const jill: {
  fullName: string;
  age: number;
  married: boolean;
  spouse?: string;
} = {
  fullName: "Jill",
  age: 39,
  married: false,
};

console.log(joe, typeof joe);
console.log(jill, typeof jill);

Die Konstanten joe und jill haben jeweils einen Typ annotiert, der drei zwingende Eigenschaften (fullName, age, married) und eine optionale Eigenschaft (spouse, mit ? als optional deklariert) verlangt.

Die Ausgabe mag erneut überraschen:

{ fullName: "Joe Doe", age: 42, married: true, spouse: "Jane Done" } object
{ fullName: "Jill", age: 39, married: false } object

Auch hier lautet der Datentyp object, denn die Typinformation ist zur Laufzeit verschwunden.

Nun wird es auf Dauer umständlich, die ganze Struktur des Objekts für jeden Wert neu zu beschreiben. Zum Glück lassen sich mit type auch Objektliteralen beschreiben und unter einem anderen Namen abspeichern:

type Person = {
  fullName: string;
  age: number;
  married: boolean;
  spouse?: string;
};

Neue Werte können nun mit dem Datentyp Person annotiert werden:

const jack: Person = {
  fullName: "Jack Jackson",
  age: 33,
  married: false,
};
const jane: Person = {
  fullName: "Jill Doe",
  age: 39,
  married: true,
  spouse: "Joe Doe",
};

console.log(jack, typeof jack);
console.log(jane, typeof jane);

Ausgabe:

{ fullName: "Jack Jackson", age: 33, married: false } object
{ fullName: "Jill Doe", age: 39, married: true, spouse: "Joe Doe" } object

Objekttypen mit Funktionen (Methoden)

Betrachten wir ein weiteres Beispiel: arithmetische Operationen. Es sollen Objekte für die vier Grundrechenarten erstellt werden. Hierzu definieren wir zunächst einen Datentyp für den Operator:

type Operator = "+" | "-" | "*" | "/";

Ein Ausdruck vom Typ Operator kann nicht etwa verschiedene Datentypen annehmen, sondern nur ganz bestimmte Werte. Diesen Mechanismus bezeichnet man als literalen Typ.

Eine arithmetische Operation soll folgendermassen definiert sein:

type Operation = {
  operator: Operator;
  perform: (l: number, r: number) => number;
  display: (l: number, r: number) => string;
};

Ein Objekt vom Typ Operation verfügt über einen Operator und zwei Funktionen bzw. Methoden: perform und display, welche jeweils zwei Zahlen (l und r als linker und rechter Operand) erwarten und eine Zahl bzw. eine Zeichenkette zurückgeben. Die konkrete Implementierung der Funktion ist aber dem konkreten Operator überlassen.

Die Funktionen werden dabei nicht definiert sondern nur als Signatur deklariert: Nach der Parameterliste folgt der Rückgabetyp hinter dem => Operator. Anstelle einer Implementierung zwischen { und } folgt ein Semikolon.

Die Grundrechenart Addition kann nun folgendermassen implementiert und verwendet werden:

const addition: Operation = {
  operator: "+",
  perform: (l, r): number => l + r,
  display: (l, r): string => {
    return `${l} + ${r} = ${l + r}`;
  },
};

console.log(addition.perform(2, 1));
console.log(addition.display(2, 1));

Die Methode perform führt die Rechnung aus und gibt das Ergebnis zurück. Die Methode display gibt die ganze Rechnung inkl. Lösung als Zeichenkette zurück.

Die Ausgabe sieht vielversprechend aus:

3
2 + 1 = 3

Implementieren und verwenden wir also eine weitere Operation, die Subtraktion:

const subtraction: Operation = {
  operator: "-",
  perform: (l, r): number => l - r,
  display: (l, r): string => {
    return `${l} - ${r} = ${l - r}`;
  },
};

console.log(subtraction.perform(8, 3));
console.log(subtraction.display(8, 3));

Auch hier funktioniert alles wie gewünscht:

5
8 - 3 = 5

Der Objekttyp definiert die Gemeinsamkeiten, die Objektliteralen füllen dann die konkreten Eigenheiten jeder Operation aus. Doch der Code weist einige Schwächen auf:

  1. Zwar wird der Operator als Eigenschaft hinterlegt, aber nicht weiter verwendet.
  2. Sowohl perform und display führen den +-Operator im Code auf, obwohl der bereits bekannt wäre.
  3. In display wird das Ergebnis separat berechnet, statt die Implementierung von perform wiederzuverwenden.

Das Problem ist, dass eine Objektliterale keine Referenz auf sich selber hat. Der Zugriff auf die eigenen Felder könnten zwar über den Namen der Konstanten erfolgen:

const subtraction: Operation = {
  operator: "-",
  perform: (l, r): number => l - r,
  display: (l, r): string => {
    return `${l} ${subtraction.operator} ${r} = ${subtraction.perform(l, r)}`;
  },
};

Sollte sich der Name der Konstanten aber ändern, müssten sämtliche Referenzen ebenfalls angepasst werden.

Dennoch müssen die Funktionen bzw. Methoden perform und display für jede Operation aufs neue Implementiert werden, wo doch diese immer nach dem gleichen Muster erfolgen.

Sinnvoller wäre es, wenn man die Implementierung gleich der Typdefinition und nicht beim Erstellen der Instanz angeben könnte. Und genau hierfür gibt es nun Klassen.

Klassen

Eine Klasse ist die Definition eines neuen Datentyps, die Werte als Eigenschaften (engl. properties) und Funktionen als Methoden (engl. methods) enthält. Eigenschaften und Methoden werden im nächsten Unterrichtsblock genauer betrachtet. Heute soll es nur um die grundsätzliche Idee gehen.

Implementieren wir die arithmetischen Operatoren von vorher neu als Klasse. Der Operator-Typ wird dabei von vorher übernommen:

type Operator = "+" | "-" | "*" | "/";

class Operation {
  private operator: Operator;

  public constructor(operator: Operator) {
    this.operator = operator;
  }

  public perform(l: number, r: number): number {
    switch (this.operator) {
      case "+":
        return l + r;
      case "-":
        return l - r;
      case "*":
        return l * r;
      case "/":
        return l / r;
    }
  }

  public display(l: number, r: number): string {
    return `${l} ${this.operator} ${r} = ${this.perform(l, r)}`;
  }
}

Die Klasse verfügt über eine private Eigenschaft operator vom Typ Operator. Diese Eigenschaft wird dem Konstruktor (constructor) mitgegeben. Diese spezielle Methode wird beim Erstellen der Instanz aufgerufen.

Die beiden Methoden perform und display sind nun für alle möglichen Grundrechenarten implementiert. Hierzu kommt switch/case zum Einsatz. (Ein default-Branch ist dabei nicht nötig, da die Werte von operator per Typdefinition eingeschränkt sind.)

Der Zugriff auf die Eigenschaften und Methoden des Objekts erfolgt mit this. Dies ist kein konkretes Objekt, sondern eine Referenz auf die jeweilige Instanz.

Die konkreten Grundrechenarten können nun viel einfacher implementiert werden:

const addition: Operation = new Operation("+");
const subtraction: Operation = new Operation("-");

Der new-Operator erzeugt eine neue Instanz der angegebenen Klasse. In der Parameterliste werden die Argumente mitgegeben. Dadurch wird die Methode constructor aufgerufen.

Die Verwendung der Objekte addition und subtraction gestaltet sich nun ganz gleich wie vorher bei den gleichnamigen Objektliteralen:

console.log(addition.perform(2, 1));
console.log(addition.display(2, 1));

console.log(subtraction.perform(8, 3));
console.log(subtraction.display(8, 3));

Ausgabe:

3
2 + 1 = 3
5
8 - 3 = 5

Dank der Klasse kann Programmcode wiederverwendet werden, der bei Objektliteralen noch neu geschrieben werden musste.

Konstruktor und Eigenschaften

Betrachten wir noch einmal den oberen Teil der Klasse Operation:

class Operation {
  private operator: Operator;

  public constructor(operator: Operator) {
    this.operator = operator;
  }
  // ...
}

Dieses Muster taucht sehr oft auf: Es wird eine private Eigenschaft definiert, die dann im Konstruktor über den entsprechenden und gleichnamigen Parameter initialisiert wird.

Weil dieses Muster so verbreitet ist, bietet TypeScript eine vereinfachte Syntax an, welche das gleiche ausdrückt:

class Operation {
  public constructor(private operator: Operator) {}
  // ...
}

Die Eigenschaft ist verschwunden, bzw. wird neu in der Parameterliste des Konstruktors definiert und automatisch initialisiert. Die Implementierung des Konstruktors ist in diesem Fall leer ({}).