Skip to content

Instantly share code, notes, and snippets.

@ekazakov
Last active August 29, 2015 14:16
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ekazakov/130ec60926f5386bf835 to your computer and use it in GitHub Desktop.
Save ekazakov/130ec60926f5386bf835 to your computer and use it in GitHub Desktop.
JS Metaobjects

Пишем свою объектную модель на JavaScript

Введение

Эта статья является вольным изложением идей из этой замечательной книги JavaScript Spressore (обязательно к прочтению).

Всем известно что в JavaScript классов нет, а наследование реализуется на прототипах. И каждый уважающий себя JavaScript-программист пишет свою систему классов. Мы тоже пойдем путем, но свернем немного в сторону и погрузимся в мир безумия. Надеюсь будет интересно и познавательно.

А зачем реальным поцонам ООП?

Давным давно по земле бродили динозавры, а программисты писали на Фортране и C (настоящие джедаи писали только на Лиспе, но мы не о них). И все было у них хорошо, кроме Accidental Complexity. Хоть Фортран и C были высокоуровневыми языками (по сравнению с ассемблером все будет высокоуровневым), но написание больших приложений выливалось в сотни тысяч и миллионы строк кода. В ответ на возникшие проблемы родилась концепция ООП. Обещавшая поднять выразительность языков на новый уровень и сохранить милионы жизней человеко-часов программистов.

ООП родилось в начле 70-х годов 20 века. Его прародителем была Simula, а сам термин придумал Алан Кей (он, правда, понимал ООП не так как принято сегодня).

Быстро пробежимся по основным идеям:

  • Мы описываем систему (предметную область) в виде отделтных сущностей.
  • Сущность объединяет в себе данные и операции над этими данными
  • Сущность скрывает свое внутренне устройство, выставляя наружу только определенный набор операций. Через которые они взаимодействуют с окружащим миром.
  • Одни сущности могут наследовать свойства и поведение других сущностей.
  • Сущности могут содержать в себе другие сущности.

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

Полиморфизм, инкапсуляция, народность

Насделование — это дар небес, для ленивых программистов. Не нужно перепечатывать код для каждой новой сущности, достаточно унаследовать ее от какого-нибудь непутевого родителя. И, вуаля, у вас есть совершенно новая, не ношенная, сущность.

Но, давайте взглянем на него повнимательней. Например, рассмотрим модель классов для транспортных средств.

enter image description here На первый взгляд все хорошо, класс Vehicle наиболее абстрактный, содержит наиболее общие свойства транспортного средства. Двинемся дальше и добавим нашим машинам электрические и бензиновые двигатели. Как тогда будет выглядеть цепочка наследования?

enter image description here

GroundElectroVehicle и GroundGasolineVehicle или CargoElectroCar, CargoGasolineCar, PassangerElectroCar и т.д. Схема становится сложнее. А если еще добавить возможность бронирования. А куда отнести самолет-амфибию или летающую машину? Что бы схема наследования хорошо работала, ее нужно тщательно продумать наперед. И чем она больше и глубже, тем сложнее будет вносить в нее изменения.

Has против Is

Если наследование не подходит, то что же делать? Как добавить нашим сущностям различные аспекты поведения, да так, чтобы не пришлось дублировать код в разных местах? На помощь спешит делегирование! Вместо создания громоздких схем наследования, выделим отдельные сущности с нужным поведением. И сделаем их свойствами наших классов.

Например, класс Autopilot.

class Autopilot {
	activate () {...}
	moveToPoint () {...}
}

class Plane {
	constructor () {
		this.autopilot = new Autopilot();
	}
}

var plane = new Plane();
plane.autopilot.activate();

Теперь мы можем добавить автопилот кораблю, самолету и другой любой сущности.

Но у нашего решения тоже есть недостаток. Чтобы самолет мог воспользоваться новым поведением, нужно, сперва обратиться к автопилоту и вызвать его метод.

Поступая так мы нарушаем закон Деметры. Он гласит:

Каждый программный модуль:

  • должен обладать ограниченным знанием о других модулях: знать о модулях, которые имеют «непосредственное» отношение к этому модулю.
  • должен взаимодействовать только с известными ему модулями «друзьями», не взаимодействовать с незнакомцами.
  • обращаться только к непосредственным «друзьям».

Товарищи родители программисты объясните своим сущностям, что они не должны разговаривать с незнакомцами. Уберегите их от проблем!

Поправить проблему просто:

class Autopilot {
	activate () {...}
	moveToPoint () {...}
}

class Plane {
	constructor () {
		this.autopilot = new Autopilot();
	}
	
	activateAutopilot () {
		this.autopilot.activate();
	}
}

var plane = new Plane();
plane.activateAutopilot();

Теперь мы можем спокойно менять внутреннюю реализацию не боясь сильной связанности и Дяди Боба. И все бы хорошо, но опять одно «но». Нам пришлось вручную создать метод обертку. Для одного метода одной сущности сойдет. Но становиться грустно, когда приходится делегировать поведение многим сущностям с многими методами.

Чем меньше кода, тем лучше. «DRY'ить от забора и до стендапа» — как говорил один тимлид. Мы назвали метод активации автопилота activateAutopilot, а другой программист из нашей команды назовет подобный метод, просто, activate. Предвижу незабываемое время в отладчике.

Что мы можем с этим сделать? Конечно, мы же пишем на JavaScript'e. У нас есть примеси (mixins).

var Autopilot = {
	activate () {
		this._state = {...};
	}
	
	moveToPoint () {}
}

function extend (target, source) {
	Object.keys(source).forEach(function (key) {
		target[key] = source[key];
	});
	
	return target;
}

class Plane {
	...
}

extend(Plane.prototype, Autopilot);

var plane = new Plane();
plane.activate();

Это самый простой способ создать примесь. Теперь мы можем создавать сущности с хорошо определенной ответственностью. И легко комбинировать их между собой.

Вот так

class Plane {}
extend(Plane.prototype, Vehicle, Flying, Autopilot);

или так

var PlaneMetaObject = extend({}, Vehicle, Flying, Autopilot);
var plane = Object.create(PlaneMetaObject);

и даже использовать фабрики (специально для джавистов)

function metaFactory (metaobject) {
	return function factory (options) {
		var instance = Object.create(metaobject);
		return extend(options, instance);
	}
}

var planeMaker = metaFactory(PlaneMetaObject);
var plane = planeMaker({type: 'Boeing'});

Однако наши злоключения на этом не заканчиваются. У примесей в такой реализации тоже есть серьезные недостатки.

Во-первых, все методы сущностей добавляются в один объект, и с ростом числа этих сущностей, неизбежно, начнуться конфликты между методами с одинаковыми именами. Во-вторых, все примеси разделяют общий контекст исполнения (this) и следовательно имеют доступ к состоянию друг друга. Отсюда рост сложности и сильная связанность со всеми вытекающими проблемами.

Лень, гордыня и нетерпимость

Мы рассмотрели несколько вариантов создания объектной модели. Поняли их основные достоинства и недостатки. Есть ли решение лучше рассмотренных? Давайте подумаем, что нам нужно от объектной модели.

  1. Создавать новые сущности на основе композиции из других сущностей.
  2. Сущности должны обладать своим приватным состоянием, недоступным другим примесям.
  3. Если сущности должны иметь доступ к методам друг друга, то зависимости должны быть явно задекларированны.
  4. Если существует конфликт имен между сущностями, то должен быть механизм их разрешения.

Давайте поробуем разработать объектную систему удовлетворяющую этим требованиям.

По первому пункту все просто, достаточно взять функцию типа .assign/.extend. Для добавления приватного состояния, напишем вот такую штуку:

var _ = require('lodash');

function extendPrivately (receiver, mixin) {
    var privateContext = Object.create(null);

    return Object.keys(mixin)
        .reduce(function (metaobject, method) {
	        metaobject[method] = function () {
                var result = mixin[method].apply(privateContext, arguments);

                return result === privateContext ? this : result;
            };

            return metaobject;
        }, receiver);
}

Проходим по всем свойствам mixin и создаем в receiver соответствующие методы-обертки. Внутри метода обертки вызываем исходный метод примеси в контексте privateContext. Если метод примеси пробует вернуть this, т. е. privateContext, то возвращаем контекст метода-обертки, т. е. receiver.

Все. Теперь, наши примеси будут работать, каждая в своем контексте. Ура! Сильное связывание поверженно!

Теперь перейдем к пункту 3.

С одной стороны нам нужно, чтобы сущности обладали приватным контекстом. Но с другой стороны мы хотим, чтобы они имели доступ к методам друг друга, т. е. обращались к итоговой сущности. Давайте немного поменяет функцию extendPrivately.

var _ = require('lodash');

function proxy (baseObject, methods, optionalPrototype) {
    var methodName;
    var proxyObject = Object.create(optionalPrototype || null);

    for (methodName in baseObject) {
        (function (methodName) {
            proxyObject[methodName] = function proxyWrapper () {
                var result = baseObject[methodName].apply(baseObject, arguments);
                return result === baseObject ? proxyObject : result;
            };
        }(methodName));
    }

    return proxyObject;
}

Функция proxy создает обертку вокруг объекта и позволяет указать список методов для проксирования. Параметр optionalProtype оставим на потом.

А теперь фокус! В extendPrivately в качестве приватного контекста мы используем простой объект. Давайте вместо него создадим прокси к receiver.

var _ = require('lodash');
var proxy = require('./proxy');

function extendWithProxy (receiver) {
    var mixins = _.slice(arguments, 1);

    return mixins.reduce(function (acc, mixin) {
        return compose(receiver, mixin, acc);
    }, receiver);

    function compose (receiver, mixin, acc) {
        var privateContext = proxy(receiver);

        return Object.keys(mixin)
            .reduce(function (metaobject, method) {
                metaobject[method] = function () {
                    var result = mixin[method].apply(privateContext, arguments);

                    return result === privateContext ? this : result;
                };

                return metaobject;
            }, acc);
    }
}

var ModelA = {
    setMessage: function (msg) {
        this._msg = msg;
    },
    methodA1: function () {
        return this._msg;
    }
};

var ModelB = {
    methodB1: function () {
        return 'methodB1 called and ' + this.methodA1();
    },
    methodB2: function () {
        return this._msg;
    }
};

var Meta = extendWithProxy({}, ModelA, ModelB);
var meta = Object.create(Meta);

meta.setMessage('methodA1 called!');
meta.methodB1() // => 'methodB1 called and methodA1 called!'
meta.methodB2() // => undefined

Теперь примеси ModelA и ModelB имеют доступ к методам друг друга через общий контекст — Meta.

Упс! Видите в чем проблема? Вот пример:

var meta1 = Object.create(Meta);
var meta2 = Object.create(Meta);

meta1.setMessage('Hello!');
meta1.methodA1(); // => 'Hello!'
meta2.methodA1(); // => 'Hello!'

Приватный контекст примесей завязан на объект Meta. И следовательно разделяется всеми экземплярами. Давайте еще раз проявим инженерную смекалку и сделаем как надо.

Нам нужно, что бы у каждого экземпляра был был свой приватный контекст для каждой примеси. Самый простой способ — создать у объекта-экземпляра свойство в котором и будет храниться контекст. Выглядеть это будет как-то так:

{ 
	__privateStateForModuleA__: { _msg: 'Hello!' },
	__privateStateForModuleB__: ...,
	methodA1() {...},
	methodB1() {...} 
}

Напишем новую функцию safeExtend

var _ = require('lodash');
var proxy = require('./proxy');

var privateStateId = 0;

Тут и так все ясно.

function safeExtend (receiver) {
    return _.slice(arguments, 1).
        reduce(extendInternal(), receiver)
    ;
}

