Це можна покращити!
Відвідувач та ECMAScript 6.
Передмова
Відвідувач (англ. visitor) - один з класичних шаблонів проектування. Про нього часто розказують як у навчальних закладах, так і на різноманітних курсах з програмування. Він не зав'язаний на особливості якоїсь конкретної мови, а тому (в теорії) застосувати його можна будь де.
Нещодавно мені випала нагода використати відвідувач в ECMAScript 6 на робочому проекті. До цього я стикався з ним лише в мовах зі статичною типізацією, а тому моя перша реалізація не була позбавлена вад властивих цьому класу мов. Але час проведений наодинці з JavaScript не минув без наслідків. Динамічна типізація все ж зробила свою брудну справу і коли я отямився було вже надто пізно... Відвідувача тепер не впізнала б і рідна мати! Він вже не був тою класичною реалізацією, яку ми всі знали. На щастя.
Я вирішив пошукати схожі реалізації цього шаблону на ES6, але не знайшов чогось суттєво просунутішого за класичний варіант. А це при тому, що мова є динамічно-типізованою, тобто дозволяє повертати і передавати в якості аргументів довільні об'єкти! Ай-яй-яй... що ж, доведеться мені самому взятись за це1!
Область застосування
Для початку варто було б згадати навіщо взагалі потрібен відвідувач. Припустимо що у нас є деяка ієрархія класів. Наприклад, примітиви типу чисел, стрічок, масивів і т.д. (див. Приклад коду №1). Наповнення цих класів не є важливим, а тому ми залишимо його за кулісами. Завдання - реалізувати програму яка б перетворювала екземпляри цих класів в, наприклад, JSON.
Ми не випадково обрали саме такі класи і саме такий формат даних. Усі вони - логічні типи, числа, стрічки, масиви - вже реалізовані в JavaScript. Те саме можна сказати і про механізм їхнього перетворення в JSON. У цьому є перевага, адже працювати з добре відомими явищами легше. Окрім цього методи вирішення цієї задачі можна порівняти з уже існуючими підходами. Це дуже добре показує те, що вирішувана задача не синтетична а цілком реальна.
То що б ми зробили будучи на місці розробників JavaScript? Одним із найпростіших рішень є додавання в кожен клас спеціального методу. Цей метод відповідає за перетворення відповідного об'єкта і викликається безпосередньо (див. Приклад коду №2). На перший погляд така ідея є абсолютно здоровою, адже вона вирішує поставлену задачу. Але це лише на перший погляд...
Приклад коду №1
Ієрархія класів базових об'єктів.

class Object {}
class Boolean extends Object {}
class Number extends Object {}
class String extends Object {}
class Array extends Object {}
Приклад коду №2
Похідні класи реалізують абстрактний метод з базового класу.

class Object {
ToJSON(); // абстрактний
}
class Boolean extends Object {
ToJSON(); // реалізація
}
class Number extends Object {
ToJSON(); // реалізація
}
class String extends Object {
ToJSON(); // реалізація
}
class Array extends Object {
ToJSON(); // реалізація
}

const number = new Number();
number.ToJSON();
Тепер звернемось до свого внутрішнього критика і спробуємо знайти вади в подібному дизайні. Перше що можна помітити - це нагромадження обов'язків. Кожен з наших класів тепер не лише реалізує конкретний тип даних, а ще й вміє перетворювати його в JSON. Це проблема, оскільки предметні області бути числом/стрічкою/масивом і вміти перетворюватись в JSON стають зв'язаними. Ми вже не зможемо використовувати їх окремо2. Вони стають залежними одна від одної, хоча це не є необхідним.
Далі, якщо нам знадобиться підтримка ще одного формату, наприклад, XML, то доведеться додавати в існуючі класи нові методи (див. Приклад коду №3). Така залежність змушує нас модифікувати вже написані класи, замість того щоб розширювати їх.
Приклад коду №3
Зі збільшенням кількості підтримуваних форматів росте і кількість методів.
class Object {
ToJSON(); // абстрактний
ToXML(); // абстрактний
}
class Boolean extends Object {
ToJSON(); // реалізація
ToXML(); // реалізація
}
class Number extends Object {
ToJSON(); // реалізація
ToXML(); // реалізація
}
class String extends Object {
ToJSON(); // реалізація
ToXML(); // реалізація
}
class Array extends Object {
ToJSON(); // реалізація
ToXML(); // реалізація
}
Приклад коду №4
Динамічний вибір формату при перетворенні даних.
// type = "json" або "xml"
function f(type) {
switch (type) {
case "json": return object.ToJSON();
case "xml" : return object.ToJXML();
}
}

