Привязка контекста и карринг: "bind"

Функции в JavaScript никак не привязаны к своему контексту this, с одной стороны, здорово – это позволяет быть максимально гибкими, одалживать методы и так далее.

Но с другой стороны – в некоторых случаях контекст может быть потерян. То есть мы вроде как вызываем метод объекта, а на самом деле он получает this = undefined.

Такая ситуация является типичной для начинающих разработчиков, но бывает и у «зубров» тоже. Конечно, «зубры» при этом знают, что с ней делать.

Пример потери контекста

В браузере есть встроенная функция setTimeout(func, ms), которая вызывает выполнение функции func через ms миллисекунд (=1/1000 секунды).

Мы подробно остановимся на ней и её тонкостях позже, в главе setTimeout и setInterval, а пока просто посмотрим пример.

Этот код выведет «Привет» через 1000 мс, то есть 1 секунду:

setTimeout(function() {
  alert( "Привет" );
}, 1000);

Попробуем сделать то же самое с методом объекта, следующий код должен выводить имя пользователя через 1 секунду:

var user = {
  firstName: "Вася",
  sayHi: function() {
    alert( this.firstName );
  }
};

setTimeout(user.sayHi, 1000); // undefined (не Вася!)

При запуске кода выше через секунду выводится вовсе не "Вася", а undefined!

Это произошло потому, что в примере выше setTimeout получил функцию user.sayHi, но не её контекст. То есть, последняя строчка аналогична двум таким:

var f = user.sayHi;
setTimeout(f, 1000); // контекст user потеряли

Ситуация довольно типична – мы хотим передать метод объекта куда-то в другое место кода, откуда он потом может быть вызван. Как бы прикрепить к нему контекст, желательно, с минимумом плясок с бубном и при этом надёжно?

Есть несколько способов решения, среди которых мы, в зависимости от ситуации, можем выбирать.

Решение 1: сделать обёртку

Самый простой вариант решения – это обернуть вызов в анонимную функцию:

var user = {
  firstName: "Вася",
  sayHi: function() {
    alert( this.firstName );
  }
};

setTimeout(function() {
  user.sayHi(); // Вася
}, 1000);

Теперь код работает, так как user достаётся из замыкания.

Это решение также позволяет передать дополнительные аргументы:

var user = {
  firstName: "Вася",
  sayHi: function(who) {
    alert( this.firstName + ": Привет, " + who );
  }
};

setTimeout(function() {
  user.sayHi("Петя"); // Вася: Привет, Петя
}, 1000);

Но тут же появляется и уязвимое место в структуре кода!

А что, если до срабатывания setTimeout (ведь есть целая секунда) в переменную user будет записано другое значение? К примеру, в другом месте кода будет присвоено user=(другой пользователь)… В этом случае вызов неожиданно будет совсем не тот!

Хорошо бы гарантировать правильность контекста.

Решение 2: bind для привязки контекста

Напишем вспомогательную функцию bind(func, context), которая будет жёстко фиксировать контекст для func:

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

Посмотрим, что она делает, как работает, на таком примере:

function f() {
  alert( this );
}

var g = bind(f, "Context");
g(); // Context

То есть, bind(f, "Context") привязывает "Context" в качестве this для f.

Посмотрим, за счёт чего это происходит.

Результатом bind(f, "Context"), как видно из кода, будет анонимная функция (*).

Вот она отдельно:

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

Если подставить наши конкретные аргументы, то есть f и "Context", то получится так:

function() { // (*)
  return f.apply("Context", arguments);
};

Эта функция запишется в переменную g.

Далее, если вызвать g, то вызов будет передан в f, причём f.apply("Context", arguments) передаст в качестве контекста "Context", который и будет выведен.

Если вызвать g с аргументами, то также будет работать:

function f(a, b) {
  alert( this );
  alert( a + b );
}

var g = bind(f, "Context");
g(1, 2); // Context, затем 3

Аргументы, которые получила g(...), передаются в f также благодаря методу .apply.

Иными словами, в результате вызова bind(func, context) мы получаем «функцию-обёртку», которая прозрачно передаёт вызов в func, с теми же аргументами, но фиксированным контекстом context.

Вернёмся к user.sayHi. Вариант с bind:

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

var user = {
  firstName: "Вася",
  sayHi: function() {
    alert( this.firstName );
  }
};

setTimeout(bind(user.sayHi, user), 1000);

Теперь всё в порядке!

Вызов bind(user.sayHi, user) возвращает такую функцию-обёртку, которая привязывает user.sayHi к контексту user. Она будет вызвана через 1000 мс.

Полученную обёртку можно вызвать и с аргументами – они пойдут в user.sayHi без изменений, фиксирован лишь контекст.

var user = {
  firstName: "Вася",
  sayHi: function(who) { // здесь у sayHi есть один аргумент
    alert( this.firstName + ": Привет, " + who );
  }
};

var sayHi = bind(user.sayHi, user);

// контекст Вася, а аргумент передаётся "как есть"
sayHi("Петя"); // Вася: Привет, Петя
sayHi("Маша"); // Вася: Привет, Маша

В примере выше продемонстрирована другая частая цель использования bind – «привязать» функцию к контексту, чтобы в дальнейшем «не таскать за собой» объект, а просто вызывать sayHi.

Результат bind можно передавать в любое место кода, вызывать как обычную функцию, он «помнит» свой контекст.

Решение 3: встроенный метод bind

В современном JavaScript (или при подключении библиотеки es5-shim для IE8-) у функций уже есть встроенный метод bind, который мы можем использовать.

Он работает примерно так же, как bind, который описан выше.

Изменения очень небольшие:

function f(a, b) {
  alert( this );
  alert( a + b );
}

// вместо
// var g = bind(f, "Context");
var g = f.bind("Context");
g(1, 2); // Context, затем 3

Синтаксис встроенного bind:

var wrapper = func.bind(context[, arg1, arg2...])
func
Произвольная функция
context
Контекст, который привязывается к func
arg1, arg2, …
Если указаны аргументы arg1, arg2... – они будут прибавлены к каждому вызову новой функции, причем встанут перед теми, которые указаны при вызове.

Результат вызова func.bind(context) аналогичен вызову bind(func, context), описанному выше. То есть, wrapper – это обёртка, фиксирующая контекст и передающая вызовы в func. Также можно указать аргументы, тогда и они будут фиксированы, но об этом чуть позже.

Пример со встроенным методом bind:

var user = {
  firstName: "Вася",
  sayHi: function() {
    alert( this.firstName );
  }
};

// setTimeout( bind(user.sayHi, user), 1000 );
setTimeout(user.sayHi.bind(user), 1000); // аналог через встроенный метод

Получили простой и надёжный способ привязать контекст, причём даже встроенный в JavaScript.

Далее мы будем использовать именно встроенный метод bind.

bind не похож на call/apply

Методы bind и call/apply близки по синтаксису, но есть важнейшее отличие.

Методы call/apply вызывают функцию с заданным контекстом и аргументами.

А bind не вызывает функцию. Он только возвращает «обёртку», которую мы можем вызвать позже, и которая передаст вызов в исходную функцию, с привязанным контекстом.

Привязать всё: bindAll

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

for (var prop in user) {
  if (typeof user[prop] == 'function') {
    user[prop] = user[prop].bind(user);
  }
}

В некоторых JS-фреймворках есть даже встроенные функции для этого, например _.bindAll(obj).

Карринг

До этого мы говорили о привязке контекста. Теперь пойдём на шаг дальше. Привязывать можно не только контекст, но и аргументы. Используется это реже, но бывает полезно.

Карринг (currying) или каррирование – термин функционального программирования, который означает создание новой функции путём фиксирования аргументов существующей.

Как было сказано выше, метод func.bind(context, ...) может создавать обёртку, которая фиксирует не только контекст, но и ряд аргументов функции.

Например, есть функция умножения двух чисел mul(a, b):

function mul(a, b) {
  return a * b;
};

При помощи bind создадим функцию double, удваивающую значения. Это будет вариант функции mul с фиксированным первым аргументом:

// double умножает только на два
var double = mul.bind(null, 2); // контекст фиксируем null, он не используется

alert( double(3) ); // = mul(2, 3) = 6
alert( double(4) ); // = mul(2, 4) = 8
alert( double(5) ); // = mul(2, 5) = 10

При вызове double будет передавать свои аргументы исходной функции mul после тех, которые указаны в bind, то есть в данном случае после зафиксированного первого аргумента 2.

Говорят, что double является «частичной функцией» (partial function) от mul.

Другая частичная функция triple утраивает значения:

var triple = mul.bind(null, 3); // контекст фиксируем null, он не используется

alert( triple(3) ); // = mul(3, 3) = 9
alert( triple(4) ); // = mul(3, 4) = 12
alert( triple(5) ); // = mul(3, 5) = 15

При помощи bind мы можем получить из функции её «частный вариант» как самостоятельную функцию и дальше передать в setTimeout или сделать с ней что-то ещё.

Наш выигрыш состоит в том, что эта самостоятельная функция, во-первых, имеет понятное имя (double, triple), а во-вторых, повторные вызовы позволяют не указывать каждый раз первый аргумент, он уже фиксирован благодаря bind.

Функция ask для задач

В задачах этого раздела предполагается, что объявлена следующая «функция вопросов» ask:

function ask(question, answer, ok, fail) {
  var result = prompt(question, '');
  if (result.toLowerCase() == answer.toLowerCase()) ok();
  else fail();
}

Её назначение – задать вопрос question и, если ответ совпадёт с answer, то запустить функцию ok(), а иначе – функцию fail().

Несмотря на внешнюю простоту, функции такого вида активно используются в реальных проектах. Конечно, они будут сложнее, вместо alert/prompt – вывод красивого JavaScript-диалога с рамочками, кнопочками и так далее, но это нам сейчас не нужно.

Пример использования:

ask("Выпустить птичку?", "да", fly, die);

function fly() {
  alert( 'улетела :)' );
}

function die() {
  alert( 'птичку жалко :(' );
}

Итого

  • Функция сама по себе не запоминает контекст выполнения.

  • Чтобы гарантировать правильный контекст для вызова obj.func(), нужно использовать функцию-обёртку, задать её через анонимную функцию:

    setTimeout(function() {
      obj.func();
    })
  • …Либо использовать bind:

    setTimeout(obj.func.bind(obj));
  • Вызов bind часто используют для привязки функции к контексту, чтобы затем присвоить её в обычную переменную и вызывать уже без явного указания объекта.

  • Вызов bind также позволяет фиксировать первые аргументы функции («каррировать» её), и таким образом из общей функции получить её «частные» варианты – чтобы использовать их многократно без повтора одних и тех же аргументов каждый раз.

Задачи

важность: 3

Если вы вдруг захотите копнуть поглубже – аналог bind для IE8- и старых версий других браузеров будет выглядеть следующим образом:

function bind(func, context /*, args*/) {
  var bindArgs = [].slice.call(arguments, 2); // (1)
  function wrapper() {                        // (2)
    var args = [].slice.call(arguments);
    var unshiftArgs = bindArgs.concat(args);  // (3)
    return func.apply(context, unshiftArgs);  // (4)
  }
  return wrapper;
}

Использование – вместо mul.bind(null, 2) вызывать bind(mul, null, 2).

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

Страшновато выглядит, да? Работает так (по строкам):

  1. Вызов bind сохраняет дополнительные аргументы args (они идут со 2-го номера) в массив bindArgs.
  2. … и возвращает обертку wrapper.
  3. Эта обёртка делает из arguments массив args и затем, используя метод concat, прибавляет их к аргументам bindArgs (карринг).
  4. Затем передаёт вызов func с контекстом и общим массивом аргументов.
важность: 5

Что выведет функция?

function f() {
  alert( this );
}

var user = {
  g: f.bind("Hello")
}

user.g();

Ответ: Hello.

function f() {
  alert( this );
}

var user = {
  g: f.bind("Hello")
}

user.g();

Так как вызов идёт в контексте объекта user.g(), то внутри функции g контекст this = user.

Однако, функции g совершенно без разницы, какой this она получила.

Её единственное предназначение – это передать вызов в f вместе с аргументами и ранее указанным контекстом "Hello", что она и делает.

Эта задача демонстрирует, что изменить однажды привязанный контекст уже нельзя.

важность: 5

Что выведет этот код?

function f() {
  alert(this.name);
}

f = f.bind( {name: "Вася"} ).bind( {name: "Петя" } );

f();

Ответ: "Вася".

function f() {
  alert(this.name);
}

f = f.bind( {name: "Вася"} ).bind( {name: "Петя"} );

f(); // Вася

Первый вызов f.bind(..Вася..) возвращает «обёртку», которая устанавливает контекст для f и передаёт вызов f.

Следующий вызов bind будет устанавливать контекст уже для этой обёртки. Это ни на что не повлияет.

Чтобы это проще понять, используем наш собственный вариант bind вместо встроенного:

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

Код станет таким:

function f() {
  alert(this.name);
}

f = bind(f, {name: "Вася"} ); // (1)
f = bind(f, {name: "Петя"} ); // (2)

f(); // Вася

Здесь видно, что первый вызов bind, в строке (1), возвращает обёртку вокруг f, которая выглядит так (выделена):

function bind(func, context) {
  return function() {
    // здесь this не используется
    return func.apply(context, arguments);
  };
}

В этой обёртке нигде не используется this, контекст context берётся из замыкания. Посмотрите на код, там нигде нет this.

Поэтому следующий bind в строке (2), который выполняется уже над обёрткой и фиксирует в ней this, ни на что не влияет. Какая разница, что будет в качестве this в функции, которая этот this не использует? Контекст context, как видно в коде выше, она получает через замыкание из аргументов первого bind.

важность: 5

В свойство функции записано значение. Изменится ли оно после применения bind? Обоснуйте ответ.

function sayHi() {
  alert( this.name );
}
sayHi.test = 5;
alert( sayHi.test ); // 5

var bound = sayHi.bind({
  name: "Вася"
});

alert( bound.test ); // что выведет? почему?

Ответ: undefined.

Результатом работы bind является функция-обёртка над sayHi. Эта функция – самостоятельный объект, у неё уже нет свойства test.

важность: 5

Вызов user.checkPassword() в коде ниже должен, при помощи ask, спрашивать пароль и вызывать loginOk/loginFail в зависимости от правильности ответа.

Однако, его вызов приводит к ошибке. Почему?

Исправьте выделенную строку, чтобы всё работало (других строк изменять не надо).

"use strict";

function ask(question, answer, ok, fail) {
  var result = prompt(question, '');
  if (result.toLowerCase() == answer.toLowerCase()) ok();
  else fail();
}

var user = {
  login: 'Василий',
  password: '12345',

  loginOk: function() {
    alert( this.login + ' вошёл в сайт' );
  },

  loginFail: function() {
    alert( this.login + ': ошибка входа' );
  },

  checkPassword: function() {
    ask("Ваш пароль?", this.password, this.loginOk, this.loginFail);
  }
};

user.checkPassword();

P.S. Ваше решение должно также срабатывать, если переменная user будет перезаписана, например вместо user.checkPassword() в конце будут строки:

var vasya = user;
user = null;
vasya.checkPassword();

Решение с bind

Ошибка происходит потому, что ask получает только функцию, без объекта-контекста.

Используем bind, чтобы передать в ask функцию с уже привязанным контекстом:

"use strict";

function ask(question, answer, ok, fail) {
  var result = prompt(question, '');
  if (result.toLowerCase() == answer.toLowerCase()) ok();
  else fail();
}

var user = {
  login: 'Василий',
  password: '12345',

  loginOk: function() {
    alert( this.login + ' вошёл в сайт' );
  },

  loginFail: function() {
    alert( this.login + ': ошибка входа' );
  },

  checkPassword: function() {
    ask("Ваш пароль?", this.password, this.loginOk.bind(this), this.loginFail.bind(this));
  }
};

var vasya = user;
user = null;
vasya.checkPassword();

Решение через замыкание

Альтернативное решение – сделать функции-обёртки над user.loginOk/loginFail:

var user = {
  ...
  checkPassword: function() {
    ask("Ваш пароль?", this.password,
      function() { user.loginOk(); }, function() { user.loginFail(); });
  }
}

…Но такой код использует переменную user, так что если объект переместить из неё, к примеру, так, то работать он не будет:

var vasya = user; // переместим user в vasya
user = null;
vasya.checkPassword(); // упс будет ошибка, ведь в коде объекта остался user

Для того, чтобы избежать проблем, можно использовать this. Внутри checkPassword он всегда будет равен текущему объекту, так что скопируем его в переменную, которую назовём self:

"use strict";

function ask(question, answer, ok, fail) {
  var result = prompt(question, '');
  if (result.toLowerCase() == answer.toLowerCase()) ok();
  else fail();
}

var user = {
  login: 'Василий',
  password: '12345',

  loginOk: function() {
    alert( this.login + ' вошёл в сайт' );
  },

  loginFail: function() {
    alert( this.login + ': ошибка входа' );
  },

  checkPassword: function() {
    var self = this;
    ask("Ваш пароль?", this.password,
      function() {
        self.loginOk();
      },
      function() {
        self.loginFail();
      }
    );
  }
};

var vasya = user;
user = null;
vasya.checkPassword();

Теперь всё работает. Анонимные функции достают правильный контекст из замыкания, где он сохранён в переменной self.

важность: 5

Эта задача – усложнённый вариант задачи Использование функции вопросов. В ней объект user изменён.

Теперь заменим две функции user.loginOk() и user.loginFail() на единый метод: user.loginDone(true/false), который нужно вызвать с true при верном ответе и с false – при неверном.

Код ниже делает это, соответствующий фрагмент выделен.

Сейчас он обладает важным недостатком: при записи в user другого значения объект перестанет корректно работать, вы увидите это, запустив пример ниже (будет ошибка).

Как бы вы написали правильно?

Исправьте выделенный фрагмент, чтобы код заработал.

"use strict";

function ask(question, answer, ok, fail) {
  var result = prompt(question, '');
  if (result.toLowerCase() == answer.toLowerCase()) ok();
  else fail();
}

var user = {
  login: 'Василий',
  password: '12345',

  // метод для вызова из ask
  loginDone: function(result) {
    alert( this.login + (result ? ' вошёл в сайт' : ' ошибка входа') );
  },

  checkPassword: function() {
    ask("Ваш пароль?", this.password,
      function() {
        user.loginDone(true);
      },
      function() {
        user.loginDone(false);
      }
    );
  }
};

var vasya = user;
user = null;
vasya.checkPassword();

Изменения должны касаться только выделенного фрагмента.

Если возможно, предложите два решения, одно – с использованием bind, другое – без него. Какое решение лучше?

Решение с bind

Первое решение – передать в ask функции с привязанным контекстом и аргументами.

"use strict";

function ask(question, answer, ok, fail) {
  var result = prompt(question, '');
  if (result.toLowerCase() == answer.toLowerCase()) ok();
  else fail();
}

var user = {
  login: 'Василий',
  password: '12345',

  loginDone: function(result) {
    alert( this.login + (result ? ' вошёл в сайт' : ' ошибка входа') );
  },

  checkPassword: function() {
    ask("Ваш пароль?", this.password, this.loginDone.bind(this, true), this.loginDone.bind(this, false));
  }
};

user.checkPassword();

Решение с локальной переменной

Второе решение – это скопировать this в локальную переменную (чтобы внешняя перезапись не повлияла):

"use strict";

function ask(question, answer, ok, fail) {
  var result = prompt(question, '');
  if (result.toLowerCase() == answer.toLowerCase()) ok();
  else fail();
}

var user = {
  login: 'Василий',
  password: '12345',

  loginDone: function(result) {
    alert( this.login + (result ? ' вошёл в сайт' : ' ошибка входа') );
  },

  checkPassword: function() {
    var self = this;
    ask("Ваш пароль?", this.password,
      function() {
        self.loginDone(true);
      },
      function() {
        self.loginDone(false);
      }
    );
  }
};

user.checkPassword();

Оба решения хороши, вариант с bind короче.

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

Комментарии

перед тем как писать…
  • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.