16 июля 2012 в 19:40

Свежий взгляд на примеси в JavaScript перевод

В этой статье я детально исследую примеси в JavaScript, и покажу менее общепринятую, но, на мой взгляд, более естественную стратегию «примешивания», которую, надеюсь, вы найдете полезной. Закончу я матрицей результатов профилирования, подводящей итог влиянию на производительность каждой техники. (Большое спасибо блистательному @kitcambridge за ревью и улучшение кода, на котором основан этот пост!)

Повторное использование функций

В JavaScript каждый объект ссылается на объект-прототип, из которого он может наследовать свойства. Прототипы — отличные инструменты для повторного использования кода: один экземпляр прототипа может определять свойства бесконечного числа зависимых сущностей. Прототипы могут так же наследоваться от других прототипов, формируя, таким образом, цепочки прототипов, которые более-менее повторяют иерархии наследования «классовых» языков типа Java and C++. Многоэтажные иерархии наследования иногда бывают полезны при описании природного порядка вещей, но, если первичным мотивом служит повторное использование кода, такие иерархии могут быстро стать искривленными лабиринтами бессмысленных субклассов, утомительных избыточностей и неуправлямой логики («кнопка — это прямоугольник или контрол? Вот что, давайте унаследуем Button от Rectangle, а Rectangle может наследоваться от Control… так, стоп…»).

К счастью, когда дело доходит до повторного использования функций, JavaScript предлагает жизнеспособные альтернативы. В противоположность более жестко структурированным языкам, объекты в JavaScript могу вызывать любую публичную функцию независимо от родословной. Наиболее прямолинейный подход — делегирование; любая публичная функция может быть вызвана напрямую через call или apply. Это эффективная особенность, и я широко ее использую. Как бы то ни было, делегирование столь удобно, что иногда начинает работать против структурной дисциплины кода; более того, синтаксис может стать слегка многословным. Примеси — отличный компромисс, который позволяет заимствовать и иметь доступ к целым функциональным единицам с помощью минималистичного синтаксиса, и они отлично работают в одной упряжке с прототипами. Они предлагают описательную мощь иерархического наследования без мозголомных проблем, связанных с многоэтажным, восходящим к одному корню наследованием.

Основы

В программировании примесь — это класс, определяющий набор функций, относящихся к типу (например, Person, Circle, Observer). Примесные классы обычно считаются абстрактными в том смысле, что они не имеют экземпляров сами по себе — вместо этого их методы копируются (или «заимствуются») конкретными классами в качестве «наследования» поведения без вступления в формальные отношения с поставщиком поведения.
OK, но это JavaScript, и у нас нет классов. Это, на самом деле, хорошо, так как означает, что вместо этого мы можем использовать объекты (экземпляры), что дает ясность и гибкость: наши примеси могут быть обычным объектом, прототипом или функцией — в любом случае процесс «примешивания» становится прозрачным и очевидным.

Использование

Я намереваюсь обсудить несколько техник примесей, но все примеры кода сводятся к одному use-case: создание круглых, овальных или прямугольных кнопок. Вот схематичное представление (созданное с помощью последних hi-tech гаджетов). В прямоугольниках — примеси, в кружках — полноценные кнопки.


1. Классические примеси

Проглядев две первые страницы выдачи гугла по запросу «javascript mixin», я заметил, что большинство авторов определяют обьекты примесей как полновесный тип с конструктором и методами, определенными в прототипе. Можно рассматривать это как естественный прогресс — ранее примеси были классами, и это самое близкое к классам, что есть в JS. Вот примесь круга, созданная в этом стиле:
var Circle = function() {};
Circle.prototype = {
  area: function() {
    return Math.PI * this.radius * this.radius;
  },
  grow: function() {
    this.radius++;
  },
  shrink: function() {
    this.radius--;
  }
};

На практике, однако, такая тяжеловесная примесь излишня. Достаточно простого литерала обьекта:
var circleFns = {
  area: function() {
    return Math.PI * this.radius * this.radius;
  },
  grow: function() {
    this.radius++;
  },
  shrink: function() {
    this.radius--;
  }
};


