7 июня 2022 г.

Функции-обёртки, декораторы

Материал на этой странице устарел, поэтому скрыт из оглавления сайта.

Более новая информация по этой теме находится на странице https://learn.javascript.ru/call-apply-decorators.

JavaScript предоставляет удивительно гибкие возможности по работе с функциями: их можно передавать, в них можно записывать данные как в объекты, у них есть свои встроенные методы…

Конечно, этим нужно уметь пользоваться. В этой главе, чтобы более глубоко понимать работу с функциями, мы рассмотрим создание функций-обёрток или, иначе говоря, «декораторов».

Декоратор – приём программирования, который позволяет взять существующую функцию и изменить/расширить её поведение.

Декоратор получает функцию и возвращает обёртку, которая делает что-то своё «вокруг» вызова основной функции.

bind – привязка контекста

Один простой декоратор вы уже видели ранее – это функция bind:

function bind(func, context) {
  return function() {
    return func.apply(context, arguments);
  };
}

Вызов bind(func, context) возвращает обёртку, которая ставит this и передаёт основную работу функции func.

Декоратор-таймер

Создадим более сложный декоратор, замеряющий время выполнения функции.

Он будет называться timingDecorator и получать функцию вместе с «названием таймера», а возвращать – функцию-обёртку, которая измеряет время и прибавляет его в специальный объект timer по свойству-названию.

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

function f(x) {} // любая функция

var timers = {}; // объект для таймеров

// отдекорировали
f = timingDecorator(f, "myFunc");

// запускаем
f(1);
f(2);
f(3); // функция работает как раньше, но время подсчитывается

alert( timers.myFunc ); // общее время выполнения всех вызовов f

При помощи декоратора timingDecorator мы сможем взять произвольную функцию и одним движением руки прикрутить к ней измеритель времени.

Его реализация:

var timers = {};

// прибавит время выполнения f к таймеру timers[timer]
function timingDecorator(f, timer) {
  return function() {
    var start = performance.now();

    var result = f.apply(this, arguments); // (*)

    if (!timers[timer]) timers[timer] = 0;
    timers[timer] += performance.now() - start;

    return result;
  }
}

// функция может быть произвольной, например такой:
var fibonacci = function f(n) {
  return (n > 2) ? f(n - 1) + f(n - 2) : 1;
}

// использование: завернём fibonacci в декоратор
fibonacci = timingDecorator(fibonacci, "fibo");

// неоднократные вызовы...
alert( fibonacci(10) ); // 55
alert( fibonacci(20) ); // 6765
// ...

// в любой момент можно получить общее количество времени на вызовы
alert( timers.fibo + 'мс' );

Обратим внимание на строку (*) внутри декоратора, которая и осуществляет передачу вызова:

var result = f.apply(this, arguments); // (*)

Этот приём называется «форвардинг вызова» (от англ. forwarding): текущий контекст и аргументы через apply передаются в функцию f, так что изнутри f всё выглядит так, как была вызвана она напрямую, а не декоратор.

Декоратор для проверки типа

В JavaScript, как правило, пренебрегают проверками типа. В функцию, которая должна получать число, может быть передана строка, булево значение или даже объект.

Например:

function sum(a, b) {
  return a + b;
}

// передадим в функцию для сложения чисел нечисловые значения
alert( sum(true, { name: "Вася", age: 35 }) ); // true[Object object]

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

Многие языки программирования позволяют прямо в объявлении функции указать, какие типы данных имеют параметры. И это удобно, поскольку повышает надёжность кода.

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

Декораторы способны упростить рутинные, повторяющиеся задачи, вынести их из кода функции.

Например, создадим декоратор, который принимает функцию и массив, который описывает для какого аргумента какую проверку типа применять:

// вспомогательная функция для проверки на число
function checkNumber(value) {
  return typeof value == 'number';
}

