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

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

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

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

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

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

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

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

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

В одном из предыдущих примеров, мы попытались реализовать сложение строк вида "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<T> или IEnumerable<T> можно использовать в качестве обёрток. Но ведь в списке может хранится несколько значений, как же мы можем его "развернуть"? Оказывается, между завёрнутыми и развёрнутыми типами не должно быть соответствия один-к-одному.

В данном случае аналогия с "обёрткой" немного вводит в заблуждение. Вернёмся к методу 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<T>.

Фактически, List<T> вообще не является "реальным" типом. List<int> — реальный тип и List<string> — реальный тип. Но сам по себе List<T> — неполный. У него есть параметр, и мы должны его предоставить, чтобы он стал реальным типом.

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

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

Но если "тип-обёртка" — это всего лишь функция, которая отображает один тип в другой, то наверняка функция, которая отображает тип сам в себя (традиционно она называется 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.

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

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

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

Комментарии (2)


  1. CorwinH
    15.04.2024 08:34

    В данном случае аналогия с "обёрткой" немного вводит в заблуждение.

    Можно вместо аналогии "обёртка" использовать аналогию "эффект" (вычисления с эффектом). Эффект для Option<T> - результат вычислений может отсутствовать. Эффект для List<T> - вычисления могут иметь ноль или несколько результатов.


    1. markshevchenko Автор
      15.04.2024 08:34

      Да, согласен. Но здесь у меня перевод, я стараюсь не сильно отходить от оригинального текста. Там у Скотта wrapper type.