Неизменяемость данных


Оглавление

  • Введение
  • Характеристики неизменяемости данных
  • Резюме

Введение

Неизменяемость данных — это концепция, применяемая к значениям, которые создаются один раз и не могут быть изменены впоследствии. Значения находятся в режиме «только для чтения», застыв во времени.

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

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

Примером изменяемого значения может быть экземпляр класса, методы которого изменяют значения его свойств:

class User {
  constructor(private name: string) {}
  setName(newName: string): void { this.name = newName }
  getName(): string { return this.name }
}

const user = new User('Bob')
console.log(user.getName()) // "Bob"
user.setName('Henri')
console.log(user.getName()) // "Henri"
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

interface User { name: string }

function setName(user: User, newName: string): User {
  user.name = newName
  return user
}

const user: User = { name: 'Bob' }
console.log(user.name) // "Bob"
const newUser = setName('Henri')
console.log(user.name, user === newUser) // "Henri", true
Вход в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что в обоих случаях мы использовали const для объявления переменной user, хотя мы все еще могли изменять их свойство name. В JavaScript ключевое слово const гарантирует, что мы не можем присвоить переменной новое значение, но значение внутри переменной может быть изменено, если оно не является примитивным типом, таким как string, number или boolean.

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

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

В частности, по TypeScript/JavaScript, не стесняйтесь искать:

  • Неизменяемость в TS во время компиляции, используя синтаксис типов as const, readonly и Readonly<A>.
  • Неизменяемость в JS во время выполнения, используя Object.freeze как для массивов, так и для объектов.
  • Неизменяемость в JS во время выполнения с помощью сторонней библиотеки, такой как Immutable.js.
  • Неизменяемость в JS во время выполнения, используя неизменяемые записи и кортежи (подробнее об этом в следующем параграфе).

В последнем докладе State of JavaScript 2021, одной из наиболее востребованных функций в JS, которые люди хотели бы использовать, являются неизменяемые структуры данных, такие как Record и Tuple.

Предложение JavaScript Records & Tuples Proposal, которое в настоящее время находится на стадии 2 из 4, должно позволить разработчикам использовать глубоко неизменяемые объектоподобные и массивоподобные структуры, используя соответственно #{ x: 1, y: 2} и #[1, 2, 3].

Это показывает, что люди (или, по крайней мере, разработчики JS/TS) действительно заинтересованы в неизменяемости данных.


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

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

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

Давайте посмотрим, каковы преимущества и недостатки использования неизменяемых данных в наших программах. Список, приведенный в следующей главе, не является исчерпывающим; не стесняйтесь поделиться своим мнением.

Характеристики неизменяемости данных

Код более предсказуем

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

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

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

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

Давайте рассмотрим пример. Вот представление программы, где квадраты — модули, эллипсы — изменяемые значения, а стрелки — взаимодействия между модулями и этими значениями (от значения к модулю = чтение, от модуля к значению = запись):

Можете ли вы предположить, в каком порядке происходят эти стрелки?

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

  • A → B → C → D → F → G → I → E → H
  • B → A → F → D → C → I → G → H → E
  • A → B → C → F → D → I → E → H → G

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

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

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

  • На этой новой иллюстрации: A → B → (C → D → E).
  • На исходной иллюстрации сверху: A → B → C → F → D → I → H → E → G

Кроме того, тип этих значений может быть определен более точно. Например, в самом левом модуле мы знаем, что значение зеленого цвета имеет вид {a, b, e}. Другими словами, мы знаем, что e определено, и нам не придется делать утверждения позже в программе. С этого момента и далее тип будет {a, b, e}, а не {a, b, e?}, как было в исходной программе.

Безопасность потоков

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

Потоки могут использовать локальное изменяемое состояние, пока к нему не обращается какой-либо другой поток. Координатор отвечает за сбор результатов от потоков, а затем создает новое, неизменяемое состояние на основе этих результатов.

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

Примерами таких механизмов могут быть:

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

Отладка путешествий во времени

Как говорит Microsoft:

Time Travel Debugging (TTD) может помочь вам легче отлаживать проблемы, позволяя вам «перематывать» сеанс отладчика, вместо того чтобы воспроизводить проблему, пока вы не найдете ошибку.

Многие действия в программе имеют последствия для состояния программы. Возьмем базовый пример: приложение «список дел». Эта программа представляет список задач, которые нужно сделать. Мы можем добавлять, изменять или удалять задачи из этого списка, а также отмечать некоторые из них как «выполненные».

Если нам удастся:

  • Сохранить начальное состояние, например, пустой список «дел».
  • после каждого действия сохранять снимок (или копию) выполненного действия, состояния на тот момент и результирующего состояния после выполнения действия.

Затем мы можем легко реализовать отладку с перемещением во времени.

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

Приятным побочным эффектом (не путать с побочными эффектами) является то, что мы можем очень легко реализовать действия отмены/повтора. Все, что нам нужно сделать, это переместиться назад или вперед во времени, то есть восстановить предыдущее состояние.

Если вы знакомы с фронтенд-разработкой с использованием TypeScript или JavaScript, то вы, возможно, слышали о Redux. Это библиотека для управления состоянием, часто используемая вместе с React, особенностью которой является использование редукторов для обновления состояния программы. Редуктор — это чистая функция, которая принимает действие и состояние в качестве аргументов и возвращает новое состояние. Мы можем легко подключить промежуточное ПО для отслеживания каждого вызова редуктора, что позволит нам создать инструмент для отладки путешествий во времени, такой как Redux DevTools.

Больше распределения памяти

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

Мы должны продублировать все значение перед применением изменений, таково правило. Как следствие, наша программа должна работать на устройстве, которое имеет больше памяти, чем ей на самом деле нужно для корректной работы. (Отказ от ответственности: я полагаю, что современные движки достаточно умны, чтобы сделать оптимизацию в этой области, но у меня нет достаточных знаний, чтобы сделать такое утверждение. Не стесняйтесь поделиться, если вы знаете об этом больше!)

В большинстве случаев программы, которые мы пишем, используются на устройствах с большим объемом памяти. Кроме того, движки, на которых выполняется код, имеют такие механизмы, как Garbage Collection, он же GC, для освобождения неиспользуемой памяти. Если нам не нужно отслеживать предыдущие значения (например, для отладки путешествий во времени, истории, аудита…), предыдущее значение, которое было скопировано, становится бесполезным, поэтому оно может быть безопасно удалено из памяти GC.

Однако существуют устройства, в которых память не так богата. Это касается IoT (Internet of Things), или программ, выполняемых на Raspberry Pi, или подобных устройств. В этих случаях неизменяемость может оказаться неприемлемой даже для больших значений. Более того, дисциплина разработчиков, о которой мы говорили ранее, может оказаться неприменимой: ограниченный объем памяти может заставить нас намеренно мутировать значения, поскольку памяти мало.

Обновление глубоко вложенных значений может быть затруднено.

Возьмем следующую модель User:

interface User {
  name: string
  job: Job
}

interface Job {
  title: string
  company: Company
}

interface Company {
  name: string
  address: Address
}

interface Address {
  street: AddressStreet
  zipCode: string
  country: string
}

interface AddressStreet {
  name: string
  nb: number
  special?: string
}
Вход в полноэкранный режим Выход из полноэкранного режима

Конечно, мы могли бы использовать простую string для адреса компании, но это академический пример. Более того, возможно, люди захотят (или будут ограничены) использовать сложное решение для моделирования адреса, такое как это.

Итак, помня о неизменности данных, как бы мы обновили название улицы?

Мы можем использовать оператор spread, чтобы перестроить объект User, применяя при этом нужное нам изменение (изменения):

declare const user: User

