Подход, который мы разобрали, нам позволяет строить Fluent Interface и делать различные DSL. Но в этом подходе есть недостатки, некоторые из которых очень важны.
В этом уроке мы рассмотрим одну важную особенность — «неизменяемость». Она нужна, чтобы исправлять недостатки реализации Fluent Interface.
Неизменяемый объект
Допустим, у нас есть коллекция машин, которые мы оборачиваем в Enumerable, и делаем из нее выборку:
const cars = [
{ brand: 'bmw', model: 'm5', year: 2014 },
{ brand: 'bmw', model: 'm4', year: 2013 },
{ brand: 'kia', model: 'sorento', year: 2014 },
{ brand: 'kia', model: 'rio', year: 2010 },
{ brand: 'kia', model: 'sportage', year: 2012 },
]
const coll = new Enumerable(cars)
const coll2 = coll.where(car => car.year > 2012)
coll2.where(car => car.brand === 'kia') // 1
coll2.where(car => car.brand === 'bmw') // 0
В этом примере нам нужны только те машины, которые производились после 2012 года. При этом мы хотим не просто выбрать машины одного бренда, а показать пользователям разницу между тем, какие есть машины разных брендов. Например, мы хотим показать отдельно Kia и BMW. Причем нам нужны обе модели.
Если сначала сделать выборку машин Kia, и после этого сделать из этой же коллекции выборку BMW машин, то окажется, что в коллекции нет машин BMW.
Зная, как работает код, нам это не покажется странным, а пользователям этой библиотеки — покажется. Кроме того, это крайне неудобно.
Получается, что когда мы вызываем where
, то идет внутренняя замена и изменение объекта. Это означает, что coll
, с которым мы работаем, уже изменен. Также внутри уже выбраны только те машины, которые относятся к бренду Kia. В итоге в последней строчке у нас ничего не может быть выбрано кроме машин Kia.
Это неудобно и плохо. В подобной задаче нужно понять, как сделать общую выборку, задать общие параметры и потом выбирать разные подмножества изначального множества. При этом это нужно делать одновременно в одном и том же участке кода.
Чтобы добиться этого, нужно сделать наш объект неизменяемым. Дело в том, что состояние приводит к проблемам, и чаще всего всё крутится вокруг него. И именно изменяемое состояние приводит к таким проблемам, с которыми нужно бороться.
Неизменяемость
Существует два подхода в работе с изменениями:
- Императивный подход — возвращается измененная структура данных
- Функциональный подход — возвращается новая структура данных, которая является преобразованием старой
Вернемся к тому же коду:
const cars = [
{ brand: 'bmw', model: 'm5', year: 2014 },
{ brand: 'bmw', model: 'm4', year: 2013 },
{ brand: 'kia', model: 'sorento', year: 2014 },
{ brand: 'kia', model: 'rio', year: 2010 },
{ brand: 'kia', model: 'sportage', year: 2012 },
]
const coll = new Enumerable(cars)
const coll2 = coll.where(car => car.year > 2012)
coll2.where(car => car.brand === 'kia') // 1
coll2.where(car => car.brand === 'bmw') // 2
В этом случае мы создаем coll
, из которого делаем выборку и сохраняем ее в coll2
. Далее делаем where
, где car.brand === 'kia'
, и where
, где car.brand === bmw
. В итоге BMW был выбран без учета предыдущей выборки. То есть строчка coll.where((car) => car.brand === 'kia'); // 1
не повлияла на следующую строчку.
На самом деле здесь не происходят изменения. Каждая выборка, каждый вызов новой функции и нового метода, который идет после точки, возвращает нечто новое. В итоге это не изменяет предыдущее состояние.
Теперь мы можем делать одно большое подможество, выделять из него мелкие подможества и работать с ними одновременно. При этом не нужно бояться, что можно что-то сломать.
Тесты
Тестировать эту вещь достаточно просто:
const cars = [
{ brand: 'bmw', model: 'm5', year: 2014 },
{ brand: 'bmw', model: 'm4', year: 2013 },
{ brand: 'kia', model: 'sorento', year: 2014 },
{ brand: 'kia', model: 'rio', year: 2010 },
{ brand: 'kia', model: 'sportage', year: 2012 },
]
// Делаем выборку
const result = coll
.where(car => car.brand === 'kia')
.where(car => car.year > 2011)
// Используем ту же коллекцию
// Делаем сортировку по возрастанию
coll.orderBy(car => car.year, 'asc')
assert.deepEqual(result.toArray(), [cars[2], cars[4]])
В этом примере при проверке участвует не coll
, а result
.
Если бы у нас была изменяемость, то orderBy
изменил бы сам coll
. В итоге наши машины шли бы не в том порядке. А чтобы добиться неизменяемости, нужно применить подход, который описали выше. То есть мы делаем изменение coll
, а на result
это не должно сказаться, потому что это разные объекты.
Когда мы делаем result.toArray
, мы получаем машины в том порядке, в котором они появились у нас в выборке. В итоге у нас будут элементы под индексами 2 и 4 — 2014 и 2012 года.
Теперь разберемся, как реализовать код, чтобы этот тест прошел.
Реализация
В реализации практически ничего не меняется, только в функциях обработчиков мы не меняем this.collection
:
class Enumerable {
constructor(collection) {
this.collection = collection
}
where(fn) {
const filtered = this.collection.filter(fn)
return new Enumerable(filtered)
}
}
Здесь мы применяем функции фильтрации или другую функцию высшего порядка. Фактически они всегда возвращают новую коллекцию и никогда не изменяют старую. В итоге мы возвращаем новый Enumerable
.
Каждый раз, когда мы вызываем любой метод, определенный в Enumerable
, он делает обработку коллекции и возвращает новый Enumerable
, в котором уже лежит какое-то подмножество.
Получается, что если немного изменить код, мы получим большое количество преимуществ.
In-Place замена
Не все функции работают так, как мы описали выше. Например, это касается функции sort
почти во всех языках программирования:
const arr = [5, 1, 3]
arr.sort()
console.log(arr) // [1, 3, 5]
sort
сделан так, что он меняет исходную сущность. В нашем примере мы вызвали sort
, и если мы распечатаем массив, то получаем 1, 3, 5
.
То есть sort
не возвращает новый массив. Сам исходный массив меняется — то есть это тот же массив, но порядок уже другой. В случае нашей коллекции это плохо, потому что мы не сможем делать неизменяемые коллекции и возвращать новые.
Это можно обойти. Для этого существует специальный метод, который есть у массивов. Он называется slice
и позволяет создать копию массива. После этого мы можем сделать с ним всё что угодно:
const arr2 = [5, 1, 3]
const sortedArr2 = arr2.slice().sort()
console.log(arr2) // => [5, 1, 3]
console.log(sortedArr2) // => [1, 3, 5]
Здесь видно, как мы с помощью Fluent Interface на массивах получаем slice
— срез. Но поскольку мы не указываем параметров, мы получаем копию всего массива. Далее мы сортируем эту копию, после чего sort
возвращает эту же копию, с которой мы можем продолжать работать.
Если мы распечатаем исходный массив, то увидим, что его внутреннее состояние не изменилось, это по-прежнему те же 5, 1, 3
.
Выводы
В этом уроке мы рассмотрели одну важную особенность — «неизменяемость». Теперь мы знаем, что она поможет исправлять недостатки реализации Fluent Interface. Также мы научились тестировать код на неизменяемость и реализовывать ее.
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.