// type = "ToJSON" або "ToXML"
function g(type) {
return object[type]();
}
Зрештою, перетворення в JSON та XML виглядають дуже схожими операціями. І було б природно мати можливість динамічно замінювати одну операцію іншою3. Але вибрана нами стратегія не дає нам змоги зробити це. Для цього необхідно замінити метод. А зробити це динамічно в статично-типізованих мовах програмування неможливо. На щастя тут ми можемо використати особливість JavaScript і викликати метод за іменем (див. Приклад коду №4). В даному випадку це спрацює, хоч такий пошук і не є швидкою операцією.
Ідея
Для усунення цих вад і використовують відвідувача. Ідея полягає у тому, що потрібно відділити дані від алгоритмів їх опрацювання. Кінцевим результатом мав би стати дизайн, де методи ToJSON та ToXML перетворюються в окремі класи JSON та XML (див. Приклад коду №5).
Приклад коду №5
Логіку перетворення можна винести в окремі класи.

class Object {}
class Boolean extends Object {}
class Number extends Object {}
class String extends Object {}
class Array extends Object {}

class JSON {
To(entity);
}
class XML {
To(entity);
}
Але і тут не все так просто. Це завдання теж можна вирішити не єдиним способом, причому з різною ефективністю. Є три досить поширені та однаково неефективні ідеї (див. Приклад коду №6, №7 та №8):
Ввести допоміжне поле для визначення типу об'єкта і в залежності від нього проводити галуження.
Проводити галуження виходячи з конструктора об'єкта.
Намагатись приводити об'єкти до відповідних типів вручну.
Ці підходи теж вирішують завдання, але кожен з них має свої вади. По-перше, якщо проводити галуження виходячи з конструктора об'єкта (див. Приклад коду №6), то ми втрачаємо сумісність з усіма похідними класами. По-друге, введення додаткового поля для визначення типу (див. Приклад коду №7) є надлишковим, оскільки інформація про тип може бути виведена з деякого аналогу таблиці віртуальних методів. По-третє і галуження, і приведення до типу не є дешевими операціями. В даному випадку є можливість обійтись без їхнього використання.
Приклад коду №6
Галуження з використанням конструктора.


switch (object.constructor) {
case Boolean : return ... ;
case Number : return ... ;
case String : return ... ;
...
}
Приклад коду №7
Галуження з використанням типу об'єкта.
class Number extends Object {
get Type() {
return "number";
}
}
...
switch (object.Type) {
case "boolean" : return ... ;
case "number" : return ... ;
case "string" : return ... ;
...
}
Приклад коду №8
Ручне приведення до типу.
if (object instanceof Boolean) {
return ... ;
}
else if (object instanceof Number) {
return ... ;
}
...
Реалізація
Класична реалізація відвідувача позбавлена згаданих вище вад. Перший крок - це створення деякої проміжної ланки, яка б надавала можливість звернення до об'єктів відповідно до їхнього типу (див. Приклад коду №9). Наступний крок - це передача процесу диспетчеризації в область відповідальності самого об'єкта. Тобто, тепер саме число приймає рішення що з ним потрібно працювати як з числом (див. Приклад коду №10).
Приклад коду №9
Клас Visitor зосереджує в собі методи, які будуть опрацьовувати відповідні типи даних в похідних класах.

class Visitor {
VisitBoolean(boolean); // абстрактний
VisitNumber (number); // абстрактний
VisitString (string); // абстрактний
VisitArray (array); // абстрактний
}
Приклад коду №10
Відвідувані класи викликають відповідні їм методи з класу Visitor.

class Object {
Accept(visitor); // абстрактний
}
class Number {
Accept(visitor) { // реалізація
visitor.VisitNumber(this);
}
}
class String {
Accept(visitor) { // реалізація
visitor.VisitString(this);
}
}
Останній крок - це використання поліморфізму в класах з попередніх кроків. Введення абстрактного методу в об'єкт дозволить опрацьовувати як число так і стрічку без визначення їхнього типу. Далі, реалізація методів відвідувача як абстрактних дозволить замістити їх. Таким чином похідні класи зможуть визначати алгоритми опрацювання для конкретних типів, уникаючи приведення.
Класи JSON та XML лише успадковують Visitor і реалізовують методи для опрацювання відповідних класів (див. Приклад коду №11). Перенаправлення викликів відповідно до типу відбувається неявно, зусиллями самих опрацьовуваних об'єктів. Усе це відбувається задешево - ціною виклику лише одного віртуального методу. Це дешевше ніж галуження чи приведення до типу. Можливо, виходячи з постановки задачі, це в принципі найдешевший варіант з точки зору швидкодії.
Приклад коду №11
Похідний клас JSON лише реалізує абстрактні методи Visitor. Перенаправлення відповідно до типу об'єкта відбувається за кулісами.
class Visitor {
VisitBoolean(boolean); // абстрактний
VisitNumber (number); // абстрактний
VisitString (string); // абстрактний
VisitArray (array); // абстрактний
}

