ECMAScript 5, die nächste Version von JavaScript – Teil 3: Property Descriptors, Getter- und Setter-Funktionen

Achtung: dieser Beitrag ist alt! Es kann gut sein, dass seine Inhalte nicht mehr aktuell sind.

So ziemlich alles kann in JavaScript jederzeit verändert werden. Man kann einem Objekt zu jedem Zeitpunkt neue Eigenschaften verpassen, bestehenden Eigenschaften beliebige neue Werte zuweisen und auf alles zugreifen darf man sowieso. Möchte man nur möglichst schnell ein kleines Script zusammenklöppeln (wofür JavaScript ja ursprünglich gedacht war), ist das sehr praktisch, nicht jedoch, wenn man jedoch dickere Bretter bohrt. Hat man eine viele hundert Zeilen lange JS-Applikation ist es wünschenswert, sich im Sinne der Programmrobustheit gegen versehentliche Mutationen eines Objekts zu versichern. Hier helfen die in ES5 frisch eingeführten Möglichkeiten, Objekte ganz oder in Teilen gegen Veränderungen abzusichern. Und die Getter- und Setter-Funktionen, die ES5 als Bonus mitbringt, sind auch ganz nützlich.

Warum sollte ich meine Objekte nicht mehr modifizieren können wollen?

Eine neue Objekteigenschaft legt man in JavaScript seit jeher in Form eines eleganten Einzeilers an:

var huhn = {};  // Neues Objekt "Huhn"
huhn.eier = 42; // Neue Eigenschaft "eier" für das Objekt "Huhn"
huhn.alter = 3; // Neue Eigenschaft "alter" für das Objekt "Huhn"

Das funktioniert so auch weiterhin in ES5. Ebenso einfach kann man Eigenschaften mit dem delete-Operator wieder aus einem Objekt entfernen:

var huhn = {};
delete huhn.eier;
delete huhn.alter; // Hm...

Nun ist ja ein Huhn ohne Eier durchaus vorstellbar, aber ein lebendiges, gesundes Huhn sollte eigentlich immer ein Alter haben. Ein Programmierer einer Huhn-Applikation würde aller Wahrscheinlichkeit nach davon ausgehen, dass ein huhn.alter immer vorhanden ist. Wenn nun aber irgendwann im Programm huhn.alter aufgrund eines Programmierfehlers verloren geht …

// Löscht bis auf "alter" alle Eigenschaften aus einem Huhn
// Wer findet den Bug?
function clean_huhn(huhn){
    for(var prop in huhn){
        if(prop !== 'allter' && typeof huhn[prop] != 'undefined'){
            delete huhn[prop];
        }
    }
    return huhn;
}

huhn = clean_huhn(huhn);
var doppeltes_alter = huhn.alter * 2; // NaN! Autsch...

… kann das zu extrem ekelhaften Bugs führen. Selbst wenn irgendwann klar wird, dass die Quelle eines Fehlers in der alter-Eigenschaft eines Huhns zu suchen ist (was durchaus nicht immer offensichtlich sein muss!) weiß man immer noch nicht, wo im Programm alter verloren geht. Wäre es da nicht praktisch, wenn man kritische Objekteigenschaften so absichern könnte, dass allein der Versuch sie zu löschen mit einer lauten Fehlermeldung fehlschlägt? Die in ECMAScript 5 eingeführten Property Descriptors helfen dabei und bei vielem mehr.

Robuste JavaScript-Objekte mit property descriptor maps

Das in ES5 neu eingeführte Object.defineProperty() definiert eine Eigenschaft auf einem Objekt, in etwa wie huhn.alter = 3. Der Clou ist, dass man dieser Eigenschaft ein Konfigurationsobjekt mitgeben kann, das bestimmt, wie sich die neue Objekt-Eigenschaft verhält. Das Ganze funktioniert, indem man Object.defineProperty() neben dem betroffenen Objekt und dem Namen der zu definierenden Eigenschaft eine sogenannte property descriptor map übergibt – einfach ein weiteres Objekt, in dem neben dem Wert der Eigenschaft die Einstellungen writable, configurable und enumerable festgelegt werden:

var huhn = {};  // Neues Objekt "Huhn"
Object.defineProperty(huhn, 'alter', {
    writable: true,      // Wert KANN verändert werden
    enumerable: true,    // DARF in for-in-Schleifen auftauchen
    configurable: false, // DARF NICHT gelöscht werden
    value: 3             // Wert von "alter" ist 3
});

Dieser Code entspricht in etwa huhn.alter = 3, legt aber über die property descriptor map neben Namen und Wert der Eigenschaft auch fest, dass sie nicht gelöscht werden kann; configurable steht auf false. Die drei Eigenschaften in der property descriptor map haben folgende Funktionen:

  • writable; wenn false, kann der Wert der betroffene Objekt-Eigenschaft nicht mehr verändert werden
  • enumerable; wenn false, taucht die betroffene Objekt-Eigenschaft nicht mehr in den Auflistungen aller Eigenschaften des Objekts (wie z.B. bei for-in-Schleifen) auf
  • configurable; wenn false, kann die Eigenschaft nicht gelöscht werden und auch seine writeable- enumerable- und configurable-Werte können nicht mehr verändert werden

Die mit einem configurable-Wert von false abgesicherte alter-Eigenschaft sorgt in unserem Beispiel mit der clean_huhn()-Funktion dafür, dass alter nicht mehr gelöscht wird, komme was wolle. Im Strict Mode wird dort, wo das Problem entsteht (eine Objekteigenschaft, die nicht gelöscht werden kann, wird versucht zu löschen) vom Browser sogar eine handfeste Fehlermeldung ausgespuckt:

"use strict";
var huhn = {};

// Neue _unlöschbare_ Eigenschaft "alter" für das Objekt "Huhn"
Object.defineProperty(huhn, 'alter', {
    writable: true,      // Wert KANN verändert werden
    enumerable: true,    // DARF in for-in-Schleifen auftauchen
    configurable: false, // DARF NICHT gelöscht werden
    value: 3             // Wert von "alter" ist 3
});

// Löscht bis auf "alter" alle Eigenschaften aus einem Huhn
// Wer findet den Bug?
function clean_huhn(huhn){
    for(var prop in huhn){
        if(prop !== 'allter' && typeof huhn[prop] != 'undefined'){
            delete huhn[prop]; // FEHLER: property is non-configurable and can't be deleted
        }
    }
    return huhn;
}

huhn = clean_huhn(huhn);
var doppeltes_alter = huhn.alter * 2; // So weit kommt das Programm gar nicht

So kriegt man erstens sicher mit, dass es überhaupt einen Programmfehler gibt und zweitens bekommt man auch gleich den wahren Quell des Problems geliefert. Theoretisch könnte man das konkrete Problem im Beispiel auch dadurch lösen, dass man enumerable auf false setzt – alter würde dann gar nicht erst in der for-in-Schleife auftauchen.

Übrigens: alle drei Werte der property descriptor map sind standardmäßig false, so dass man eine komplett gegen Veränderungen gesicherte Objekteigenschaft bequem in einer Zeile definieren kann:

// Die Secret ID ist unsichtbar und unveränderlich
Object.defineProperty(huhn, 'secretid', { value: 'H2045' });

Wer gleich mehrere Objekteigenschaften auf einen Schlag anlegen möchte, hat hierfür Object.defineProperties zur Verfügung. Einfach als zweites Argument ein Objekt voller property descriptor maps angeben, fertig:

Object.defineProperties(huhn, {
    'eier': {
        writable: true,
        enumerable: true,
        configurable: true,
        value: 42
    }
    'alter': {
        writable: true,
        enumerable: true,
        configurable: false,
        value: 3
    }
});

Möchte man die property descriptor map einer Objekteigenschaft abfragen, so geht dies mit Object.getOwnPropertyDescriptor:

// Gibt die komplette property descriptor map als Objekt zurück
console.log(Object.getOwnPropertyDescriptor(huhn, 'alter'));

Einen Haufen Beispiele für die Benutzung von Object.defineProperty() zum herumspielen und forken gibt es bei jsFiddle.

Ganze Objekte absichern

Mit property descriptor maps kann man einzelne Objekteigenschaften gegen ungewollte Veränderungen oder zu viel Sichtbarkeit absichern, doch auch für ganze Objekte bietet ES5 vergleichbare Funktionen. So kann man mit Object.preventExtensions() dafür sorgen, dass einem Objekt keine neuen Eigenschaften hinzugefügt werden können. Diese Aktion ist endgültig; ein nicht erweiterbare Objekt kann nicht wieder in ein erweiterbares Objekt verwandelt werden. Mit Object.isExtensible() kann man überprüfen, ob ein Objekt erweiterbar ist:

var huhn = {};
huhn.eier = 42
Object.preventExtensions(huhn);

huhn.alter = 3    // Funktioniert nicht
delete huhn.eier; // Funktioniert

Ein gegen Erweiterungen geschütztes Huhn ist wirklich nur vor Erweiterungen sicher, andere Operationen wie das Löschen von bereits vorhandenen Objekteigenschaften funktionieren weiterhin. Soll auch das verhindert werden, hilft Object.seal(). Ein so behandeltes Objekt kann nicht nur keine neuen Erweiterungen erhalten, sondern seine sämtlichen vorhandenen Eigenschaften erhalten obendrein einen configureable-Wert von false:

var huhn = {};
huhn.eier = 42
Object.seal(huhn);

huhn.alter = 3    // Funktioniert nicht
delete huhn.eier; // Funktioniert nicht

Ob ein Objekt mit Object.seal() behandelt wurde, lässt sich via Object.isSealed() ermitteln und wie schon bei Object.preventExtensions() gibt es keine Möglichkeit, eine Sperre rückgängig zu machen. Mit Object.freeze() lassen sich Objekte letztlich komplett gegen jede Art von Veränderungen absichern. Weder können neue Eigenschaften eingebaut oder gelöscht werden, noch lassen sich die property descriptor maps der vorhandenen Eigenschaften verändern:

var huhn = {};
Object.defineProperty(huhn, 'eier', {
    value: 42,
    writable: true
});
Object.freeze(huhn);

huhn.alter = 3       // Funktioniert nicht
delete huhn.eier;    // Funktioniert nicht
Object.defineProperty(huhn, 'eier', {
    writable: false  // Funktioniert nicht
});

Ob ein Objekt via Object.freeze() eingefroren wurde, lässt sich via Object.isFrozen() ermitteln.

Getter- und Setter-Funktionen

Das allseits bekannte DOM-Node-Eigenschaft innerHTML ist ein seltsamer Geselle. Sie ist eine normales Objekt-Eigenschaft, doch ihr Verhalten ähnelt eher einer Funktion – denn schließlich passiert etwas, wenn man ihren Wert modifiziert (das DOM der Webseite verändert sich). Eine Änderung des Wertes einer Eigenschaft löst also eine Funktion aus. Mit althergebrachtem JavaScript kann man derartiges Verhalten nicht programmieren, Sonderlinge wie innerHTML sind in C++ fest in die Browser einprogrammiert. Rettung naht in Form der in ES5 eingeführten Getter- und Setter-Methoden.

Getter- und Setter-Methoden lassen sich definieren, indem man sie mit den Schlüsseln get bzw. set in die property descriptor map einer Objekteigenschaft hineinschreibt:

var huhn = {};

Object.defineProperty(huhn, 'eier', {
    set: function(x){
        alert('Gacker!');
    }
});

huhn.eier = 3; // Löst das "Gacker"-Alert aus

