Чистота функций и референциальная прозрачность

Оглавление

  • Чистота функций
  • Прозрачность ссылок
  • Как работать с нечистыми функциями?
  • Практический пример (длинная глава)

Чистота функции

Функция считается чистой, если она не имеет побочных эффектов. Чтобы понять, что такое побочный эффект, обратитесь к предыдущей статье этой серии.

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

Предположим, у нас есть функция greet. Нечистая версия выполняет HTTP-запрос к некоторой конечной точке API, чтобы получить конечное сообщение с приветствием. Чистая версия выполняет простую конкатенацию строк. Когда сеть больше не доступна, функция с побочным эффектом больше не работает, в то время как чистая версия продолжает работать.

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

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

Прозрачность ссылок

Ссылочная прозрачность — это возможность заменить выражение его результатом без изменения смысла/поведения программы.

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

В качестве простого примера можно привести выражение 2 + 3. Это выражение является чистым (т.е. не имеет побочных эффектов), поэтому оно ссылочно прозрачно. Мы можем заменить 2 + 3 на его результат 5 без изменения поведения программы. Например, если бы программа вычисляла следующее выражение: ((2 + 3) + 2) / 7, то вычисление ((5) + 2) / 7 даст тот же результат, 1. Поведение программы сохраняется, хотя ее определение было упрощено.

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

Для функций мы можем представить это свойство как отображение между «ссылкой на функцию + ее входы» и «ее выходом»:

Мы можем использовать свойство ссылочной прозрачности в целях оптимизации (как разработчики, так и компиляторы), например, используя ленивую оценку, мемоизацию или распараллеливание.

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

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

Как быть с нечистыми функциями?

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

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

Кроме того, когда приходит время тестировать эти функции, нам часто приходится перед каждым тестовым примером моделировать зависимости. Это может привести к созданию сложных крючков «до/после» для настройки тестов и восстановления состояния перед запуском этих тестов.

Однако мы не можем избавиться от побочных эффектов, так как же нам поступить в этой ситуации?

Мы должны определить, какие части функции являются чистыми, а какие — нет. Затем мы можем переместить чистые и не очень чистые части в их собственные функции. Наконец, мы можем переписать исходную функцию, используя чистые части напрямую и явно предоставляя (побочные) зависимости в качестве аргумента, используя Dependency Injection (она же DI).

Я не собираюсь говорить здесь о фреймворках DI. Мы будем использовать самую простую версию DI: передавать зависимость в качестве одного из аргументов функции.

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

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

Практический пример

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

Рассмотрим следующую функцию, целью которой является получение определения термина, предоставленного пользователем. Она делает следующее:

  • Отслеживает искомые термины
  • Убедиться, что термин действителен
  • Получить определение термина из кэша, если он доступен.
  • Если нет, сделать запрос к веб-службе, а затем сохранить результат в файле.

Далее приводится его определение с использованием языка TypeScript:

import { promises as fs } from 'fs'
import fetch from 'node-fetch'

const searchedTerms = new Set<string>()

export async function getTermDefinition(term: unknown): Promise<string> {
  if (!term || typeof term !== 'string') {
    throw new Error(`Invalid term: ${term}`)
  }

  const lcTerm = term.toLowerCase()

  if (searchedTerms.has(lcTerm)) {
    return fs.readFile(`definitions/${lcTerm}.txt`, 'utf8')
  }

  const response = await fetch(`/api/definition?term=${lcTerm}`)
  const { definition } = await response.json()

  await fs.writeFile(`definitions/${lcTerm}.txt`, definition)
  searchedTerms.add(lcTerm)

  return definition
}
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Мы знаем, что она нечиста, потому что имеет следующие побочные эффекты:

  • Он читает из глобального состояния: searchedTerms.
  • Он читает из файла, чтобы получить определение термина: fs.readFile.
  • Вызывает конечную точку API с помощью HTTP: fetch.
  • Обновляет глобальное состояние: searchedTerms.add.
  • Производит запись в файл: fs.writeFile.

Вот чистые части этой функции:

  • Проверяем термин, убеждаемся, что он является непустой строкой.
    • Технически, поскольку мы выбрасываем ошибку, эта часть не совсем чистая. Мы могли бы вместо этого вернуть тип данных Either/Validation/Result. Но, ради простоты, давайте сохраним выброс ошибки и будем считать эту часть чистой.
  • Преобразование термина путем перевода его в нижний регистр
    • Здесь можно добавить больше шагов преобразования, например, санирование строки для удаления специальных символов.

Тестирование функции

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

jest.mock('fs', () => ({
  promises: {
    readFile: jest.fn(),
    writeFile: jest.fn()
  }
}))
import { promises as fs } from 'fs'

jest.mock('node-fetch')
import fetch from 'node-fetch'
const { Response } = jest.requireActual('node-fetch')

import { getTermDefinition } from './getTermDefinition'

describe('getTermDefinition', () => {
  type MockedReadFile = jest.MockedFunction<typeof fs.readFile>
  type MockedWriteFile = jest.MockedFunction<typeof fs.writeFile>
  type MockedFetch = jest.MockedFunction<typeof fetch>

  beforeEach(() => {
    ;(fs.readFile as MockedReadFile).mockReset()
    ;(fs.writeFile as MockedWriteFile).mockReset()
    ;(fetch as MockedFetch).mockReset()
  })

  test('invalid term', async () => {
    await expect(getTermDefinition(42)).rejects.toEqual(
      new Error('Invalid term: 42')
    )
  })

  test('valid term "foo", cache miss', async () => {
    ;(fetch as MockedFetch).mockResolvedValue(
      new Response(JSON.stringify({ definition: 'description' }))
    )
    expect(await getTermDefinition('foo')).toBe('description')
    expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), 'description')
  })

  test('valid term "foo", cache hit', async () => {
    ;(fs.readFile as MockedReadFile).mockResolvedValue('description')
    expect(await getTermDefinition('foo')).toBe('description')
    expect(fetch).not.toHaveBeenCalled()
    expect(fs.writeFile).not.toHaveBeenCalled()
  })
})
Вход в полноэкранный режим Выход из полноэкранного режима

Подражательные части составляют примерно 40% строк, написанных для тестирования функции getTermDefinition.

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

  • Сначала вызвать функцию с заданным термином «foo» для кэширования результата и обновления списка searchedTerms.
  • Затем вызвать функцию второй раз, с точно таким же термином «foo», чтобы попасть в кэш.

Один тестовый пример не должен зависеть от другого. Мы должны иметь возможность копировать/вставлять блоки test(...) куда угодно.

Для этого нам нужно управлять списком searchedTerms. Один из способов сделать это — экспортировать его из модуля, а затем настроить его в тестах:

- import { getTermDefinition } from './getTermDefinition'
+ import { searchedTerms, getTermDefinition } from './getTermDefinition'

describe('getTermDefinition', () => {
  beforeEach(() => {
+   searchedTerms.clear()
  })

  test('valid term "foo", cache hit', async () => {
+   searchedTerms.add('foo')
    ;(fs.readFile as MockedReadFile).mockResolvedValue('description')
    expect(await getTermDefinition('foo')).toBe('description')
    expect(fetch).not.toHaveBeenCalled()
    expect(fs.writeFile).not.toHaveBeenCalled()
  })
})
Вход в полноэкранный режим Выход из полноэкранного режима

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

Более того, если в будущем мы захотим перейти на другую библиотеку HTTP или файловую систему, нам придется:

  • Адаптировать реализацию этой функции
  • Адаптировать все мокинговые части тестов.

Давайте попробуем перенести различные чистые и нечистые части в специальные функции, затем использовать инъекцию зависимостей и составить эти функции вместе, чтобы перестроить getTermDefinition.

Разделение функции

Давайте перенесем чистые части в 2 отдельные функции: validateTerm и transformTerm.

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

  • Список искомых терминов: searchedTerms.
  • Функция для чтения из файла: readFile
  • Функция для записи в файл: writefile
  • Функция для получения определения термина: fetchDefinition.

Мы также собираемся предоставить значения по умолчанию для этих зависимостей, используя модули fs и node-fetch, для живых сред (т.е. разработки и производства).

Обратите внимание, что список searchedTerms больше не экспортируется, так как мы можем предоставить его с помощью объекта Dependencies.

import { promises as fs } from 'fs'
import fetch from 'node-fetch'

const searchedTerms = new Set<string>()

function validateTerm(term: unknown): asserts term is string {
  if (!term || typeof term !== 'string') {
    throw new Error(`Invalid term: ${term}`)
  }
}

function transformTerm(term: string): string {
  return term.toLowerCase()
}

async function readFile(path: string): Promise<string> {
  return fs.readFile(path, 'utf8')
}

async function writeFile(path: string, content: string): Promise<void> {
  return fs.writeFile(path, content)
}

async function fetchDefinition(term: string): Promise<string> {
  const response = await fetch(`/api/definition?term=${term}`)
  const { definition } = await response.json()
  return definition
}

export interface Dependencies {
  searchedTerms: Set<string>
  readFile: (path: string) => Promise<string>
  writeFile: (path: string, content: string) => Promise<void>
  fetchDefinition: (term: string) => Promise<string>
}

const defaultDependencies: Dependencies = {
  searchedTerms,
  readFile,
  writeFile,
  fetchDefinition
}

export async function getTermDefinition(
  term: unknown,
  { searchedTerms, readFile, writeFile, fetchDefinition } = defaultDependencies
): Promise<string> {
  validateTerm(term)

  const lcTerm = transformTerm(term)

  if (searchedTerms.has(lcTerm)) {
    return readFile(`definitions/${lcTerm}.txt`)
  }

  const definition = await fetchDefinition(lcTerm)

  await writeFile(`definitions/${lcTerm}.txt`, definition)
  searchedTerms.add(lcTerm)

  return definition
}
Вход в полноэкранный режим Выход из полноэкранного режима

Тестирование новой версии

Поскольку мы добавили параметр Dependencies в функцию, мы можем предоставлять значения и макеты, в зависимости от тестового случая. Мы по-прежнему используем моделируемые функции, но на этот раз мы больше не будем моделировать целые модули.

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

import { getTermDefinition, Dependencies } from './getTermDefinition'

describe('getTermDefinition', () => {
  const baseDependencies: Dependencies = {
    searchedTerms: new Set<string>(),
    readFile: jest.fn(),
    writeFile: jest.fn(),
    fetchDefinition: jest.fn()
  }

  test('invalid term', async () => {
    await expect(getTermDefinition(42, baseDependencies)).rejects.toEqual(
      new Error('Invalid term: 42')
    )
  })

  test('valid term "Foo", cache hit', async () => {
    const dependencies = {
      ...baseDependencies,
      searchedTerms: new Set<string>(['foo']),
      readFile: jest.fn().mockResolvedValue('definition')
    }

    const definition = await getTermDefinition('Foo', dependencies)

    expect(definition).toEqual('definition')
    expect(dependencies.fetchDefinition).not.toHaveBeenCalled()
    expect(dependencies.writeFile).not.toHaveBeenCalled()
  })

  test('valid term "foo", cache miss', async () => {
    const dependencies = {
      ...baseDependencies,
      fetchDefinition: jest.fn().mockResolvedValue('definition')
    }
    expect(dependencies.searchedTerms.size).toBe(0)

    const definition = await getTermDefinition('foo', dependencies)

    expect(definition).toEqual('definition')
    expect(dependencies.writeFile).toHaveBeenCalled()
    expect([...dependencies.searchedTerms]).toEqual(['foo'])
  })
})
Вход в полноэкранный режим Выход из полноэкранного режима

Сделанные нами изменения позволяют нам рефакторить код для использования другого модуля HTTP или файловой системы, просто изменив реализации readFile, writeFile и fetchDefinition. Кроме того, нам вообще не придется обновлять тесты, потому что интерфейс Dependencies не меняется.


Этот пример не был полностью посвящен «очистке» нечистой функции. Мы уже видели, как обрабатывать побочные эффекты в предыдущей статье этого цикла. Здесь я попытался показать вам, как мы можем организовать наш код, чтобы разделить чистую и нечистую части на специальные функции. Затем мы можем использовать инъекцию зависимостей, чтобы сделать эти зависимости с побочными эффектами явными и предоставить полностью контролируемые значения для написания тестов. Надеюсь, вам понравилась эта глава!


Спасибо, что дочитали до конца! Как всегда, не стесняйтесь делиться своим мнением в комментариях 🙂 В следующий раз мы поговорим о неизменяемости данных. До встречи!


Фото Xavi Cabrera на Unsplash.

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

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *