Магия на кончиках пальцев

Ваши функции действительно чистые?

О чистоте функций в JavaScript
Публикация от

Оригинал: http://staltz.com/is-your-javascript-function-actually-pure.html (André Staltz)

В русскоязычной практике не применяется термин, аналогичный английскому "impure". В данном переводе он иногда заменяется нетипичным "нечистая".

Что значит "чистая функция" в контексте JavaScript? В программировании в целом, чистота также известна как "referential transparency", иначе говоря “замена выражения или вызова функции ее результатом никогда не изменит поведение программы” или “каждый раз, когда вы передаете те же входные данные, вы всегда получаете тот же результат”.

Кажется интуитивно, и функция вроде x => x * 10 выглядит чистой, т.к. каждый раз, как вы передаете в неё число 3 в качестве аргумента вы получаете 30 на выходе. Так как мы можем определить, что одна функция чистая, а другая -- нет? Достаточно ли для этого просто прочитать код?

Давайте посмотрим на мнения людей. Вчера (25 авг 2016) Я запустил в Twitter голосование “Чистая или нет?” с тремя вариантами ответа:

Со следующим кодом.

function sum(arr) {
  var z = 0;
  for (var i = 0; i < arr.length; i++) {
    z += arr[i];
  }
  return z;
}

Вот результаты опроса:

Понятно, почему большинство людей думают, что она чистая: даже если используются мутации внутри, получая массив со значениями [1, 2, 3] в качестве аргумента, вы всегда получите 6 на выходе. Каждый. Грёбаный. Раз.

Но также понятно, почему 18% считают, что она не является чистой: тело функции использует грязные выражения с побочными эффектами. В конце концов, я спросил “Чистая или нет?”, а не “эта функция чистая?”.

Удивительно то, что оба лагеря ошибаются. Те самые, "неуверенные" 8% были правы: это зависит от поведения во время выполнения. Просто прочитав функцию, мы не можем быть уверены. В самом деле, 18%, которые думают, что она не чистая, “более правы”, чем 74%, считающих её чистой, потому что бывают случаи, когда sum не будет чистой.

Код обманчиво простой, и в то время как мы читаем его, как люди мы, естественно, делаем предположения. Вот несколько предположений, которые вы сделали, вероятно, без явного понимания:

Но, вот проблема: все эти предположения могут провалиться, и приведенный выше код не скажет вам об этом. Вот несколько способов, как мы можем сломать функцию и сделать её грязной:

sum(); // TypeError: Cannot read property 'length' of undefined

var arr = [{}, {}, {}];
arr[0].valueOf = arr[1].valueOf = arr[2].valueOf = Math.random;
sum(arr); // 2.393660612848899
sum(arr); // 2.3418339292845998
sum(arr); // 2.15048094452324
// Одинаковые аргументы, разный результат!

Итак, sum -- нечистая функция.

Не так быстро! Все функции JavaScript на самом деле являются “процедурами”. Чистая функция -- это просто “процедура”, которая ведёт себя как математическая функция, единственно верная чистая функция. Это и есть разница между функцией и "функцией". Мы можем только сказать, что “моя функция JavaScript, в данном случае, ведет себя как математическая функция”.

Я предполагаю, что вы знаете, о чем я говорю, но даю подсказку: математическая функция -- это отношение определенное на множестве, отображаемом в другое множество. Например, мы могли бы сказать, что sum работает только с массивами чисел. Массивы объектов не поддерживаются. 1

Итак, возвращаясь к JavaScript, функция sum будет вести себя как математическая функция, в зависимости от того, как вы используете её. Если это вся ваша программа:

function sum(arr) {
  var z = 0;
  for (var i = 0; i < arr.length; i++) {
    z += arr[i];
  }
  return z;
}

var arr = [1, 2, 3];
var x = sum(arr);
var y = sum(arr);
var z = sum(arr);

console.log(x, y, z);

Тогда, конечно, sum будет чистой функцией! Она ведёт себя как математическая функция. Поставьте её в другую ситуацию, и она перестанет вести себя как математическая функция.

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

Помните, наши невинные х => х * 10? Бедняга. Мы даже не можем сказать, что эта функция чистая. Посмотрите, она не является чистой:

var a = {}; a.valueOf = Math.random;
var fn = x => x * 10;
fn(a); // 5.107926817373938
fn(a); // 3.4100775757245416
fn(a); // 5.1903831613695095
// Одинаковый вызов, разный результат!

Чёрт возьми! Есть ли вообще что-нибудь чистое в JavaScript? Вы могли бы сказать: “это неважно, потому что на практике мы не встретим этих странных случаев, которые ты придумал”. Действительно, мы не будем valueOf подменять Math.random.

Пока... в один прекрасный день мы это не сделаем. Вы знаете, те самые сложные ошибки, с которыми вы боретесь иногда? Можно подумать, что в JavaScript за этим стоит черная магия. Он проклят. Происходит что-то мистическое. Эти мистические случаи обычно происходят потому, что что-то где-то там случилось, что-то, что вы не предполагали. Да, теперь это кажется знакомым, верно?

Итак, мы прокляты? х => х * 10 -- это так мило и просто в использовании, но эта функция также не является чистой абсолютно всегда. Есть ли что-нибудь чистое на JavaScript? Чистота в JavaScript вообще возможна? Этот ваш JavaScript совершенно нечистый?

Ну, нет. Вот как мы можем сделать sum похожей на математическую функцию:

function sum(arr) {
  if (!arr) return void 0;
  if (typeof arr !== 'object') return void 0;
  if (!Array.isArray(arr)) return void 0;
  var z = 0;
  for (var i = 0; i < arr.length; i++) {
    if (typeof arr[i] !== 'number') {
      return void 0;
    }
    z += arr[i];
  }
  return z;
}

Что, если кто-то подменил Array.isArray?

Array.isArray = (arr) => Math.random() < 0.5;

Ладно, подождите минутку:

 function sum(arr) {
   if (!arr) return void 0;
   if (typeof arr !== 'object') return void 0;
+  if (Array.isArray.toString() !== 'function isArray() { [native code] }') {
+    return void 0;
+  }
   if (!Array.isArray(arr)) return void 0;
   var z = 0;
   for (var i = 0; i < arr.length; i++) {
     if (typeof arr[i] !== 'number') {
       return void 0;
     }
     z += arr[i];
   }
   return z;
 }

Чтобы сделать её чистой, мы в основном перечислили все предположения о входных данных. Кстати, я до сих пор чувствую себя неловко, что кто-то найдет хитрый способ сломать мою “чистую” sum. Список проверок нуден и делает код менее читабельным. Наверное, вы написали такой код для основных аргументов, которые являются недопустимыми. Но как я чувствовал себя неловко, так и вы. Вы уверены, что учли все случаи и возможные ситуации? Она всегда ведёт себя как математическая функция?

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

В TypeScript, мы можем написать наши проверки в сигнатуре:

function sum(arr: Array<number>): number

Тело функции, реализующее тоже самое, что и в JavaScript:

function sum(arr: Array<number>): number {
  var z = 0;
  for (var i = 0; i < arr.length; i++) {
    z += arr[i];
  }
  return z;
}

Если вы попробуете использовать эту функцию так:

sum();

Это даже не компилируется! Это значит, что ваша программа даже не “ведёт себя” в самом начале. Также и этот код не будет компилироваться:

var arr = [{}, {}, {}];
arr[0].valueOf = arr[1].valueOf = arr[2].valueOf = Math.random;
sum(arr);
sum(arr);
sum(arr);

Впрочем, внимательный читатель увидит, что моя TypeScript'овая sum может быть также сломана:

sum(null);

Компилируется успешно, но возвращает ошибки во время выполнения “TypeError: Cannot read property ‘length’ of null”. Это потому, что TypeScript pre-v2.0 считает, что этот Array<number> включает в себя null. Даже если мы используем TypeScript v2.1, мы можем обмануть TypeScript через приведение типов:

var arr = [{}, {}, {}] as Array<number>; // верь мне, компилятор!
arr[0].valueOf = arr[1].valueOf = arr[2].valueOf = Math.random;
sum(arr); // 2.393660612848899
sum(arr); // 2.3418339292845998
sum(arr); // 2.15048094452324

Компилируется, но возвращает различные результаты для sum(arr).

Итак, TypeScript тоже обречен? Ну, вроде да, но гораздо меньше, чем JavaScript. TypeScript typings добавляет проверки в ваш код, так что он будет отлавливать больше случаев, чем вы обычно отлавливаете при написании наивного кода. Итак, я за TypeScript. Он помогает мне чувствовать себя немного лучше.

Мы действительно можем быть уверены, что функция чиста, просто прочитав её? Ну, в актуальных функциональных языках программирования, таких как PureScript или Haskell -- да, мы можем:

sum :: [Int] -> Int
sum = foldl (+) 0

Если Вы не понимаете синтаксис, вот важная часть: [Int] -> Int. Это означает, что это функция, которая принимает список Int и возвращает только Int. Список не может быть undefined, не может быть null. И я не думаю, что вы можете изменять числа так, как это возможно в JavaScript. И есть много проверок, встроенных в Int. Это тип, который удовлетворяет многие типоклассы: Num (это число), Ord (целые числа могут быть упорядочены), Eq (числа могут строго сравниваться2), Show (мы можем сделать удобочитаемый формат для целых чисел) и т. д. Все эти проверки отлавливают много крайних случаев. Может быть, есть некоторые ошибки времени выполнения и опасные операции в Haskell, но это чертовски хорошо делает код похожим на математическую функцию.

Заключение

Ок, так что в Haskell функции являются чистыми, и вы можете это узнать просто прочитав код. Но разве название этой статьи не о JavaScript?

Я думал о чистоте в JavaScript некоторое время, потому что недавно я имел дискуссию с людьми о “оператор scan в RxJS чистый?” и я защищал его, настаивая, что он чистый. Я был неправ. На самом деле был. Это зависит от следующих факторов. Если используется вне контекста высшестоящих Observable, как это было в Elm (актуальный функциональный язык программирования, в лиге Haskell и PureScript), то он чист. Он ведет себя словно математическая функция. Но, если вы используете scan в вышестоящих Observable, существует высокая вероятность, что он не будет вести себя как математическая функция.

Почему все это важно? Потому что я надеюсь, что мы можем начать перенос дискуссии из “это чистая функция?” в

“Эта функция ведет себя как математическая во всех ситуациях, с которыми я могу столкнуться в своём коде?”

Я знаю, что это уже приговор. Я знаю, что на этот вопрос трудно найти ответ в большинстве случаев. Однако это единственное, что мы можем сделать для чистоты JavaScript. Мы не можем посмотреть на код и заявить, что он чистый. У нас было много непроверенных предположений. Давайте говорить “ведет себя, как Math, в данной конкретной ситуации” вместо этого.


  1. Фу́нкция (отображе́ние, опера́тор, преобразова́ние) — в математике соответствие между элементами двух множеств, установленное по такому правилу, что каждому элементу одного множества ставится в соответствие некоторый элемент из другого множества.
    Математическое понятие функции выражает интуитивное представление о том, как одна величина полностью определяет значение другой величины. Так, значение переменной x однозначно определяет значение выражения x2, а значение месяца однозначно определяет значение следующего за ним месяца.
    Аналогично, задуманный заранее алгоритм по значению входного данного выдаёт значение выходного данного. -- Википедия
     

  2. Имеется ввиду оператор сравнения ===