Основные возможности платформы Hexlet не доступны в вашем браузере. Пожалуйста, обновитесь.
,

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

Это перевод заметки Эрика Норманда Global Mutable State.

Одно из самых проблемных мест в программировании — mutable state — изменяемое состояние. Оно делает код сложным, и как только вы ввязались в него, всё со временем становится более запутанным. Сокращение глобального изменяемого состояния в программе — один из лучших способов повысить качество кода, независимо от того процедурный он или функциональный.

Определение

Global mutable state содержит в себе три слова, каждое из которых имеет важное значение:

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

Mutable — означает изменяемый (в русскоязычной среде часто говорят «мутабельный», — прим. ред.). Часто можно заметить: все, что может прочитать значение, может так же и изменить его. Два считывания данных, следующих одно за другим, могут возвращать разные значения. Или, что еще хуже, сами возвращаемые структуры данных изменяются после чтения.

Дать определение состоянию (State) сложнее. Но, по существу, смысл в том, что значение зависит от истории программы. Насколько глубокой истории? В худшем случае (при наличии глобального изменяемого состояния) полной истории, от начала программы. Вам нужно знать всё об исполнении программы, включая то, как чередовались треды.

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

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

Выявление глобального изменяемого состояния

Моменты, которые его выдают: несколько переменных в глобальной области видимости (в Clojure: несколько atoms в верхнем уровне namespace), чтение и запись данных в глобальные переменные с нечёткими паттернами (или чтение из глобальных переменных несколько раз в маленьком куске кода). Переменная может измениться между считываниями данных.

Очистка кода

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

1) Должна ли переменная быть глобальной?

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

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

Очень заманчиво использовать глобальные переменные, потому что их наличие — простой способ разным фрагментам кода работать совместно. Вот пример:

var file;                            // несчастные глобальные переменные
var recordCount;

function readFile() {
  file = openFile("input.txt");      // глобальная мутация
}

function countRecords() {
  recordCount = 0;
  for(var c in file.lines()) {       // глобальное чтение
    recordCount++;                   // глобальная мутация
  }
}

function generateOutput() {
  for(var c in file.lines()) {       
    print(c + "," + recordCount);
  }
}

function processFile() {
  readFile();                        // эти строки обязаны выполняться в таком порядке
  countRecords();
  generateOutput();
}

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

function readFile(state) {                // теперь функция принимает состояние
  state.file = openFile("input.txt");
}

function countRecords(state) {            // состояние теперь — аргумент
  var x = 0;                              // используем локальную переменную
  for(var c in state.file.lines()) {      // вместо хранения промежуточных значений глобально
    x++;
  }
  state.recordCount = x;                  // задаем состояние один раз
}

function generateOutput(state) {          // состояние снова — аргумент
  for(var c in state.file.lines()) {
    print(c + "," + state.recordCount);   
  }
}

function processFile() {
  var state = {};                         // состояние локально (все еще мутабельно)
  readFile(state);                       
  countRecords(state);                   
  generateOutput(state);
}

Самая крупная трансформация — это передача объекта state в каждый из методов. Теперь он больше не глобальный. Каждый раз, когда мы запускаем processFile, генерируется новый экземпляр. Мы начинаем с известного исходного состояния и знаем, что у нас не будет конкуренции для этого объекта.

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

2) Должна ли она быть изменяемой?

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

Другими словами, пишите код с использованием только аргументов и возвратом значений функций настолько часто, насколько возможно. Изолируйте мутацию переменной маленьким отрезком кода.

Давайте применим эту методику к нашему коду:

function readFile() {
  return openFile("input.txt");     // вместо изменения состояния
}                                   // просто вернем значение

function countRecords(file) {       // берем нужное состояние как аргумент
  var x = 0;
  for(var c in file.lines()) {
    x++;
  }
  return x;                         // вернем ответ
}

function generateOutput(file, recordCount) { // берем два нужных значения
  for(var c in file.lines()) {               // аргументами
    print(c + "," + recordCount);
  }
}

function processFile() {
  var file = readFile();     // используем локальные переменные
                             // (они никогда не мутируют)
  var recordCount = countRecords(file);
  generateOutput(file, recordCount);
}

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

Заметьте, насколько меньше работы теперь выполняет функция readFile (там просто один вызов). Возможно, мы захотим удалить эту функцию вообще и просто вызывать openFile напрямую. Решать вам, но одна из вещей, которые я часто замечал, удаляя мутацию: функции становятся очевиднее для чтения и записи, а иногда настолько проще, что вам захочется заменить их на inline-вызов.

function countRecords(file) {
  var x = 0;
  for(var c in file.lines()) {
    x++;
  }
  return x;
}

function generateOutput(file, recordCount) {
  for(var c in file.lines()) {
    print(c + "," + recordCount);
  }
}

function processFile() {
  var file = openFile("input.txt"); // просто в одну строчку!
  var recordCount = countRecords(file);
  generateOutput(file, recordCount);
}

3) Должна ли она иметь состояние?

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

Программам нужно состояние. Но нужно ли нам полагаться на него, чтобы получить правильный ответ? И нужно ли, чтобы состояние зависело от всей истории программы?

Давайте пройдём пошагово наш код, удаляя состояние.

function countRecords(file) {
  var x = 0;                    // состояние
  for(var c in file.lines()) {
    x++;                        // оно изменяется в каждом шаге цикла
  }
  return x;
}

Переменная x — это состояние. Её значение зависит от того, сколько раз исполнялось тело цикла. Обычно такой вид цикла со счётчиком не нужен, потому что стандартная библиотека может сама считать коллекцию.

function countRecords(file) {
  return file.lines().length();  // предпочитаем не париться с состоянием вообще
}

Вау! Теперь больше нет состояния. И вообще, тут всё так коротко, что мы можем делать вызов на месте. Функция вызывается всего один раз в processFile. Давайте встроим её сюда.

function processFile() {
  var file = openFile("input.txt");
  var recordCount = file.lines().length(); // если хочется, можно встроить
  generateOutput(file, recordCount);
}

Вот так лучше. Но у нас всё ещё есть состояние. Его не так много, но давайте продолжим. Заметьте, насколько мы полагаемся на состояние recordCount, передаваемое в generateOutput. Есть ли гарантия, что задаваемый нами счётчик не отличается от счётчика в  file? Единственный способ — переместить вычисление recordCount в generateOutput. Почему generateOutput должен доверять кому-то ещё, когда он может вычислять сам?

function generateOutput(file) { // убираем аргумент, требующий синхронизации
  var recordCount = file.lines().length(); // и считаем его сами
  for(var c in file.lines()) {
    print(c + "," + recordCount);
  }
}

function processFile() {  // теперь у нас два шага
  var file = openFile("input.txt");
  generateOutput(file);
}

А теперь нам не нужна эта маленькая локальная переменная с названием file.

function processFile() {
  generateOutput(openFile("input.txt")); // можно в одну строку
}

Заключение

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

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

Поделиться Вконтакте
Отправить в Телеграм
Мы учим программированию с нуля до стажировки и работы. Попробуйте наш бесплатный курс «Введение в программирование» или полные программы обучения по Node, PHP и Java. Хекслет

Подробнее о том, почему наше обучение работает →