class JSON extends Visitor {
VisitBoolean(boolean) { // реалізація
print("boolean");
}
VisitNumber(number); { // реалізація
print("number");
}
VisitString(string); { // реалізація
print("string");
}
VisitArray(array); { // реалізація
print("array");
}
}

let json = new JSON();
let object = new Number();

object.Accept(json); // json.VisitNumber(object)
Ось це була класична реалізація відвідувача. Та, яка часто використовується в статично-типізованих мовах типу C#, Java чи C++. Але JavaScript, а тим паче ECMAScript 6, має певні особливості які дозволяють робити речі, неможливі з точки зору вищезгаданих мов.
Розвиваємо ідею
Перше, що можна покращити - це зовнішній вигляд. Можна перенести виклик object.Accept(visitor) всередину нового методу: visitor.Visit(object) (див. Приклад коду №12). З точки зору продуктивності ми радше за все нічого не втратимо, такий виклик легко може бути оптимізований4. З точки ж зору синтаксису метод Visit візуально легше зіставити з методами VisitNumber, VisitString і т.д.. Створюється ілюзія того, що поліморфізм діє через аргументи і автоматично вибирає ту реалізацію, яка збігається з типом відвідуваного об'єкту.
Приклад коду №12
Легше зіставити VisitNumber з Visit, а не з Accept.
class Visitor {
Visit(object) {
object.Accept(this);
}
...
}
...
class JSON extends Visitor {
...
}
...
let json = new JSON();
let object = new Number();

json.Visit(object); // json.VisitNumber(object)
Іншими словами - зв'язок між number.Accept(visitor) та visitor.VisitNumber(number) не є очевидний. Дуже важко здогадатись, що перший метод об'єкта number призведе до виклику іншого методу об'єкта visitor. А от у випадку visitor.Visit(number) та visitor.VisitNumber(number) все простіше. Методи не лише мають схожі назви, але й стосуються одного і того ж об'єкта. У цьому випадку користувачу легше дистанціюватись від особливостей реалізації, віддавши все на волю магії.
Далі, ми можемо використати динамічну типізацію JavaScript і дозволити методам Visit, Accept та VisitNumber повертати результат. В статично типізованих мовах цього важко досягнути, оскільки тип результату має бути відомий заздалегідь. Доводиться йти на компроміси. Можна відмовитись від результату, акумулюючи його всередині об'єкта. Але це робить неможливим використання функціонального програмування, та й узагалі погано впливає на зручність механізму в цілому. Можна повертати абстрактний результат, який потім можна буде привести до потрібного типу. Це створює зайву операцію приведення, але допомагає обійти дану проблему. На щастя JavaScript позбавлений цих вад5, а тому ми можемо дозволити собі подібну розкіш (див. Приклад коду №13).
Приклад коду №13
Метод VisitNumber повертає значення замість того, щоб зберігати його. П'ятірка виводиться лише з міркувань абстрагування від наповнення класу Number : )
class Visitor {
Visit(object) {
return object.Accept(this)
}
...
}
...
class Number extends Object {
Accept(visitor) {
return visitor.VisitNumber(this);
}
}
...
class JSON extends Visitor {
VisitNumber(number) { // заміщений
return "5";
}
...
}
...
let json = new JSON();
let object = new Number();

let result = json.Visit(object); // "5"
Також часто виникає питання про додаткові аргументи при відвідуванні об'єктів. І знову перешкодою для цього може стати статична типізація, відсутня в JavaScript. В інших мовах часто доводиться передавати такі аргументи через поля самого відвідувача, але в нашому випадку це не потрібно. Окрім цього, використовуючи ECMAScript 6 можна не лише зробити додаткові аргументи можливими, але й зручними та інтуїтивно зрозумілими. Використовуючи оператори для решти аргументів та розпакування можна вирішити все розмноживши єдиний рядок - ...others (див. Приклад коду №14). Це не лише не буде конфліктувати з базовим класом і рештою інфраструктури, але й дозволить реалізувати різну кількість і типи аргументів для різних відвідуваних об'єктів.
Приклад коду №14
Використовуючи динамічну типізацію можна передавати в методи додаткові аргументи.
class Visitor {
Visit(object, ...others) {
return object.Accept(this, ...others)
}
...
}
...
class Number extends Object {
Accept(visitor, ...others) {
return visitor.VisitNumber(this, ...others);
}
}
...
class JSON extends Visitor {
VisitNumber(number, x, y, z) { // заміщений
return `number: ${x}, ${y}, ${z}`;
}
VisitString(string, x) { // заміщений
return `string: ${x}`;
}
...
}
...
let json = new JSON();
let object = new Number();

