Привет, Хабр! Начну с того, что мне надоела убогость классов и наследования в 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 проекта. Всем спасибо за внимание!
Комментарии (61)
Andreyika
09.01.2017 08:01+6Если вот такое придется писать
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 все это (кроме множественного наследования) есть и работает проще и выглядит красивее (код разрабатываемого приложения, а не самого компилятора).PaulZi
09.01.2017 08:15-3Если не ошибаюсь, в TypeScript все эти protected будут protected только до стадии компиляции включительно, в runtime же все это теряется.
rMX
09.01.2017 09:58+2А в чем конкретно глубокий смысл наличия protected свойств/методов в рантайме? Вроде как эти вещи нужны лишь для удобства разработки, не более.
PaulZi
09.01.2017 10:01Например, в том, что стороннему разработчику, чтобы расширить ваш класс, не нужно изучать TypeScript и при этом, соблюдать принципы инкапсуляции.
mayorovp
09.01.2017 10:07+9Лучше уж выучить TypeScript, чем чужую библиотеку где классы создаются через
Class.create(null, SortableMixin, function ($public, $protected, _) {
rMX
09.01.2017 11:53Вопрос был именно про рантайм. Зачем в рантайме это нужно?
Зачем тогда protected, если не для соблюдения инкапсуляции?
Все это не наезда ради, просто хочется разобраться, для чего может потребоваться данная библиотека и чем она лучше других решений, помимо бонуса множественного наследования (сомнительного, на мой взгляд).PaulZi
09.01.2017 12:05-1Собственно, нужно для того, чтобы можно было на нативном JS писать почти настоящие классы, без необходимости в TypeScript. Всё таки JavaScript !== TypeScript. И если в каждом браузере работал TypeScript и все фронтенд разработчики писали на нём, конечно, всё было бы прекрасно.
Согласен, что для тех, кто пишет на TypeScript всё это не нужно.raveclassic
09.01.2017 12:13+1В нативном JS нет классических классов и не может быть из-за прототипов. То, что ввели в ES6 — это просто сахар. С этим ничего не поделать и делать ничего не нужно. Если вам нужны «почти настоящие классы», лучше взять другой язык, который в них умеет.
PaulZi
09.01.2017 12:19Ну подытожив всё написанное в комментариях — хочешь писать хорошо и без велосипедов, пиши на TypeScript или не используй protected.
raveclassic
09.01.2017 12:23+2Мы в команде используем jsdoc и соглашение об именовании приватных полей через "_":
class Disposable {
/**
* @type {Boolean}
* @protected
*/
_isDisposed;
}
IDE прекрасно подхватывает
raveclassic
09.01.2017 11:21+1Ну, кстати, множественное наследование можно сделать через миксины/декораторы
vintage
09.01.2017 08:40+6Вам этот приват и протектед что в итоге дали? Отладку упростили? Среда разработки подчёркивает ошибки в именах методов?
Пример с примесями странный. Как отнаследоваться и примешать две примеси?
gro
09.01.2017 08:41+12Да, я тоже делал такое.
Да, вообще все делали такое.
Лет пять-десять назад.
Я потом я сделал над собой усилие и перешёл на TypeScript.
mayorovp
09.01.2017 09:07+1У реализации приватных полей через WeakMap есть две проблемы. С циклическими ссылками и с просмотром значений "полей" в отладчике.
Почему вы не стали использовать символы?
var SomeClass = function() { var privateSymbol = Symbol("privateData"); function SomeClass(value) { this[privateSymbol] = value; } SomeClass.prototype[privateSymbol] = 0; SomeClass.prototype.someMethod = function () { return this[privateSymbol]; } return SomeClass; }()
Вот так символ выглядит в консоли Хрома:
k12th
09.01.2017 11:50+2Велосипед, конечно, богатый, с кучей возможностей. К сожалению, недостатки у него такие же, какие были у всех остальных:
- Каждый фреймворк юзал свою такую вот библиотечку, стандарта де факто (как с jQuery) не получилось
- Поддержка в IDE, в силу динамической природы трюка, никакая, а в рантайме все эти private/protected ни к чему
Присоединюсь к советующим посмотреть на TypeScript.
RubaXa
09.01.2017 12:03+7Уиии, 2017 год, а добрая традиция, или даже лучше сказать дисциплина, «пишем свои классы в JS» до сих пор живет!
jbubsk
09.01.2017 23:24TypeScript пожалуй лучше всего подчеркивает модификаторы доступа, тем более это важно именно при моделировании кода.
dom1n1k
09.01.2017 23:43+1> Особенно когда переключаешься с бэкенда с использованием Yii2, на фронтенд. Ведь в Yii2 и php есть настоящие классы
Много лет назад я знакомился с php и меня выбесило отсутствие в нём перегрузки арифметических операций, как в C++. Я посчитал такое ООП неполноценным и забросил сей язык. Забросил не только поэтому, но этот эпизод стал важной последней каплей. Такие вот превратности судьбы.Free_ze
10.01.2017 10:03+1Как перегрузка операторов относится к ООП?
k12th
10.01.2017 10:43Так же, как private/protected:)
PaulZi
10.01.2017 11:37Перегрузка — да, вещь хорошая, но необходима в меньшей степени, чем private/protected.
Предложите в каком-нибудь сообществе типа java или php отказаться от private/protected — вам покрутят у виска. В JS-сообществе же с точностью наоборот.k12th
10.01.2017 11:44+1А предложите в питоньем сообществе private/protected — покрутят точно так же. Вопрос привычки и удобства, реальной необходимости ни в перегрузке, ни в сокрытии нет.
dom1n1k
10.01.2017 15:41Когда нет перегрузки операторов, матричная или комплексная арифметика выглядит как-то так:
a.add(b); // a, b - операнды
или так
mat.add(a, b); // mat - объект, играющий роль неймспейса
а могла бы вот так
a + b
k12th
10.01.2017 15:44Да, понятно, что так красивее и удобнее и приятнее, и мне тоже не хватает иногда перегрузки операторов в JS. Но решающей принципиальной разницы нет, далеко не в каждой задаче есть матрицы или комплексные числа.
vintage
10.01.2017 20:37+1a * b — это векторное, скалярное или поэлементное умножение?
dom1n1k
10.01.2017 21:20-1Ответ очевиден — на усмотрение конкретной реализации.
vintage
10.01.2017 21:41+1Речь о том, что перегрузка операторов без возможности объявления своих операторов — так себе решение. Чего только стоит перегрузка битового сдвига (<<) для стримов в C++.
dom1n1k
11.01.2017 02:09Нормальное при разумном подходе и чувстве меры. В C++ есть миллион способов прострелить себе ногу, так что из того?
Free_ze
10.01.2017 13:02-1Надеюсь, что вы шутите. private/protected в отрыве от ООП представить сложно.
SerafimArts
10.01.2017 13:41+1В случае с protected — соглашусь, но вот private… Никто не мешает использовать процедурный или функциональный язык, где private определяет наличие или отсутствие какой-то структуры (например функции) при экспорте модуля.
… Ничего не напоминает? Модификаторы доступа — это лишь одна из возможностей реализации инкапсуляции, в JS инкапсуляция реализуется наличием/отсутствием кейворда export, например. Замените export на public, а его отсутствие на private, вот и получаем ту же самую инкапсуляцию на уровне модулей, а не на уровне класса/объект, что, к слову, местами профитнее (код интерфейса (не interface, а публичной части) тупо чище).
Free_ze
10.01.2017 17:03-1А давайте не будем заменять «папу» на «маму» и будем читать ветку полностью?Речь шла конкретно о ключевых словах protected/private и возможности их использовании вне ООП и даже объектного подхода.mayorovp
10.01.2017 17:15А давайте не будем принимать совпадения за закономерности? Сегодня "вне объектного подхода" таких ключевых слов нет — а завтра кто-нибудь придумает новый чистый функциональный язык программирования, где эти слова будут присутствовать.
Free_ze
10.01.2017 17:21Изначально я отвечал на конкретную реплику и лишь в ее контексте. Далее понабигали любители холиваров, пытаясь возводить каждое мое слово в абсолют.
Не надо так.SerafimArts
10.01.2017 17:40Ну не правда же, я просто провёл параллель с JS и намекнул, что private есть и в JS, просто он иначе называется и иначе работает, а заодно сделал намёк, что этот "private" есть и в других не ОО языках, иногда даже так и называется, например в Ada или Fortran
UPD. И согласился по поводу protected, т.к. не припомню не ОО языков, где был бы подобный модификатор, хотя придумать можно.
Free_ze
10.01.2017 17:50Вы шутите, да? Я все это время рассказывал, что в C++ protected/private имеют отношение к ООП, а перегрузка операторов — нет.
Весь остальной флуд меня изумляет своей беспочвенностью =)SerafimArts
10.01.2017 18:18Ну с моей стороны диалог выглядит так (насколько я его понял):
- Доминик: У каждого языка, мол, свои плюшки, мне, например, не понравился ООП в пыхе, т.к. там нет перегрузки операторов.
- Вы: Перегрузка операторов — это не ООП
- k12th: Так статья тоже не про ООП, а просто частности переноса одного в другое
- Вы: Да ладно, модификаторы как раз про ООП.
А дальше моё замечание, мол, ну почему же. Если я кого-то не совсем правильно понял, прошу понять и простить =)
zxcabs
10.01.2017 12:27+2Вот серьезно, такие библиотеки должны умереть.
Мало вам классов в JS берите Dart там есть все и даже больше, без вот этих ваших костылей и прочего.PaulZi
10.01.2017 14:05-1Dart только сам уже почти мёртв.
Из реальных альтернатив только TypeScript, с его реализацией всего этого дела на этапе компиляции.
Согласен, что велосипед и очередная библиотека. Лично для меня весь этот проект — показатель того, что на чистом JS можно реализовать настоящий protected, пусть и несколько нетривиально.zxcabs
10.01.2017 15:35+1я не стал бы говорить что Dart мертв, насколько мне видно, он все так же развивается и поддерживает. Другой вопрос что он не получил популярности и у него не очень понятное будущее. Но это куда лучше чем вот такая библиотека, потому что будущее этой библиотеки еще туманней чем у Darta.
Я так и не понял зачем вам настоящие приватные и защищенные методы в js? Для контроля кода в момент разработки достаточно писать jsdoc и тогда нормальная IDE сама сообщит что это за метод и как его следует использовать. Для иллюзии защищенности кода в момент выполнения, так это только иллюзия и лишнее переосложнение системы, еще не раз будет напоминать себе при отладке.PaulZi
10.01.2017 16:04-1По поводу необходимости protected. Взгляните на какой-нибудь класс, реализующий, например, ActiveRecord. Protected очень помогает отделять методы которые можно вызывать из любого места, от тех, которые можно переопределить только расширяя класс. Наследование без protected это большой недостаток ООП в этом языке. Я бы не стал говорить что они не нужны.
Другое дело, что JS из коробки это не предоставляет, а обходные пути либо сложные (как представлено здесь), либо работают только в определённой среде (предупреждения IDE, ошибки компиляции TypeScript). Проблема в том, что большинство библиотек написаны на чистом JS, а не на TypeScript. И не факт, что все работают в IDE с соблюдением всех рекомендаций.raveclassic
10.01.2017 16:13+2Ну почему все пытаются воткнуть в JS ООП (в терминах Java/C++)? Тут прототипное наследование. Какие protected? Ну нет их тут и быть не может.
Если вы хотите, чтобы вам компилятор жаловался, что вы обращаетесь к private/protected полю снаружи, так это вам компилируемый язык нужен, при чем тут JS-то вообще?Free_ze
10.01.2017 17:15-1Ну почему все пытаются воткнуть в JS ООП (в терминах Java/C++)? Тут прототипное наследование.
Потому что прототипное наследование в реализации JS — это грусть и погибель для поддержки более или менее сложных приложений, очевидно. Тренд в JS сейчас — это спрятать реальный, злой JS поглубже, обложившись разного рода трансляторами.LexS007
11.01.2017 15:37+1В чем проблема? Просто не пишите на js, сейчас достаточно транс-компилируемых языков.
Если кому-то не нравится ASM, то он на нем и не пишет, с js тоже самое.Free_ze
11.01.2017 17:16В чем проблема? Просто не пишите на js
Проблема в том, что не писать на js в некоторых областях — удовольствие того же уровня, что и писать на нем. Как такой код дебажить, например? Производительности это так же не добавит, ибо js — это совсем не asm.
транс-компилируемых языков
Транслируемых же, на выходе обычный текст.
zxcabs
10.01.2017 21:28+1Я все же с Вами не соглашусь.
Мое мнение все же останется прежним, что правильней это помечать jsdoc`ом приватные/защищенные методы, чем реализовывать это через библиотеки.
Что касается сторонних библиотек, то это проблема библиотек что там комментариев к коду нет, значит библиотека «не очень» и лучше воздержаться от ее использования.
Что касается IDE то не надо думать о всех, нужно думать о себе. Настройте IDE что бы оно понимало jdoc, помечайте методы jsdoc'ом, а те кто пишут в блокноте должны либо читать ваш код и помнить головой где какой метод или использовать IDE.
zxcabs
10.01.2017 21:36+1добавление к предыдущему моему комментарию:
Так же можно ввести договоренность в именовании методов, например:
Методы которые начинаются на "$" это приватные, а "$$" это защищенные.
И это во много раз проще в поддержке и отладке, чем копаться потом в стектрейсе который будет захламлен библиотечными функциями.
Так же это проще для стороннего человека, ему не нужно въезжать в детали реализации библиотеки, достаточно только узнать о принятой методологии именовании методов.
titulusdesiderio
10.01.2017 19:26Тоже велосипедил протектед но более суровый, без общего хранилища Object_defineProtectedProperty
Sirion
Здесь должна быть картинка про 14 конкурирующих стандартов, но её вырезала полиция банальности.
З.Ы. Кажется, я где-то уже видел JS классы на викмапах, но искать лень.
PaulZi
Если 14 стандартов не предоставляют того, что перечислено в заголовке статьи, то почему бы и нет.