Цікавий 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
Це нам одразу не годиться, так як:
- В JavaScript таким чином можна реалізувати приватне властивість: ми можемо читати calls примірника (що нам і потрібно), так і записувати в нього значення ззовні (що нам НЕ потрібно). Звичайно, ми можемо використовувати замикання в конструкторі, але тоді в чому сенс класу? А свіжі приватні поля я б поки побоювався використовувати без babel 7.
- Мова підтримує функціональну парадигму, і створення екземпляра через new здається тут не кращим рішенням. Приємніше написати функцію, яка повертає іншу функцію. Так!
- Нарешті, синтаксис 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 різних випадки:
- Визначення тіла функції count (FunctionDeclaration)
- Ініціалізація повернутого об’єкта
- Визначення тіла функції invoke (FunctionExpression) з двома виразами
- Визначення тіла функції 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’єрів у вільний від корисної роботи час.
Висновок
Питається, а кому це корисно і навіщо воно треба? Це зовсім шкідливо для початківців, так як формує хибне уявлення про зайвої складності і девіантності мови. Але може бути корисним практикуючим, так як дозволяє поглянути на особливості мови з іншого боку: заклик не уникати, а заклик спробувати, щоб уникати надалі.