JS: функции

2023-03-15
29 мин.

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

fp-meme

Чистые функции (pure functions)

❗️ Эта секция самая важная ❗️
Все остальные примеры могут работать плохо, если использовать "грязные" функции.

Самый важный стиль написания функций - это чистые функции. Функция называется чистой, если она:

  1. Детерминирована, то есть всегда для одних и тех же параметров возвращает одинаковый результат
  2. Не производит побочных эффектов

Задача: Найти медиану в массиве.

Решаем в лоб:

// код со stackoverflow. 91 👍 от пользователей
function getMedian(values) {
  values.sort(function (a, b) {
    return a - b
  })
  const half = Math.floor(values.length / 2)
  return values.length % 2
    ? values[half]
    : (values[half - 1] + values[half]) / 2.0
}

const values = [10, 4, 3, 1, 7]
const median = getMedian(values)
console.log(median) // 👈 4

Задача выполнена! Код очень простой и понятный.

Есть только одна проблема. Функция getMedian не является чистой. Она производит side effect: изменяет переданный ей список. После выполнения данной функции, массив станет отсортирован. Это плохо? Ну, наверное, нет...

Но тут задача дополнилась:

Теперь нам сначала нужно найти медиану, а потом вывести первый элемент исходного массива.

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

function getMedian(values) {
  values.sort(function (a, b) {
    return a - b
  })
  const half = Math.floor(values.length / 2)
  return values.length % 2
    ? values[half]
    : (values[half - 1] + values[half]) / 2.0
}

const values = [10, 4, 3, 1, 7]
const first = values[0] // 💩

console.log(getMedian(values)) // сначала медиана
console.log(first) // потом первый

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

function getMedian(values) {
  const copy = [...values] // или structuredClone(values)
  copy.sort(function (a, b) {
    return a - b
  })
  const half = Math.floor(copy.length / 2)
  return copy.length % 2
    ? copy[half]
    : (copy[half - 1] + copy[half]) / 2.0
}

И теперь можно не боясь решать задачу последовательно, так как список меняться не будет.

console.log(getMedian(values))
console.log(values[0])

Плюсы чистых функций:

Именно поэтому стоит стремиться максимизировать число чистых функций, которые мы пишем.

Материалы:

Функции как аргументы функций (функции высшего порядка, higher-order functions)

pass-fn-to-fn

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

function multiplyBy2(x) {
  return x * 2
}

const array = [1, 2]
const mapped = array.map(multiplyBy2) // 👈 передали функцию
console.log(mapped) // 👈 [2, 4]

Такой же принцип можно использовать при написании своих функций.

Задача: Написать калькулятор для 2х действий: сложение и умножение.

Решаем в лоб

function onAddBtnClick() {
  const a = document.querySelector('#value1').valueAsNumber
  const b = document.querySelector('#value2').valueAsNumber
  document.getElementById('result').value = a + b
}

function onMultiplyBtnClick() {
  const a = document.querySelector('#value1').valueAsNumber
  const b = document.querySelector('#value2').valueAsNumber
  document.getElementById('result').value = a * b
}

Очень хочется вынести как-то все в одну функцию, потому что разница (выделена в предыдущем коде) очень мала. Давайте сделаем это:

function add(a, b) {
  return a + b
}

function multiply(a, b) {
  return a * b
}

// Саму операцию будем ожидать как аргумент
function act(perform) {
  const a = document.querySelector('#value1').valueAsNumber
  const b = document.querySelector('#value2').valueAsNumber
  // принимаем любую функцию, принимающую 2 аргумента
  // и возвращающую что-нибудь
  document.getElementById('result').value = perform(a, b)
}

function onAddBtnClick() {
  act(add)
}

function onMultiplyBtnClick() {
  act(multiply)
}

Но кода-то в 2 раза больше! Согласен, как-то не выиграли. Но здесь мы можем использовать стрелочные функции. Да и объявить их можно прямо "на месте", раз они нигде больше не нужны:

function act(perform) {
  const a = document.querySelector('#value1').valueAsNumber
  const b = document.querySelector('#value2').valueAsNumber
  document.getElementById('result').value = perform(a, b)
}

function onAddBtnClick() {
  act((a, b) => a + b)
}

function onMultiplyBtnClick() {
  act((a, b) => a * b)
}

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

Материалы:

Замыкания (closures)

Замыкания обязательно встретятся на собеседованиях. Это один из самых важных принципов работы функций.

❗️ Все пункты далее будут базироваться на понимании замыканий.

Представим, что нам нужно подсчитать, сколько раз пользователь нажал на кнопку. Ну, решение в лоб не заставит себя долго ждать:

const btn = document.getElementById('btn-1')

let counter = 0
btn.onclick = function () {
  counter++
  console.log('Нажато', counter, 'раз')
}

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

const btn1 = document.getElementById('btn-1')
let counter1 = 0
btn1.onclick = function () {
  counter1++
  console.log(
    `Кнопка ${button1.innerText} нажата ${counter1} раз`
  )
}

const btn2 = document.getElementById('btn-2')
let counter2 = 0
btn2.onclick = function () {
  counter2++
  console.log(
    `Кнопка ${button2.innerText} нажата ${counter2} раз`
  )
}

И тут вещи становятся менее приятными, так как код пришлось продублировать дважды. Но нам нужны эти 2 переменные, иначе, где же мы сможем хранить значения?

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

function getCounter(name) {
  let counter = 0
  return function () {
    counter++
    console.log(`Кнопка ${name} нажата ${counter} раз`)
  }
}

// Так как getCounter возвращает функцию,
// мы можем сразу передать ee в onclick!
const btn1 = document.getElementById('btn-1')
btn1.onclick = getCounter(btn1.innerText)

// Каждый новый вызов getCounter
// создаст новую переменную counter
const btn2 = document.getElementById('btn-2')
btn1.onclick = getCounter(btn2.innerText)

Ну вот, красота!

Материалы:

Композиция функций (composition)

Как вы уже знаете из математики о композиции \(f \circ g\) - это когда результат функции g передается в качестве аргумента в f. То есть на JS это можно написать так:

const f = (num) => num * 2
const g = (num) => num + 1

const value = 1

// По действиям (императивно)
const sum = g(value) // sum = 2
const result = f(sum) // result = 4

// Композиция
const result = f(g(value))

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

const result = bar(bar(foo(g(f(a)))))
Получается какой-то Lisp со скобочками и читать это становится в разы сложнее.

Вот пример кода на Lisp:

(defun one-of (set)
  "Pick one element of set, and make a list of it."
  (list (random-elt set)))

(defun random-elt (choices)
  "Choose an element from a list at random."
  (elt choices (random (length choices))))

Для того, чтобы реализовать что-то наподобие \(f \circ g\), напишем функцию pipe. Она будет принимать список функций и возвращать функцию (то есть она будет функцией высшего порядка).

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

function pipe(...fns) {
  return function (data) {
    let result = data
    for (const f of fns) {
      result = f(result)
    }
    return result
  }
}

const result = pipe(
  (x) => x + 1, // сначала добавить 1
  (x) => x * 100 // потом умножить на 100
)(1) // 👈 сразу вызовем полученную из pipe() функцию
console.log(result) // 👈 200

// Можно и так
// const add1Times100 = pipe(
//   (x) => x + 1,
//   (x) => x * 100
// )
// const result = add1Times100(1);
// console.log(result) // 👈 200

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

Материалы:

Самовызывающиеся функции (IIFE)

❗ Основано на замыканиях

❗️ Такой подход немного устарел, но не потерял своего шарма. В реальных проектах зачастую лучше использовать обычные модули - файлы

Еще один плюс замыкания - все переменные внутри замыкания становятся приватными. А значит можно создать модуль на замыкании, спрятав данные в нем.

Например, нам нужно создать 2 функции с одинаковым именем. Что вернет вызов функции?

let number = 0
function getNumber() {
  number++
  return number
}

function getNumber() {
  return 10 + Math.random()
}

getNumber() // 👈 1 или random() ?

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

const counterModule = (function () {
  let number = 0
  function getNumber() {
    number++
    return number
  }
  return {
    getNumber: getNumber,
  }
})() // 👈 сразу же вызвана

const randomModule = (function () {
  const number = 10
  function getNumber() {
    return number + Math.random()
  }
  return {
    getNumber: getNumber,
  }
})() // 👈 сразу же вызвана

console.log(counterModule.getNumber()) // 👈 1
console.log(randomModule.getNumber()) // 👈 10 с чем-то
// console.log(getNumber()) // Ошибка. Такой функции нет

Таким образом, мы разбили наше приложение на 2 независимых модуля: counter и random. Каждый модуль внутри может содержать переменные и функции, названные одинаково.

Материалы:

Каррирование и частичное применение (carrying)

❗ Основано на замыканиях

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

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

Здесь намеренно будут использованы только стрелочные функции. Такая нотация позволит писать код более кратко. Однако и обычные функции в данном случае будут работать так же.

const add = (a, b) => a + b
const sum = add(1, 2)
console.log(sum) // 👈 3

// А можно и так:
const add = (a) => (b) => a + b
// эквивалентно
// function add(a) {
//   return function (b) {
//     return a + b
//   }
// }

// но вызвать теперь нужно по-другому:
const sum = add(1)(2) // 👈 магия
console.log(sum) // 👈 3

Почему так: add(1) возвращает функцию, которую можно сразу же вызвать: add(1)(2)

Задача:

Применить скидку к каждой цене в зависимости от пользователя. Для приятных пользователей 10%, для обычных - 0% (тоже скидка)

В лоб (тоже неплохо):

const NICE_DISCOUNT = 0.1

const user = {
  name: 'Andrew',
  isNice: Math.random() > 0.5, // иногда nice, иногда - нет
}

const prices = [10, 20, 30]
const discounted = prices.map((price) => {
  if (user.isNice) {
    return price * (1 - NICE_DISCOUNT)
  } else {
    return price
  }
})

console.log(discounted)

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

// вызывая эту функцию, получаем функцию,
// которая примет цену и вернет цену со скидкой.
const getDiscountedPrice = (discount) => (price) => {
  return price * (1 - discount)
}

Ну и теперь вся задача решается так:

const getDiscountedPrice = (discount) => (price) => {
  return price * (1 - discount)
}

const NICE_DISCOUNT = 0.1

const user = {
  name: 'Andrew',
  isNice: Math.random() > 0.5,
}
// 👇 getPrice - функция!
const getPrice = getDiscountedPrice(
  user.isNice ? NICE_DISCOUNT : 0
)

const prices = [10, 20, 30]
const discounted = prices.map(getPrice)

console.log(discounted)

Материалы:

Все сразу

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

Задача:

Дано имя студента и список оценок студента. Но в списке есть "плохие" значения \(x \notin [1, 10]\). Необходимо избавиться от "плохих" значений и найти среднее арифметическое всех оценок. Список после фильтрации и среднее значение нужно вывести на экран. Решить задачу для 2х разных студентов

Решаем в лоб:

function getMarksMean(student, marks) {
  // 1. Отфильтруем плохие значения
  const filtered = []
  for (const mark of marks) {
    if (mark >= 1 && mark <= 10) {
      filtered.push(mark)
    }
  }
  // 2. Логгируем отфильтрованный список оценок
  console.log(student, ':', filtered)

  // 3. Найдем среднее
  let total = 0
  for (const mark of filtered) {
    total += mark
  }
  const mean = total / filtered.length
  // 4. Логгируем среднее
  console.log(student, ':', mean)
  return mean
}

getMarksMean('Alice', [-5, 1, 8, 7, 14, 10])
getMarksMean('Bob', [-7, 6, 0, 9, 100, 4])

Этот код выдает следующий результат:

Alice : [ 1, 8, 7, 10 ]
Alice : 6.5
Bob : [ 6, 9, 4 ]
Bob : 6.333333333333333

А теперь давайте воспользуемся всем, что мы уже знаем:

function getMarksMean(student, marks) {
  // замыкание. Функция log не является чистой!
  const log = (data) => {
    console.log(student, ':', data)
    return data // 👈 без этого работать не будет! Почему?
  }
  return pipe(
    filter((x) => x >= 1 && x <= 10), // 1
    log, // 2
    getMean, // 3
    log // 4
  )(marks)
}

getMarksMean('Alice', [-5, 1, 8, 7, 14, 10])
getMarksMean('Bob', [-7, 6, 0, 9, 100, 4])

// Конец решения. Все далее — вспомогательные функции

// Каррирование, функция высшего порядка
function filter(isValid) {
  return function (numbers) {
    const filtered = []
    for (const n of numbers) {
      if (isValid(n)) {
        filtered.push(n)
      }
    }
    return filtered
  }
}

function getMean(numbers) {
  let total = 0
  for (const n of numbers) {
    total += n
  }
  return total / numbers.length
}

// Композиция, функция высшего порядка
function pipe(...fns) {
  return function (data) {
    let result = data
    for (const f of fns) {
      result = f(result)
    }
    return result
  }
}

Кода точно стало больше. Но хуже ли это?

Мы написали утилиты, которые можно переиспользовать. Код внутри getMarksMean стал декларативным. То есть мы не говорим, что именно нужно сделать, чтобы, например, отфильтровать массив. От нас необходимо только описать, как это сделать, а весь императивный код сделает все за нас.

Давайте прочитаем эту функцию:

function getMarksMean(student, marks) {
  // сделай функцию log
  const log = (data) => {
    console.log(student, ':', data)
    return data
  }
  // верни результат выполнения pipe(...)(marks)
  return pipe(
    // отфильтруй marks по переданному условию
    filter((x) => x >= 1 && x <= 10),
    // покажи, что получилось после предыдущего шага
    log,
    // найди среднее
    getMean,
    // покажи, что получилось после предыдущего шага
    log
  )(marks)
}

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

Но, будем честны, писать тонны утилит - не весело. Стоит использовать стороннюю библиотеку для этого. Самые популярные - Lodash (и ее модуль fp) и Ramda. Утилиты оттачивались там годами, а значит можно использовать их не опасаясь, что они что-то нам сломают.

Вот как это могло бы выглядеть с lodash/fp:

import fp from 'lodash/fp' // 👈 заберем все утилиты

function getMarksMean(student, marks) {
  const log = (data) => {
    console.log(student, ':', data)
    return data
  }
  return fp.pipe(
    fp.filter((x) => x >= 1 && x <= 10),
    log,
    fp.mean,
    log
  )(marks)
}

getMarksMean('Alice', [-5, 1, 8, 7, 14, 10])
getMarksMean('Bob', [-7, 6, 0, 9, 100, 4])

Все познается в сравнении. Можно сравнить 2 разных решения: "в лоб" и функциональный подход:

function getMarksMean(student, marks) {
  const filtered = []
  for (const mark of marks) {
    if (mark >= 1 && mark <= 10) {
      filtered.push(mark)
    }
  }
  console.log(student, ':', filtered)

  let total = 0
  for (const mark of filtered) {
    total += mark
  }
  const mean = total / filtered.length
  console.log(student, ':', mean)

  return mean
}

vs.

import fp from 'lodash/fp'

function getMarksMean(student, marks) {
  const log = (data) => {
    console.log(student, ':', data)
    return data
  }
  return fp.pipe(
    fp.filter((x) => x >= 1 && x <= 10),
    log,
    fp.mean,
    log
  )(marks)
}

Лично мне приятно читать функциональный код. Однако императивный подход мне кажется гораздо проще.

Мнения, "что же лучше", сильно делятся. Для себя я вынес одну простую мысль:

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

ММФ-ноутс, ммф нотс, ммф-ноутс, mmf, notes, mmf-notes