Создали новый идентификатор для приватного контекста. Это идентификатор будет связывать каждую примесь с приватным контекстом внутри экземпляра метаобъекта.

function extendInternal () {
    return function (acc, mixin) {
        var safekeepingName = getSafekeepingName(++privateStateId);

        return _.methods(mixin).
            reduce(function (metaobject, method) {
                metaobject[method] = createWrapper(mixin, method, safekeepingName);

                return metaobject;
            }, acc);
    };
}

Тут тоже ничего оригинального. Стоит обратить внимание на то, что метод wrapper будет выполняться в контексте конкретного экземпляра метаобъекта. И для каждого экземпляра будет создаваться отдельный приватный контекст. Точнее по контексту на примесь.

function createWrapper (mixin, method, safekeepingName) {
    return function wrapper () {
        var privateContext = getContext(this, safekeepingName, mixin);
        var result = mixin[method].apply(privateContext, arguments);

        return result === privateContext ? this : result;
    };
}

Здесь происходит самое важное. Если у текущего объекта еще нет приватного контекста, то создаем его. При этом контекст — это прокси к этому же объекту! Сохраняем наш новенький контекст, опять же, как свойство исходного объекта. Да, это уличная магия.

function getContext (receiver, safekeepingName) {
    var context = receiver[safekeepingName];

    if (context == null) {
        context = proxy(receiver);

        Object.defineProperty(receiver, safekeepingName, {
            value: context,
            enumerable: false,
            writable: false
        });
    }

    return context;
}

function getSafekeepingName (id) {
    return '__' + id + '__';
}

Пример в студию.

var Person = {
    setName: function (name) { this._name = name; },
    name: function () { return this._name; },
    description: function () {
        return this.name() + ' the ' + this.profession();
    }
};

var RingKeeper = {
    disappear: function () {
        this._ringOn();
    },

    _ringOn: function () {
        this._isInvisible = true;
    }
};

var Profession = {
    setProfession: function (profession) { this._profession = profession; },
    profession: function () { return this._profession; }
};

var Hobbit = extendWithProxy({}, Person, Profession, RingKeeper);
var bilbo = Object.create(Professional);
var frodo = Object.create(Professional);

bilbo.setName('Bilbo Baggins');
bilbo.setProfession('Burglar');
bilbo.description(); // => 'Bilbo Baggins the Burglar'

frodo.name(); // => undefined

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

У старика Бильбо в прототипе лежат все методы наших метообъектов. Точнее обертки (методы wrapper), вызывающие эти методы в приватном контексте. Сами же приватные контексты хранятся в свойствах __3__, __4__ и __5__. Также приватные контексты обладают всеми методами нашего метообъекта (методы proxyWrapper). Это обертки вокруг методов wrapper из прототипа объекта. Т. е. обертки над обертками.

Примерный стек вызова метода description:

bilbo.description()
|— wrapper
|—— Person.description
|——— proxyWrapper
|———— wrapper
|————— Person.description

Если вы все поняли и не лишились рассудка, то это означает только одно. Вы уже безумны. Поздравляю.

Давайте еще разок, теперь уже внимательно, посмотрим на картинку выше. Что тут не так? Правильно, опять сильное связывание нас достало. Каждый приватный контекст имеет доступ ко всем методам всех примесей. А виноват в этом метод proxy, который проксирует все методы подряд. В добавок ко всему, в интерфейс метаобъекта затесался приватный метод.

Давайте это быстренько исправим. Сперва перепишем метод proxy так, чтобы он принимал список методов которые можно проксировать и параметр для указания произвольного прототипа для прокси-объекта.

function proxy (baseObject, methods, optionalPrototype) {
    if (methods == null) {
        methods = deepMethods(baseObject);
    }

    var proxyObject = Object.create(optionalPrototype || null);

    return methods.reduce(function (metaobject, methodName) {
        metaobject[methodName] = function proxyWrapper () {
            var result = baseObject[methodName].apply(baseObject, arguments);
            return result === baseObject ? proxyObject : result;
        };

        return metaobject;
    }, proxyObject);
}

