Классы на JS с protected, множественным наследованием, геттерами/сеттерами и примесями
Привет, Хабр!
Начну с того, что мне надоела убогость классов и наследования в JavaScript! Просидев тысячи часов над крупным JS-проектом, это стало для меня просто очевидным. Особенно когда переключаешься с бэкенда с использованием Yii2, на фронтенд. Ведь в Yii2 и php есть настоящие классы, настоящие protected/private поля, есть trait, всякие dependency injection и behavior. И вот сразу после всех этих штук, создаёшь такой файл NewClass.js
для того чтобы написать какой-нибудь класс, и понимаешь, что в JavaScript ничего этого нет. И даже более того, классы можно писать сотнями разных способов — прототипное/функциональное наследование, ES6 классы, и разные сахара с использованием внешних библиотек. Тут я сказал себе — «хватит это терпеть!».
Что нам предлагают в современных стандартах?
В ES6 появилась возможность описания классов более привычным для всех языков способом, с помощью синтаксиса class {}
. Однако это скорее более привычная запись классов с использованием старого прототипного наследования, и в нём так и не появилось ни protected, ни privatе модификаторов доступа к свойствам класса. В новейшем ES2017 стандарте этого до сих пор и нет.
Велосипедим
Конечно, не хотелось быть собирателем велосипедов, и первое, что я сделал, прежде чем сесть за свой вариант библиотеки, я стал искать уже существующие решения. И, всё что будет описываться ниже, не моё открытие — раму для велосипеда уже нашёл в идеях других источников, и библиотеке mozart. Последнюю хотелось бы особо отметить, т. к. она послужила хорошей основой для дальнейшего развития идеи реализации почти настоящих классов.
Краткий обзор возможностей
Чтобы не превращать статью в пересказ README проекта, опишу лишь кратко список возможностей, и приведу пример использования, а ниже расскажу, как работает вся эта магия.
var Figure = Class.create(function ($public, $protected, _) {
$public.x = 0;
$public.y = 0;
$protected.name = 'figure';
$protected.init = function (x, y) {
_(this).id = 123; // private
this.x = x;
this.y = y;
};
$protected.protectedMethod = function () {
console.log('protectedMethod: ', this.id, this.name, this.self.x, this.self.y);
};
this.square = function (circle) {
return 2 * Math.PI * circle.radius;
}
});
var Circle = Class.create(Figure, function ($public, $protected, _) {
$public.radius = 10;
$public.publicMethod = function () {
console.log('publicMethod: ', _(this).id, _(this).name, this.radius);
_(this).protectedMethod();
};
});
var circle = new Circle(2, 7);
circle.radius = 5;
circle.publicMethod(); // publicMethod: undefined figure 5 / protectedMethod: 123 figure 2 7
console.log(Circle.square(circle)); // 31.415926536
var Layer = Class.create(function ($public, $protected, _) {
$protected.uid = null;
$protected.init = function () {
_(this).uid = Date.now();
}
});
var Movable = Class.create(function ($public, $protected, _) {
$public.x = 0;
$public.y = 0;
$protected.init = function (x, y) {
this.x = x;
this.y = y;
}
$public.move = function () {
this.x++;
this.y++;
}
});
var MovableLayer = Class.create([Layer, Movable], function ($public, $protected, _, $super) {
$protected.init = function (x, y) {
$super.get(Layer).init.apply(this, arguments);
$super.get(Movable).init.apply(this, arguments);
}
});
var layer = new MovableLayer(); // смотрите предыдущий пример
console.log(layer instanceof Layer, layer instanceof Movable); // true false
console.log(Class.is(layer, Layer), Class.is(layer, Movable)); // true true
var Human = Class.create(function ($public, $protected, _) {
$protected.birthday = null;
$public.getBirthday = function () {
return _(this).birthday;
};
$public.setBirthday = function (day) {
_(this).birthday = day;
};
$public.getAge = function () {
var date = new Date(_(this).birthday);
return Math.floor((Date.now() - date.getTime()) / (1000 * 3600 * 24 * 365));
};
});
var human = new Human();
human.birthday = '1975-05-01';
console.log(human.age);
var SortableMixin = function ($public, $protected, _) {
$public.sort = function () {
_(this).data.sort();
};
};
var Archive = Class.create(null, SortableMixin, function ($public, $protected, _) {
$protected.init = function () {
_(this).data = [3, 9, 7, 2];
};
$public.outData = function () {
console.log(_(this).data);
};
});
var archive = new Archive();
archive.sort();
archive.outData(); // [2, 3, 7, 9]
Разоблачаем фокус
Так как объекты в JavaScript не имеют никаких настроек доступов для его свойств, мы сможем сымитировать похожее на protected/private поведение, путём скрытия защищённых данных. При обычном функциональном наследовании это делается путём замыкания на самом конструкторе, а все методы создаются для каждого экземпляра класса:
var SomeClass = function () {
var privateProperty = 'data';
this.someMethod = function () {
return privateProperty;
};
};
var data = [];
for (var i = 0; i < 10000; i++) {
data.push(new SomeClass());
}
При выполнении данного кода в памяти создадутся помимо самих объектов, ещё 10000 функций someMethod, что сильно откушает память. При этом нельзя так просто вынести объявление функции за пределы конструктора, так как в этом случае функция потеряет доступ к privateProperty.
Для решения данной проблемы, нам нужно объявлять функцию метода лишь один раз, а получать защищённые данные только за счёт указателя на объект this
:
var SomeClass;
(function () {
var privateData = [];
var counter = -1;
SomeClass = function () {
this.uid = ++counter;
};
SomeClass.prototype.someMethod = function () {
var private = privateData[this.uid];
};
})();
Так уже лучше, но всё-таки плохо. Во-первых, извне становится доступен некий идентификатор uid. А во-вторых, сборщик мусора никогда не очистит то, что попадёт в массив privateData и будет медленно, но верно отжирать память. Для решения сразу двух проблем в ES6 появились замечательные классы Map и WeakMap.
Map — это почти те же массивы, но в отличие от них, в качестве ключа можно передать любой объект JavaScript. На для нас будут более интересны WeakMap — это тоже что и Map, но в отличие от него, WeakMap не мешает сборщику мусора очищать объекты, которые попадают в него.
Перепишем:
var SomeClass;
(function () {
var privateData = new WeakMap();
SomeClass = function () {};
SomeClass.prototype.someMethod = function () {
var private = privateData.get(this);
};
})();
Так мы получили private. С реализацией protected всё гораздо сложнее — для хранения защищённых данных их нужно разместить в неком общем хранилище для всех производных классов, но при этом давать доступы для конкретного класса не для всех свойств, а только те, что объявлены в нём самом. В качестве такого хранилища мы опять используем WeakMap, а в качестве ключа — прототип объекта:
SomeClass.prototype.someMethod = function () {
var protected = protectedData.get(Object.getPrototypeOf(this));
};
Для ограничения доступа только к тем protected-свойствам, которые есть в самом классе, мы будем выдавать классу не сам объект с защищёнными данными, а связанный объект, нужные свойства которого будут получаться из основного объекта, путём объявления геттера и сеттера:
var allProtectedData = { notAllowed: 'secret', allowed: 'not_secret' };
var currentProtectedData = {};
Object.defineProperties(currentProtectedData, {
allowed: {
get: function () { return allProtectedData.allowed; },
set: function (v) { allProtectedData.allowed = v; },
}
});
currentProtectedData.allowed = 'readed';
console.log(allProtectedData.allowed, currentProtectedData.allowed, currentProtectedData.notAllowed); // readed readed undefined
Вот примерно как-то так это работает.
Ну, а дальше осталось лишь обвесить всё это красотой и возможностями, и вуаля!
Заключение
Подробное описание возможностей вы найдёте в README проекта. Всем спасибо за внимание!
Проект: https://github.com/paulzi/oopify/
Комментарии (6)
9 января 2017 в 05:21 (комментарий был изменён)
0↑
↓
Здесь должна быть картинка про 14 конкурирующих стандартов, но её вырезала полиция банальности.З.Ы. Кажется, я где-то уже видел JS классы на викмапах, но искать лень.
9 января 2017 в 08:12
0↑
↓
Если 14 стандартов не предоставляют того, что перечислено в заголовке статьи, то почему бы и нет.
9 января 2017 в 08:01
+1↑
↓
Если вот такое придется писатьvar Archive = Class.create(null, SortableMixin, function ($public, $protected, _) { $protected.init = function () { _(this).data = [3, 9, 7, 2]; }; $public.outData = function () { console.log(_(this).data); }; });
то «у Билла» (который на картинке) в typescript все это (кроме множественного наследования) есть и работает проще и выглядит красивее (код разрабатываемого приложения, а не самого компилятора).
9 января 2017 в 08:15
0↑
↓
Если не ошибаюсь, в TypeScript все эти protected будут protected только до стадии компиляции включительно, в runtime же все это теряется.
9 января 2017 в 08:40
0↑
↓
Вам этот приват и протектед что в итоге дали? Отладку упростили? Среда разработки подчёркивает ошибки в именах методов?
Пример с примесями странный. Как отнаследоваться и примешать две примеси?
9 января 2017 в 08:41
0↑
↓
Да, я тоже делал такое.
Да, вообще все делали такое.
Лет пять-десять назад.
Я потом я сделал над собой усилие и перешёл на TypeScript.