Цікавий JavaScript: Без фігурних дужок

 

Мене завжди дивував JavaScript насамперед тим, що він, напевно, як жоден інший широко поширений мова підтримує одночасно обидві парадигми: нормальне і ненормальне програмування. І якщо про адекватні best-практики та шаблони прочитано майже всі, то дивовижний світ того, як не треба писати код, залишається лише злегка відкритим.

 

У цій статті ми розберемо ще одну надуману завдання, що вимагає великих наруги над нормальним рішенням.

 

Формулювання

Реалізуйте функцію-декоратор, що рахує кількість викликів переданої функції і надає можливість отримати це число на вимогу. У вирішенні заборонено використовувати фігурні дужки і глобальні змінні.

Лічильник викликів — це лише привід, адже є console.count(). Суть в тому, що наша функція акумулює деякі дані при виклику оберненої функції і надає певний інтерфейс для доступу до них. Це може бути і збереження всіх результатів виклику, та збір логів, і якась мемоизация. Просто лічильник — примітивний і зрозумілий усім.

Вся складність — в ненормальній обмеження. Не можна використовувати фігурні дужки, а значить доведеться переглянути побутові практики і ординарний синтаксис.

 

Звичне рішення

 

Спочатку треба вибрати відправну точку. Зазвичай, якщо мова або його розширення не надають необхідної функції декорації, ми реалізуємо деякий контейнер самостійно: реверсивна функція, акумульовані дані і інтерфейс доступу до них. Часто це клас:

 

class CountFunction {
 constructor(f) {
 this.calls = 0;
 this.f = f;
}
 invoke() {
 this.calls += 1;
 return this.f(...arguments);
}
}

const csum = new CountFunction((x, y) => x + y);
csum.invoke(3, 7); // 10
csum.invoke(9, 6); // 15
csum.calls; // 2

 

Це нам одразу не годиться, так як:

 

  1. В JavaScript таким чином можна реалізувати приватне властивість: ми можемо читати calls примірника (що нам і потрібно), так і записувати в нього значення ззовні (що нам НЕ потрібно). Звичайно, ми можемо використовувати замикання в конструкторі, але тоді в чому сенс класу? А свіжі приватні поля я б поки побоювався використовувати без babel 7.
  2. Мова підтримує функціональну парадигму, і створення екземпляра через new здається тут не кращим рішенням. Приємніше написати функцію, яка повертає іншу функцію. Так!
  3. Нарешті, синтаксис ClassDeclaration і MethodDefinition не дозволить нам при всьому бажанні позбутися від усіх фігурних дужок.

 

Але у нас є чудовий патерн Модуль, який реалізує приватність з допомогою замикання:

 

function count(f) {
 let calls = 0;
 return {
 invoke: function() {
 calls += 1;
 return f(...arguments);
},
 getCalls: function() {
 return calls;
}
};
}

const csum = count((x, y) => x + y);
csum.invoke(3, 7); // 10
csum.invoke(9, 6); // 15
csum.getCalls(); // 2

 

З цим вже можна працювати.

 

Цікаве рішення

 

Для чого взагалі тут використовуються фігурні дужки? Це 4 різних випадки:

 

  1. Визначення тіла функції count (FunctionDeclaration)
  2. Ініціалізація повернутого об’єкта
  3. Визначення тіла функції invoke (FunctionExpression) з двома виразами
  4. Визначення тіла функції getCalls (FunctionExpression) з одним виразом

 

Почнемо з другого пункту. Насправді нам немає чого повертати новий об’єкт, при цьому ускладнюючи виклик кінцевої функції через invoke. Ми можемо скористатися тим фактом, що функція в JavaScript є об’єктом, а значить може містити свої власні поля та методи. Створимо нашу повертається функцію df і додамо їй метод getCalls, який через замикання буде мати доступ до calls як і раніше:

 

function count(f) {
 let calls = 0;
 function df() {
 calls += 1;
 return f(...arguments);
}
 df.getCalls = function() {
 return calls;
}
 return df;
}

 

З цим і працювати приємніше:

 

const csum = count((x, y) => x + y);
csum(3, 7); // 10
csum(9, 6); // 15
csum.getCalls(); // 2

 

C четвертим пунктом все зрозуміло: ми просто замінимо FunctionExpression на ArrowFunction. Відсутність фігурних дужок нам забезпечить коротка запис стрілочної функції у випадку єдиного вираження в її тілі:

 

function count(f) {
 let calls = 0;
 function df() {
 calls += 1;
 return f(...arguments);
}
 df.getCalls = () => calls;
 return df;
}

 

З третім — все складніше. Пам’ятаємо, що першим ділом ми замінили FunctionExpression функції invoke на FunctionDeclaration df. Щоб переписати на ArrowFunction доведеться вирішити дві проблеми: не втратити доступ до аргументів (зараз це псевдо-масив arguments) і уникнути тіла функції з двох виразів.

 

