Nowe podejście do obiektów w ECMAScript 5. Deskryptory własności

W ostatnią sobotę w Sali Senatu Polsko-Japońskiej Wyższej Szkoły Technik Komputerowych w Warszawie odbył się barcamp meet.js, na którym przedstawiłem prezentację pod tytułem „JavaScript 1.8.5: Object.*. Nowe sposoby na obiekty w JavaScripcie 1.8.5 i ECMAScripcie 5”. Jeśli kogoś nie było, albo chciałby sobie przypomnieć, o czym była mowa, może obejrzeć sobie slajdy z mojej prezentacji.

Na blogu rozwijam nieco bardziej przedstawiony tam temat. Dziś pierwsza część – w której przypominam krótko, o co chodzi z obiektami JS, i wyjaśniam, czym są deskryptory własności.

ECMAScript 5: Deskryptory własności

ECMAScript 5 (którego niektóre nowe możliwości są już dostępne jako JavaScript 1.8.5 w Firefoksie 4) wprowadza kilka nowości dotyczących sposobu definiowania własności obiektów – programista może teraz określać, czy własności mają być modyfikowalne, definiować settery i gettery, czy też ograniczać lub blokować możliwość manipulacji własnościami.

Obiekty w JS – przypomnienie

Zacznijmy jednak od przypomnienia kilku podstawowych informacji na temat obiektów w JS. Obiekty w JavaScripcie to zbiór par klucz-wartość. Pary takie nazywamy własnościami (ang. „properties”). Obiekt o własnościach prop1propN można zapisać w sposób następujący:

var anObject = {
    prop1: value1,
    prop2: value2,
    // ...
    propN: valueN
};

Do takich własności obiektu można odwołać się używając dwóch zapisów. Bardziej uniwersalny z wykorzystaniem nawiasów kwadratowych: anObject["prop1"], lub prostszy: anObject.prop1. W tym drugim przypadku nazwa własności musi być poprawną nazwą JS, nie może więc zawierać np. spacji ani znaków specjalnych.

Wartość własności może być dowolnego typu – mogą to być zarówno własności proste (logiczne, liczbowe, ciągi znaków) jak i inne obiekty (w tym tablice, funkcje, wyrażenia regularne, jako że wszystko oprócz wartości prostych w JS jest obiektem).

Własności można odczytywać: x = anObject.prop1 (to wyrażenie przypisze zmiennej x wartość własności prop1 obiektu anObject), modyfikować anObject.prop2 = 8 (jeśli własność prop2 nie istnieje w obiekcie, zostanie do niego dodana), usuwać: delete anObject.prop3 (po tej operacji anObject.prop3 zwróci wartość undefined, a "prop3" in anObjectfalse).

Jeśli własność jest funkcją, można ją wywołać w kontekście danego obiektu: anObject.prop4() – przy takim wywołaniu wartością słowa kluczowego this w ciele funkcji będzie anObject. Zwyczajowo takie funkcje będące własnościami obiektu nazywa się czasem „metodami”.

Łańcuch prototypów

Każdy obiekt (z wyjątkiem obiektu Object.prototype) jest powiązany z innym obiektem, po którym dziedziczy własności. Oznacza to, że jeśli obiekt nie zawiera sam własności o danej nazwie, a własność taką posiada którykolwiek z obiektów nadrzędnych względem niego w łańcuchu prototypów, to anObj.propX zwróci własność z najbliższego temu obiektowi obiektu w łańcuchu prototypów. Inaczej mówiąc, jeśli prototypem obiektu obj2 jest obj1 i obj1 zawiera własność obj1.someProp, ale obj2 nie posiada własności o tej nazwie, obj2.someProp zwróci obj1.someProp.

Na samym szczycie łańcucha prototypów jest obiekt Object.prototype, po którym dziedziczą wszystkie obiekty JS (może to nie dotyczyć tzw. obiektów hosta, tj. obiektów, które nie powstały w wirtualnej maszynie JS, tylko przekazane zostały z innych środowisk – jak np. obiekty DOM i obiekty udostępniane przez wtyczki (Flash, Java) poprzez API LiveConnect).