Функция extend

Как же такой объект-примесь примешивается к нашим объектам? С помощью функции extend (иногда она называется augment). Обычно extend просто копирует (но не клонирует) функции примеси в принимающий объект. Быстрый обзор показывает несколько небольших вариаций этой реализации. Например, Prototype.js пропускает проверку hasOwnProperty (предполагается, что примесь не имеет перечислимых свойств в цепочке прототипов), тогда как другие версии исходят из того, что вы хотите скопировать только свойства из прототипа примеси. Вот безопасный и гибкий вариант…
function extend(destination, source) {
  for (var k in source) {
    if (source.hasOwnProperty(k)) {
      destination[k] = source[k];
    }
  }
  return destination; 
}

…который мы можем вызвать, чтобы расширить наш прототип…
var RoundButton = function(radius, label) {
  this.radius = radius;
  this.label = label;
};

extend(RoundButton.prototype, circleFns);
extend(RoundButton.prototype, buttonFns);
//etc. ...


2. Функциональные примеси

Если функции, определенные в примесях, предназначены для использования только другими объектами, зачем вообще создавать примеси как объекты? Другими словами, примесь должна быть процессом, а не объектом. Логично было бы превратить наши примеси в функции, которые объекты внедряют сами в себя через делегирование. Тем самым отпадает нужда в посреднике — функции extend.
var asCircle = function() {
  this.area = function() {
    return Math.PI * this.radius * this.radius;
  };
  this.grow = function() {
    this.radius++;
  };
  this.shrink = function() {
    this.radius--;
  };
  return this;
};

var Circle = function(radius) {
    this.radius = radius;
};
asCircle.call(Circle.prototype);
var circle1 = new Circle(5);
circle1.area(); //78.54

Такой подход выглядит правильным. Примеси как глаголы, а не существительные; легковесные универсальные магазинчики. Тут есть и другие вещи, которые могут понравится — стиль кода естественен и лаконичен: this всегда указывает на получателя наборов функции, а не на абстрактный объект, который нам не нужен и который мы не используем; более того, в противоположность традиционному подходу, нам не нужна защита от непреднамеренного копирования унаследованных свойств, и (что бы это ни означало) функции теперь клонируются, а не копируются.
Вот функция-примесь для кнопок:
var asButton = function() {
  this.hover = function(bool) {
    bool ? mylib.appendClass('hover') : mylib.removeClass('hover');
  };
  this.press = function(bool) {
    bool ? mylib.appendClass('pressed') : mylib.removeClass('pressed');
  };
  this.fire = function() {
    return this.action();
  };
  return this;
};

Берем две примеси вместе и получаем круглые кнопки:
var RoundButton = function(radius, label, action) {
    this.radius = radius;
    this.label = label;
    this.action = action;
};

asButton.call(RoundButton.prototype);
asCircle.call(RoundButton.prototype);

var button1 = new RoundButton(4, 'yes!', function() {return 'you said yes!'});
button1.fire(); //'you said yes!'


3. Добавляем опции

Стратегия с функциями так же позволяет параметризовать заимствованное поведение — путем передачи аргумента опций. Давайте посмотрим, как это работает, создав примесь asOval с настраиваемыми параметрами grow и shrink:
var asOval = function(options) {
  this.area = function() {
    return Math.PI * this.longRadius * this.shortRadius;
  };
  this.ratio = function() {
    return this.longRadius/this.shortRadius;
  };
  this.grow = function() {
    this.shortRadius += (options.growBy/this.ratio());
    this.longRadius += options.growBy;
  };
  this.shrink = function() {
    this.shortRadius -= (options.shrinkBy/this.ratio());
    this.longRadius -= options.shrinkBy;
  };
  return this;
}

var OvalButton = function(longRadius, shortRadius, label, action) {
  this.longRadius = longRadius;
  this.shortRadius = shortRadius;
  this.label = label;
  this.action = action;
};

