Patterns und Best Practices in JavaScript: Typüberprüfungen continued

Tales from the Web side  –  3 Kommentare

Im vorigen Artikel dieser Serie hatten wir gesehen, dass der instanceof-Operator in JavaScript in einigen Fällen problematisch sein kann, nämlich immer dann, wenn man mit mehreren Versionen der gleichen "Klasse" (bzw. des gleichen Prototypen) arbeitet. In diesem Artikel nun möchte ich auf diese Problematik zurückkommen und einige Lösungsansätze skizzieren.

Zur Erinnerung: Problematisch war die Verwendung von instanceof beispielsweise, wenn unter Node.js, wie im Folgenden gezeigt, das gleiche Modul mehrmals in der Hierarchie der node_modules-Verzeichnisse vorkommt:

example-package       // Hauptmodul
--> node_modules
--> package-a
--> package-b // package-b als Abhängigkeit von package-a ...
--> package-b // ... und als Abhängigkeit des Hauptmoduls.
--> package-c

Erstellt man beispielsweise von package-a eine Objektinstanz einer Klasse aus package-b, übergibt diese Objektinstanz dann an example-package und prüft dort wiederum mit instanceof, liefert diese Überprüfung ein inkorrektes Ergebnis (weil für die Überprüfung nicht die Klasse aus package-a > package-b verwendet wird, sondern die aus package-b auf der obersten Ebene).

Patterns und Best Practices in JavaScript

Übrigens hilft hier auch nicht die etwas allgemeinere Methode Object.isPrototypeOf(), die für die Erkennung des Prototypen (im Gegensatz zum instanceof-Operator) nicht zwangsweise einen Konstruktor (bzw. eine Konstruktorfunktion) voraussetzt, um den Test durchzuführen, sondern prinzipiell gegen beliebige Objekte testen kann.

Lassen Sie mich daher im Folgenden ausgehend von folgendem zugrundeliegenden Objektmodell einige Lösungsansätze diskutieren. Wohlgemerkt: hierbei handelt es sich um Ideenskizzen, die zwar den weiter unten aufgeführten Unit-Test bestehen, in der Praxis aber noch nicht hinreichend erprobt wurden (insofern sind Kommentare an dieser Stelle sehr willkommen).

'use strict';
class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
}
class Employee extends Person {
constructor(firstName, lastName, id) {
super(firstName, lastName);
this.id = id;
}
}

Zudem definieren wir eine Helferklasse inklusive Helferfunktion isInstanceOf(), anhand derer dann die Lösungsansätze implementiert werden. Zunächst macht diese Helferfunktion aber noch Gebrauch von dem instanceof-Operator:

class TypeChecker {
static isInstanceOf(instance, clazz) {
return instance instanceof clazz;
}
}

Zuletzt noch einen Testfall, welcher die Implementierung von isInstanceOf() testet:

const mocha = require('mocha');
const assert = require('assert');
describe('TypeChecker', () => {
describe('isInstanceOf', () => {
it("should check for instances", () => {
let max = new Person('Max', 'Mustermann');
assert.equal(TypeChecker.isInstanceOf(max, Person), true);
assert.equal(TypeChecker.isInstanceOf(max, Employee), false);

let moritz = new Employee('Moritz', 'Mustermann', 4711);
assert.equal(TypeChecker.isInstanceOf(moritz, Person), true);
assert.equal(TypeChecker.isInstanceOf(moritz, Employee), true);
});
});
});

Lösungsansatz 1: Klassen durch ID eindeutig bestimmen

Die Idee hierbei ist es, Klassen eine neue Eigenschaft zu geben, über die sie eindeutig identifiziert werden und diese dann innerhalb von isInstanceOf() zu überprüfen. Zur Beschreibung der Eigenschaft bieten sich Symbole an, die mit ES2015 neu in den Sprachstandard hinzugekommen sind:

const IDENTIFIER = Symbol();
Person[IDENTIFIER] = '802e2228-2044-47c7-82b6-edbab38730d5';
Employee[IDENTIFIER] = 'c744bb74-9e3b-451f-84ab-57b9414227bf';

Innerhalb der Methode isInstanceOf() kann nun überprüft werden, ob der Wert dieser Eigenschaft in der Klasse, mit der die Objektinstanz erzeugt wurde, übereinstimmt mit dem Wert der Eigenschaft in der Klasse, gegen die geprüft wird. Dabei muss das Ganze rekursiv geschehen, um auch Oberklassen zu testen (beispielsweise ist die Objektinstanz moritz ja eine direkte Instanz von Employee, implizit aber durch die Klassenhierarchie auch eine Instanz von Person).

class TypeChecker {
static isInstanceOf(instance, clazz) {
let instancePrototype = Object.getPrototypeOf(instance);
let instanceClass = instancePrototype.constructor;
let isInstanceOf = instanceClass[IDENTIFIER] === clazz[IDENTIFIER];
let parentPrototype = Object.getPrototypeOf(instancePrototype);
let parentClass = parentPrototype.constructor;
if(!isInstanceOf) {
if(!(parentClass === Object)) {
isInstanceOf = TypeChecker.isInstanceOf(new parentClass(), clazz);
}
}
return isInstanceOf;
}
}

Nachteil dieses Ansatzes ist ganz klar, dass (alle) Klassen im jeweiligen Softwaresystem um die zusätzliche Eigenschaft erweitert werden müssen, damit das Ganze funktioniert und zudem sichergestellt werden muss, dass die Werte (die IDs) eindeutig sind. Somit kann bei diesem Ansatz in keinster Weise von einem skalierbaren Ansatz gesprochen werden kann.

Lösungsansatz 2: Namen der der Klasse überprüfen

Möchte man nicht wie in Lösungsansatz 1 beschrieben die Klassen um IDs erweitern, gibt es eine zweite, wenn auch (noch) weniger zuverlässige Art, zu prüfen, ob ein Objekt Instanz einer Klasse ist. Die Idee dabei ist es, innerhalb der isInstanceOf()-Methode einfach den Namen der Klasse zu überprüfen (instanceClass.name === clazz.name).

class TypeChecker {
static isInstanceOf(instance, clazz) {
let instancePrototype = Object.getPrototypeOf(instance);
let instanceClass = instancePrototype.constructor;
let isInstanceOf = instanceClass.name === clazz.name;
let parentPrototype = Object.getPrototypeOf(instancePrototype);
let parentClass = parentPrototype.constructor;
if(!isInstanceOf) {
if(!(parentClass === Object)) {
isInstanceOf = TypeChecker.isInstanceOf(new parentClass(), clazz);
}
}
return isInstanceOf;
}
}

Dieser Ansatz verhindert zwar das Eingreifen in die Klassen, funktioniert aber nur solange, wie jede Klasse im System einen eindeutigen Namen hat. Um dies ein bisschen einzugrenzen, könnte man (im Falle von Node.js-Modulen) innerhalb der Methode isInstanceOf() zusätzlich noch auf Paketinformationen des jeweiligen Moduls zurückgreifen, beispielsweise auf Namen und Versionsnummer. Aber auch hierbei muss sichergestellt werden, dass innerhalb des jeweiligen Moduls nicht zwei Klassen mit gleichem Namen existieren.

Lösungsansatz 3: Duck Typing

Die Idee bei Duck Typing ist, nicht auf den konkreten Typen einer Objektinstanz zu achten und darauf, ob sie mit einer bestimmten Klasse (bzw. Konstruktorfunktion) erzeugt wurde, sondern lediglich auf die Methoden (bzw. Eigenschaften), über die die Objektinstanz verfügt. Getreu dem namensgebenden Motto

"When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck.“

bestimmt sich beim Duck Typing der Typ eines Objekts also implizit durch dessen Methoden/Eigenschaften. Eine entsprechende Implementierung von isInstanceOf() zeigt folgendes Listing:

const _ = require('underscore');
class TypeChecker {
static isInstanceOf(instance, clazz) {
let objectPropertyNames = Object.getOwnPropertyNames(instance);
let classPropertyNames = Object.getOwnPropertyNames(new clazz());
return classPropertyNames.length ===
_.intersection(classPropertyNames, objectPropertyNames).length;
}
}

Was passiert hier? Zunächst einmal werden die Namen alle Eigenschaften der übergebenen Objektinstanz und die Namen aller Eigenschaften der übergebenen Klasse (bzw. einer Instanz davon) in den zwei Variablen objectPropertyNames und classPropertyNames gespeichert. Anschließend wird (mit Hilfe der Bibliothek underscore und dessen Methode intersection()) geprüft, ob alle Eigenschaften der Objektinstanz auch in der über die Klasse erzeugten Instanz vorkommen. Ist dies der Fall, geht man davon aus, dass die Objektinstanz auch eine Instanz der Klasse ist.

Lösungsansatz 4: Überschreiben des instanceof-Verhaltens

Kein echter vierter Lösungsansatz, sondern vielmehr eine Erweiterung des bisher beschriebenen ist das Überschreiben des instanceof-Operators. Momentan ist das zwar noch Zukunftsmusik, allerdings lässt sich der folgende Code bereits im JavaScript-Transpiler BabelJS ausführen. Ein Aufruf des instanceof-Operators führt seit ES2015 intern zum Aufruf derjenigen Methode, die der Eigenschaft Symbol.hasInstance hinterlegt ist. Überschreibt man also diese (statische) Methode, kann man das Verhalten des instanceof-Operators überschreiben, beispielsweise wie in folgendem Listing zu sehen auf den TypeChecker zugreifen:

'use strict';
class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
static [Symbol.hasInstance](instance) {
return TypeChecker.isInstanceOf(instance, this);
}
}
class Employee extends Person {
constructor(firstName, lastName, id) {
super(firstName, lastName);
this.id = id;
}
}

let max = new Person('Max', 'Mustermann');

Auf den direkten Aufruf von TypeChecker.isInstanceOf() kann nun innerhalb des Unit-Tests verzichtet werden:

describe('TypeChecker', () => {
describe('isInstanceOf', () => {
it('should check for instances', () => {
let max = new Person('Max', 'Mustermann');
assert.equal(max instanceof Person, true);
assert.equal(max instanceof Employee, false);
let moritz = new Employee('Moritz', 'Mustermann', 4711);
assert.equal(moritz instanceof Person, true);
assert.equal(moritz instanceof Employee, true);
});
});
});;

Fazit

Typüberprüfungen von Objektinstanzen bleiben in JavaScript eine haarige Angelegenheit. Lösungsansatz 1 erzwingt Änderungen am Objektmodell und skaliert nicht, Lösungsansatz 2 erfordert zwar keine Änderungen am Objektmodell, skaliert aber auch nicht, Lösungsansatz 3 – das bekannte Prinzip des Duck Typings – prüft nur Verhalten, nicht aber den konkreten Typen (was in vielen Fällen auch ausreichend ist) und Lösungsansatz 4 greift ohnehin nur auf die anderen Lösungsansätze zurück.

Aber vielleicht haben Sie, die Leser, ja noch Ideen, wie man die beschriebene Problemstellung angemessen lösen könnte. Oder muss man sie überhaupt lösen? Haben Sie andere Lösungsansätze? Eventuell auch schon welche, die sich in der Praxis bewährt haben? Oder verwenden Sie den instanceof-Operator grundsätzlich nicht? Eine Idee wäre auch, das Ursprungsproblem der verschachtelten Node.js Module zu umgehen und innerhalb eines Projekts einfach alle (lokalen) Module auf einer Verzeichnisebene (ähnlich wie globale Module) zu verwalten.