Железнодорожно-ориентированное программирование

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

Recipe Function ErrorTrack

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

Проектирование функций представляющих собой шаг

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

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

Recipe Validation Paths

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

type Result<'TSuccess,'TFailure> = 
    | Success of 'TSuccess
    | Failure of 'TFailure

Тогда схема будет выглядеть так:

Recipe_Validation_Union2

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

type Request = {name:string; email:string}

let validateInput input =
   if input.name = "" then Failure "Name must not be blank"
   else if input.email = "" then Failure "Email must not be blank"
   else Success input  // happy path

Если вы посмотрите на сигнатуру функции, то увидите что компилятор решил что получает Request и выплевывает Result на вывод, с Request для успешного случая и string для неудачного:

  validateInput : Request -> Result<Request,string>

Мы можем проанализировать и другие шаги в потоке аналогичным образом. И мы увидим что каждый имеет ту же самую "форму" - что то передаем на вход и потом получаем ее успешный/неуспешный вывод.

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

Железнодорожно ориентированное программирование

Так вот у нас есть много этих "один ввод -> Успех/Неудача вывод" функций, как мы можем их соединить вместе?

Нам нужно соединить вывод Success с входом следующего, и каким-нибудь образом пропустить следующую функцию в случае вывода Failure. Основная идея выглядит вот так:

Recipe Validation Update

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

Железная дорога имеет переключатели ("стрелки") для перехода поезда на другой путь. Мы можем воспринимать эти функции "Success/Failure" как переключатели на железной дороге:

Recipe RailwaySwitch

А вот два в один ряд.

Recipe RailwaySwitch 1

Как же нам комбинировать их так, что бы неуспешные пути были соединены? Это же очевидно! Вот так:

Recipe RailwaySwitch 2

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

Recipe RailwaySwitch 3

Верхний путь - успешный, а нижний не успешный.

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

Recipe Railway Opaque

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

Recipe Railway Transparent

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

Базовая композиция

Перед тем как рассмотреть "клей" для объединения функций, давайте рассмотрим как работает композиция.

Представите что обычная функция это черная короба (скажем туннель), построен на одной колее. У нее есть один вход и конечно же один выход.

Если нам надо соединить последовательность таких одноколейных функций, мы можем использовать оператор композиции, символ >>.

Recipe Railway Compose1

Аналогично композиция работает и для двухколейных функций:

Recipe Railway Compose2

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

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

Recipe Railway Compose3

Конвертирование переключателей в двухколейный ввод

Теперь мы столкнулись с проблемой.

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

Как же мы сможем добавить переключатели в двухколейную систему?

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

Recipe Railway BindAdapter

А вот так выглядит код. Я назову этот адаптер bind (связать), собственно это его стандартное имя.

let bind switchFunction = 
    fun twoTrackInput -> 
        match twoTrackInput with
        | Success s -> switchFunction s
        | Failure f -> Failure f

Функция связывания получает функцию переключатель как параметр, и возвращает новую функцию. Эта новая функция имеет двухколейный ввод (который с типом Result) и проверяет оба случая. Если это вход Success, то вызывается switchFunction с передачей значения. Но если вход это Failure - функция будет опущеная.

Скомпилируйте ее и посмотрите на сигнатуру функции:

val bind : ('a -> Result<'b,'c>) -> Result<'a,'c> -> Result<'b,'c>

Один из способов интерпретировать эту сигнатуру: функция bind принимает один параметр - функцию переключатель (a -> Result<..>) и возвращает полностью двухколейную функцию (Result<..> -> Result<..>).

Или более детально:

  • Параметр (switchFunction) функции bind, получает какой-нибудь тип 'a и генерирует Result типа 'b (для успешной колеи) и (в неуспешной колее).
  • Возвращенная функция (twoTrackInput) сама имеет параметр Result с типом 'a (для успеха) и (для неудачи). Тип 'a должен иметь тот же самый тип который ожидает одноколейный switchFunction.
  • Вывод возвращенной функции другой Result, в этом случае с типом 'b (в случае успеха) и (для неудачи) -- того же типа что и вывод функции переключателя.

Если немного подумать, эта сигнатура точно то что нам надо.

Обратите внимание: Эта функция очень обобщенная, она будет работать с любой функцией переключателем и любыми типами. Она заботится о "форме" switchFunction, но не об задействованных типах.

Другие варианты написания функции связывания

С другой стороны есть и другие варианты написания функций подобной этой.

Один из вариантов это использовать явный параметр для twoTrackInput нежели определения внутренней функции, примерно так:

let bind switchFunction twoTrackInput = 
    match twoTrackInput with
    | Success s -> switchFunction s
    | Failure f -> Failure f

Это в точности то же самое что и в первом определении. Но если вам интересно как функция с двумя параметрами, в точности то же самое как и с одним, то вам нужно почитать пост об каррировании (англ.)!

Еще один вариант написания, это заменить синтаксис match..with более лаконичным ключевым словом function:

let bind switchFunction = 
    function
    | Success s -> switchFunction s
    | Failure f -> Failure f

Вы можете увидеть все три стиля в коде, но лично я предпочитаю использовать второй стиль (let bind switchFunction twoTrackInput =), потому что считаю, что явные параметры делают код более читабельным для неспециалистов.

Пример: Комбинирование функций валидации

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

Начнем с того, что у нас уже определено. Request, Result bind:

type Result<'TSuccess,'TFailure> = 
    | Success of 'TSuccess
    | Failure of 'TFailure

type Request = {name:string; email:string}

let bind switchFunction twoTrackInput = 
    match twoTrackInput with
    | Success s -> switchFunction s
    | Failure f -> Failure f

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

let validate1 input =
   if input.name = "" then Failure "Name must not be blank"
   else Success input

let validate2 input =
   if input.name.Length > 50 then Failure "Name must not be longer than 50 chars"
   else Success input

let validate3 input =
   if input.email = "" then Failure "Email must not be blank"
   else Success input

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

Мы можем их соединить, используя двухколейные функции и стандартную композицию:

/// Склеивание трех функций валидации
let combinedValidation = 
    // конвертирование переключателей в двухколейный ввод
    let validate2' = bind validate2
    let validate3' = bind validate3
    // соединение двухколейных вместе
    validate1 >> validate2' >> validate3'

Функции validate2' и validate3' - новые функции которые имеют двухколейный ввод. Если вы посмотрите на их сигнатуры, то увидите что они получают Result и возвращают Result. Обратите внимание на то, что validate1 не должен быть конвертирован на двухколейный ввод. Ее ввод оставлен одноколейным, а вывод уже двухколейный, как собственно и необходимо для работы композиции.

Вот иллюстрация которая показывает переключатель Validate1 (несвязанный) и переключатели Validate2 и Validate3, вместе с адаптерами Validate2' и Validate3'.

Recipe Railway Validator2and3

Мы можем написать короткую версию:

let combinedValidation = 
    // соединение двухколейных вместе
    validate1 
    >> bind validate2 
    >> bind validate3

Давайте протестируем ее с двумя плохими данными и одним хорошим.

// test 1
let input1 = {name=""; email=""}
combinedValidation input1 
|> printfn "Result1=%A"

// ==> Result1=Failure "Name must not be blank"

// test 2
let input2 = {name="Alice"; email=""}
combinedValidation input2
|> printfn "Result2=%A"

// ==> Result2=Failure "Email must not be blank"

// test 3
let input3 = {name="Alice"; email="good"}
combinedValidation input3
|> printfn "Result3=%A"

// ==> Result3=Success {name = "Alice"; email = "good";}

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

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

bind как конвейерная операция

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

А вот определение, где два параметра поменяны местами для облегчение соединения:

/// создание оператора вставки
let (>>=) twoTrackInput switchFunction = 
    bind switchFunction twoTrackInput 

Один из способов запомнить этот символ это думать о нем как символ композиции >>, за которым следует двухколейный символ =.

Когда он используется, он похож на конвейер (|>) только для функций переключателей.

В обычных конвейерах, левая часть одноколейное значение а правая сторона это обычная функция. Но в "конвейере bind", левая сторона двухколейное значение а правая сторона - функция переключатель.

Вот пример использования для создания еще одной реализации функции combinedValidation.

let combinedValidation x = 
    x 
    |> validate1   // обычный конвейер, потому что validate1 имеет одноколейный вход
                   // но результат validate1 имеет двухколейный выход...
    >>= validate2  // ... поэтому ми используем "конвейер bind". Результат снова двухколейный
    >>= validate3  // ... поэтому используем снова "конвейер pipe". 

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

В предыдущей реализации (повторенная ниже), не было параметра вообще! Здесь фокус на функциях самих по себе, а не на данных которые проходят сквозь них.

let combinedValidation = 
    validate1 
    >> bind validate2 
    >> bind validate3

Альтернатива bind

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

становится вот этим: Recipe RailwaySwitch2

Но если немного подумать, то комбинирование этих путей это просто другой переключатель! Вы можете это увидеть если немного прикроете середину. Здесь один вход и два выхода:

Recipe RailwaySwitch2a

То есть на самом деле мы просто соединили переключатели, наподобие:

Recipe Railway MComp

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

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

let (>=>) switch1 switch2 x = 
    match switch1 x with
    | Success s -> switch2 s
    | Failure f -> Failure f 

И как обычно, реализация очень простая. Пропустите одноколейный ввод x сквозь первый переключатель. В случае успеха, передать результата в второй переключатель, иначе совсем его пропустить.

Теперь мы можем переписать функцию combinedValidation чтоб использовать переключатели, а не ’bind’:

let combinedValidation = 
    validate1 
    >=> validate2 
    >=> validate3 

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

bind против композиции переключателей

У нас есть два разные концепты, которые не первый взгляд очень похожи. В чем же разница.

Напомним:

  • bind имеет один параметр - функцию переключатель. Собственно он имеет поведение переходника который конвертирует в полноценную двухколейную функцию (с двухколейным входом и выходом).
  • Композиция переключателей имеет два параметра переключатели. Комбинирует их в последовательности для создания новой функции переключателя.

Почему же вам лучше использовать bind чем композицию переключателей? На самом деле это зависит от контекста. Если у вас существует двухколейная система и вам надо добавить переключатель, то вы должны использовать bind как переходник для конвертирования переключателя в что-то что имеет двухколейный вход.

Recipe_Railway_WhyBind

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

Recipe_Railway_WhyCompose

Композиция переключателей в терминах bind

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

Вот два раздельных переключатели: Recipe_RailwaySwitch1

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

Recipe RailwaySwitch2

А вот здесь та же вещь только реализованная использую bind для второго переключателя:

Recipe_Railway_BindIsCompose

Ниже представлен оператор композиции переключателей переписанный с использованием вышеописанным подходом:

let (>=>) switch1 switch2 = 
    switch1 >> (bind switch2)

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

Конвертирование простых функций в железнодорожно-ориентированную модель программирования

Однажды проникшись этим, вы можете использовать эту модель в множестве случаев.

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

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

let canonicalizeEmail input =
   { input with email = input.email.Trim().ToLower() }

Этот код ожидает (одноколейный) Request и возвращает (одноколейный) Request.

Как нам вставить это после шага валидации, но перед шагом обновления?

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

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

Recipe_Railway_SwitchAdapter

Код который это делает - тривиальный. Все что нам нужно так это получить вывод с одноколейной функции и превратить в двухколейный результат. В этом случае мы всегда возвращаем Успех.

// превращает обычную функцию в переключатель
let switch f x = 
    f x |> Success

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

Recipe_Railway_SwitchAdapter2

Как только switch будет доступен, мы можем легко добавить функцию canonicalizeEmail в конец цепочки. Так как мы начали ее расширять, то давайте переименуем функцию в usecase.

let usecase = 
    validate1 
    >=> validate2 
    >=> validate3 
    >=> switch canonicalizeEmail

Попробуйте протестировать ее и посмотреть что из этого получиться:

let goodInput = {name="Alice"; email="UPPERCASE   "}
usecase goodInput
|> printfn "Canonicalize Good Result = %A"

//Canonicalize Good Result = Success {name = "Alice"; email = "uppercase";}

let badInput = {name=""; email="UPPERCASE   "}
usecase badInput
|> printfn "Canonicalize Bad Result = %A"

//Canonicalize Bad Result = Failure "Name must not be blank"

Создание двухколейных функций на основании одноколейных

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

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

Recipe Railway MapAdapter2

Еще раз, нам просто нужен блок-адаптер с пазом для простой функции. Обычно этот адаптер называют map.

Recipe_Railway_MapAdapter

Фактическая реализация очень простая. Если двухколейный вход Success, то вызвать функцию, и результат ее работы возвратить на Success. В противном случае, если двухколейный вход Failure, то просто пропустить функцию.

Вот как это выглядит в коде:

// конвертируем обычную функцию в двухколейную
let map oneTrackFunction twoTrackInput = 
    match twoTrackInput with
    | Success s -> Success (oneTrackFunction s)
    | Failure f -> Failure f

А вот ее использование с canonicalizeEmail:

let usecase = 
    validate1 
    >=> validate2 
    >=> validate3 
    >> map canonicalizeEmail  // обычная композиция

Обратите внимание что используется обычная композиция потому что map canonicalizeEmail полностью двухколейная функция и может быть соединена с прямо с переключателем.

Другими словами, одноколейная функция >=> switch это то же самое что и >> map. Ваш выбор.

Конвертирование тупиковых функций в двухколейные

Еще одна функция которая часто необходимая в работе называется "тупиковая" - функция которая принимает ввод но не возвращает ничего полезного.

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

Как же мы сможем соединить этот тип функций с нашим потоком?

Нам нужно сделать следующее:

  • Сохранить копию ввода.
  • Вызвать функцию и если она что-нибудь возвращает то игнорировать это.
  • Вернуть оригинальный ввод передавая ее в следующую функцию по цепочке.

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

Recipe Railway Tee

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

Recipe Railway TeeAdapter

А вот и ее код, которую я буду звать tee, по аналогии с UNIX командой tee:

let tee f x = 
    f x |> ignore
    x

Имея готовую тупиковую функцию, мы можем ее использовать в нашем потоке конвертируя ее через switch или map как описано выше.

Пример кода, с использованием стиля "композиции переключателей":

// тупиковая функция    
let updateDatabase input =
   ()   // пока будем использовать заглушку

let usecase = 
    validate1 
    >=> validate2 
    >=> validate3 
    >=> switch canonicalizeEmail
    >=> switch (tee updateDatabase)

Или как вариант, вместо использования switch и потом соединять >=>, мы можем использовать map и соединять >>.

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

let usecase = 
    validate1 
    >> bind validate2 
    >> bind validate3 
    >> map canonicalizeEmail   
    >> map (tee updateDatabase)

Обработка исключений

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

Код очень похож на функцию switch, за исключением того что ловит исключения. Я ее назову tryCatch:

let tryCatch f x =
    try
        f x |> Success
    with
    | ex -> Failure ex.Message

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

let usecase = 
    validate1 
    >=> validate2 
    >=> validate3 
    >=> switch canonicalizeEmail
    >=> tryCatch (tee updateDatabase)

Функции с двухколейным входом

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

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

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

Recipe Railway DoubleMapAdapte

А вот и код:

let doubleMap successFunc failureFunc twoTrackInput =
    match twoTrackInput with
    | Success s -> Success (successFunc s)
    | Failure f -> Failure (failureFunc f)

Кстати, мы можем использовать эту функцию для создания упрощенной версии map, используя id для неуспешного пути:

let map successFunc =
    doubleMap successFunc id

Давайте используем doubleMap для вставки журналирования в поток:

let log twoTrackInput = 
    let success x = printfn "DEBUG. Success so far: %A" x; x
    let failure x = printfn "ERROR. %A" x; x
    doubleMap success failure twoTrackInput 

let usecase = 
    validate1 
    >=> validate2 
    >=> validate3 
    >=> switch canonicalizeEmail
    >=> tryCatch (tee updateDatabase)
    >> log

Несколько тестов с результатами:


let goodInput = {name="Alice"; email="good"}
usecase goodInput
|> printfn "Good Result = %A"

// DEBUG. Success so far: {name = "Alice"; email = "good";}
// Good Result = Success {name = "Alice"; email = "good";}

let badInput = {name=""; email=""}
usecase badInput 
|> printfn "Bad Result = %A"

// ERROR. "Name must not be blank"
// Bad Result = Failure "Name must not be blank"

Конвертирование функций с одним значением в двухколейное значение

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

let succeed x = 
    Success x

let fail x = 
    Failure x

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

Паралельное комбинирование функций

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

Recipe Railway Parallel

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

Recipe_Railway_MPlus

Какая же логика для сложения двух переключателей в паралельный?

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

Функция которую я буду называть plus:

let plus switch1 switch2 x = 
    match (switch1 x),(switch2 x) with
    | Success s1,Success s2 -> Success (s1 + s2)
    | Failure f1,Success _  -> Failure f1
    | Success _ ,Failure f2 -> Failure f2
    | Failure f1,Failure f2 -> Failure (f1 + f2)

Только вот мы получили проблему. Что мы будем делать если получим два "успехи" или две "неудачи"? Как мы должны комбинировать внутренние значения?

Я использовал s1 + s2 и f1 + f2 в примере выше, но это подразумевает что здесь есть какой нибудь оператор + который мы можем использовать. Это может быть справедливо для строк или чисел, но и это в общем неверно.

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

Вот переделанная версия:

let plus addSuccess addFailure switch1 switch2 x = 
    match (switch1 x),(switch2 x) with
    | Success s1,Success s2 -> Success (addSuccess s1 s2)
    | Failure f1,Success _  -> Failure f1
    | Success _ ,Failure f2 -> Failure f2
    | Failure f1,Failure f2 -> Failure (addFailure f1 f2)

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

Реализация параллельной валидации

Теперь давайте реализуем операцию "суммирования" для функции валидации.

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

Тогда для валидации, нам нужно чтобы операция "суммирования" имела поведение как функция "И" ("AND"). Только если обе части "истинны" то результат будет "истина".

Обычно это приводит к тому что необходимо использовать символ оператора &&. К сожалению, && зарезервирован, но мы можем использовать &&&, вот так:

// создание функции суммирования для функции валидации
let (&&&) v1 v2 = 
    let addSuccess r1 r2 = r1 // возвращаем первый
    let addFailure s1 s2 = s1 + "; " + s2  // обедняем
    plus addSuccess addFailure v1 v2

Сейчас используя &&& мы можем создать одну функцию валидации которая комбинирует три малы:

let combinedValidation = 
    validate1 
    &&& validate2 
    &&& validate3 

Давайте проведем несколько тестов которые мы имели ранее:

// test 1
let input1 = {name=""; email=""}
combinedValidation input1 
|> printfn "Result1=%A"
// ==>  Result1=Failure "Name must not be blank; Email must not be blank"

// test 2
let input2 = {name="Alice"; email=""}
combinedValidation input2 
|> printfn "Result2=%A"
// ==>  Result2=Failure "Email must not be blank"

// test 3
let input3 = {name="Alice"; email="good"}
combinedValidation input3 
|> printfn "Result3=%A"
// ==>  Result3=Success {name = "Alice"; email = "good";}

Теперь первый тест имеет две ошибки валидации соединенные в одну строку, так как мы и хотели.

Далее, мы приукрасить основный поток функция используя в usecase одну функцияю вместо трех, как было прежде:

let usecase = 
    combinedValidation
    >=> switch canonicalizeEmail
    >=> tryCatch (tee updateDatabase)

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

// test 4
let input4 = {name="Alice"; email="UPPERCASE   "}
usecase input4
|> printfn "Result4=%A"
// ==>  Result4=Success {name = "Alice"; email = "uppercase";}

Вы можете спросить, можем ли мы таким же путем создать функцию валидации только которая следует политике "OR"? Это когда общий результат "истина" когда хотя бы одна часть валидна? Ответ, да, конечно же. Попробуйте! Я предлагаю вам использовать символ |||.

Динамическая инъекция функций

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

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

Вот идея:

let injectableFunction = 
    if config.debug then debugLogger else id

Давайте попробуем ее на реальном коде:

type Config = {debug:bool}

let debugLogger twoTrackInput = 
    let success x = printfn "DEBUG. Success so far: %A" x; x
    let failure = id // don't log here
    doubleMap success failure twoTrackInput 

let injectableLogger config = 
    if config.debug then debugLogger else id

let usecase config = 
    combinedValidation 
    >> map canonicalizeEmail
    >> injectableLogger config

А вот здесь ее испльзование:

let input = {name="Alice"; email="good"}

let releaseConfig = {debug=false}
input 
|> usecase releaseConfig 
|> ignore

// no output

let debugConfig = {debug=true}
input 
|> usecase debugConfig 
|> ignore

// debug output
// DEBUG. Success so far: {name = "Alice"; email = "good";}

Железнодорожные функции: набор

Давайте немного остановимся и пересмотрим то что у нас уже есть.

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

Мы можем их грубо класифициоровать на:

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

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

Функции наподобие bind, map, plus и т. п. возникают во всех сценариях функционального програмирования и вы можте думать о ник как о функциональных шаблонах -- похожих но не тех самых что фигурируют в ООП, типа "visitor", "singleton", "facade" и т.п.

Вот они все вместе:

Concept Description
succeed Конструктор, который принимает одноколейное значение и создает двухколейное значение на "Успешной" ветке. В другом контексте оно может быть назван return или pure.
fail Конструктор, который принимает одноколейное значение и создает двухколейное значение на "Не успешной" ветке.
bind Переходник, который получает функцию переключатель и создает новую функцию которая принимает двухколейный ввод.
>>= Инфиксная версия bind для передачи двухколейных значений в функцию переключатель
>> Обычная композиция. Комбинатор который получает две обычные функции и создает новую функцию соединяя их последовательно.
>=> Композиция переключателей. Комбинатор который получает две функции переключатели и создает новую комбинируя их последовательно.
switch Переходник, который получает одноколейную функцию и превращает ее в функцию переключатель. (Также известный как "lift")
map Переходник, который получает нормальную одноколейную функцию и превращает ее в двухколейную функцию.
tee Переходник, который получает тупиковую функцию и превращает ее в одноколейную функцию которая может быть использована в потоке. (Известная как tap)
tryCatch Переходник который получает нормальную одноколейную функцию и превращает ее в функцию переключатель, но также ловит исключения
doubleMap Переходник, который получает две одноколейные функции и превращает их в одну двухколейную функцию. (так же известный как bimap)
plus Комбинатор который получает две функции переключателя и создает новую функцию соединяя их "параллельно" и "сумируя" их результаты. (Известный как ++ или <+>)
&&& Комбинатор plus специально настроен для функций валидации, моделирует бынарую операцию "И" ("AND")

Железнодорожные функции: готовый код

А вот готовый код для всех этих функций в одном месте.

Я немного их подкорректировал из тех что были представлены выше:

  • Большинство функций сейчас определены в терминах базовых функций названых either.
  • Добавлен дополнительный параметр функции tryCatch который обрабатывает исключения.
// двухколейны тип
type Result<'TSuccess,'TFailure> = 
    | Success of 'TSuccess
    | Failure of 'TFailure

// конвертирует одиночное значение в двухколейный результат
let succeed x = 
    Success x

// конвертирует одиночное значение в двухколейный результат
let fail x = 
    Failure x

// применяет либо успешную функцию или неуспешную функцию 
let either successFunc failureFunc twoTrackInput =
    match twoTrackInput with
    | Success s -> successFunc s
    | Failure f -> failureFunc f

// конвертирует функцию переключатель в двухколейную функцию
let bind f = 
    either f fail

// трубопровод для двухколейных значений в функцию переключателя 
let (>>=) x f = 
    bind f x

// комбинирует два переключатели в один переключатель
let (>=>) s1 s2 = 
    s1 >> bind s2

// конвертирует одноколейную функцию в переключатель
let switch f = 
    f >> succeed

// конвертирует одноколейную функцию в двухколейную
let map f = 
    either (f >> succeed) fail

// конвертирует тупиковую функцию в одноколейную функцию
let tee f x = 
    f x; x 

// конвертирует одноколейную функцию в переключатель который ловит исключения
let tryCatch f exnHandler x =
    try
        f x |> succeed
    with
    | ex -> exnHandler ex |> fail

// конвертирует две одноколейные функции в одну двухколейную
let doubleMap successFunc failureFunc =
    either (successFunc >> succeed) (failureFunc >> fail)

// суммирует два переключатели "параллельно"
let plus addSuccess addFailure switch1 switch2 x = 
    match (switch1 x),(switch2 x) with
    | Success s1,Success s2 -> Success (addSuccess s1 s2)
    | Failure f1,Success _  -> Failure f1
    | Success _ ,Failure f2 -> Failure f2
    | Failure f1,Failure f2 -> Failure (addFailure f1 f2)

Типы против форм

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

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

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

Recipe_Railway_Cargo1

И груз из яблок будет трансформирован в бананы, когда проходит через функцию funtion2.

Recipe Railway Cargo2

Эта магическая дороги имеет весьма важное правило, а именно что мы можем соединять пути которые везут одинаковый тип груза. В этом случае мы можем соединить function1 с function2 потому что груз который выходит из function1 (яблоки) это тот же груз который входит в function2 (тоже яблоки).

Recipe Railway Cargo3

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

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

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

Обобщенные типы - мощны

Почему же нас не волнуют типы грузов? Потому что все функции "переходники" и "комбинаторы" совсем обобщены. Функции bind, map, switch и plus не заботятся о типе груза на пути.

Имея такие обобщенные функции имеет два плюса. Первый очевиден: чем более обобщенная функция тем более ее можно использовать повторно. Реализация bind будет работать с любыми типами (до тех пор пока форма правильная).

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

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

val map : ('a -> 'b) -> (Result<'a,'c> -> Result<'b,'c>)

Она получает параметр функции 'a -> 'b, значение Result<'a,'c> и возвращает значение Result<'b,'c>.

Мы абсолютно ничего не знаем о типах 'a, 'b и 'c. Мы только знаем что:

  • Тот самый тип 'a используется в обоих параметрах функции и успешном случае первого Result.
  • Тот самый тип 'b используется в обоих параметрах функции и успешном случае второго Result.
  • Тот самый тип 'c используется в "не успешном" случае в первом и втором Results, но не используется как параметр функции.

Какой вывод мы можем сделать с этого?

Возвращаемое значение имеет тип 'b. Но откуда он берется? Мы не знаем что такое тип 'b, поэтому мы не знаем как его создать. Но параметр функция знает как его создать. Передать ей 'a и она создаст 'b для нас.

Откуда же нам получить тип 'a? Мы также не знаем что такое тип 'a, опять мы не знаем как его создать. Но первый параметр результата имеет 'a которую мы можем использовать, поэтому мы вынуждены получить Success значения с параметра Result<'a,'c>, и передать его параметром в функцию. После в успешном случае Result<'b,'c> должен быть создан с результата функции.

И наконец, аналогично применяется и к 'c. Мы вынуждены получить значение Failure с Result<'a,'c> и использовать ее для создания Failureслучая для возвращаемого значения Result<'a,'c>.

Другими словами, существует только один путь реализации функции map. Сигнатура функции настолько обобщенная поэтому у мы не имеем выбора.

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

val map : (int -> int) -> (Result<int,int> -> Result<int,int>)

В этом случае мы можем прийти к большому количеству реализаций. Вот скосок из нескольких:

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

Все эти реализации "забагованы", в том смысле что они не делают то что мы ожидаем. Но они все возможны потому что мы дополнительно знаем что они имеют тип int, и поэтому можем манипулировать значениями так как не должны. Чем меньше мы знаем о типах то менее вероятно допустить ошибку.

Тип ошибки

Большинство наших функций, применяют трансформацию только для успешных путей. Неуспешный путь оставлен в одиночестве (map) или объеденен с входящим неуспешным путем (bind).

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

Резюме и советы

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

Сейчас вы можете почувствовать себя немного перегружено. Вместо того что бы сделать все проще, похоже что я все еще более усложнил. Я показал вам много разных путей, которые приводят к одной цели. bind против композиции. map против switch. Который подход использовать? Какой путь лучший?

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

Советы

  • Используйте двухколейную дорогу в вашей основной модели потока данных.
  • Создайте функцию для каждого шага в варианте использования. Функция для каждого шага может быть создана из меньших функций (как в примере с валидацией).
  • Используйте стандартную композицию для соединения функций.
  • Если вам надо вставить переключатель в поток - используйте bind.
  • Если нужно вставить одноколейную функцию в поток - используйте map.
  • Если надо вставить функцию с другим типом в поток - создайте блок переходник, и используйте его.

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

Вот основные куски реализации основаны на этих советах. Обратите внимание, что >> используется в конечно функции usecase.

open RailwayCombinatorModule 

let (&&&) v1 v2 = 
    let addSuccess r1 r2 = r1 // return first
    let addFailure s1 s2 = s1 + "; " + s2  // concat
    plus addSuccess addFailure v1 v2 

let combinedValidation = 
    validate1 
    &&& validate2 
    &&& validate3 

let canonicalizeEmail input =
   { input with email = input.email.Trim().ToLower() }

let updateDatabase input =
   ()   // dummy dead-end function for now

// new function to handle exceptions
let updateDatebaseStep = 
    tryCatch (tee updateDatabase) (fun ex -> ex.Message)

let usecase = 
    combinedValidation 
    >> map canonicalizeEmail
    >> bind updateDatebaseStep
    >> log

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

  • Не используйте "странные" операторы кроме >> и |>.
  • Практически это значить что вы не должны использовать операторы на подобие >>= или >=> пока все их не осознают.
  • Исключением может быть если вы определите оператор в начале модуля или функции где он будет использован. Например, оператор &&& мог бы быть определен на начале модуля валидации а после использован позже в самом модуле.


Предыдущий пост

Источник

Добавить комментарий