let result = json.Visit(object, 1, 2, 3); // "number 1, 2, 3"
Ще не все!
Можна не зупинятись на досягнутому. Ще одна область для вдосконалення - це особлива диспетчеризація тих класів, які успадковують Object, але не реалізують метод Accept. Для цього можна ввести у відвідувач додатковий метод, який буде опрацьовувати ці об'єкти. Також метод Object.Accept тепер буде мати початкову реалізацію, а похідні класи зможуть змінювати її за бажанням (див. Приклад коду №15).
Приклад коду №15
Тепер корові не обов'язково займатись диспетчеризацією.
class Visitor {
VisitObject(object, ...others); // абстрактний
...
}
...
class Object {
Accept(visitor, ...others) { // віртуальний
return visitor.VisitObject(this, ...others);
}
}
class Number extends Object {
Accept(visitor, ...others) { // заміщений
return visitor.VisitNumber(this, ...others);
}
}
...
class Cow extends Object {
// нічого не заміщуємо!
}

let visitor = new Visitor();
let cow = new Cow();

visitor.Visit(cow); // муу Visitor.VisitObject
Ще один розповсюджений недолік класичної реалізації відвідувача - це необхідність визначати всі його методи, навіть якщо більша їх половина має ідентичний код. Припустимо, що ми хочемо перетворювати в JSON лише числа. Усі ж інші об'єкти ми будемо повертати у вигляді порожньої стрічки. В такій ситуації нам доведеться визначити стільки методів, скільки класів існує в нашій ієрархії (див. Приклад коду №16). Окрім цього, така реалізація буде не стабільною, оскільки додавання нових класів буде змушувати нас додавати нові однотипні методи до похідного відвідувача.
Приклад коду №16
Проблеми при опрацьовуванні класів, які ми не хочемо підтримувати.


class JSON extends Visitor {
// числа
VisitNumber(number) {
return "number";
}
// решта типів
VisitRecord() {
return "";
}
VisitBoolean() {
return "";
}
VisitString() {
return "";
}
VisitObject() {
return "";
}
}
Приклад коду №17
Тепер обробкою усіх нецікавих класів займається метод VisitOther.


class Visitor {
VisitOther(object); // абстрактний
VisitObject(object) { // віртуальний
return this.VisitOther(object);
}
VisitNumber(number) { // віртуальний
return this.VisitOther(number);
}
VisitString(string) { // віртуальний
return this.VisitOther(string);
}
...
}
...
class JSON extends Visitor {
// числа
VisitNumber(number) {
return "number";
}
// решта типів
VisitOther() {
return "";
}
}
Але є спосіб боротись з цим. Можна оголосити ще один опрацьовувач в базовому класі відвідувача. Він буде працювати лише з тими об'єктами, обробка яких не є заміщеною в похідному класі. Досягається це за допомогою перенаправлення з усіх методів Visit* базового класу (див. Приклад коду №17). Заміщені ж методи не будуть наділені такою поведінкою, реалізуючи натомість код для опрацювання об'єкта конкретного типу. Такий підхід не лише зменшить кількість коду, але й позбавить нас необхідності в додаванні нових методів.
Післямова
Відвідувач - доволі корисний шаблон проектування. Він допомагає не лише правильно відділити та інкапсулювати логіку обробки даних, але й зробити цей процес ефективним з точки зору швидкодії. Багато талановитих розробників яким пощастило не знати про існування відвідувача самі винаходять його в своїй практиці, хоч і називають по іншому.
Не зважаючи на свою потужність цей підхід все ж має недоліки. Але деякі з них можна обійти в залежності від вибраної мови програмування. Навряд чи я зміг привести тут усі можливі розвинення відвідувача для ECMAScript 6. Але я сподіваюсь що описані тут ідеї все ж знадобляться комусь в його роботі.
1
Насправді мені просто було ліньки шукати матеріал по темі.
2
А це нам точно знадобиться, Мерфі гарантує!.
3
Принцип підстановки Лісков тут не зовсім доречний, оскільки мова все ж йде не про успадкування. Даний випадок радше входить до змішаної категорії, в комбінації з іншими принципами SOLID, а саме принципом розділення інтерфейсу та принципом інверсії залежностей.
4
Це не повинно створювати для компілятора якихось труднощів, адже мова йде про не віртуальну функцію.
5
Звичайно, статична типізація не є вадою безпосередньо і в прямому розумінні. Тут мається на увазі те, що разом з іншими особливостями статично типізованих мов реалізувати подібне рішення стає важко.