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

в 15: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 гаджетов). В прямоугольниках — примеси, в кружках — полноценные кнопки.
Свежий взгляд на примеси в JavaScript

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, сведенные в таблицу по техникам и браузерам.
Результат выражен в тысячах операций в секунду, так что чем выше число, тем лучше.
Свежий взгляд на примеси в JavaScript
(Прим. переводчика: лучше посмотреть более свежие результаты непосредственно на jsperf.com; таблица из исходного поста приведена просто чтобы показать порядок цифр)

Заключение

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

var myCircle = asCircle.call({radius:25});
myCircle.area(); //1963.50

Удачи в изучении примесей, и не бойтесь присылать исправления и любой другой фидбэк!

Автор: k12th


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js