function deepMethods (target) {
    var methods = [];

    for (var method in target) {
        if (_.isFunction(target[method])) {
            methods.push(method);
        }
    }

    return methods;
}
var privateStateId = 0;

function extend (receiver) {
    var mixins = _.slice(arguments, 1);
    return mixins.reduce(extendInternal(), receiver);
}

Теперь для создания метаобъекта используются только публичные методы.

function extendInternal () {
    return function (acc, mixin) {
        var safekeepingName = getSafekeepingName(++privateStateId);

        return publicMethods(mixin)
            .reduce(function (metaobject, method) {
                metaobject[method] = createWrapper(mixin, method, safekeepingName);

                return metaobject;
            }, acc);
    };
}
function createWrapper (mixin, method, safekeepingName) {
    return function wrapper () {
        var privateContext = getContext(this, safekeepingName, mixin);
        var result = mixin[method].apply(privateContext, arguments);

        return result === privateContext ? this : result;
    };
}

function getContext (receiver, safekeepingName, mixin) {
    var context = receiver[safekeepingName];

    if (context == null) {
        context = createContext(receiver, mixin);

        Object.defineProperty(receiver, safekeepingName, {
            value: context,
            enumerable: false,
            writable: false
        });
    }

    return context;
}

Теперь createContext создает прокси только для публичных метододов примеси и задекларированных зависимостей. А в прототип прокси-объекта идут приватные методы примеси.

function createContext (receiver, mixin) {
    var proto = privateMethods(mixin).reduce(function (acc, methodName) {
        acc[methodName] = mixin[methodName];
        return acc;
    }, {});

    var methods = _.compact([].concat(mixin.dependencies, publicMethods(mixin)));

    return proxy(receiver, methods, proto);
}

function getSafekeepingName (id) {
    return '__' + id + '__';
}

function isPrivateMethod (methodName) {return methodName[0] === '_';}

function isPublicMethod (methodName) { return methodName[0] !== '_'; }

function methodFilter (predicate) {
    return function (object) {
        return _(object).
            methods().
            filter(predicate).
            value()
        ;
    };
}

var publicMethods = methodFilter(isPublicMethod);
var privateMethods = methodFilter(isPrivateMethod);

В пимере, только, добавилось явное описание зависимостей.

var Person = {
	dependencies: ['profession'],
    ...
};

Ну что же, с приватностью разобрались. Теперь нас ждут новые глубины безумия.

Как разрулить все конфликты и начать жить

Если активно конструировать новые объекты на основе примесей, то быстро возникнут конфликты между именами методов.

Не существует одного универсального способа разрешения конфилктов, но можно представить несколько вариантов:

  • Вызывать только первый метод
  • Вызывать только посдедний
  • Выполнять все методы по порядку и возвращать первый не undefined результат
  • Выполнять все методы в обратном порядке и возвращать первый не undefined результат
var _ = require('lodash');
var exec = _.curry(function exec (context, args, fn) {
    return fn.apply(context, args);
});

var policies = {
    overwrite: function overwrite () {
        return arguments[0];
    },

    discard: function discard () {
        return _(arguments).slice(-1).first();
    },

    before: function before () {
        var fns = _.slice(arguments);

        return function () {
            return _(fns)
                .map(exec(this, arguments))
                .filter(_.negate(_.isUndefined))
                .first()
            ;
        };
    },

    after: function after () {
        var fns = _.slice(arguments);

        return function () {
            return _(fns.reverse())
                .map(exec(this, arguments))
                .filter(_.negate(_.isUndefined))
                .first()
            ;
        };
    },

	// Выполняем только первый метод, а остальные 
	// передаем ему в качестве первого аргумента
    around: function around () {
        var fns = _.slice(arguments);
        var fn = fns.shift();

        fns = fns.map(bindWith(this));

        return function () {
            var argArray = [fns].concat(_.slice(arguments));
            return fn.apply(this, argArray);
        };

        function bindWith(context) {
            return function (fn) {
                return fn.bind(context);
            };
        }
    }
}

