[Перевод] Вычислительные выражения: Подробнее про типы-обёртки

765cc589ac82b736009a461461accc34.jpg

В предыдущем посте мы познакомились с концепцией «типов-обёрток» и с тем, как они связаны с вычислительными выражениями.
В этом посте мы разберёмся, какие типы можно использовать в качестве обёрток.

Какие типы могут быть обёртками?

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

Существует одно основное правило, которое гласит:

Например, мы видели, что можно использовать Option, DbResult, и подобные типы, как обёртки.

Что насчёт других обобщённых типов, таких как List или IEnumerable?
Это типы-коллекции, так что кажется странным, что в них можно завернуть какое-то одно значение.
На самом деле, они тоже могут быть обёртками, и чуть позже мы разберёмся, как это работает.

Подойдут ли необобщённые типы-обёртки?

Можно ли создать тип-обёртку, у которого нет обобщённого параметра?

В одном из предыдущих примеров, мы попытались реализовать сложение строк вида "1" + "2".
Нельзя ли в этом случае трактовать string как тип-обёртку над int?
Было бы здорово.

Давайте попробуем.
Мы можем использовать сигнатуры методов Bind и Return в качестве отправной точки.

  • Bind получает кортеж. Первая часть кортежа — тип-обёртка (в нашем случае string), а вторая часть кортежа — функция, которая принимает незавёрнутый тип и превращает его в завёрнутый тип. В данном случае, её сигнатура будет int -> string.

  • Return получает незавёрнутый тип (в нашем случае int) и превращает его в завёрнутый тип. И в этом случае, сигнатура Return будет int -> string.

Теперь, вот что у нас получается:

  • Реализация «заворачивающей» функции с сигнатурой int -> string превращает любое число в строку. Это обычный метод «toString» типа int.

  • Функция связывания должна развернуть значение из string в int и затем передать его в функцию. Для реализации мы можем использовать int.Parse.

  • Что произойдёт, если функция связывания не сможет извлечь значени из строки, потому что оно не является корректным числом? Вы этом случае функция связывания все ещё должна вернуть тип-обёртку (string), так что мы можем просто вернуть что-то вроде строки «ошибка».

Вот реализация соответствующего класса-построителя:

type StringIntBuilder() =

    member this.Bind(m, f) =
        let b,i = System.Int32.TryParse(m)
        match b,i with
        | false,_ -> "ошибка"
        | true,i -> f i

    member this.Return(x) =
        sprintf "%i" x

let stringint = new StringIntBuilder()

Испытания:

let good =
    stringint {
        let! i = "42"
        let! j = "43"
        return i+j
        }
printfn "хороший результат=%s" good

А что произойдёт, если одна из строк не окажется числом?

let bad =
    stringint {
        let! i = "42"
        let! j = "xxx"
        return i+j
        }
printfn "плохой результат=%s" bad

Это действительно здорово — внутри нашего процесса мы можем обращаться со строками как с числами!

Но подождите, не всё так безоблачно.

Представим, что мы передадим значение в процесс, развернём его (с помощью let!) и затем немедленно завернём (с помощью return), не выполняя никаких других действий.
Что случится тогда?

let g1 = "99"
let g2 = stringint {
            let! i = g1
            return i
            }
printfn "g1=%s g2=%s" g1 g2

Никаких проблем.
Входное значение g1 и выходное значение g2 совпадают, как мы и ожидали.

Но что будет в случае ошибки?

let b1 = "xxx"
let b2 = stringint {
            let! i = b1
            return i
            }
printfn "b1=%s b2=%s" b1 b2

Здесь мы получили не то, что ожидали.
Входное значение b1 и выходное значение b2 не совпадают.
У нас появилось несоответствие.

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

Правила для процесса, который использует тип-обёртку

А вот вам вопрос на засыпку!
Есть ли отличия между этими двумя фрагментами кода, и должен ли код вести себя по разному?

// фрагмент до рефакторинга
myworkflow {
    let wrapped = // какое-то завёрнутое значение
    let! unwrapped = wrapped
    return unwrapped
    }

// фрагмет после рефакторинга
myworkflow {
    let wrapped = // какое-то завёрнутое значение
    return! wrapped
    }

Ответ — нет, они не должны вести себя по разному.
Единственное отличие второго примера заключается в том, что значение unwrapped было выброшено в результате рефакторинга, и значение wrapped — возвращено напрямую.

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

Правило 1: Если вы начинаете с незавёрнутого значения, затем заворачиваете его (используя return) и снова разворачиваете (используя bind), вы должны получить оригинальное незавёрнутое значение.

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

Первое правило в виде кода:

myworkflow {
    let originalUnwrapped = something

    // заворачиваем
    let wrapped = myworkflow { return originalUnwrapped }

    // разворачиваем
    let! newUnwrapped = wrapped

    // убеждаемся, что значения совпадают
    assertEqual newUnwrapped originalUnwrapped
    }

Правило 2: Если вы начинаете с завёрнутого значения, затем разворачиваете его (используя bind) и снова заворачиваете (используя return), вы должны получить оригинальное завёрнутое значение.

Процесс stringInt, описанный ранее, нарушает именно это правило.

Второе правило в виде кода:

myworkflow {
    let originalWrapped = something

    let newWrapped = myworkflow {

        // разворачиваем
        let! unwrapped = originalWrapped

        // заворачиваем
        return unwrapped
        }

    // убеждаемся, что значения совпадают
    assertEqual newWrapped originalWrapped
    }

Правило 3: Дочерний процесс должен возвращать тот же результат, как если бы он был «встроен» в основной процесс.

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

Если вы будете следовать некоторым рекомендациям (про которые я расскажу в следующем посте), ваш код будет соответсвовать всем правилам автоматически.

А вот пример со встроенным процессом:

// встроенный
let result1 = myworkflow {
    let! x = originalWrapped
    let! y = f x  // какая-то функция с x
    return! g y   // какая-то функция с y
    }

// используя дочерний процесс ("выделение" рефакторинг)
let result2 = myworkflow {
    let! y = myworkflow {
        let! x = originalWrapped
        return! f x // какая-то функция с x
        }
    return! g y     // какая-то функция с y
    }

// убеждаемся, что значения совпадают
assertEqual result1 result2

Список как тип-обёртка

Ранее я говорил, что типы вроде List или IEnumerable можно использовать в качестве обёрток.
Но ведь в списке может хранится несколько значений, как же мы можем его «развернуть»?
Оказывается, между завёрнутыми и развёрнутыми типами не должно быть соответствия один-к-одному.

В данном случае аналогия с «обёрткой» немного вводит в заблуждение.
Вернёмся к методу bind, соединяющему выход одного выражения с входом другого.

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

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