З першою проблемою нам допоможе впоратися явно вказаний для функції параметр args з spread operator. А щоб об’єднати два вирази в одне, можна скористатися logical AND. На відміну від класичного логічного оператора кон’юнкції, повертає логічне значення, він обчислює операнди зліва направо до першого “помилкового” і повертає його, а якщо всі “справжні” – то останнє значення. Перше ж приріст лічильника дасть нам 1, а значить це-вираз завжди буде приводиться до true. Приводимость до “істини” результату виклику функції у другому під-вираженні нас не цікавить: обчислювач в будь-якому випадку зупиниться на ньому. Тепер ми можемо використовувати ArrowFunction:

 

function count(f) {
 let calls = 0;
 let df = (...args) => (calls += 1) && f(...args);
 df.getCalls = () => calls;
 return df;
}

 

Можна трохи прикрасити запис, використовуючи префіксний інкремент:

 

function count(f) {
 let calls = 0;
 let df = (...args) => ++calls && f(...args);
 df.getCalls = () => calls;
 return df;
}

 

Рішення першого і самого складного пункту почнемо з заміни FunctionDeclaration на ArrowFunction. Але у нас поки залишиться тіло в фігурних дужках:

 

const count = f => {
 let calls = 0;
 let df = (...args) => ++calls && f(...args);
 df.getCalls = () => calls;
 return df;
};

 

Якщо ми хочемо позбавитися від обрамляють тіло функції фігурних дужок, нам доведеться уникнути оголошення і ініціалізації змінних через let. А змінних у нас цілих дві: calls і df.

 

Спочатку розберемося з лічильником. Ми можемо створити локальну змінну, визначивши її у списку параметрів функції, а початкове значення передати викликом з допомогою IIFE (Immediately Invoked Function Expression):

 

const count = f => (calls => {
 let df = (...args) => ++calls && f(...args);
 df.getCalls = () => calls;
 return df;
})(0);

 

Залишилося конкатенувати три вирази в одне. Так як у нас всі три вирази являють собою функції, наведені завжди до true, то ми можемо також використовувати logical AND:

 

const count = f => (calls => (df = (...args) => ++calls && f(...args)) && (df.getCalls = () => calls) && df)(0);

 

Але є ще один варіант конкатенації виразів з допомогою comma operator. Він краще, так як не займається зайвими логічними перетвореннями і вимагає меншої кількості дужок. Операнди обчислюються зліва-направо, а результатом є значення останнього:

 

const count = f => (calls => (df = (...args) => ++calls && f(...args), df.getCalls = () => calls, df))(0);

 

Напевно мені вдалося вас обдурити? Ми сміливо позбулися оголошення змінної df і залишили тільки присвоєння нашій стрілочної функції. У цьому разі ця змінна буде оголошена глобально, що неприпустимо! Повторимо для df ініціалізацію локальної змінної в параметрах нашої IIFE функції, тільки не будемо передавати ніякого початкового значення:

 

const count = f => ((calls, df) => (df = (...args) => ++calls && f(...args), df.getCalls = () => calls, df))(0);

 

Таким чином мета досягнута.

 

Варіації на тему

 

Цікаво, що ми змогли уникнути створення і ініціалізації локальних змінних, кількох виразів у функціональних блоках, створення літерала об’єкта. При цьому зберегли чистоту вихідного рішення: відсутність глобальних змінних, приватність лічильника, доступ до аргументів обіг функції.

 

В цілому можна взяти будь-яку реалізацію і спробувати провернути подібне. Наприклад, полифилл для функції bind в цьому плані досить простий:

 

const bind = (f, ctx, ...a) => (...args) => f.apply(ctx, a.concat(args));

 

Однак, якщо аргумент f не є функцією, по-хорошому ми повинні викинути виняток. А виключення throw не може бути викинуто в контексті вираження. Можна почекати throw expressions (stage 2) і спробувати ще раз. Або у кого-то вже зараз є думки?

 

Або розглянемо клас, що описує координати деякої точки:

 

class Point {
 constructor(x, y) {
 this.x = x;
 this.y = y;
}
 toString() {
 return `(${this.x}, ${this.y})`;
}
}

 

Який може бути представлений функцією:

 

const point = (x, y) => (p => (p.x = x, p.y = y, p.toString = () => ['(', x, ', ', y ')'].join("), p))(new Object);

 

Тільки ми тут втратили прототипних спадкування: toString є властивістю об’єкта-прототипу Point, а не окремо створеного об’єкта. Чи можна цього уникнути, якщо неабияк постаратися?

 

Результати перетворень ми отримуємо нездорову суміш функціонального програмування з імперативними хакамі і деякими особливостями самої мови. Якщо подумати, то з цього може вийти цікавий (але не практичний) обфускатор вихідного коду. Ви можете придумати свій варіант завдання “скобочного обфускатора” і розважати колег і друзів JavaScript’єрів у вільний від корисної роботи час.

 

Висновок

 

Питається, а кому це корисно і навіщо воно треба? Це зовсім шкідливо для початківців, так як формує хибне уявлення про зайвої складності і девіантності мови. Але може бути корисним практикуючим, так як дозволяє поглянути на особливості мови з іншого боку: заклик не уникати, а заклик спробувати, щоб уникати надалі.

You may also like...

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *