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:
Ausgabe:
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:
Führt man es aus und gibt einen String ein, wird dieser ausgegeben:
Wird die Eingabe aber abgebrochen, z.B. mit der Tastenkombination [Ctrl]-[D] in der Bash, sieht die Ausgabe folgendermassen aus:
Die Funktion prompt kann also eine Zeichenkette oder null zurückgeben. Eine entsprechende Typannotation sieht folgendermassen aus:
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:
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:
- Man verwendet den non-null assertion operator
!bei der Zuweisung. - 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:
Dadurch hat der Rückgabewert nun den Datentyp string und nicht mehr string | null.
Bei der zweiten Variante behält der Ausdruck seinen Wert:
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:
Ausgabe:
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:
Einem neuen Typbezeichner Identifier wird die Typdefinition string | number zugewiesen. Der Typbezeichner kann ab sofort als Datentyp verwendet werden.
Die Ausgabe mag überraschen:
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:
typeofist ein JavaScript-Schlüsselwort, das den Datentyp eines Ausdrucks zur Laufzeit zurückgibt.typeist 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:
Ausgabe:
Doch woher wissen wir, auf welche Bezeichner wird auf dem Objekt zugreifen können?
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:
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:
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:
Neue Werte können nun mit dem Datentyp Person annotiert werden:
Ausgabe:
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:
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:
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:
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:
Implementieren und verwenden wir also eine weitere Operation, die Subtraktion:
Auch hier funktioniert alles wie gewünscht:
Der Objekttyp definiert die Gemeinsamkeiten, die Objektliteralen füllen dann die konkreten Eigenheiten jeder Operation aus. Doch der Code weist einige Schwächen auf:
- Zwar wird der Operator als Eigenschaft hinterlegt, aber nicht weiter verwendet.
- Sowohl
performunddisplayführen den+-Operator im Code auf, obwohl der bereits bekannt wäre. - In
displaywird das Ergebnis separat berechnet, statt die Implementierung vonperformwiederzuverwenden.
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:
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:
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:
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:
Ausgabe:
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:
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:
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 ({}).