habrahabr

Очень быстрые классы на JavaScript с красивым синтаксисом

  • четверг, 12 февраля 2015 г. в 02:13:41
http://habrahabr.ru/post/250311/

При написании серьезных проектов перед JavaScript программистами встает выбор: пожертвовать качеством кода и писать классы руками, или же пожертвовать скоростью и использовать систему классов. А если использовать систему, то какую выбрать?

В статье рассмотрена система автора, которая не уступает по скорости классам, написанным «от руки» (другими словами — одна из самых быстрых в мире). Но при этом классы имеют приятную структуру в стиле Си.

Системы классов


Есть шутка, что каждый программист должен написать свою систему классов. Кто не знаком с проблемой — смотрите этот комментарий, там их собрано минимум 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(); // вызов родительского метода
}

Мы скопировали метод из прототипа родителя, и при этом его переименовали. Быстрее уже просто нельзя. Само собой, мы не будем делать это руками — за нас все сделает система классов.

В этом примере не будет работать оператор instanceof, но на практике без него можно прекрасно обходиться. Я говорю про реальные приложения и задачи: если вам нужно отличать тип Animal от Cat — то это реальная задача, и она прекрасно решается. Но если вы хотите делать это оператором instanceof — то извините, вам к другому доктору.

Еще при таком наследовании нет цепочки прототипов (так как прототипы копируются) — это дает небольшое ускорение по сравнению с традиционными решениями.

Удобные свойства

Присваивать руками в конструкторе свойства по умолчанию — это тоже не слишком приятно. Так что, пускай за нас это делает скрипт, как в 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


Теперь немного пиара моего решения. Тест скорости, ClassManager vs Native (ссылка на jsperf):



Разницу в скорости создания классов можно списать на погрешность jsperf (на старых графиках она одинакова для всех вариантов теста). К сведению: на практике у меня бывало что один и тот же код, запущенный как 2 разных теста — выполнялся с 20% разницей в скорости.

Почему вызов Native метода такой медленный — там написано вот такое:

NativeChildClass.prototype.method = function() {
    NativeParentClass.prototype.method.apply(this);
}

Сразу заметно разницу в скорости между вызовом из своего собственного прототипа и через apply. Если вам кажется, что тут я считерил — то напишите свои тесты, быстрее все равно не будет.

Отдельно стоит сказать про Firefox: создание класса, который сгенерирован в браузере — сейчас существенно медленнее (на моем старом ноуте — всего 400 000 операций в секунду). Но мой ClassManager позволяет собирать классы на сервере — и в FF они работают даже быстрее, чем Native. К тому же это ускорит загрузку страницы.

ClassManager vs другие системы

За основу я взял тест автора DotNetWise, но… его тест подло врет: у него тестируется генерация класса плюс 500 итераций по методам. Как вы понимаете, качество и скорость сгенерированного кода не зависят от времени его генерации, и у каждого протестированного фреймворка это время вносит свою погрешность. Более того, у меня классы можно собрать на сервере.

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

В оригинальном тесте — система автора DNW, конечно же, лидирует. Но если исправить тест, то в хроме на первом месте будет мой ClassManager, за ним идет Fiber, а потом уже DNW. В FF на первом месте TypeScript, потом Native, потом ClassManager. Даже так, это очень специфический тест — тут создание класса меряется вместе с вызовом методов (в неправильных пропорциях), так что я считаю, что реальной картины он не отражает. Тем не менее, вот ссылка и результаты:



Возможности ClassManager


Начну с очень важной детали: для моих классов работают подсказки 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('мышь'); // выведет в консоль "Гарфилд сьел мышь"


Стандартные директивы:
  1. Extends — прямое наследование. Потомок может быть наследован только от одного родителя.
  2. Implements — для миксинов и множественного наследования. Домержит в потомка свойства и методы из миксина, но все что переопределено в классе — имеет приоритет.
  3. Shared — выносит объект в прототип. По умолчанию все объекты в теле класса — копируются для каждого экземпляра, но их можно сделать общими.

Бонусы:
  1. Есть возможность патчинга методов класса «на лету» и статические конструкторы. Например, вы хотите применить багфикс внутри IE, и отключить его в других браузерах. В конструкторе класса вы можете выбрать нужный вам метод и заменить его в прототипе — даже если ваш класс находится в середине цепочки наследования.
  2. Экспорт сгенерированных классов. Вы можете сгенерировать конструкторы на сервере — это сэкономит время загрузки страницы и ускорит создание объектов в Firefox.
  3. Пространства имен (namespaces) и пакеты. Подробности читайте в документации.

В планах есть добавление таких модификаторов как abstract и final.

Недостатки:
  1. Сейчас директива Shared умеет переносить в прототип только объекты (не массивы). Как временное решение — можно создать объект со свойством-массивом, так что это всего лишь небольшое неудобство. Есть задача на доработку, но она пока не в приоритете.
  2. И более заметный недостаток: сейчас нет инструмента, который мог бы сжимать имена членов класса (если их просто переименовать — то сломается вызов родительских методов). Есть планы по его созданию, он обязательно появится, но не завтра. Интересно, если бы я не сказал об этом сам, то вы бы обратили на это внимание?

Где взять?

Standalone-версия лежит в этом репозитории. В нем же есть ссылка на сайт основного фреймворка — там вы найдете отличную документацию (на английском), еще там можно посмотреть примеры в коде, и взять несколько универсальных классов типа Observable (события), Properties (свойства с событиями) и Enumerable («живой» массив).

P.S.
Да, кстати: основной фреймворк называется LiquidLava, и создавался он как лучшая альтернатива Angular и Ember. Интересно?

UPD
В комментариях меня исправили: скорость вызова Native метода можно увеличить, если заменить apply на call. Первый тест ClassManager vs Native был обновлен: в FF скорость вызова Native метода сравнялась со скоростью ClassManager, а в Хроме все еще немного уступает.