Łańcuch prototypów dla obiektu anObject wyglądać więc może w najprostszym przypadku tak:

Object.prototype
  anObject

Między Object.prototype a naszym anObject może istnieć dowolna liczba obiektów w łańcuchu prototypów, z których – licząc od dołu – pobierane będą własności nieobecne bezpośrednio w anObject. W takim przypadku łańcuch prototypów wygląda tak:

Object.prototype
  ...
    someOtherProto
      someProto
         ...
           anObject

Dotychczasowa składnia JavaScriptu nie pozwalała w łatwy sposób określić, czy prototypem danego obiekt jest konkretny inny obiekt, ponieważ JS maskował swoją prototypowość składnią udającą model klasowy znany z Javy. Jedynym (zgodnym ze standardem) sposobem na ustawienie prototypu była konstrukcja obiektu przy pomocy funkcji-konstruktora z użyciem operatora new:

function MyObj() {
    /* treść konstruktora; może być pusta */
}

MyObj.prototype = {
    prop1: value1
};

var myObj = new MyObj();

W ten sposób prototypem myObj stawał się obiekt MyObj.prototype. Oznaczało to, że w praktyce prototypami mogły być tylko obiekty, które były przypisane do własności prototype którejś z funkcji.

Douglas Crockford zaproponował swego czasu funkcję Object.beget() która umożliwiała tworzenie obiektów, których prototypem byłby zadany obiekt:

Object.beget = function (o) {
    function F() {}
    F.prototype = o;
    return new F();
};

Ta funkcja tworzyła tymczasową funkcję-konstruktor F(), a do jej własności F.prototype przypisywała obiekt przekazany jako jej argument o, po czym zwracała nowy obiekt będący instancją tego konstruktora F (new F()). Przykładowo:

var obj1, obj2;

obj1 = {
    prop1: 42
};

obj2 = Object.beget(obj1);
obj2.prop2 = 1234;

alert(obj2.prop2); // wyświetla "1234"
alert(obj2.prop1); // wyświetla "42"

Niektóre implementacje, w tym SpiderMonkey (Firefox) i WebKit pozwalały modyfikować prototyp obiektu przy użyciu niestandardowej własności __proto__ (na początku i końcu nazwy są dwa znaki podkreślenia):

var obj1, obj2;

obj1 = {
  prop1: 42;
}

obj2 = {};
obj2.__proto__ = obj1;

alert(obj2.prop1); // wyświetla: "42"

Zapis ten powoduje jednak problemy, jest niezgodny ze standardami i nie jest implementowany przez Operę ani IE. Stosowanie go jest wysoce niezalecane, a w przyszłości prawdopodobnie zostanie usunięty z silnika JavaScriptu używanego przez Firefoksa.

Ograniczenia dotychczasowego modelu

Dotychczas nie można było (zgodnie ze standardem ECMA262) uniemożliwić zapisu, odczytu, usunięcia czy wywołania danej własności. Każdy, kto tylko miał dostęp do referencji do naszego obiektu, mógł z jego zawartością zrobić wszystko, także dodać nową własność. Jak widać powyżej, mocno utrudnione było ustawienie jednego obiektu jako prototypu drugiego.

Nie było też standardowej możliwości tworzenia tzw. getterów i setterów (funkcji wywoływanych przy próbie zapisu lub odczytu danej własności). Obchodzono to poprzez funkcje udające rzeczywiste gettery i settery. Niektóre implementacje pozwalały jednak – jako niestandardowe rozszerzenie – definiować gettery i settery przy użyciu metod __defineGetter__ i __defineSetter__.

Deskryptor własności w ECMAScript 5