// декоратор, проверяющий типы для f
// второй аргумент checks - массив с функциями для проверки
function typeCheck(f, checks) {
  return function() {
    for (var i = 0; i < arguments.length; i++) {
      if (!checks[i](arguments[i])) {
        alert( "Некорректный тип аргумента номер " + i );
        return;
      }
    }
    return f.apply(this, arguments);
  }
}

function sum(a, b) {
  return a + b;
}

// обернём декоратор для проверки
sum = typeCheck(sum, [checkNumber, checkNumber]); // оба аргумента - числа

// пользуемся функцией как обычно
alert( sum(1, 2) ); // 3, все хорошо

// а вот так - будет ошибка
sum(true, null); // некорректный аргумент номер 0
sum(1, ["array", "in", "sum?!?"]); // некорректный аргумент номер 1

Конечно, этот декоратор можно ещё расширять, улучшать, дописывать проверки, но… Вы уже поняли принцип, не правда ли?

Один раз пишем декоратор и дальше просто применяем эту функциональность везде, где нужно.

Декоратор проверки доступа

И наконец посмотрим ещё один, последний пример.

Предположим, у нас есть функция isAdmin(), которая возвращает true, если у посетителя есть права администратора.

Можно создать декоратор checkPermissionDecorator, который добавляет в любую функцию проверку прав:

Например, создадим декоратор checkPermissionDecorator(f). Он будет возвращать обёртку, которая передаёт вызов f в том случае, если у посетителя достаточно прав:

function checkPermissionDecorator(f) {
  return function() {
    if (isAdmin()) {
      return f.apply(this, arguments);
    }
    alert( 'Недостаточно прав' );
  }
}

Использование декоратора:

function save() { ... }

save = checkPermissionDecorator(save);
// Теперь вызов функции save() проверяет права

Итого

Декоратор – это обёртка над функцией, которая модифицирует её поведение. При этом основную работу по-прежнему выполняет функция.

Декораторы можно не только повторно использовать, но и комбинировать!

Это кардинально повышает их выразительную силу. Декораторы можно рассматривать как своего рода «фичи» или возможности, которые можно «нацепить» на любую функцию. Можно один, а можно несколько.

Скажем, используя декораторы, описанные выше, можно добавить к функции возможности по проверке типов данных, замеру времени и проверке доступа буквально одной строкой, не залезая при этом в её код, то есть (!) не увеличивая его сложность.

Предлагаю вашему вниманию задачи, которые помогут выяснить, насколько вы разобрались в декораторах. Далее в учебнике мы ещё встретимся с ними.

Задачи

важность: 5

Создайте декоратор makeLogging(f, log), который берет функцию f и массив log.

Он должен возвращать обёртку вокруг f, которая при каждом вызове записывает («логирует») аргументы в log, а затем передаёт вызов в f.

В этой задаче можно считать, что у функции f ровно один аргумент.

Работать должно так:

function work(a) {
  /* ... */ // work - произвольная функция, один аргумент
}

function makeLogging(f, log) { /* ваш код */ }

var log = [];
work = makeLogging(work, log);

work(1); // 1, добавлено в log
work(5); // 5, добавлено в log

for (var i = 0; i < log.length; i++) {
  alert( 'Лог:' + log[i] ); // "Лог:1", затем "Лог:5"
}

Открыть песочницу с тестами для задачи.

Возвратим декоратор wrapper который будет записывать аргумент в log и передавать вызов в f:

function work(a) {
  /*...*/ // work - произвольная функция, один аргумент
}

function makeLogging(f, log) {

  function wrapper(a) {
      log.push(a);
      return f.call(this, a);
    }

  return wrapper;
}

var log = [];
work = makeLogging(work, log);

work(1); // 1
work(5); // 5

for (var i = 0; i < log.length; i++) {
  alert( 'Лог:' + log[i] ); // "Лог:1", затем "Лог:5"
}

Обратите внимание, вызов функции осуществляется как f.call(this, a), а не просто f(a).

Передача контекста необходима, чтобы декоратор корректно работал с методами объекта. Например:

user.method = makeLogging(user.method, log);

Теперь при вызове user.method(...) в декоратор будет передаваться контекст this, который надо передать исходной функции через call/apply.

function makeLogging(f, log) {

  function wrapper(a) {
    log.push(a);
    return f.call(this, a);
  }

  return wrapper;
}

Открыть решение с тестами в песочнице.

важность: 3

Создайте декоратор makeLogging(func, log), для функции func возвращающий обёртку, которая при каждом вызове добавляет её аргументы в массив log.

Условие аналогично задаче Логирующий декоратор (1 аргумент), но допускается func с любым набором аргументов.

Работать должно так:

function work(a, b) {
  alert( a + b ); // work - произвольная функция
}

function makeLogging(f, log) { /* ваш код */ }

var log = [];
work = makeLogging(work, log);

work(1, 2); // 3
work(4, 5); // 9

for (var i = 0; i < log.length; i++) {
  var args = log[i]; // массив из аргументов i-го вызова
  alert( 'Лог:' + args.join() ); // "Лог:1,2", "Лог:4,5"
}

Открыть песочницу с тестами для задачи.

Решение аналогично задаче Логирующий декоратор (1 аргумент), разница в том, что в лог вместо одного аргумента идёт весь объект arguments.

Для передачи вызова с произвольным количеством аргументов используем f.apply(this, arguments).

function work(a, b) {
  alert( a + b ); // work - произвольная функция
}

function makeLogging(f, log) {

  function wrapper() {
      log.push([].slice.call(arguments));
      return f.apply(this, arguments);
    }

  return wrapper;
}

var log = [];
work = makeLogging(work, log);

work(1, 2); // 3
work(4, 5); // 9

for (var i = 0; i < log.length; i++) {
  var args = log[i]; // массив из аргументов i-го вызова
  alert( 'Лог:' + args.join() ); // "Лог:1,2", "Лог:4,5"
}
function makeLogging(f, log) {

  function wrapper() {
    log.push([].slice.call(arguments));
    return f.apply(this, arguments);
  }

  return wrapper;
}

Открыть решение с тестами в песочнице.

важность: 5

Создайте декоратор makeCaching(f), который берет функцию f и возвращает обёртку, которая кеширует её результаты.

В этой задаче функция f имеет только один аргумент, и он является числом.

  1. При первом вызове обёртки с определённым значением аргумента – она вызывает f и запоминает её результат.
  2. При втором и последующих вызовах с тем же значением аргумента – возвращается сохранённое значение результата.

Должно работать так:

function f(x) {
  return Math.random() * x; // random для удобства тестирования
}

function makeCaching(f) { /* ваш код */ }

f = makeCaching(f);

var a, b;

a = f(1);
b = f(1);
alert( a == b ); // true (значение закешировано)

b = f(2);
alert( a == b ); // false, другой аргумент => другое значение

Открыть песочницу с тестами для задачи.

Запоминать результаты вызова функции будем в замыкании, в объекте cache: { ключ:значение }.

function f(x) {
  return Math.random()*x;
}

function makeCaching(f) {
  var cache = {};

  return function(x) {
    if (!(x in cache)) {
      cache[x] = f.call(this, x);
    }
    return cache[x];
  };

}

f = makeCaching(f);

var a = f(1);
var b = f(1);
alert( a == b ); // true (значение закешировано)

b = f(2);
alert( a == b ); // false, другой аргумент => другое значение

Обратите внимание: проверка на наличие уже подсчитанного значения выглядит так: if (x in cache). Менее универсально можно проверить так: if (cache[x]), это если мы точно знаем, что cache[x] никогда не будет false, 0 и т.п.

function makeCaching(f) {
  var cache = {};

  return function(x) {
    if (!(x in cache)) {
      cache[x] = f.call(this, x);
    }
    return cache[x];
  };

}

Открыть решение с тестами в песочнице.

Карта учебника