- PVSM.RU - https://www.pvsm.ru -
При написании серьезных проектов перед JavaScript программистами встает выбор: пожертвовать качеством кода и писать классы руками, или же пожертвовать скоростью и использовать систему классов. А если использовать систему, то какую выбрать?
В статье рассмотрена система автора, которая не уступает по скорости классам, написанным «от руки» (другими словами — одна из самых быстрых в мире). Но при этом классы имеют приятную структуру в стиле Си.
Есть шутка, что каждый программист должен написать свою систему классов. Кто не знаком с проблемой — смотрите этот комментарий [1], там их собрано минимум 50 штук.
Каждый из этих велосипедов отличается своим набором возможностей, своим стилем программирования и своим падением скорости. Так, например, создание класса MooTools примерно в 90 раз медленнее, чем создание класса, написанного от руки. Зачем тогда нужны все эти системы?
На практике получается, что написанные от руки классы очень тяжело поддерживать. Когда ваше JS приложение вырастет до приличных размеров, то прототипы перестанут быть такими «прикольными» как раньше, и вы наверняка задумаетесь: может стоит немного пожертвовать производительностью, зато людям будет легче с этим работать. Представьте, например, как бы выглядел Ext.JS, написанный на прототипах.
Замечание: некоторые серьезные проекты все же не используют систему классов, и похоже не сильно от этого страдают. Как пример — смотрите исходник Derby.js. Но я воспринимаю Derby как черный ящик, который делает что-то за вас, так что разработчики не сильно поощряют копание в его внутренностях (поправьте, если не прав); а в Ext наследование наоборот очень важно.
Чего мы хотим от системы? Прежде всего — это вызов родительских методов. Вот пример из MooTools:
var Cat = new Class({
Extends: Animal,
initialize: function(name, age){
this.parent(age); // вызов родительского метода
}
});
Поначалу это очень красиво выглядит: внутри любой функции у вас есть метод parent. И рефакторить удобно — если переименовать метод, то вызов родителя не сломается. Но за красоту и удобство приходится платить большую цену — каждый метод в классе будет обернут в такую вот страшную упаковку:
var wrapper = function(){
if (method.$protected && this.$caller == null) throw new Error('The method "' + key + '" cannot be called.');
var caller = this.caller, current = this.$caller;
this.caller = current; this.$caller = wrapper;
var result = method.apply(this, arguments);
this.$caller = current; this.caller = caller;
return result;
}.extend({$owner: self, $origin: method, $name: key});
Сильно мешает при дебаге, не говоря уже о том, что это очень медленно — этот код будет выполняться при вызове любого метода класса.
Что еще критически важно? У каждого экземпляра класса должны быть свои свойства:
var Cat = new Class({
food: [],
initialize: function(name){
this.name = name;
}
});
var cat1 = new Cat('Мурка');
var cat2 = new Cat('Мурзик');
// массивы разные
cat1.food.push('Мышь');
cat2.food.length == 0; // пустой массив
Как видите, MooTools создал для каждого класса свой собственный массив food. Как бы это все делалось при традиционном подходе? Свойства мы присваивали бы в конструкторе:
function Cat() {
this.food = [];
Cat.superclass.constructor.call(this)
}
Cat.prototype.meow = function() {/*...*/}
Насчет методов есть несколько вариантов, в примере выше показан вариант с функцией extend Дугласа Крокфорда. При традиционной системе в коде много мусора типа «Cat.prototype...» и «superclass.constructor.call(this...)», такой код тяжело воспринимать и рефакторить.
То, что абсолютно нормально в С++, бывает очень вредным в JavaScript. Я говорю это из своего опыта: если в классах есть приватные методы и переменные, то такие классы часто становятся неподдерживаемыми. Если вы хотите что-то изменить в таком куске кода — то иногда вам не остается ничего кроме как выбросить старый код и переписать всё с нуля.
Приватные члены — это плохая практика. Правильно иметь защищенные члены (имя начинается с "_"), а если вы боитесь что какая-то обезьяна начнет доставать их извне — то это уже его дело. Тогда получается, что вы прячете их от программиста, который будет ваш класс наследовать. Возможно, это и есть ваша цель, но чаще всего приватные члены ничего не решают, а только усложняют класс и создают проблемы для адекватных программистов.
А теперь давайте создадим систему классов, которая была бы такой же удобной, как C++, но при этом такой же быстрой, как классы, написанные от руки. И чтобы работало без препроцессоров.
Итак, самый быстрый способ создать класс на JS — это написать его руками, используя прототипы:
function Animal() {}
Animal.prototype.init = function() {}
Под этот способ оптимизированы все движки браузеров. Шаг в сторону — и получим падение производительности, например:
Animal.prototype = {
init: function() {}
}
В этом примере прототип был присвоен как объект. Хром это кушает нормально, а вот в Firefox скорость создания классов падает существенно.
Теперь нам нужно вызывать родительские методы. Существует ли что-нибудь быстрее, чем цепочка прототипов? А давайте просто переименуем родительский метод в классе-наследнике!
function Cat() {} // наследник Animal
Cat.prototype.Animal$init = Animal.prototype.init;
Cat.prototype.init = function() {
this.Animal$init(); // вызов родительского метода
}
Мы скопировали метод из прототипа родителя, и при этом его переименовали. Быстрее уже просто нельзя. Само собой, мы не будем делать это руками — за нас все сделает система классов.
В этом примере не будет работать оператор typeof, но на практике без него можно прекрасно обходиться. Я говорю про реальные приложения и задачи: если вам нужно отличать тип Animal от Cat — то это реальная задача, и она прекрасно решается. Но если вы хотите делать это оператором typeof — то извините, вам к другому доктору.
Еще при таком наследовании нет цепочки прототипов (так как прототипы копируются) — это дает небольшое ускорение по сравнению с традиционными решениями.
Присваивать руками в конструкторе свойства по умолчанию — это тоже не слишком приятно. Так что, пускай за нас это делает скрипт, как в MooTools. Как это будет работать: система классов сама сгенерирует функцию-конструктор, которая присвоит свойства по умолчанию. Выглядеть это будет так:
ClassManager.define(
'Cat',
{
Extends: 'Animal',
food: [],
init: function() {
this.Animal$init();
}
});
В результате получим:
// сгенерированный конструктор
function Cat() {
this.food = [];
this.init.apply(this, arguments);
}
// у которого будет такой прототип
Cat.prototype.Animal$init = Animal.prototype.init;
Cat.prototype.init = init: function() {
this.Animal$init();
}
Переопределенные родительские методы переименовываются по такому правилу:
<имя_класса_родителя> + "$" + <имя_метода>
Такой синтаксис — это самое малое, чем мы заплатили за скорость, и на практике он абсолютно не доставляет неудобств. А сами классы приятно дебажить, и на них приятно смотреть.
Теперь немного пиара моего решения. Тест скорости, ClassManager vs Native (ссылка на jsperf [2]):
Разницу в скорости создания классов можно списать на погрешность jsperf (на старых графиках она одинакова для всех вариантов теста). К сведению: на практике у меня бывало что один и тот же код, запущенный как 2 разных теста — выполнялся с 20% разницей в скорости.
Почему вызов Native метода такой медленный — там написано вот такое:
NativeChildClass.prototype.method = function() {
NativeParentClass.prototype.method.apply(this);
}
Сразу заметно разницу в скорости между вызовом из своего собственного прототипа и через apply. Если вам кажется, что тут я считерил — то напишите свои тесты, быстрее все равно не будет.
Отдельно стоит сказать про Firefox: создание класса, который сгенерирован в браузере — сейчас существенно медленнее (на моем старом ноуте — всего 400 000 операций в секунду). Но мой ClassManager позволяет собирать классы на сервере — и в FF они работают даже быстрее, чем Native. К тому же это ускорит загрузку страницы.
За основу я взял тест автора DotNetWise, но… его тест подло врет: у него тестируется генерация класса плюс 500 итераций по методам. Как вы понимаете, качество и скорость сгенерированного кода не зависят от времени его генерации, и у каждого протестированного фреймворка это время вносит свою погрешность. Более того, у меня классы можно собрать на сервере.
Так что намного более справедливо будет сперва создать классы, а потом уже их тестировать. И если нужно сравнивать время генерации классов — то правильно будет создать для этого отдельный тест, а не домешивать его к скорости вызова методов.
В оригинальном тесте — система автора DNW, конечно же, лидирует. Но если исправить тест, то в хроме на первом месте будет мой ClassManager, за ним идет Fiber, а потом уже DNW. В FF на первом месте TypeScript, потом Native, потом ClassManager. Даже так, это очень специфический тест — тут создание класса меряется вместе с вызовом методов (в неправильных пропорциях), так что я считаю, что реальной картины он не отражает. Тем не менее, вот ссылка [3] и результаты:
Начну с очень важной детали: для моих классов работают подсказки IDE! По крайней мере, в большинстве случаев (пользуюсь PhpStorm). Вот пример того, как могут выглядеть классы:
Lava.ClassManager.define(
// все классы лежат в пространствах имен, даже глобальные
'Lava.Animal',
{
// добавляет методы on(), _fire() и другие
Extends: 'Lava.mixin.Observable',
// можно и так:
// Implements: 'Lava.mixin.Observable',
name: null,
toys: [], // для каждого экземпляра - свой массив
init: function(name) {
this.name = name;
},
takeToy: function(toy) {
this.toys.push(toy)
}
});
Lava.ClassManager.define(
'Lava.Cat',
{
Extends: 'Lava.Animal',
// перечисляем имена объектов, которые будут вынесены в прототип
Shared: ['_shared'],
// этот объект будет вынесен в прототип, он станет общим для всех классов
_shared: {
likes_food: ['мышь', 'вискас']
},
breed: null,
init: function(name, breed) {
this.Animal$init(name);
this.breed = breed;
},
eat: function(food) {
if (this._shared.likes_food.indexOf(food) != -1) {
// отправляем событие, метод из Lava.mixin.Observable
this._fire('eaten', food);
}
}
});
var cat = new Lava.Cat('Гарфилд', 'Персидская');
// добавляем слушатель - такую возможность предоставил нам Lava.mixin.Observable
cat.on('eaten', function(garfield, food) {
console.log('Гарфилд сьел ' + food);
}, {});
cat.eat('мышь'); // выведет в консоль "Гарфилд сьел мышь"
Стандартные директивы:
Бонусы:
В планах есть добавление таких модификаторов как abstract и final.
Недостатки:
Standalone-версия лежит в этом репозитории [4]. В нем же есть ссылка на сайт основного фреймворка — там вы найдете отличную документацию (на английском), еще там можно посмотреть примеры в коде, и взять несколько универсальных классов типа Observable (события), Properties (свойства с событиями) и Enumerable («живой» массив).
P.S.
Да, кстати: основной фреймворк называется LiquidLava, и создавался он как лучшая альтернатива Angular и Ember. Интересно?
Автор: kogarashisan
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/82477
Ссылки в тексте:
[1] этот комментарий: http://habrahabr.ru/post/132698/#comment_4404597
[2] ссылка на jsperf: http://jsperf.com/liquidlava-class-system-performance/7
[3] вот ссылка: http://jsperf.com/js-inheritance-performance/62
[4] этом репозитории: https://github.com/kogarashisan/ClassManager
[5] Источник: http://habrahabr.ru/post/250311/
Нажмите здесь для печати.