Функциональная и объектно-ориентированная парадигмы часто считаются несовместимыми. «Конечно, вы можете использовать функции более высокого порядка в JavaScript, но если вы используете классы, вы не пишете функциональный код». Позволю себе не согласиться.

Функциональное программирование — это неизменность. Это означает, что структуры данных никогда не могут измениться. Одной из характеристик объектов в ООП-смысле этого слова является то, что каждый объект имеет свое собственное внутреннее мутативное состояние, поэтому вместо использования объектов вы должны просто использовать функции, верно? Ну не так быстро. Во-первых, давайте просто попробуем реализовать стек без изменения каких-либо данных.

class Stack {
  constructor () {
    this._array = []
  }
}

Подожди секунду! Это задание? Мы мутируем объект, поэтому мы не остаемся неизменными, верно?

Единственное место, где присваивание допустимо, — внутри конструктора, потому что вы инициализируете объект. Сказать, что присваивания конструктора недопустимы, это все равно, что сказать, что let i = 123 недопустимо.

Теперь у нас есть объект с внутренним состоянием, но мы еще никак не манипулировали данными. Добавим метод.

class Stack {
  constructor () {
    this._array = []
  }
  get length () {
    return this._array.length
  }
}

Нет проблем, верно? У нас есть простая геттерная функция, которая не изменяет никаких данных, она просто возвращает значение. Однако как реализовать мутативный метод push? Сначала рассмотрим мутативный пример.

class Stack {
  constructor () {
    this._array = []
  }
  get length () {
    return this._array.length
  }
  push (value) {
    this._array.push(value)
  }
}

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

Первый шаг в решении проблемы прост: перемещение внутреннего массива в параметр конструктора:

class Stack {
  constructor (array) {
    this._array = array
  }
  get length () {
    return this._array.length
  }
  push (value) {
    ???
  }
}

Теперь метод push может использовать свой внутренний массив, создать новый массив с добавленным значением, создать новый стек с массивом и вернуть результат:

class Stack {
  constructor (array) {
    this._array = array
  }
  get length () {
    return this._array.length
  }
  push (value) {
    return new Stack(
      [...this._array, value]
    )
  }
}

Посмотри на это! Мутации больше нет. Потребитель класса будет использовать его следующим образом:

const stack = new Stack([])
const stackWithItem = stack.push(1)

Далее реализуем pop:

class Stack {
  constructor (array) {
    this._array = array
  }
  get length () {
    return this._array.length
  }
  push (value) {
    return new Stack(
      [...this._array, value]
    )
  }
  pop () {
    return new Stack(
      this._array.slice(0, this._array.length - 1)
    )
  }
}

Хорошо, теперь у нас есть метод, который возвращает новый стек с одним элементом меньше. Но pop должен возвращать удаляемое значение!

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

class Stack {
  constructor (array) {
    this._array = array
  }
  get length () {
    return this._array.length
  }
  push (value) {
    return new Stack(
      [...this._array, value]
    )
  }
  pop () {
    const item = this._array[this._array.length - 1]
    const stack = new Stack(
      this._array.slice(0, this._array.length - 1)
    )
    return [stack, item]
  }
}

Затем потребитель может использовать метод следующим образом:

const stack = new Stack([])
const stackWithOne = stack.push(1)
const [stackWithoutOne, one] = stackWithOne.pop()

Другое решение состоит в том, чтобы просто заставить потребителя заранее получить верхний элемент в стеке:

class Stack {
  constructor (array) {
    this._array = array
  }
  get length () {
    return this._array.length
  }
  push (value) {
    return new Stack(
      [...this._array, value]
    )
  }
  get top () {
    return this._array[this._array.length - 1]
  }
  pop () {
    return new Stack(
      this._array.slice(0, this._array.length - 1)
    )
  }
}

А дальше дело за потребителем:

const stack = new Stack([])
const stackWithOne = stack.push(1)
const stackWithoutOne = stackWithOne.pop()
const one = stackWithOne.top

Обратите внимание, что в приведенном выше примере не имеет значения, что мы обращаемся к верхнему объекту после извлечения стека, потому что исходный стек никогда не изменяется, поэтому значение все еще там!

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

class Stack {
  constructor (array) {
    this._array = array
  }
  static get empty () {
    return new Stack([])
  }
  get length () {
    return this._array.length
  }
  push (value) {
    return new Stack(
      [...this._array, value]
    )
  }
  get top () {
    return this._array[this._array.length - 1]
  }
  pop () {
    return new Stack(
      this._array.slice(0, this._array.length - 1)
    )
  }
}

В конечном счете, я думаю, мы получили довольно хороший API:

return Stack.empty
  .push(1)
  .push(2)
  .pop()
  .push(3)
  .push(4)
  .pop()
// Stack { _array: [1, 3] }

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

Последствия таких объектов распространяются. Например, как бы вы зациклились на массиве и поместили бы каждый элемент в стек?

const stack = Stack.empty
for (const item of [1, 2, 3]) {
  stack.push(item)
}

Это не сработает, потому что исходный стек никогда не меняется. Итак, нам нужно следить за объектом:

const stack = Stack.empty
for (const item [1, 2, 3]) {
  stack = stack.push(item) // Error!
}

Мы не можем этого сделать, потому что нам придется изменить переменную на изменяемый let, что противоречит нашим неизменяемым правилам.

Один из способов решения - рекурсия:

const source = [1, 2, 3]
const target = Stack.empty
const result = (function f (stack, index) {
  if (index >= target.length) {
    return stack
  }
  const item = source[index]
  const newStack = stack.push(item)
  return f(newStack, index + 1)
})(target, 0)

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

Поэтому, на мой взгляд, лучший способ решить эту проблему — использовать метод reduceRight:

const source = [1, 2, 3]
const target = Stack.empty
const result = source.reduceRight(
  (stack, item) => stack.push(item),
  target
)

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

Второй аргумент (target) будет передан функции вместе с первым элементом массива. Возвращаемое значение (т. е. стек с [1]) будет передано обратно в функцию вместе со вторым элементом, что даст стек из [1, 2] и т. д. Нет мутации.

Сочетание функциональных и объектно-ориентированных парадигм очень заметно в моей библиотеке пользовательского интерфейса TweedJS. Проверьте это, почему бы и нет!