В этом посте мы отклонимся от основной темы и познакомимся с парой трюков, которые помогут вам разнообразить методы в построителе вычислительных выражений.
В конечном итоге наши исследования заведут нас в тупик, но я надеюсь, что путешествие станет источником прозрений, как правильно проектировать вычислительные выражения.
Обратите внимание, что "построитель" в контексте вычислительных выражений — это не то же самое, что объектно-ориентированный паттерн "строитель", который используется для конструирования и валидации объектов.
Озарение: методы построителя можно перегружать
В какой-то момент у вас может возникнуть озарение:
Методы построителя — обычные методы класса, и, в отличие от автономной функций, методы поддерживают перегрузку с отличающимися типами параметров. Это значит, что мы можем делать отличающиеся реализации, если типы их параметров отличаются.
Возможно, эта идея вас вдохновляет и вы с энтузиазмом придумываете, как можно её использовать.
Однако, может оказаться, что она не настолько полезна, насколько вы думаете.
Взглянем на несколько примеров.
Перегрузка "Return"
Скажем, у вас есть тип-объединение.
Вы решаете перегрузить Return
или Yield
с разными реализациями для каждого варианта объединения.
Вот очень простой пример, где Return
имеет две перегрузки:
type SuccessOrError =
| Success of int
| Error of string
type SuccessOrErrorBuilder() =
member this.Bind(m, f) =
match m with
| Success s -> f s
| Error _ -> m
/// перегрузка для int
member this.Return(x:int) =
printfn "Return a success %i" x
Success x
/// перегрузка для strings
member this.Return(x:string) =
printfn "Return an error %s" x
Error x
// создаём экземпляр процесса
let successOrError = new SuccessOrErrorBuilder()
А вот как его можно использовать:
successOrError {
return 42
} |> printfn "Результат в случае успеха: %A"
// Результат в случае успеха: Success 42
successOrError {
return "error for step 1"
} |> printfn "Результат в случае ошибки: %A"
// Результат в случае ошибки: Error "error for step 1"
Как вы думаете, что здесь не так?
Ну, во-первых, если мы вернёмся к обсуждению типов-обёрток, то обнаружим, что типы-обёртки лучше делать обобщёнными.
Мы хотим, насколько это возможно, использовать наш код повторно, в том числе, и процессы. Зачем привязывать реализацию к какому-то конкретному примитивному типу?
В нашем случае это значит, что тип объединения должны быть обобщён:
type SuccessOrError<'a,'b> =
| Success of 'a
| Error of 'b
Но теперь, из-за обобщённых типов, метод Return
больше не может быть перегружен! (В случае, если 'a
и 'b
совпадают, возникнет неоднозначность — прим. переводчика.)
Во-вторых, не самая хорошая идея — раскрывать детали реализации типа внутри выражения.
Концепция вариантов "успех" и "неудача" полезна, но лучшим способом было бы скрыть вариант "неудачи" и обрабатывать его автоматически внутри Bind
:
type SuccessOrError<'a,'b> =
| Success of 'a
| Error of 'b
type SuccessOrErrorBuilder() =
member this.Bind(m, f) =
match m with
| Success s ->
try
f s
with
| e -> Error e.Message
| Error _ -> m
member this.Return(x) =
Success x
// создаём экземпляр процесса
let successOrError = new SuccessOrErrorBuilder()
Здесь Return
используется только в случае "успеха", а "неудача" спрятана.
successOrError {
return 42
} |> printfn "Результат в случае успеха: %A"
successOrError {
let! x = Success 1
return x/0
} |> printfn "Результат в случае ошибки: %A"
Больше примеров этой техники мы увидим в следующем посте.
Множество реализаций "Combine"
Другая ситуация, когда у вас может возникнуть соблазн перегрузить метод — это Combine
.
Для примера перепишем метод Combine
для процесса trace
.
Если вы помните, в предыдущей реализации мы просто складывали числа.
Но что, если мы изменим наши требования, скажем, так:
если мы возвращаем несколько значений в процессе
trace
, мы объединяем их в список.
Первая попытка переписать Combine
будет выглядеть так:
member this.Combine (a,b) =
match a,b with
| Some a', Some b' ->
printfn "комбинируем %A с %A" a' b'
Some [a';b']
| Some a', None ->
printfn "комбинируем %A с None" a'
Some [a']
| None, Some b' ->
printfn "комбинируем None с %A" b'
Some [b']
| None, None ->
printfn "комбинируем None с None"
None
В методе Combine
мы разворачиваем значения из переданного опционального типа с комбинируем их в список-обёртку в Some
(т.е. Some [a';b']
).
Для двух операторов yield
всё будет работать, как мы и ожидали:
trace {
yield 1
yield 2
} |> printfn "Результат yield и последющий yield: %A"
// Результат yield и последющий yield: Some [1; 2]
И при возврате None
, всё тоже будет работать как надо:
trace {
yield 1
yield! None
} |> printfn "Результат yield и последующий None: %A"
// Результат yield и последующий None: Some [1]
Но что получится, если мы попробуем скомбинировать три значения? Например, так:
trace {
yield 1
yield 2
yield 3
} |> printfn "Результат yield x 3: %A"
Внезапно мы получим ошибку компиляции:
Error FS0193 : Несоответствие ограничений типов. Тип
"int list option"
несовместим с типом
"int option"
В чём проблема?
Ответ в том, что после комбинирования 2-го и 3-го значений (yield 2; yield 3
), мы получаем опциональное значение, содержащее список целых или int list option
.
Ошибка происходит, когда мы пытаемся комбинировать первое значение (Some 1
) с комбинированным значением (Some [2;3]
).
То есть мы передаём int list option
в качестве второго параметра Combine
, но первый параметр всё ещё обычный int option
.
Компилятор говорит нам, что он хочет, чтобы второй параметр имел такой же тип, как и первый.
Мы снова можем использовать трюк с перегрузкой.
Напишем две реализации Combine
с различными типами второго параметра — одну, которая принимает int option
и вторую которая принимает int list option
.
Вот два метода с различными типами параметра:
/// комбинируем с опциональным списком
member this.Combine (a, listOption) =
match a,listOption with
| Some a', Some list ->
printfn "комбинируем %A с %A" a' list
Some ([a'] @ list)
| Some a', None ->
printfn "комбинируем %A с None" a'
Some [a']
| None, Some list ->
printfn "комбинируем None с %A" list
Some list
| None, None ->
printfn "комбинируем None с None"
None
/// комбинируем с опциональным одиночным значением
member this.Combine (a,b) =
match a,b with
| Some a', Some b' ->
printfn "комбинируем %A с %A" a' b'
Some [a';b']
| Some a', None ->
printfn "комбинируем %A с None" a'
Some [a']
| None, Some b' ->
printfn "комбинируем None с %A" b'
Some [b']
| None, None ->
printfn "комбинируем None с None"
None
Теперь мы можем скомбинировать три значения и получим именно то, что хотели.
trace {
yield 1
yield 2
yield 3
} |> printfn "Результат yield x 3: %A"
// Результат yield x 3: Some [1; 2; 3]
К сожалению, этот трюк сломал часть предыдущего кода!
Если сейчас вы попробуете вернуть None
, вы получите ошибку компиляции.
trace {
yield 1
yield! None
} |> printfn "Результа yield с последующим None: %A"
Ошибка:
Невозможно определить уникальную перегрузку метода "Combine" на основе сведений о типе, заданных до данной точки программы. Возможно, требуется аннотация типа.
Прежде чем впасть в раздражение, попробуйте думать как компилятор.
Если бы вы были компилятором, и получили бы None
, какой бы метод вызвали вы?
Правильного ответа нет, потому что None
можно передать вторым параметром в оба метода.
Компилятор не знает, относится ли этот None
к типу int list option
(первый метод) или к типу int option
(второй метод).
Как и говорит компилятор, нам помогла бы аннотация типа, поэтому давайте её и предоставим. Заставим None
быть int option
.
trace {
yield 1
let x:int option = None
yield! x
} |> printfn "Результат yield с последующим None: %A"
Конечно, это уродливо, но на практике встречается не слишком часто.
Гораздо важнее, что подобное уродство — признак плохого дизайна.
Иногда вычислительное выражение возвращает 'a option
, а иногда — 'a list option
.
Мы должны быть последовательны в своём дизайне, так что вычислительное выражение всегда должно иметь один и тот же тип, независимо от того, сколько в нём встретилось операторов yield
.
Если мы действительно хотим разрешить сколько угодно операторов yield
, то в качестве типа-обёртки мы с самого начала должны использовать 'a list option
.
В этом случае метод Yield
будет создавать list option
, а метод Combine
снова получит только одну реализацию.
Вот третья версия нашего кода.
type TraceBuilder() =
member this.Bind(m, f) =
match m with
| None ->
printfn "Bind с None. Выход."
| Some a ->
printfn "Bind с Some(%A). Продолжаем" a
Option.bind f m
member this.Zero() =
printfn "Zero"
None
member this.Yield(x) =
printfn "Yield незавёрнутого %A" x
Some [x]
member this.YieldFrom(m) =
printfn "Yield завёрнутого (%A)" m
m
member this.Combine (a, b) =
match a,b with
| Some a', Some b' ->
printfn "комбинируем %A с %A" a' b'
Some (a' @ b')
| Some a', None ->
printfn "комбинируем %A с None" a'
Some a'
| None, Some b' ->
printfn "комбинируем None с %A" b'
Some b'
| None, None ->
printfn "комбинируем None с None"
None
member this.Delay(f) =
printfn "Delay"
f()
// создаём экземпляр процесса
let trace = new TraceBuilder()
Теперь пример работает так, как ожидается, без всяких особых трюков:
trace {
yield 1
yield 2
} |> printfn "Результат yield с последующим yield: %A"
// Результат yield с последующим yield: Some [1; 2]
trace {
yield 1
yield 2
yield 3
} |> printfn "Результат yield x 3: %A"
// Результат yield x 3: Some [1; 2; 3]
trace {
yield 1
yield! None
} |> printfn "Результат yield с последующим None: %A"
// Результат yield с последующим None: Some [1]
Этот код не только чище, но универсальней, поскольку вместо конкретного типа int option
мы используем обобщённый тип 'a option
. То же самое ранее нам удалось сделать c методом Return
.
Перегрузка "For"
Разумный вариант, где перегрузка может оказаться полезной — метод For
.
Возможные причины:
Вы хотите поддерживать различные типы коллекций (т.е.
list
иIEnumerable
).Вы хотите реализовать эффективный обход для определённых типов коллекций.
Вы хотите «обернуть» список в другой тип (скажем, сделать
LazyList
) и собираетесь поддерживать обе версии — завёрнутую и незавёрнутую.
Вот пример построителя списков, который поддерживает не только списки, но и последовательности:
type ListBuilder() =
member this.Bind(m, f) =
m |> List.collect f
member this.Yield(x) =
printfn "Yield незавёрнутого %A" x
[x]
member this.For(m,f) =
printfn "For %A" m
this.Bind(m,f)
member this.For(m:_ seq,f) =
printfn "For %A используя seq" m
let m2 = List.ofSeq m
this.Bind(m2,f)
// создаём экземпляр процесса
let listbuilder = new ListBuilder()
А вот как его использовать:
listbuilder {
let list = [1..10]
for i in list do yield i
} |> printfn "Результат для list: %A"
listbuilder {
let s = seq {1..10}
for i in s do yield i
} |> printfn "Результат для seq : %A"
Если вы закомментируете второй метод For
, пример с последовательностью начнёт приводить к ошибке компиляции.
Так что в данном случае перегрузка нужна.
Заключение
Что ж, мы узнали, что методы можно перегружать, если нужно, но надо быть осторожными, прежде, чем бросаться в подобного рода решения с головой , потому что потребность в таком коде может быть признаком слабого дизайна.
В следующем посте мы вернёмся к основной теме и познакомимся с тонкой настройкой процесса вычисления выражений, используя отложенные вычисления вне построителя.