asButton.call(OvalButton.prototype);
asOval.call(OvalButton.prototype, {growBy: 2, shrinkBy: 2});

var button2 = new OvalButton(3, 2, 'send', function() {return 'message sent'});
button2.area(); //18.84955592153876
button2.grow();
button2.area(); //52.35987755982988 
button2.fire(); //'message sent'


4. Добавляем кэширование

Возможно, вас беспокоит, что такой подход ухудшает производительность, так как мы переопределяем одни и те же функции снова и снова при каждом вызове. С помощью такой отличной штуки, как jsperf.com, я снял метрики всех стратегий примесей (результаты — в конце статьи). На удивление, в Chrome 12 производительность выше с использованием функционального подхода, тогда как в остальных браузерах такие примеси выполняются в два раза медленнее, чем классические. Принимая, что такие примеси скорее всего будут вызываться по одному разу на на каждый тип (а не при создании каждого экземпляра), это различие не должно играть существенной роли — учитывая, что речь идет о 26000 примесей в секунду даже в IE8!
На всякий случай, если такие цифры не дают спать по ночам вашему менеджеру, вот решение. Поместив примеси в замыкание, мы сможем закешировать результат определения, что сильно повлияет на производительность. Функциональные примеси теперь легко побивают классические по производительности во всех браузерах (в моих тестах порядка 20 раз в Chrome и порядка 13 в FF4). Опять же, это не так существенно, но оставляет приятное чувство:)
Вот версия asRectangle с добавлением кэширования…
var asRectangle = (function() {
  function area() {
    return this.length * this.width;
  }
  function grow() {
    this.length++, this.width++;
  }
  function shrink() {
    this.length--, this.width--;
  }
  return function() {
    this.area = area;
    this.grow = grow;
    this.shrink = shrink;
    return this;
  };
})();

var RectangularButton = function(length, width, label, action) {
  this.length = length;
  this.width = width;
  this.label = label;
  this.action = action;
}

asButton.call(RectangularButton.prototype);
asRectangle.call(RectangularButton.prototype);

var button3 = new RectangularButton(4, 2, 'delete', function() {return 'deleted'});
button3.area(); //8
button3.grow();
button3.area(); //15
button3.fire(); //'deleted'


5. Добавляем каррирование

В жизни все является результатом компромисса, и вышеупомянутое улучшение с помощью кеширования — не исключение. Мы потеряли возможность создавать настоящие клоны каждой примеси, и, более того, мы больше не можем кастомизировать заимствованные функции, передавая параметры. Последнюю проблему можно решить каррированием каждой закешированной функции — таким образом назначив параметры заранее, до последующих вызовов. Вот примесь asRectangle с правильно каррированными функциями, позволяющими параметризацию grow и shrink.
Function.prototype.curry = function() {
  var fn = this;
  var args = [].slice.call(arguments, 0);
  return function() {
    return fn.apply(this, args.concat([].slice.call(arguments, 0)));
  };
}

var asRectangle = (function() {
  function area() {
    return this.length * this.width;
  }
  function grow(growBy) {
    this.length += growBy, this.width +=growBy;
  }
  function shrink(shrinkBy) {
    this.length -= shrinkBy, this.width -= shrinkBy;
  }
  return function(options) {
    this.area = area;
    this.grow = grow.curry(options['growBy']);
    this.shrink = shrink.curry(options['shrinkBy']);
    return this;
  };
})();

asButton.call(RectangularButton.prototype);
asRectangle.call(RectangularButton.prototype, {growBy: 2, shrinkBy: 2});

var button4 = new RectangularButton(2, 1, 'add', function() {return 'added'});
button4.area(); //2
button4.grow();
button4.area(); //12
button4.fire(); //'added'

Метрики производительности


Как и было обещано, вот итоги тестов jsperf, сведенные в таблицу по техникам и браузерам.
Результат выражен в тысячах операций в секунду, так что чем выше число, тем лучше.

