Javascript: объекты vs структуры

Как мы рассмотрели в прошлых статьях, система типов в JavaScript не так, чтобы уж очень стройная.

И есть ещё один теоретический нюанс. Object в JS, по-сути, отвечает за две совершенно различные сущности.

С одной стороны это объект из ООП: отдельная индивидуальность со своим поведением и интерфейсом.

С другой: хранилище структурированных данных. Тоже, что и struct в Си, Dict в Питоне, Hash в Перле или ассоциативные массивы в PHP.
В питоне Dict, конечно, тоже объект, но там мы чётко можем разобрать — это словарь а не что-то иное. В JS разобрать это сложнее.

Зачем?

Где нам это может пригодиться? Например, нам нужно написать функцию `merge()` для рекурсивного слияния структур. Зачем нам эта функция? Например, у нас есть список каких-то настроек по умолчанию:

var options = {
 
    /* Параметры AJAX-запроса */
    'ajax_request' : {
        'url'    : "/ajax/action/",
        'method' : "POST",
        'data'   : {
            'x': 1,
            'y': 2
        }
    },
 
    /* Обработчик результата */
    'handler': function (result) {
        /* ... */
    },
 
    /* Периодичность отправки запроса */
    'period': 1000    
};

Мы хотим для одного конкретного объекта переопределить некоторые настройки, оставив остальные со значениями по умолчанию:

var spec_options = {
 
    'ajax_request': {
        'method': "GET", // запрос другим методом
        'data': {
            'x': 3 // немного другие данные
        }
    },
 
    'handler': function (result) { // и другой обработчик
        /* ... */
    }
};

Теперь нам нужно рекурсивно смержить options и spec_options. Проходим по свойствам:

1. Нашли ajax_request, он есть в обоих структурах и в обоих он — объект. Рекурсивно сливаем его.
2. Внутри есть method, он — скаляр. Заменяем.
3. Нашли внутри data — object, сливаем.

Дальше нашли handler: это функция. Что делать с ней? По идее, функция, это тоже объект со своим списком свойств. Можно также понапихать в изначальный handler свойства из переопределённого. Но, тот, кто это писал, явно подразумевал, что обработчик следует заменить новым.

С функцией просто, у неё typeof === "function". Так что определяем «расширенный» тип и всё, что не «object» заменяем. Всё, что «object» — сливаем.

Здорово, но вот опять незадача:

var options = {
    /* ... */
 
    /* Объект, которому передавать данные о запросе 
       Интерфейс: listener.onRequest(request)
    */
    'listener': new MyClass()
};
 
var spec_options = {
    'listener': new MyOtherClass("arg1", "arg2")
};

listener здесь объект. У него и typeof === Object и Object.prototype.toString.call(listener) === "[object Object]".
Но явно, здесь подразумевается не структура с данными, а индивидуальный объект, который следует заменять целиком.

Поэтому всегда в подобных функциях требуется различать две этих сущности.

isDict() — простой словарь или нет

Напишем isDict:

function isDict(value) {
    return value && (value.constructor === Object);
}

Элементарно, Ватсон: простой объект создаётся через new Object. Объекты же, созданные через наши конструкторы будут ссылаться на них. В приведённом примере: listener.constructor === MyClass.

Не учли три вещи:

1. Если словарь пришел из фрейма, то его constructor будет ссылаться на Object из того фрейма.

2. Конструкторы с переопределённым прототипом:

function MyClass() {
}
MyClass.prototype = {
    'method1': function () {},
    'method2': function () {}
};

В этом случае в переопределённом прототипе не будет свойства constructor и по цепочке поиск пойдёт до глобального прототипа. А у него constructor === Object. То есть, кажется, это объект, а будет определён, как словарь.

Конечно, тот, кто так делает, сам себе злобный буратино: constructor надо восстанавливать. Но всё равно неприятный осадок остаётся.

Здесь можно выкрутиться с помощью getPrototypeOf, но в IE < 9 этой функции нет, ну и опять-таки проблема со фреймами. Кстати, jQuery.isPlainObject() этот момент вообще не отслеживает.

3. Опять-таки старые IE и host-объекты, у которых нет конструктора.

Итого

/**
 * Является ли значение простым словарём
 *
 * @param {String} value
 * @return {Boolean}
 */
function isDict(value) {
    "use strict";
 
    if ((!value) || (typeof value !== "object")) {
        /* Сразу отсеить по typeof. (!value) требуется, так как typeof null === "object" */
        return false;
    }
 
    if (value.constructor === Object) {
        if (Object.getPrototypeOf && (Object.getPrototypeOf(value) !== Object.prototype)) {
            /* Случай с переопределённым прототипом и не восстановленным constructor */
            return false;
        }
        return true;
    }
 
    if (value instanceof Object) {
        /* value из нашего фрейма, значит constructor должен был быть Object */
        return false;
    }
    if (Object.getPrototypeOf) {
        /* value не из нашего фрейма, можно выкрутиться с помощью getPrototypeOf, так как у прототипа объекта прототип - null */
        value = Object.getPrototypeOf(value);
        if (!value) {
            return false;
        }
        return (Object.getPrototypeOf(value) === null);
    }
 
    /**
     * Все нормальные браузеры на этот момент уже определились.
     * Остались только IE<9 и значения пришедшие из фреймов.
     * Пытаемся определить по имени конструктора.
     *
     * В IE нет constructor.name, приходится заниматься разбором строкового представления.
     * toString() тоже может не быть, да ещё и могут выбрасываться исключения.
     *
     * Определить объект с уничтоженным прототипом в IE<9 не получается. 
     */
    try {
        return ((value.constructor + ":").indexOf("function Object()") !== -1);
    } catch (e) {
        return false;
    }
 
    return false;
}

Единственное, что не удалось определить без излишних извращений: объект с убитым прототипом из фрейма.
Для IE < 9, также не работает для того же объекта, но даже и в своём фрейме (Object.getPrototypeOf() там нет).

Если кто знает, как это определить, расскажите.

Смысл басни

1. Различайте объекты и структуры.
2. IE — говно.
3. Всегда восстанавливайте constructor при переопределении прототипа.

UPD

Ладно, объект с убитым прототипом из фрейма тоже можно определить в нормальных браузерах с помощью getPrototypeOf().
getPrototypeOf(getPrototypeOf(value)) === null — прототип прототипа Object === null.

Остались только IE<9 и убитые прототипы.

4 комментария »

Leave a comment