Wenn Ein Getter oder ein Setter definiert sind, darf die property descriptor map weder einen einen Startwert noch einen writeable-Wert definieren! Der Grund: Objekteigenschaften mit Getter- und Setter-Methoden repräsentieren selbst keine Werte in einem Objekt sondern sind nur Pseudo-Eigenschaften, die andere Objekt-Eigenschaften verändern können. Der obrige Code löst zwar, wenn wir huhn.eier auf 3 setzen, die Setter-Methode aus, macht aber mit dem gesetzten Wert (Funktionsargument x) nichts. Würden wir huhn.eier abfragen, bekämen wir undefined zurück – huhn.eier selbst hat keinen Wert (da es nur eine Pseudo-Eigenschaft ist) und weil wir keine Getter-Funktion definiert haben, kann huhn.eier auch nichts anderes zurückgeben.

Eine Umsetzung von huhn.eier, die auch tatsächlich funktioniert, könnte so aussehen:

var huhn = {};

// Die wahre Anzahl der Eier
Object.defineProperty(huhn, 'eierImNest', { writable: true, value: 42 });

// Eier-Pseudo-Eigenschaft leitet alles auf this.eierImNest um
Object.defineProperty(huhn, 'eier', {
    get: function(){
        return this.eierImNest;
    },
    set: function(x){
        this.eierImNest = x;
    }
});

console.log(huhn.eier); // 42
huhn.eier = 50;
console.log(huhn.eier); // 50
huhn.eier++;
console.log(huhn.eier); // 51

Die wahre Anzahl der Eier liegt in der Objekteigenschaft eierImNest, die durch den Setter von eier gesetzt und durch den Getter von eier zurückgegeben wird. Nutzlos? Absolut nicht! Es ist ja nicht gesagt, dass Getter und Setter nicht noch mehr machen dürfen. Wie wäre es zum Beispiel mit einem eingebauten Profiler für unser Huhn, der alle Zugriffe auf eier mitzählt?

var huhn = {};

// Die wahre Anzahl der Eier
Object.defineProperty(huhn, 'eierImNest', { writable: true, value: 42 });

// Die Anzahl der Eier-Zugriffe
Object.defineProperty(huhn, 'log', { writable: true, value: 0 });

// Eier-Pseudo-Eigenschaft leitet alles auf this.eierImNest um
Object.defineProperty(huhn, 'eier', {
    get: function(){
        this.log++; // Zugriff zählen
        return this.eierImNest;
    },
    set: function(x){
        this.log++; // Zugriff zählen
        this.eierImNest = x;
    }
});

console.log(huhn.eier); // 42
huhn.eier = 50;
console.log(huhn.eier); // 50
huhn.eier++;
console.log(huhn.eier); // 51

console.log(huhn.log);  // 6 (huhn.eier++ sind zwei Operationen)

Mit ein bisschen Trickserei können wir die echten Objekteigenschaften auch komplett aus unserer Huhn-API entfernen und Zugriff ausschließlich über die Getter- und Setter-Methoden der Pseudoeigenschaften erlauben:

var Huhn = function(){
    var that = this;

    // Die wahre Anzahl der Eier
    Object.defineProperty(this, 'eierImNest', { writable: true, value: 42 });

    // Die Anzahl der Eier-Zugriffe
    Object.defineProperty(this, 'log', { writable: true, value: 0 });

    // Die API, die wir zurückgeben besteht nur aus Pseudo-Eigenschaften
    var api = {}

    // Eier-Pseudo-Eigenschaft leitet alles auf this.eierImNest um
    Object.defineProperty(api, 'eier', {
        get: function(){
            that.log++;
            return that.eierImNest;
        },
        set: function(x){
            that.log++;
            that.eierImNest = x;
        }
    });

    // Pseudo-Eigenschaft zur Abfrage des Logs. Kein Setter!
    Object.defineProperty(api, 'log', {
        get: function(){
            return that.log;
        }
    });

    // Nur die API zurückgeben
    return api;

}

var huhn = new Huhn();

console.log(huhn.eier); // 42
huhn.eier = 50;
console.log(huhn.eier); // 50
huhn.eier++;
console.log(huhn.eier); // 51

console.log(huhn.log);  // 6 (huhn.eier++ sind zwei Operationen)

Die echten Objekteigenschaften sind gegen unbefugten Zugriff von außen sicher und der Profiler loggt alles mit. Trotzdem ist für den Benutzer der Huhn-API alles, als würde ein ganz normales JavaScript-Objekt bearbeiten. Kleiner Tipp am Rande: Getter und Setter kann man auch ohne property descriptor map definieren, indem man sie einfach in das Objekt hineinschreibt. Wichtig ist das Auslassen des Doppelpunktes nach get und set:

var huhn = {
    eierImNest: 42,
    log: 0,
    get eier(){
        this.log++;
        return this.eierImNest;
    },
    set eier(x){
        this.log++;
        this.eierImNest = x;
    }
};

Das ist erheblich weniger Tipperei als alles bisher gesehene, hat aber den Nachteil, dass das Objekt nach althergebrachter Manier beliebig manipulierbar ist. Drückt man sich etwas kompakter aus und nutzt einige der bisher noch nicht vorgestellten ES5-Helferlein (die kommen im nächsten Artikel dran), kann man die robuste Variante des Profiler-Huhns auch mit etwas weniger Tastenabnutzung umsetzen:

var Huhn = function(){
    var that = this;
    Object.defineProperties(this, {
        'eierImNest': { writable: true, value: 42 },
        'log': { writable: true, value: 0 }
    });
    return Object.create(Object.prototype, {
        'eier': {
            get: function(){
                that.log++;
                return that.eierImNest;
            },
            set: function(x){
                that.log++;
                that.eierImNest = x;
            }
        },
        'log': {
            get: function(){
                return that.log;
            }
        }
    });
}

Immer noch recht viel Code, aber hey: ein Objekt mit eingebautem Profiler! Wer will da meckern?

Fazit

Manipulationssichere Objekte und Objekteigenschaften sind zusammen mit den Getter- und Setter-Methoden eine schöne Ergänzung des im letzten Teil der Artikelserie vorgestellten Strict Mode. Der Strict Mode definiert eine Browser-Umgebung, die es schwieriger macht, sich mit schlechtem Code in Schwierigkeiten zu bringen. Passend dazu ermöglichen es die hier vorgestellten ES5-Features, eigene APIs so zu programmieren, dass man sich bei ihrer Benutzung nicht unwissentlich ins eigene Knie schießt. Nützlich ist das Ganze daher eher Webnerds, die auch selbst API-Autoren sind – Otto Normal-DOM-Node-Schubser wird von diesen Features eher passiv profitieren. Wenn jQuery und Co robuster und einfacher werden, haben auch jene etwas davon, die nie selbst Object.defineProperty schreiben werden.

Die allmächtige Kompatibilitätstabelle zeigt mit Opera und Safari zwei Browser, deren aktuellste Versionen die vorgestellten Funktionen nicht voll bis gar nicht unterstützen und die sich damit in Gesellschaft der Internet Explorer 6 bis 8 begeben. Mit Polyfills kann man zwar nicht alle Funktionen, wohl aber die meisten APIs rund um eingefrorene Objekte, Property Descriptors sowie Getter- und Setter-Funktionen nachrüsten, was in diesem Fall schon die halbe Miete ist: denn seinen Code testen kann man ja in einem Browser mit echter ES5-Unterstützung.

Kommentare (0)

Noch keine Kommentare zu diesem Artikel.

Die Kommentarfunktion ist seit Juli 2014 deaktiviert, weil sie zu sehr von Suchmaschinenoptimierern für manuellen Spam mißbraucht wurde. Wenn du Anmerkungen oder Fragen zum Artikel hast, schreib mir eine E-Mail oder melde dich via Twitter.

Folgt mir!

Kauft mein Zeug!

Cover der HTML5-DVD
Infos auf html5-dvd.de, kaufen bei Amazon

Cover des HTML5-Buchs
Infos auf html5-buch.de, kaufen bei Amazon

Cover des CSS3-Buchs
Infos auf css3-buch.de, kaufen bei Amazon