(Прим. переводчика: лучше посмотреть более свежие результаты непосредственно на jsperf.com; таблица из исходного поста приведена просто чтобы показать порядок цифр)

Заключение

JavaScript — это сплав функций и состояния. Состояние обычно специфично для экземпляров, тогда как функции, скорее всего, будут общими. Возможно, в наших интересах разделить эти две базовые зоны ответственности, и, возможно, примеси помогут нам в этом.
В частности, паттерн функциональных примесей предлагает чистое разграничение. Объекты — это состояние, а функции организованы подобно гроздьям фруктов на дереве, созревших для сбора. На самом деле эта стратегия может быть расширена далеко за рамки примесей — наборы функций могут служить репозитарием для любого объекта.
var myCircle = asCircle.call({radius:25});
myCircle.area(); //1963.50

Удачи в изучении примесей, и не бойтесь присылать исправления и любой другой фидбэк!
+45
6366
307
k12th 42,1 G+

Комментарии (39)

0
Stdit, #
Вот за такие штуки я люблю JS. Удобно использовать, например, для надевания поведения на фрагменты вёрстки. Есть, конечно, и нюансы. Например, в функциональных примесях, поскольку это «процесс», бывает возникает желание влепить условие или состояние какое-нибудь. Что потом, несмотря на простоту реализации, для кого-то оказывается пеньком на дороге, особенно при попытке расширить («пронаследовать») примесь и изменить это поведение у «потомка».
0
k12th, #
Вот как наследовать функциональные примеси, я не очень понимаю. Впрочем, это не очень часто нужно.
+1
Stdit, #
Из тела функции примеси-потомка вызвать примесь-предка, передав ей текущий контекст. Когда предок заполнит его, добавить что-нибудь своё или изменить наполненное.
+11
TheShock, #
Отказался от примесей в пользу делегации — тяжело было следить, откуда появились методы у прототипа, объекты расли, иногда возникали неприятные конфликты имён (да, это не сказка).

Пока пользовался примесями — постоянно старался сократить количество свойств у примеси. Размеры методов расли, а их количество, даже если было необходимо — нет. После отказа архитектура стала лучше.
+1
azproduction, #
Еще объект должен знать о внутренностях примеси, приходится подстраивается под её формат и её внутренние имена переменных и зависимости. Как ты уже сказал в реальном проекте примеси сложно отследить. Подмешивание это не очень явная штука — приходится дольше разбираться в таком коде.

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

var Shape = function (size) {
    this.size = size;
};
var CircleMixin = {
    area: function () {
        return Math.PI * this.radius * this.radius; // Мы не можем примешать CircleMixin к Shape ибо radius...
    }
};

// VS

// ## Geometry.js
var Geometry = {
    area: function (radius) {
        return Math.PI * radius * radius;
    }
};
// module.exports = Geometry;

// ## Shape.js
var Shape = function (size) {
    this.size = size;
};
// module.exports = Shape;

// ## Circle.js
// var Shape = require('Shape'),
//     Geometry = require('Geometry');

var Circle = function (size) {
    this.size = size;
};

Circle.prototype = new Shape();
Circle.prototype.radius = function () {
    return Geometry.radius(this.size); // явно передаем параметр
};

Получилось длиннее, но мы не зависим от внутренностей миксина и такой код более понятный.
+1
VasilioRuzanni, #
Честно говоря, в JS попросту не хватает какого-то очень простого синтаксиса для микшенов. С явным и однозначным указанием того, что примешиваем, и чтобы при этом было понятно, откуда свойства берутся. И простое что-то, простое…
0
allex, #
> Еще объект должен знать о внутренностях примеси, приходится подстраивается под её формат и её внутренние имена переменных и зависимости.

