Существуют ли чистые функции в JavaScript?

Nikolay Tolochny
devSchacht
Published in
6 min readJul 3, 2017

Перевод статьи Robin Pokorny: Do pure functions exist in JavaScript?. Опубликовано с разрешения автора.

Тень над нашим чистым, элегантным кодом? Фото Benjamin Bousquet

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

Что такое чистая функция?

Если вы не знакомы с этим термином, то я рекомендую вам сначала прочитать небольшое вступление. Определение «чистая функция» от Alvin Alexander и Мастер JavaScript интервью: что такое чистая функция? от Eric Elliott будут отличным выбором.

Вкратце, функция называется чистой, если она удовлетворяет двум условиям:

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

Иногда добавляется третье условие: «не опирается на внешнее изменяемое состояние». Это, по сути, избыточно, поскольку такая зависимость от изменяемой переменной неизбежно приведёт к нарушению первого условия.

Что из этого является чистым?

Здесь я написал четыре примера функций. Прежде чем продолжить, просмотрите их и решите самостоятельно, какие являются чистыми, а какие нечистыми.

// A: Простое умножение
function doubleA(n) {
return n * 2
}
// B: C переменной
var two = 2
function doubleB(n) {
return n * two
}
// C: С вспомогающей функцией
function getTwo() {
return 2
}
function doubleC(n) {
return n * getTwo()
}
// D: Преобразование массива
function doubleD(arr) {
return arr.map(n => n * 2)
}

Сделали? Отлично, давайте сравним.

Когда я спрашивал, подавляющее большинство ответило, что функция doubleB является единственной нечистой, а функции doubleA, doubleC и doubleD чисты.

Итак, давайте рассмотрим условия. Последнее условие очевидно: не должно быть побочных эффектов.

Первое более интересное. При вызове с теми же аргументами все они возвращают одно и то же значение (используем toEqual для поддержки массивов):

expect( doubleA(1) ).toEqual( doubleA(1) )
expect( doubleB(1) ).toEqual( doubleB(1) )
expect( doubleC(1) ).toEqual( doubleC(1) )
expect( doubleD([1]) ).toEqual( doubleD([1]) )

Верно? Верно?

Нуууу, написано так, что да. Однако, как насчет этой части кода, опубликованного моим другом Alexander?

doubleB(1) // -> 2
two = 3
doubleB(1) // -> 3

Результат верен. Я дважды запустил функцию с теми же аргументами и получил разное значение. Это делает её нечистой. Независимо от того, что происходило между ними.

Это заставило меня задуматься. Если это подтвердилось, то что насчёт других? Выдержат ли они, если я достаточно постараюсь? Как вы догадались, нет, они этого не сделают. Фактически, я сейчас говорю:

Ни одна из четырёх функций не является чистой.

Функции как объекты первого класса

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

doubleC(1) // -> 2
getTwo = function() { return 3 }
doubleC(1) // -> 3

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

Map по массиву

«Map, filter, reduce. Повторить». Это было название одной из моих livecoding-сессий. Эти три метода — ядро преобразования данных в функциональном программировании. Поэтому они должны быть безопасными для использования в чистых функциях.

Как выясняется, в JavaScript ничто не высечено в камне. Или я должен сказать в прототипе?

doubleD([1]) // -> [2]
Array.prototype.map = function() {
return [3]
}
doubleD([1]) // -> [3]

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

Это может быть неверно, это может быть глупо. По правде говоря, я просто дважды вызвал функцию doubleDс тем же аргументом и получил разные значения. Независимо от того, что произошло между ними.

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

Поэтому функция doubleD не является чистой.

Умножение чисто

Однако в JavaScript нельзя динамически переопределять встроенные операторы, как в некоторых языках.

Кроме того, n - локальная переменная, живущая только в области видимости этой функции. Её невозможно изменить извне.

Или возможно?

Нет, это действительно невозможно. Вы должно быть невысокого мнения о JavaScript, если у вас есть на это надежда 😄.

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

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

Объекты? Какое применение у них может быть? Число, помноженное на объект, равно, эм… как 2 * {}... NaN. Проверьте это в консоли. Как это сделал я.

Хотя это не поможет. Если бы только был способ заставить среду выполнения преобразовать объект в число при умножении.

toString для чисел

Если объект появляется в контексте строки, например в случае конкатенации со строкой, движок запускает функцию toString объекта и использует результат. Если функция не реализована, он будет возвращаться к известному '[object Object]', созданному методом Object.prototype.toString.

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

var o = {
valueOf: Math.random
}
doubleA(o) // -> 1.7709942335937932
doubleA(o) // -> 1.2600863386367704

Запустите это в JS Bin.

Уфф, да. Функция вызывалась дважды с точно таким же (в любом смысле) аргументом, во второй раз она возвращала другое значение. Это не чисто.

Примечание: в предыдущей версии этой статьи использовался @@toPrimitive или, более точно, Symbol.toPrimitive. Как отметил Alexandre Morgaut, valueOf достаточно и он поддерживается с первой версии JavaScript. Если вы не знакомы с @@toPrimitive, вы всё ещё можете прочитать здесь.

И все-таки: что такое чистая функция?

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

Я хочу, чтобы все четыре функции были чистыми, если я так решил. Да, включая функции типа doubleB. Что делать, если эта переменная (в нашем случае, two) не может изменяться, например это математическая константа e, pi или phi? Она должна быть чистой.

Я хочу иметь возможность доверять встроенным функциям. Какие программы я могу создать, если я предполагаю, что всё что угодно в Array.prototype или Object.prototype может измениться? Всё просто: никто никогда не захочет их использовать.

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

В некотором роде он должен учитывать предполагаемое использование кода. Функция может считаться чистой в одном проекте и нечистой в другом. И это нормально. Пока программа работает. Пока у разработчиков есть уверенность.

У вас есть идеи для определения? Как вы решаете, что функция является чистой? Есть что-нибудь, что я упустил? Вы чему-нибудь научились?

Примечание

Есть несколько способов защиты от некоторых трюков, использованных выше.

Переопределение свободной переменной типа two или getTwo можно избежать, инкапсулируя весь блок в функцию. Либо использовать IIFE или модули:

var doubleB = (function () {
var two = 2
return function (n) {
return n * two
}
})()

Лучшим решением было бы использование const, представленного в ES2015:

const two = 2
const doubleB = (n) => n * two

Предотвращение от злоупотребления valueOf или @@toPrimitive также возможно, но громоздко. Например, вот так:

function doubleA(n) {
if (typeof n !== 'number') return NaN
return n * 2
}

Можно было обойти трюк с изменением Array.prototype, только избегая таких функций и возвращаясь к циклам for (for ... of). Это уродливо, непрактично и потенциально невозможно. Абстрагирование этого или использование библиотеки имеет свои недостатки.

Не забывайте, что для того, чтобы сделать функцию действительно чистой, нужно было бы объединить все эти анти-трюки вместе. Представьте себе, как будет выглядеть эта элегантная doubleD, какая она будет длинная и как это повредит читаемости.

Слушайте наш подкаст в iTunes и SoundCloud, читайте нас на Medium, контрибьютьте на GitHub, общайтесь в группе Telegram, следите в Twitter и канале Telegram, рекомендуйте в VK и Facebook.

Статья на GitHub

--

--