bind( [1;2;3], fun elem -> // выражение с одним элементом )

Следуя этой концепции, мы можем объединять вызовы bind в цепочку:

let add =
    bind( [1;2;3], fun elem1 ->
    bind( [10;11;12], fun elem2 ->
        elem1 + elem2
    ))

Однако, мы кое-что упустили.
Функция-продолжение, передаваемая в bind, обязана иметь определенную сигнатуру.
Она принимает развёрнутый тип, но возвращает завёрнутый тип.

Иначе говоря, фукнция-продолжение в качестве результат должна всегда возвращать новый список.

bind( [1;2;3], fun elem -> // выражение с одним элементом, возвращающее список )

Следовательно, пример с цепочкой вызовов нужно переписать так, чтобы результат elem1 + elem2 помещался в список.

let add =
    bind( [1;2;3], fun elem1 ->
    bind( [10;11;12], fun elem2 ->
        [elem1 + elem2] // список!
    ))

Так что логика для нашего метода bind сейчас выглядит следующим образом:

let bind(list,f) =
    // 1) к каждому элементу списка применить f
    // 2) f вернёт список (как того требует сигнатура)
    // 3) результатом будет список списков

Теперь у нас другая задача. Bind должен возвращать тип-обёртку, а означает, что «список списков» в качестве результата нам не подходит.
Мы должны превратить его обратно в плоский «одноуровневый» список.

Это довольно просто — в модуле List есть функция, которая именно это и делает, она называется concat.

Сложив всё вместе, получим:

let bind(list,f) =
    list
    |> List.map f
    |> List.concat

let added =
    bind( [1;2;3], fun elem1 ->
    bind( [10;11;12], fun elem2 ->
//       elem1 + elem2    // неправильно
        [elem1 + elem2]   // правильно: возвращаем список
    ))

Теперь, когда мы понимаем, как работает bind, мы можем создать «списочный процесс».

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

  • List.collect — библиотечная функция, которую можно использовать вместо связки List.map и List.concat.

  • Return превращает развёрнутое значение в завёрнутое. В нашем случае, он просто помещает отдельный элемент в список.

type ListWorkflowBuilder() =

    member this.Bind(list, f) =
        list |> List.collect f

    member this.Return(x) =
        [x]

let listWorkflow = new ListWorkflowBuilder()

Вот процесс в работе:

let added =
    listWorkflow {
        let! i = [1;2;3]
        let! j = [10;11;12]
        return i+j
        }
printfn "суммы=%A" added

let multiplied =
    listWorkflow {
        let! i = [1;2;3]
        let! j = [10;11;12]
        return i*j
        }
printfn "произведения=%A" multiplied

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

val added : int list = [11; 12; 13; 12; 13; 14; 13; 14; 15]
val multiplied : int list = [10; 11; 12; 20; 22; 24; 30; 33; 36]

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

Синтаксический сахар для «for»

Трактуя списки и последовательности как особый случай, мы можем добавить немного синтаксического сахара, чтобы заменить let! чем-то более естественным.

Например, мы можем заменить let! на выражение for..in..do:

// версия с let!
let! i = [1;2;3] in [some expression]

// версия с for..in..do
for i in [1;2;3] do [some expression]

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

Чтобы разрешить компилятору F# такую обработку, мы должны добавить метод For в наш класс-построитель.
В общем случае он делает то же, что и Bind, но традиционно используется с типами, хранящими последовательности.

type ListWorkflowBuilder() =

    member this.Bind(list, f) =
        list |> List.collect f

    member this.Return(x) =
        [x]

    member this.For(list, f) =
        this.Bind(list, f)

let listWorkflow = new ListWorkflowBuilder()

Вот пример использования:

let multiplied =
    listWorkflow {
        for i in [1;2;3] do
        for j in [10;11;12] do
        return i*j
        }
printfn "произведения=%A" multiplied

LINQ и «процесс с типом-списком»

Правда, конструкция for element in collection do выглядит знакомо?
По синтаксису она очень похожа на from element in collection..., которая используется в LINQ.
В основе LINQ и правда лежит такая же техника «закулисной» конвертации из синтасиса наподобие from element in collection ... в реальные вызовы методов.

В F#, как мы видели, bind вызывает функцию List.collect.
Эквивалентом List.collect в LINQ является метод расширения SelectMany.
Как только вы поймёте, как работает SelectMany, вы можете реализовать подобный вид запросов самостоятельно.
Джон Скит написал полезный пост в своём блоге, который объясняет, как это сделать.

Идентичный «тип-обёртка»

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

Но как быть с логгированием из предыдущего поста? У него не было никакого типа-обёртки.
Там был let!, который где-то внутри делал разные штуки, но входный тип был тем же самым, что и выходной.
Иными словами, завёрнутый тип был идентичен незавёрнутому типу.

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

Давайте вернёмся назад и разберёмся, что в действительности означает определение типа-обёртки, такое как List.

Фактически, List вообще не является «реальным» типом.
List — реальный тип и List — реальный тип.
Но сам по себе List — неполный.
У него есть параметр, и мы должны его предоставить, чтобы он стал реальным типом.

Можно думать о List не как о типе, а как о функции.
Это функция не из конкретного мира обычных значений, а из абстрактного мира типов, но как и любая другая функция она отображает одни значения в другие.
Однако, её входные значения — это типы (int и string), и выходные значения — тоже типы (List и List).
Как и у всякой функции, у неё есть параметер, и это как раз «параметр-тип».
Кстати, именно поэтому то, что мы называем «обобщёнными типами», в научных кругах называют параметрическим полиморфизмом.

Если вы ухватили концепцию функций, которые генерируют один тип из другого (они называются «конструкторами типов»), вы понимаете, что «тип-обёртка» — как раз такой конструктор.

Но если «тип-обёртка» — это всего лишь функция, которая отображает один тип в другой, то наверняка функция, которая отображает тип сам в себя (традиционно она называется identity), тоже попадает в эту категорию?

В реальном коде мы можем определить «идентичный процесс», как простейшую возможную реализацию построителя.

type IdentityBuilder() =
    member this.Bind(m, f) = f m
    member this.Return(x) = x
    member this.ReturnFrom(x) = x

let identity = new IdentityBuilder()

let result = identity {
    let! x = 1
    let! y = 2
    return x + y
    }

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

Итоги

Ещё один длинный пост.
Мы разобрались в большом количестве тем, так что, я надеюсь, теперь вы понимаете, что такое типы-обёртки.
Добравшись до общих процессов, таких как «процесс-писатель» или «процесс с состоянием», мы разберёмся, как использовать типы-обёртки на практике.
Об этом мы поговорим в одном из следующих постов.

Сводка основных тем, которые мы затронули:

  • Основное использование вычислительных выражений — разворачивать и заворачивать значения, которые хранятся в типе-обёртке.

  • Вычислительные выражения лекго компоновать, поскольку выход функции Return можно подать на вход функции Bind.

  • Каждое вычислительное выражение должно быть ассоциировано с вычислительным типом.

  • Любой тип с обобщённым параметром, даже список, может быть использован в качестве типа-обёртки.

  • При создании процесса, вы должны убедиться, что ваша реализация соответствует трём разумным правилам, касающимся заворачивания, разворачивания и композиции.

Habrahabr.ru прочитано 7030 раз