ООП учит, что с этим надо бороться с помощью инкапсуляции — определять интерфейсы и использовать только их. И тогда будет понятнее, что с чем смешивать можно.
+1
TheShock, #
А как на счёт protected свойств?
Да и у интерфейсов может быть конфликт имён.
Да и никогда не понимал, откуда возникла идея называть интерфейсы «множественным наследованием».
0
allex, #
Я говорю об интерфейсе объекта как о контракте, который объект предлагает миру. Как это технически реализовано — другой вопрос. Может быть поддержано языком, а может быть и на уровне соглашений.

Будет ли конфликт имен — зависит от схемы именования.

Про идею называть интерфейсы множественным наследованием слышу впервые. Это разные понятия. Да, в Java разрешено множественное наследование только для интерфейсов, но это не отождествляет их. В С++ разрешено множественное наследование классов.
+1
k12th, #
Мне кажется, это вопрос умеренности. Немножко примесей не помешает.
0
deser, #
Не могли бы Вы прокомментировать следующий кусок кода:
[].slice.call(arguments, 0);

Выполнение его в консоли привело к простому созданию массива аналогичному arguments.
+1
TheShock, #
arguments — не массив, а только объект, похожий на массив. Этот кусок кода возвращает настоящий массив, содержащий те данные, которые содержит arguments
+1
HDg, #
a.slice(0) создает копию массива a
но arguments — не массив и метода slice у него нет. поэтому метод Array.slice вызывается в контексте arguments, чтобы создать копию
+1
Keyten, #
Дополню, что здесь создаётся лишний пустой массив (для быстрого получения прототипа), что не слишком круто. Можно так:
Array.prototype.slice.call(arguments,0);


И ещё добавлю, что многие функции прототипа Array работают с массиво-подобными объектами (индексы [0], [1], свойство length...).
0
monolithed, #
Кстати, в ES5 расширили возможности Function.apply:

alert(Math.max.apply(null, {length: 2, 0: 0, 1: 1}));​ // 1

Однако не все реализации Function.apply могут работать с массиво-подобными объектами (generic array-like object), поэтому вот имплементация с поправками для ES5:

(function(apply) {
	'use strict';

	try {
		Function.apply(null, {length: 0});
	}
	catch (error) {
		Function.prototype.apply = function(context, object)
		{
			if (Object.prototype.toString.call(object) !== '[object Array]')
				object = Array.prototype.slice.call(object);

			return apply.call(this, context, object);
		}
	}
}(Function.prototype.apply));
–5
Keyten, #
Обычно лень такие объёмы читать, но тут читал до конца, спасибо.

> OK, но это JavaScript, и у нас нет классов
Классическое заблуждение. Не буду в милионный раз объяснять, как они делаются, но есть и классы, и наследование (да хотя бы child.prototype = parent.prototype). Впрочем если хотите — объясню.
К тому же обёртки для классов реализованы во многих библиотеках (Mootools, Prototype, Dojo, ExtJS...), есть куча и отдельных реализаций. Приводить список не буду, дам ссылку: habrahabr.ru/post/132698/#comment_4404597
0
Keyten, #
P.S. Кроме того, классы появились и в самом ECMAScript — см. функцию Object.create.
+2
k12th, #
Я думаю, автор прекрасно это знает.

Строго говоря, он прав: в JS нет классов. Существуют реализации классического наследования в библиотеках — это вы правильно отметили.
–1
Keyten, #
Да, наверное, не подумал. В любом случае кому-нибудь мой комментарий может быть полезен.

А про отсутствие классов в JS…
Class = function(){}

var a = new Class;

Что это такое, как не класс? Просто они создаются по другому.
0
KindWizzard, #
Классу, все же, присуще иметь ряд характеристик, которыми в данном случае конструктор Class не обладает. И даже если идти на разного рода ухищрения — полноценных классов мы не получим.

Похоже, что скоро все будет наоборот — классическим заблуждением станет то, что в JavaScript есть классы.
0
monolithed, #
Тема классов в ES уже затерта до дыр.

В ES явно сказано:
ECMAScript does not use classes such as those in C++, Smalltalk, or Java. Instead objects may be created in various ways including via a literal notation or via constructors which create objects and then execute code that initialises all or part of them by assigning initial values to their properties. Each constructor is a function that has a property named “prototype” that is used to implement prototype-based inheritance and shared properties.


Если резюмировать по факту, то в ES отстутствуют классовая модель, в место нее используется прототипная. Однако наличие ключевого class не есть наличие классовой модели которая принята в таких языка как C++, C#, Java и пр. Чтобы в этом убедиться достаточно поработать с Ruby, Python, CoffeeScript и пр. В этих языках с динамической типизацией принята прототипно-классовая парадигма, в котором работа с классами реализована на уровне делегирующего прототипирования.

О том как реализованы классы в CoffeeScript и как их можно усовершенствовать (модификаторы доступа) можно прочитать у меня в статье.
0
Keyten, #
Выкладывайте, какие характеристики присущи классу.
0
anmi, #
Это конструктор объекта. Описание класса это всё же некая метаконструкция, в которой содержатся свойства, методы, ссылка на описание родительской конструкции, описание поведения при определённых событиях.
0
TheShock, #
Дык этот конструктор содержит всё вышеперечисленное.
+1
TheShock, #
да хотя бы child.prototype = parent.prototype

Ну именно тут вы загнули, конечно.
0
Keyten, #
Сорри, не подумал конечно же. Расширяя child.prototype, мы расширяем parent.prototype.

Тогда так:
for(var i in parent.prototype){
  if( {}.hasOwnProperty.call(parent, i) )
    child.prototype[i] = parent.prototype[i];
}

// или, если есть jquery, $.extend(child.prototype, parent.prototype);
0
Keyten, #
или да, проще с суперклассом:

var sclass = function(){}
sclass.prototype = parent.prototype;
child.prototype = new sclass;
0
1602, #
Пожалуйста, ставьте пробел между ключевыми словами (function, if, switch, for, while) и круглой скобкой. Спасибо за статью!
0
k12th, #
Это перевод. Пожалуйста.
0
Keyten, #
Не согласен. Некрасиво
+1
k12th, #
Это вкусовщина, типа пробелы vs табы. Придерживайтесь стиля, принятого в конкрентном проекте, и всем станет щастье.
0
Keyten, #
А вот с этим согласен. У меня самого вкусы меняются. Когда-то писал многие операторы без пробелов:
var a=1
if(a==1);

// теперь
var a = 1;
if( a == 1 );


Ну а ставить пробел перед скобками — это как (моё мнение, никому не навязываю) перед точкой.
, или ставить запятую в середине строки
0
k12th, #
var a=1 во это точно ужасно, ни в одном стайлгайде не видел такого стиля.
0
Keyten, #
Согласен. А тогда у меня почему-то код с пробелами не работал :). Ну вы знаете: плохому танцору…
0
1602, #
Это общепринятый стандарт jslint. Если не вызов функции или описание именованой функции, рекомендуется ставить пробел. www.jslint.com/lint.html

Есть тому рациональное объяснение — вызов функции синтаксически выделяется от всех других конструкций. Такой код гораздо опрятнее выглядит.
0
azproduction, #
Вспомнил пример хорошей примеси — Backbone.Events
var object = {};

_.extend(object, Backbone.Events);

object.on("alert", function(msg) {
  alert("Сработало " + msg);
});

object.trigger("alert", "событие")
0
TheShock, #
Ага. И в мутулзе такая примесь была. Я в Атоме пользовался, а потом отказался в пользу делегирования:

var object = {};
object.events = new atom.Events(object);

object.events.add('alert', function(msg) {
  alert("Сработало " + msg);
});

object.events.fire('alert', [ 'событие' ]);


Так значительно лучше.
0
azproduction, #
У тебя чистый Observer (аж вспомнил Pro JavaScript Design Patterns) Зачем, кстати, передавать object в atom.Events? для this?
0
TheShock, #
Ага. Внутри евентов правильный контекст должен быть) Нафиг нам контекст Events? =)

Только зарегистрированные пользователи могут оставлять комментарии.
Войдите, пожалуйста.