Классы на JS с protected, множественным наследованием, геттерами/сеттерами и примесями

c4113f12a39d4693a1b6581d8ec31275.jpgПривет, Хабр!
Начну с того, что мне надоела убогость классов и наследования в JavaScript! Просидев тысячи часов над крупным JS-проектом, это стало для меня просто очевидным. Особенно когда переключаешься с бэкенда с использованием Yii2, на фронтенд. Ведь в Yii2 и php есть настоящие классы, настоящие protected/private поля, есть trait, всякие dependency injection и behavior. И вот сразу после всех этих штук, создаёшь такой файл NewClass.js для того чтобы написать какой-нибудь класс, и понимаешь, что в JavaScript ничего этого нет. И даже более того, классы можно писать сотнями разных способов — прототипное/функциональное наследование, ES6 классы, и разные сахара с использованием внешних библиотек. Тут я сказал себе — «хватит это терпеть!».


Что нам предлагают в современных стандартах?

В ES6 появилась возможность описания классов более привычным для всех языков способом, с помощью синтаксиса class {}. Однако это скорее более привычная запись классов с использованием старого прототипного наследования, и в нём так и не появилось ни protected, ни privatе модификаторов доступа к свойствам класса. В новейшем ES2017 стандарте этого до сих пор и нет.


Велосипедим

Конечно, не хотелось быть собирателем велосипедов, и первое, что я сделал, прежде чем сесть за свой вариант библиотеки, я стал искать уже существующие решения. И, всё что будет описываться ниже, не моё открытие — раму для велосипеда уже нашёл в идеях других источников, и библиотеке mozart. Последнюю хотелось бы особо отметить, т. к. она послужила хорошей основой для дальнейшего развития идеи реализации почти настоящих классов.


Краткий обзор возможностей

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


1) Public, protected и private доступ к членам класса
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

2) Простое и множественное наследование классов, вызов родительских методов через $super
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

3) Автоматическое создание геттеров/сеттеров
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);

4) Примеси
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

Вот примерно как-то так это работает.
img


Ну, а дальше осталось лишь обвесить всё это красотой и возможностями, и вуаля!


Заключение

Подробное описание возможностей вы найдёте в 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.

© Habrahabr.ru