Зарегистрируйтесь для доступа к 15+ бесплатным курсам по программированию с тренажером

Неизменяемость JS: Коллекции

Подход, который мы разобрали, нам позволяет строить Fluent Interface и делать различные DSL. Но в этом подходе есть недостатки, некоторые из которых очень важны.

В этом уроке мы рассмотрим одну важную особенность — «неизменяемость». Она нужна, чтобы исправлять недостатки реализации Fluent Interface.

Неизменяемый объект

Допустим, у нас есть коллекция машин, которые мы оборачиваем в Numerable, и делаем из нее выборку:

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);

coll.where((car) => car.brand === 'kia'); // 1
coll.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. Также мы научились тестировать код на неизменяемость и реализовывать ее.


Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты.

Для полного доступа к курсу нужен базовый план

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

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»