Автор фото — Kevin Ku.

Доброго времени суток, друзья! Представляю Вашему вниманию перевод статьи Richard Torruellas «A Safer Javascript».

Безопасный (предсказуемый) JavaScript


По мере того, как программы становятся больше, их сложность также возрастает. Отслеживать «баги» становится сложнее, делать ошибки — легче.

Что если бы у нас была возможность делать небольшие части наших приложений более предсказуемыми? Что если бы мы могли свести к минимуму количество мест в коде, где возможно появление ошибок?

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

  • Всё должно возвращать значение.
  • Функция, которая вызывается с одинаковыми параметрами, должна возвращать одинаковое значение.

Данные ограничения составляют основу функционального программирования.

Еще одной характеристикой функционального программирования является то, что «всё является функцией» (кто бы мог подумать?). Давайте посмотрим на то, что эти три ограничения нам дают.

Примеры ниже не рассчитаны на использование в Ваших приложениях и приводятся лишь для информации.

Всё должно возвращать значение


// application.js
import setPersonsName from './person'

console.log(setPersonsName({
    name: undefined,
    age: undefined
}, 'bob')) // undefined

В примере выше нарушается первое правило. setPersonsName ничего не возвращает. В маленькой и простой программе это еще допустимо, поскольку Вы легко можете посмотреть, что setPersonsName из "./person" делает. Но представьте, что прошли годы и Ваше приложение неимоверно разрослось. Теперь для того, чтобы понять, что делает setPersonsName, необходимо пройти через множество строк косвенного и неактуального кода. Возможно, Ваше приложение не постигнет сия печальная участь и оно останется совершенным, возможно, оно станет исключением из правила. Если не возвращать значения, то мы не будем знать, что делает или для чего используется setPersonsName. Она может куда-то записывать значения или ничего не делать.

Вместо этого мы могли бы руководствоваться ограничением, что «всё должно возвращать значение». Возможно, setPersonsName записывает значение в свойство name объекта person:

// application.js
import setPersonsName from './person'

console.log(setPersonsName('bob')) // { name: 'bob', age: undefined }

Уже лучше. Мы вполне могли бы написать несколько тестов для проверки того, что делает setPersonsName.

Функция, вызываемая с одинаковыми аргументами, возвращает одинаковое значение


import setPersonsName from './person'

console.log(setPersonsName('bob')) // { name: 'bob', age: undefined }
console.log(setPersonsName('bob')) // { name: 'bob', age: 42 }

Теперь нарушается второе правило. Но мы хотя бы что-то возвращаем, верно? Но почему во втором случае мы видим 42 в свойстве age? Мы ничего не меняли! Кто это сделал? Кто знает.

Это весьма распространенная ошибка в большом количестве программ, которые я встречал с своей практике: непонимание того, когда и как меняется код. Давайте по-возможности этого избегать.

Всё должно возвращать значение + функция, вызываемая с одинаковыми аргументами, должна возвращать одинаковое значение


Как насчет того, чтобы написать хорошее API для наших коллег? Что если вместо «невидимого» (находящегося вне нашего контроля) изменения данных, мы предоставим в распоряжение setPersonsName копию данных для изменения? Затем мы можем скопировать полученное значение.

setPersonsName({
    name: undefined,
    age: undefined
}) // {name: 'bob', age: undefined}
setPersonsName({
    name: undefined,
    age: undefined
}) // {name: 'bob', age: undefined}

Это может выглядеть так:

function setPersonsName(person, name) {
    return {
        ...person,
        name
    }
}

Всё является функцией


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

  • Всё должно быть функцией.
  • Всё должно возвращать значение.
  • Всё должно возвращать одинаковое значение при вызове с одинаковыми параметрами.

Абстракции. Обмен данными с помощью частичного применения


Допустим, Вы хотите, чтобы программа была масштабируемой. Одним из способов решения данной задачи является частичное применение функций:

function personData(person, fn) {
    let _person = {
        name: undefined,
        age: undefined
    }
    return fn(_person)
}

const a = personData({
        name: undefined,
        age: undefined
    },
    person => ({
        ...person,
        age: 23
    })
) // a = {name: undefined, age: 23}

const person = personData(a, person => ({...person, name: 'bob'})) // person = {name: 'bob', age: 23}

В примере выше слишком много кода, так что мы должны еще больше абстрагироваться. В данном случае нам может помочь новая абстракция, которую иногда называют «тоннель, труба» (pipe).

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

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x)

const person = pipe(
    person => ({...person, age: 23}),
    person => ({...person, name: 'bob'})
)
// person() = {name: 'bob', age: 23}

Вот как выглядит pipe:

function pipe(...listOfFunctions){
    return function(dataTheFunctionsOperateOn){
        return listOfFunctions.reduce(
            (data, functionFromList) => functionFromList(data),
            dataTheFunctionsOperateOn
        )
    }
}

Если мы вновь обратимся к концепции частичного применения, то получим следующее:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x)
const partiallyApply = (f, a) => b => f(a, b)

const setNameOnObject = (name, x) => ({...x, name})
const setAgeOnObject = (age, x) => ({...x, age})

const person = pipe(
    partiallyApply(setNameOnObject, 'bob')
    partiallyApply(setAgeOnObject, 23)
)
// person() = {name: 'bob', age: 23}

В будущем оператор pipe ("|>"), вероятно, станет частью JS. В настоящее время он находится на 1 стадии рассмотрения.

const partiallyApply = (f, a) => b => f(a, b)

const setNameOnObject = (name, x) => ({...x, name})
const setAgeOnObject = (age, x) => ({...x, age})

const person = pipe(
    partiallyApply(setNameOnObject, 'bob') |> partiallyApply(setAgeOnObject, 23)
)

Новый тип данных


Предположим, что нам нужен более безопасный способ добавления новых данных. Некоторые значения в JS мы можем «мапировать» (map on). Например, у массивов есть метод map. Данный метод позволяет нам вызывать функцию для каждого значения и возвращает новый массив, оставляя оригинальный массив нетронутым. Что если бы другие типы также имели метод map?

const Value = value => ({
    value,
    map: apply => Value(apply(value)),
    flatMap: apply => apply(value)
})

const person = Value({name: undefined, age: undefined})

const newPerson = person.map(o => setNameOnObject('bob', o))
const newPersonTwo = newPerson.map(o => setAgeOnObject(24, o))

console.log(person.value) // person.value = Object {name: undefined, age: undefined}
console.log(newPersonTwo.value) // newPersonTwo.value = Object {name: 'bob', age: 24}

Теперь дополним новый тип Value нашими шаблонами и абстракциями:

const person = Value({name: undefined, age:undefined}).map(
    pipe(
        partiallyApply(setNameOnObject, 'bob'),
        partiallyApply(setAgeOnObject, 24)
    )
)

Мы можем зайти еще дальше в использовании новых типов данных. Предположим, у Вас другая ментальная модель:

const flatten = fns => [].concat.apply([], fns)
const pipe = (...fns) => x => flatten(fns).reduce((y, f) => f(y), x)
const Value = value => ({
    value,
    map: apply => Value(apply(value)),
    flatMap: apply => apply(value)
})

const person = Value({name: undefined, age: undefined})
const setName = name => p => p.map(o => ({...o, name}))
const setAge = age => p => p.map(o => ({...o, age}))
const setPropertiesOn = obj => (...fns) => {
    return pipe(fns)(obj)
}

const a = pipe(setName('pam'), setAge(56))(person)
const b = setPropertiesOn(person)(setName('pam'), setAge(56))
// a.value = Object {name: 'pam', age: 56}
// b.value = Object {name: 'pam', age: 56}

Допустим, Вы просто хотите вызвать функцию для нового значения. Вы можете сделать это с помощью flatMap:

const flatten = fns => [].concat.apply([], fns)
const pipe = (...fns) => x => flatten(fns).reduce((y, f) => f(y), x)

const Value = value => ({
    value,
    map: apply => Value(apply(value)),
    flatMap: apply => apply(value)
})

const toJson = obj => JSON.stringify(obj)

const post = async body =>
    fetch('https://httpbin.org/post', {
        method: 'POST',
        headers: {
            Accept: 'application/json',
            'Content-Type': 'application/json'
        },
        body
    })

const getJsonFromResponse = async response => {
    const r = await response
    const json = await r.json()
    return json
}

async function createUser(name, age) {
    return await Value({name, age}).flatMap(
        pipe(toJson, post, getJsonFromResponse)
    )
}

async function handleUserSubmit(){
    const user = await createUser('pam', 56)
    console.log(JSON.parse(user.data)) // Object {name: 'pam', age: 56}
}

Будущее


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

Заключение


Всегда возвращая значение, но никогда не меняя его, мы создаем более безопасные и предсказуемые программы. Мы всегда копируем значение и возвращаем его. Как следствие, нам не придется волноваться о том, почему что-либо изменилось без нашего ведома.

В данной статье мы не рассматривали многих замечательных вещей из мира функционального программирования. Речь идет о таких вещах, как иммутабельность (неизменяемость или неизменность, Immutability), мемоизация (запоминание, Memorization) и параллелизм (Concurrency). Надеюсь, Вы изучите их самостоятельно.

На этом у меня все. Надеюсь, статья была Вам полезной. Благодарю за внимание.