Напишем метод compose который будет принимать список примесей и схему политик для разрешения конфликтов:

var Metaobject = compose(Mixin1, Mixin2, {
	overwrite: ['action1', 'action2'],
	before: 'action3',
	after: '*' // политика по умолчанию 
});
function compose () {
    var mixins = _.slice(arguments, 0, -1);
    var policiesSchema = _.slice(arguments, -1)[0];

    var metaobjects = _.map(mixins, function (mixin) {
        return extendInternal({}, mixin);
    });

    var conflictsShema = propertiesToArrays(metaobjects);
    policiesSchema = inversePoliciesSchema(policiesSchema);

    return resolve(conflictsShema, policiesSchema);
}

compose устроен достаточно просто.

  • Сперва проходим по всем миксинам и создаем инкапсулированные объекты.
  • Затем находим все конфликты между миксинами.
  • Инвертируем схему политик
	// из такой 
	{
		overwrite: ['action1', 'action2'],
		before: 'action3',
		after: '*' 
	}

	// в такую (каждому методу своя политика)
	{
		action1: 'overwrite',
		action2: 'overwrite',
		action3: 'before',
		'*': 'after'
    }
  • Применяем схему политик к схеме конфликтов
  • Profit!
function extendInternal (acc, mixin) {
    var safekeepingName = getSafekeepingName(++privateStateId);

    return publicMethods(mixin)
        .reduce(function (metaobject, method) {
            metaobject[method] = createWrapper(mixin, method, safekeepingName);

            return metaobject;
        }, acc);
}

function resolve (conflictsShema, policiesSchema) {
    var defaultPolicyName = policiesSchema['*'] || 'before';

    return Object.keys(conflictsShema).reduce(function (meta, fnName) {
        var policy = policiesSchema[fnName] || defaultPolicyName;
        var policyFn = policies[policy];

        if (conflictsShema[fnName].length === 1) {
            meta[fnName] = conflictsShema[fnName][0];
        } else {
            meta[fnName] = policyFn.apply(this, conflictsShema[fnName]);
        }
        return meta;
    }, Object.create(null));
}

function inversePoliciesSchema (hash) {
    return Object.keys(hash).reduce(function (inversion, policyName) {
        var methodNameOrNames = hash[policyName];
        var methodName;

        if (_.isString(methodNameOrNames)) {
            methodName = methodNameOrNames;
            inversion[methodName] = policyName;
        } else if (_.isArray(methodNameOrNames)) {
            _.each(methodNameOrNames, function (methodName) {
                inversion[methodName] = policyName;
            });
        }

        return inversion;
    }, {});
}

function propertiesToArrays (metaobjects) {
    return metaobjects.reduce(function (collected, metaobject) {
        Object.keys(metaobject).forEach(function (key) {
            if (Object.prototype.hasOwnProperty.call(collected, key)) {
                collected[key].push(metaobject[key]);
            } else {
                collected[key] = [metaobject[key]];
            }
        });

        return collected;
    }, Object.create(null));
}

Заключение

Если вы дочитали до конца, то мысленно жму вашу руку товарищ.

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

  1. Создание новых сущностей на основе композиции из других сущностей (примесей).
  2. Сущности обладают своим приватным состоянием, недоступным другим примесям.
  3. Если сущность хочет вызывать методы другой сущности, то зависимости должны быть явно задекларированны.
  4. Если существует конфликт имен между сущностями, то будет применена политика их разрешения.

Это решение не является готовым для употребления в боевых проектах. А скорее демонстрирует альтернативный подход проблеме создания объектных моделей. Не классами едиными. Хотя если запастись новомодными ништяками из ES6 то можно будет применять и на практике.

@nike-17
Copy link

nike-17 commented Mar 27, 2015

опечатка "в виде отделтных сущностей."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment