Вы, наверное, много раз читали, что цель функциональных программ - достичь состояния, при котором вы можете составлять только чистые функции или функции без побочных эффектов. Однажды вы сидите за компьютером на работе, полные решимости приступить к созданию ваших функций, отвечающих этому требованию. Все выглядит хорошо, ваши функции лаконичны и уникальны, но почти через 10 минут вы думаете: «Подождите, мне нужен доступ к файловой системе или базе данных» или просто «Мне нужно разветвить здесь свой код на основе некоторых состояние." Очевидно, вы не одиноки, поскольку это очень распространенные задачи. Могли бы вы по-прежнему делать все это в чистом виде, не вводя слишком много императивных структур? Давайте рассмотрим оба эти вопроса. Нужно:

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

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

Рассмотрим простой пример: предположим, что нам в качестве входных данных задан путь к файлу, и нам нужно прочитать указанный файл и подсчитать количество слов в нем - простая повседневная задача. Давайте положим нашу функциональную шляпу и приступим к работе: предоставленные пользователем пути могут указывать на несуществующие файлы, поэтому нам потребуется некоторая проверка ошибок вокруг этого. Файловые потоки считываются в буферы в NodeJS, что означает, что нам нужно будет декодировать что-то вроде UTF-8, чтобы получить строку. Наконец, мы разбиваем эти слова и учитываем их в результате. Мы назовем это processFile :: String -> Number, и на высоком уровне он состоит из следующих элементов:

read(path), check(buffer), decode(encoding, buffer), words(body)

Скомпоновать их все вместе с помощью Ramda будет примерно так:

processFile :: String -> Number
const processFile = R.compose(
   words,  
   decode('utf-8'),
   check,
   read 
)
processFile('/dir/somefile.txt')

Вот как мы хотели бы читать код. Он разборчивый, разборчивый и расширяемый. Но так ли это работает? Помните, что такая функция, как fs.readFile, используемая в read, является асинхронной (и, конечно, мы все должны предпочесть асинхронные API). Теперь у вас есть несоответствие между синхронной природой compose и асинхронной природой API-интерфейсов, которые она вызывает. В статье Функциональное программирование в JavaScript я представил очень похожие типы проблем. Мои решения включали использование обещания для абстрагирования понятия времени и встраивания функций, которые знали, как в основном разрешить обещание и передать значение следующей функции в цепочке. Это сработало очень элегантно и позволило мне сохранить все прекрасные свойства функциональных программ по сравнению с этими эффективными операциями.

В этом сообщении в блоге я хочу поднять планку \ (^ o ^) / и сделать ее более элегантной. Как мне поднять планку? Поднимите абстракцию.

Клейсли Композиция

Чтобы понять композиции Клейсли, мы должны сначала понять, что такое категория Клейсли. Думайте об этом как о представлении категории функций с побочными эффектами, такими как печать на экране, чтение из файла или запись в базу данных. Тип задач, с которыми мы сталкиваемся ежедневно. Итак, это способ абстрагироваться от этих побочных эффектов и вставлять их в структуры, которые знают, как их инкапсулировать - да, да… монады (см. Главу 5).

В то время как обычная категория моделирует композицию функций, категория Клейсли моделирует композицию эффектов или композицию монад. В этой категории есть все те же объекты, что и в исходной категории, но вместо наших простых f :: A -> B карт нам нужен f :: A -> m B, где m - монадический тип, обеспечивающий необходимую нам абстракцию и, по определению, реализует цепочку.

Вместо поднятия абстракции, вместо R.compose, нам также нужна высокоуровневая композиция, достаточно умная, чтобы легко связать эти монады или контейнеры, называемая composeK (для Kleisli, конечно) ._ 9_ имеет следующая форма, принимающая 3 функции Клейсли:

R.composeK(h, g, f) = R.compose(R.chain(h), R.chain(g), R.chain(f))

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

Первый пример

Начнем с простого примера использования нашей любимой монады Maybe. Предположим, вам нужно получить доступ к вложенному свойству state произвольной строки JSON (этот пример основан на примере из документации Ramda):

{
  user:
   {
     address:
       {
         state: "fl"
       }
    }
}

В этом случае после анализа объекта становится очевидным, что путь равен user.address.state; но вы делаете большие предположения (например, address может быть нулевым) о своих данных, что может быть или небезопасно. Не будем делать никаких предположений и использовать функции parse(json) и prop(obj, name), которые защищены с помощью Maybe, что приводит к maybeParse и maybeProp.

// parse :: String -> Object|null
const parse = json => {
  try {
     return JSON.parse(json)
  }
  catch(e) {
     return null
  }
}
// prop :: (Object, String) -> Object|String|null
const prop = (obj, name) => obj[name]
// maybeParse :: String => Maybe
const maybeParse = json => Maybe.fromNullable(parse(json))      *
// maybeProp :: String -> Object -> Maybe
const maybeProp = name => obj => Maybe.fromNullable(obj[name])  *

Вы можете ясно видеть мои стрелки Kleisli выше (отмеченные *), они никогда не вернут null. Поскольку Maybe - это настоящая монада (подчиняющаяся законам идентичности и ассоциативности) и реализует chain (или bind, flatmap), мое решение выглядит следующим образом:

// getStateCode :: String => Maybe
const getStateCode = R.composeK(
   R.compose(Maybe.of, R.toUpper),
   maybeProp('state'),
   maybeProp('address'),
   maybeProp('user'),
   maybeParseJson
)

Это выглядит очень элегантно и пуленепробиваемым. Фактически, я считаю, что ошибки в функциональной программе почти никогда не возникают из-за ошибки в самом коде; В 95% случаев это неверное предположение на основе данных.

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

it('Should extract the value of a JSON object', () => {    
   const result = getStateCode(
     '{"user":{"address": {"state":"fl"}}}'
   ).get()
   assert.equal(result, 'FL')
})
it('Should fail to extract a value', () => {
  const fn = () => getStateCode('[XXX]').get()
  assert.throws(fn, TypeError, 
     "Can't extract the value of a Nothing.");
})

Работает как шарм! В одном случае я получаю свое значение - Just; в другом Nothing. Итак, с Kleisli вы всегда имеете дело с этими коробочными типами. Вот немного пищи для размышлений: Just - это функция идентичности в категории Клейсли монады Maybe. Точно так же, как aList.of('apples') также является функцией идентичности в категории монады List (FruitBowl?). Есть смысл? Большой!

Второй пример

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

  1. Вместо использования обещания на этот раз я воспользуюсь типом данных Folktale Task, чтобы удалить асинхронный компонент этой программы.
  2. Для ветвления я хотел бы сообщить пользователю, что файл пуст, поэтому вместо Maybe я буду использовать Either. Этот тип позволяет моему коду принимать значения по одному пути Right или другому Left в соответствии с проверкой какого-либо условия, если хотите, с очень элементарным совпадением с шаблоном.

Все это, конечно, соответствует правильной реализации chain из спецификации Fantasy Land. Во-первых, наши стрелки Клейсли:

// read :: String -> Task
const read = path => new Task((reject, resolve) => {
   fs.readFile(path, (error, data) => error ? reject(error) :   
      resolve(data))
})
// check  :: buffer -> Task(_, Either)
const check = buffer => Task.of(
  buffer.length > 1 ?
     Either.Right(buffer) :
     Either.Left('File is empty!')
  )
// decode :: String -> Either(Buffer) -> Task(_, String)
const decode = encoding => buffer =>
  Task.of(buffer.map(b => b.toString(encoding)))
// words :: String -> Task(_, String)
const words = text => Task.of(text.map(t => t.split(' ').length))

Я использую Task и Either для разных уровней обработки ошибок. Task выдаст ошибку, если файл отсутствует (пользователь указал неверный путь). Если файл присутствует, но текст пуст, то Either разветвляется на Left и предупреждает пользователя о том, что файл пуст; в противном случае он выбирает Right.

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

// processFile :: String -> Task(Error, Either)
const processFile = R.composeK(
   words,
   decode('utf8'),
   check,
   read
)

Чище этого не может быть, это результат того, что монады удаляют эти повседневные заботы из нашего кода. И то, что Рамда карри все великолепно. Вот примеры использования, которые мы можем протестировать:

it('Should extract text from a file and count words', (done) => {
   const file = path.join(__dirname, 'test.txt')
   processFile(file).fork(
     err => {
       assert.fail(null, null, 'Should never fork left!')
       done()
     },
     result => {
      assert.isTrue(result.isRight)
      assert.equal(result.get(), 6)
      done()
   })
})
it('Should realize the file is empty and not decode at all', (done) => {
    const file = path.join(__dirname, 'empty-test.txt')
    processFile(file).fork(
      err => {
        assert.fail(null, null, 'Should never fork left!')
        done()
      },
      result => {
        assert.isTrue(result.isLeft)
        const fn = () => result.get()
        assert.throws(fn, TypeError, 
           "Can't extract the value of a Left(a).");
        done()
    })
})
it('Should error-out on invalid file path', (done) => {
   const file = path.join(__dirname, 'bad-path.txt')
   processFile(file).fork(
     err => {
       assert.match(err, /ENOENT: no such file or directory/)
       done()
     },
     result => {
      assert.fail(null, null, 'Should never fork right!')
      done()
   })
})

Прощальная мысль

По моему профессиональному мнению, когда я много лет копался в программировании с помощью FP, я считаю, что композиции Клейсли должны стать способом работы каждого. Вы никогда не можете делать предположения о своих данных, потому что именно тогда код дает сбой - в рабочей среде! Так что всегда лучше ожидать худшего и надеяться на лучшее :-)

Для тех, кто меня не знает, меня зовут Луис Атенсио @luijar, я автор книг Функциональное программирование на JavaScript и RxJS в действии.