Глобальное изменяемое состояние

Это перевод заметки Эрика Норманда Global Mutable State.
Одно из самых проблемных мест в программировании — mutable state — изменяемое состояние. Оно делает код сложным, и как только вы ввязались в него, всё со временем становится более запутанным. Сокращение глобального изменяемого состояния в программе — один из лучших способов повысить качество кода, независимо от того процедурный он или функциональный.
Определение
Global mutable state содержит в себе три слова, каждое из которых имеет важное значение:
Global — значит доступный из любого места кода. Таким способом весь код связан. Необходимо рассуждать о взаимодействии всех частей программы, а не её маленьком фрагменте, потому что любая другая часть может касаться этого фрагмента.
Mutable — означает изменяемый (в русскоязычной среде часто говорят «мутабельный», — прим. ред.). Часто можно заметить: все, что может прочитать значение, может так же и изменить его. Два считывания данных, следующих одно за другим, могут возвращать разные значения. Или, что еще хуже, сами возвращаемые структуры данных изменяются после чтения.
Дать определение состоянию (State) сложнее. Но, по существу, смысл в том, что значение зависит от истории программы. Насколько глубокой истории? В худшем случае (при наличии глобального изменяемого состояния) полной истории, от начала программы. Вам нужно знать всё об исполнении программы, включая то, как чередовались треды.
Если объединить понятия глобальный, мутабельный и состояние, получится грандиозное месиво. Когда кто-то говорит "сложно рассуждать о работе программы", он подразумевает «в ней есть баги и невозможно понять это, читая код».
Плюс в том, что можно систематически избавляться от этих трёх аспектов. И вы, в принципе, можете удалять их по-отдельности. Я люблю говорить, что функционально программировать можно на любом языке, даже на самых процедурных. Один из способов — сокращать глобальное изменяемое состояние насколько возможно.
Выявление глобального изменяемого состояния
Моменты, которые его выдают: несколько переменных в глобальной области видимости (в Clojure: несколько atoms в верхнем уровне namespace), чтение и запись данных в глобальные переменные с нечёткими паттернами (или чтение из глобальных переменных несколько раз в маленьком куске кода). Переменная может измениться между считываниями данных.
Очистка кода
Сложно избавиться от глобального изменяемого состояния, когда оно уже существует. Его применение расползётся по коду, если его не закрепить. Глобальное изменяемое состояние настолько полезно, что его можно использовать в разных целях. Спустя некоторое время сложно понять, какие механизмы использования программы применялись, и как бы вы заменили их. Но мы подробно коснёмся каждого капризного аспекта по очереди.
1) Должна ли переменная быть глобальной?
Предположим, вы можете так переработать код, чтобы объект передавался в функции, вместо того, чтобы быть глобальной переменной. Тогда вы могли бы создавать новый экземпляр каждый раз, когда запускаете код. Это, как минимум, гарантирует, что он каждый раз стартует с известного значения и что вы инкапсулируете мутацию при различных исполнениях.
Другими словами, превратите глобальные переменные в локальные. Лучше всего — локальные для функции, выполняющей мутацию (или для меньшей области видимости, если возможно). В крайнем случае — это переменная из экземпляра локального объекта.
Очень заманчиво использовать глобальные переменные, потому что их наличие — простой способ разным фрагментам кода работать совместно. Вот пример:
Давайте попробуем сделать переменные менее глобальными, используя методику описанную выше.
Самая крупная трансформация — это передача объекта state
в каждый из методов. Теперь он больше не глобальный. Каждый раз, когда мы запускаем processFile
, генерируется новый экземпляр. Мы начинаем с известного исходного состояния и знаем, что у нас не будет конкуренции для этого объекта.
Другая трансформация была нацелена на то, чтобы больше полагаться на локальные переменные для аккумуляции промежуточных величин. Возможно, это выглядит очень примитивно, но в данном случае объект state
ни при каких условиях не содержит неконсистентных данных. Он либо верный, либо не содержит данных.
2) Должна ли она быть изменяемой?
Существуют ли такие функции, которые считывают данные из переменной, но ничего не записывают в неё? Их можно изменить, чтобы они принимали текущее значение в качестве аргумента. Уменьшение объёма кода, который полагается на эти конкретные переменные — полезная вещь.
Другими словами, пишите код с использованием только аргументов и возвратом значений функций настолько часто, насколько возможно. Изолируйте мутацию переменной маленьким отрезком кода.
Давайте применим эту методику к нашему коду:
Код, который записывал данные в изменяемый аргумент, мы перевели в код, который просто возвращает вычисляемое значение. Затем использовали локальные переменные для хранения возвращаемых значений.
Заметьте, насколько меньше работы теперь выполняет функция readFile
(там просто один вызов). Возможно, мы захотим удалить эту функцию вообще и просто вызывать openFile
напрямую. Решать вам, но одна из вещей, которые я часто замечал, удаляя мутацию: функции становятся очевиднее для чтения и записи, а иногда настолько проще, что вам захочется заменить их на inline-вызов.
3) Должна ли она иметь состояние?
Можно ли переработать алгоритмы так, чтобы использовать их натуральные вводы и выводы (аргументы и возвращаемые значения), а не записи во внешний мир? Например, вы используете переменную, чтобы что-то посчитать. Может, вместо добавления в переменную функция будет просто возвращать полную сумму?
Программам нужно состояние. Но нужно ли нам полагаться на него, чтобы получить правильный ответ? И нужно ли, чтобы состояние зависело от всей истории программы?
Давайте пройдём пошагово наш код, удаляя состояние.
Переменная x
— это состояние. Её значение зависит от того, сколько раз исполнялось тело цикла. Обычно такой вид цикла со счётчиком не нужен, потому что стандартная библиотека может сама считать коллекцию.
Вау! Теперь больше нет состояния. И вообще, тут всё так коротко, что мы можем делать вызов на месте. Функция вызывается всего один раз в processFile
. Давайте встроим её сюда.
Вот так лучше. Но у нас всё ещё есть состояние. Его не так много, но давайте продолжим. Заметьте, насколько мы полагаемся на состояние recordCount
, передаваемое в generateOutput
. Есть ли гарантия, что задаваемый нами счётчик не отличается от счётчика в file
? Единственный способ — переместить вычисление recordCount
в generateOutput
. Почему generateOutput
должен доверять кому-то ещё, когда он может вычислять сам?
А теперь нам не нужна эта маленькая локальная переменная с названием file
.
Заключение
Этот простой пример был крайностью. И да, он был очевидным. Но мой опыт подсказывает, что вы заметите те же улучшения, когда будете удалять глобальное изменяемое состояние в реальных системах. О работе всех частей кода становится рассуждать проще (потому что вы делаете это локально). Становится проще рефакторить. Становится проще удалять код.
Сокращать глобальное изменяемое состояние — одна из отличительных черт функционального программирования. Но делать это, значит просто писать хороший код. Вы можете (и должны) проводить подобный рефакторинг в любом языке программирования и при любом подходе.
Natalia Bass
8 лет назад