W ES5 każda własność obiektu posiada coś więcej niż tylko wartość. Z każdą własnością związany jest tzw. „deskryptor własności” (ang. „property descriptor”), który określa nie tylko jej wartość, ale to, co z tą wartością można zrobić. Deskryptor własności jest to obiekt o następujących kluczach:

  • value – wartość własności
  • get – funkcja-getter dla tej własności, lub undefined jeśli wartość ma być odczytana bezpośrednio z value
  • set – funkcja-setter dla tej własności, lub undefined jeśli wartość ma być zapisana bezpośrednio do value
  • writable – wartość logiczna określająca, czy własność jest zapisywalna, tj. czy można ją modyfikować;
  • enumerable – wartość logiczna określająca, czy własność pojawi się przy wyliczeniach (np. pętla for...in, operator in itp.)
  • configurable – wartość logiczna określająca, czy można modyfikować deskryptor własności, oraz czy można usunąć własność operatorem delete

Aby pobrać deskryptor własności danego obiektu (a raczej: jego kopię), należy wywołać metodę: Object.getOwnPropertyDescriptor(obj, propName), gdzie obj to interesujący nas obiekt, a propName to ciąg znaków określający nazwę danej własności.

Przykładowo:

var anObj = {
    someProp: 42;
};

alert(JSON.stringify(
    Object.getOwnPropertyDescriptor(
        anObj, "someProp")));

wyświetli: {"value":42,"writable":true,"enumerable":true,"configurable":true}. Oznacza to, że własność someProp, naturalnie, jest modyfikowalna, konfigurowalna, wyliczalna i ma wartość 42.

Ciekawe może być zastosowanie getOwnPropertyDescriptor dla tablic. Otóż indeksy z tablicy są zwykłymi własnościami, jak każde inne:

Object.getOwnPropertyDescriptor([1,2], "1")
// zwróci: {"value":2,"writable":true,
// "enumerable":true,"configurable":true}

Ale wartość length jest inna, bo nie widać jej w pętli for...in. Dlaczego?

Object.getOwnPropertyDescriptor([3,4,5], "length")
// zwróci: {"value":3,"writable":true,
// "enumerable":false,"configurable":false}

Dlatego właśnie, że enumerable w jej deskryptorze ma wartość false.

Definiowanie własności przy użyciu deskryptora

Skoro już wiemy, jak wyglądają deskryptory własności, możemy teraz użyć ich do zdefiniowania konkretnej własności. ES5 udostępnia dwie metody konstruktora Object, pozwalające na zdefiniowanie jednej (Object.defineProperty()) lub wielu własności (Object.defineProperties()) przy pomocy deskryptorów.

Składnia funkcji defineProperty() jest prosta: Object.defineProperty(obj, propName, descriptor). Pierwszy argument to referencja do naszego obiektu, drugi – nazwa własności, a trzeci – jej deskryptor.

Jeśli w przekazanym do defineProperty() deskryptorze pominiemy któreś z pól, zostanie ono ustawione na wartość undefined w przypadku value, get i set lub false w przypadku value, writable i configurable. Ponadto nie można jednocześnie ustawiać writable oraz get lub set. Jeśli utworzymy getter, ale nie setter, wartości również nie będzie można zmodyfikować.

Przykładowo, odwróćmy sytuację z jednego z poprzednich przykładów – utwórzmy prosty obiekt z własnością someProp o wartości 42, którą można odczytać, zmodyfikować, wyliczyć i usunąć – odwzorujmy przy pomocy defineProperty() prostszy zapis x = { someProp: 42 }:

var x = {};

Object.defineProperty(x, "someProp", {
    writable: true,     // będzie można ustawic wartość
    enumerable: true,   // będzie widać w for-in, in
    configurable: true, // będzie można zmienić deskryptor
    value: 42
});

alert(x.someProp); // wyświetli "42"

A teraz stwórzmy własność, której nie będzie widać w pętli for...in i której nie będzie można zmienić deskryptora:

var obj = { _x: 5 };
Object.defineProperty(obj, "x", {
    enumerable: false,   
    configurable: false,
    get: function () {
        return this._x;
    },
    set: function (newValue) {
        if (newValue < 10) {
            this._x = newValue;
        } else {
            this._x = -1;
        }
  }
});

Przykład ten udostępniłem na JSFiddle, polecam z nim poeksperymentować.

Wywoływanie Object.defineProperties() dla każdej z wielu własności, które chcemy zdefiniować, z osobna może być jednak niewygodne. Na szczęście standard ECMAScript 5 przewiduje i taką sytuację. Funkcja Object.defineProperties() pozwala na zdefiniowanie wielu własności jednocześnie:

Object.defineProperties(obj, {
  prop1Name: prop1Descriptor,
  prop2Name: prop2Descriptor,
  // ...
  propKName: propKDescriptor
});

Na przykład:

var constants = {};

function constantDescriptor(value) {
  return {
    configurable: false,
    enumerable: true,
    get: function () {
      return value;
    }
  };
}

Object.defineProperties(constants, {
  tau: constantDescriptor(2 * Math.PI),
  sqrt10: constantDescriptor(Math.sqrt(10)),
  aprilFools2011: constantDescriptor(new Date("2011/04/01"))
});

Ważna uwaga: operacje niedozwolone przez deskryptor (np. zapis własności z writable: false albo z getterem, ale bez settera, czy też wywołanie defineProperty() na własności, która ma configurable: false) w trybie ścisłym zakończą się wyrzuceniem wyjątku TypeError:

(function () {
  "use strict";
  constants.sqrt10 = 0; // TypeError
  // nigdy tu nie dojdziemy:
  alert(constants.sqrt10);
}());

W zwykłym trybie JS wyjątek nie zostanie wyrzucony, ale operacja po prostu nie będzie miała żadnego skutku:

constants.sqrt10 = 0;
alert(constants.sqrt10); // 3.1622776601683795

To tyle na dzisiaj. W kolejnej notce będzie bardzo zimno, bo będziemy obiekty zamrażać. :)

6 thoughts on “Nowe podejście do obiektów w ECMAScript 5. Deskryptory własności

  1. Nie mogłem być na tym spotkaniu, ale dzięki temu wpisowi nie żałuję. Wszystko elegancko opisane i wytłumaczone :). Czekam na kolejne.

  2. Ale wartość length jest inna, bo nie widać jej w pętli for…in. Dlaczego?
    Dlatego właśnie, że configurable w jej deskryptorze ma wartość false.

    configurable? A nie enumerable?

  3. Mnie się, szczerze mówiąc, mocno nie podoba to rozszerzenie możliwości obiektów.
    Settery i gettery są chyba najciekawszą opcją. Później można byłoby się zastanowić nad właściwością enumerable, bo też potrafię znaleźć dla niej praktyczne zastosowanie (choć na siłę). Ale writable i configurable? Trzeba byłoby się postarać żeby to gdzieś naprawdę sensownie wcisnąć. Bo to takie zabezpieczenie z cyklu mam 4 zamki w drzwiach i otwarte okno na parterze – w JSie bez sensu i kompletnie na pokaz.

    W dodatku cały ten jarmark nowych możliwości został nam dostarczony pięknymi Java-stylowymi funkcjami. Object.defineProperty, Object.getOwnPropertyDescriptor – dżizys… Z jednej strony mówią wszyscy, że nie chcemy z JSa zrobić Javy i dlatego klas nie ma, ale skojarzenia nasuwają się same.

    Jedyne co mnie pociesza, to to że domyślam się, że wprowadzenie niektórych topornych funkcjonalności to tylko wstęp przed ES Harmony, które mam nadzieję odziedziczy sporo swoich cech po odrzuconym ES4. Napisałem zresztą w tym temacie kilka słów – http://code42.pl/2010/12/19/obiektowy-javascript-i-wlasciwosci-chronione-w-poszukiwaniu-swietego-graala/

Leave a comment