const userWithNewCompanyAddress: User = {
  ...user,
  job: {
    ...user.job,
    company: {
      ...user.job.company,
      address: {
        ...user.job.company.address,
        street: {
          ...user.job.company.address.street,
          name: 'Awesome avenue'
        }
      }
    }
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Но подождите, вы сказали, что мы должны клонировать/дублировать значение перед его изменением. А здесь вы не дублируете весь объект?

Действительно. Используя оператор spread, мы создаем неглубокие копии каждого промежуточного объекта. Это означает, что если user.job.title был объектом, то userWithNewCompanyAddress.job.title будет точно таким же объектом (той же ссылкой), а не его копией.

Хорошо, тогда давайте воспользуемся решением, которое действительно клонирует все значение:

declare function deepCopy<A>(obj: A): A

declare const user: User

const clonedUser = deepCopy(user)
clonedUser.job.company.address.street.name = 'Awesome avenue'
Войдите в полноэкранный режим Выход из полноэкранного режима

Должен признаться, мне не нравится этот подход:

  • Нам нужна какая-то функция deepCopy для клонирования объектов (и, возможно, массивов). Это не очень сложно реализовать, если мы используем чистые данные: что-то вроде JSON.parse(JSON.stringify(obj)) должно сделать трюк, хотя у него есть свои ограничения. Тем не менее, такая функция отсутствует в стандартной библиотеке.
    • [Недавно я услышал о встроенной функции structuredClone для глубокого копирования объекта, хотя она поддерживается только в последних версиях браузера и Node.
  • Она оказывает некоторое влияние на производительность во время выполнения. Для одного объекта оно, вероятно, незначительно. Однако, что если мы будем итерировать сотни или тысячи объектов, которые будут сложнее, чем этот?
  • У нас все еще есть шаг мутации, даже если он применяется к копии начального значения. Может показаться странным отговаривать/запрещать мутации, а потом видеть в кодовой базе такие строки кода.

Вот почему я предпочитаю первый подход:

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

Однако, как вы можете видеть, основным недостатком является то, что он довольно многословен, если мы изменяем глубоко вложенное значение.

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

Если не вдаваться в подробности, то оптика — это составной и чистый геттер/сеттер.

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

import { Lens } from 'monocle-ts'

declare const user: User

const companyStreetName = Lens.fromPath<User>()([
  'job', 'company', 'address', 'street', 'name'
])

const userWithNewCompanyAddress: User =
  companyStreetName.set('Awesome avenue')(user)
Войти в полноэкранный режим Выйти из полноэкранного режима

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

const newUser = {
  ...user,
  job: {
    ...user.job,
    company: {
      ...user.job.company,
      addresses: user.job.company.addresses.map(address => ({
        ...address,
        street: {
          ...address.street,
          name: address.street.name.toLowerCase()
        }
      }))
    }
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

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

import { fromTraversable, Lens, Traversal } from 'monocle-ts'
import { Traversable } from 'fp-ts/Array'

// optic to get the name of the street, from an address
const streetNameL = Lens.fromPath<Address>()(['street', 'name'])

// optic to get an address from a list of addresses
const companyAddressesT: Traversal<Address[], Address> =
  fromTraversable(Traversable)<Address>()

// optic to get the names of the street, from a list of addresses
const companyStreetNamesT: Traversal<Address[], string> =
  companyAddressesT.composeLens(streetNameL)

// optic to get the names of the street of the company, from a user
const userCompanyStreetNamesT: Traversal<User, string> =
  Lens.fromPath<User>()(
    ['job', 'company', 'addresses']
  ).composeTraversal(companyStreetNamesT)

const lowerCaseCompanyStreets: (u: User) => User =
  userCompanyStreetNamesT.modify(name => name.toLowerCase())

const newUser = lowerCaseCompanyStreets(user)
Вход в полноэкранный режим Выход из полноэкранного режима

Самая интересная часть (и более декларативная, IMO):

userCompanyStreetNamesT.modify(name => name.toLowerCase())
Вход в полноэкранный режим Выход из полноэкранного режима

Синтаксис неизменяемости может привести к раздуванию кода

Стандартная библиотека некоторых языков по умолчанию раскрывает изменяемые структуры данных. В TypeScript это касается массивов и объектов. Это означает, что если мы хотим обеспечить неизменяемость, нам придется использовать дополнительный синтаксис или использовать структуры данных, импортированные из сторонней библиотеки.

В TypeScript добавление таких ключевых слов, как readonly, as const и Readonly<> везде (поверх существующих типов) может привести к тому, что код станет сложнее читать и понимать.

Что из нижеперечисленного легче читать?

const actions = ['a', 'b', 'c']

type Action = 'a' | 'b' | 'c'

interface User {
  name: string
  actions: Action[]
}

function makePairs<A, B>(arr1: A[], arr2: B[]): [A, B][] {
  if (arr1.length !== arr2.length) {
    return []
  }
  return arr1.reduce(
    (acc, val, index) => [...acc, [val, arr2[index]]],
    [] as [A, B][]
  )
}

const user1: User = { name: 'Bob', actions: ['a', 'a', 'c'] }
const user2: User = { name: 'Henri', actions: ['b', 'a'] }
const arr1: User[] = [user1]
const arr2: User[] = [user2]

const res = makePairs(arr1, arr2)
// const res: [User, User][]
Войти в полноэкранный режим Выйти из полноэкранного режима
const actions = ['a', 'b', 'c'] as const

type Action = (typeof actions)[number]

interface User extends Readonly<{
  name: string
  actions: readonly Action[]
}> {}

function makePairs<A extends Readonly<any>, B extends Readonly<any>>(
  arr1: readonly A[],
  arr2: readonly B[]
): ReadonlyArray<readonly [A, B]> {
  if (arr1.length !== arr2.length) {
    return []
  }
  return arr1.reduce(
    (acc, val, index) => [...acc, [val, arr2[index]]],
    [] as ReadonlyArray<readonly [A, B]>
  )
}

const user1: User = { name: 'Bob', actions: ['a', 'a', 'c'] }
const user2: User = { name: 'Henri', actions: ['b', 'a'] }
const arr1: readonly User[] = [user1]
const arr2: readonly User[] = [user2]

const res = makePairs(arr1, arr2)
// const res: readonly (readonly [User, User])[]
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Помните, что в более сложной кодовой базе может быть трудно понять, что значения (такие как arr1, arr2 или объекты User, которые они содержат) могут быть изменены в любом месте, что приведет к нежелательным побочным эффектам. Использование синтаксиса TypeScript или сторонней библиотеки может предотвратить возникновение подобных эффектов. Как всегда в нашей работе, это вопрос компромисса между безопасностью и читабельностью.

Возможно, когда-нибудь TypeScript выпустит новую опцию компилятора «readonlyByDefault» и новый оператор типа mutable, которые позволят нам использовать неизменяемые данные по умолчанию (хотя миграция кодовой базы в этот «режим» будет, вероятно, болезненной!)

Резюме

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

Для меня самым важным является предсказуемость, которую она дает. Я думаю, это здорово — иметь возможность прочитать функцию и быть уверенным, что значения, которые она использует, не могут быть изменены где-либо еще (например, из-за какого-то произвольного события, о котором я не знаю).

Если я хочу узнать, как используются значения, я могу сделать следующее:

  • Если оно не возвращается функцией, то это означает, что:
    • Либо значение (например, типа Foo) используется только той функцией, которую я сейчас читаю => локальная область видимости, я могу сосредоточиться только на этой конкретной функции и не беспокоиться об остальном,
    • Или это глобальное неизменяемое состояние, которое никогда не изменится и всегда будет иметь один и тот же тип (например, Foo). Таким образом, я точно знаю, что функция может с ним сделать, или ей нужна дополнительная информация для правильной работы.
  • Если она возвращается функцией, то я могу найти места, где функция вызывается, и проследить пути оттуда, чтобы увидеть, как данные движутся в программе.

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

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

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

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

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

Общее состояние хорошо, если оно неизменяемо. Изменяемое состояние хорошо, если оно не разделяется.

Спасибо, что дочитали до этого момента! В следующей статье мы поговорим о керринге, частичном применении и негласном программировании (также называемом стилем без точек). Увидимся в следующий раз!


Примечание:
Изначально я хотел включить главу «Как работать с изменяемостью», где я бы взял несколько типичных примеров (например, глобальное изменяемое состояние, экземпляр объекта, свойства которого частично определены) и попытался сделать их неизменяемыми. Хотя я не ожидал, что в главе о характеристиках я напишу так много! Поэтому я решил не писать здесь еще одну главу. Но если вам будет интересно, дайте мне знать, и я, возможно, напишу еще одну статью специально для этого! 🙂


Фото Xavi Cabrera на Unsplash.

Изображения сделаны с помощью Excalidraw.

Оцените статью
Procodings.ru
Добавить комментарий