
F# Computation Expressions.pptx
- Количество слайдов: 57
F#: Computation Expressions aka workflows or monads
Prerequisites • • Sequence, list & array expressions Continuation-passing style Параметрический и ad-hoc полиморфизм OOP в F#
Мы уже знакомы с list, array и sequence expressions. Тем не менее, эти механизмы — не просто синтаксический сахар, а варианты реализации более общей идеи — computation expressions.
Зачем? • Асинхронное программирование (asynchronous workflows) • Упрощение кода • Возможность контролировать ход вычисления • DSLs и Language-Oriented Programming • Аналоги sequence expressions для других типов данных
Sequence comprehensions let rec all. Files. In base. Path = seq { yield! Directory. Get. Files base. Path yield! for subdir in Directory. Get. Directories base. Path do yield! all. Files. In subdir yield! } Seq. comprehensions достаточно мощны, чтобы содержать в себе практически любые конструкции языка, они способны даже использовать рекурсию. Есть только два ограничения: 1. Нельзя использовать mutable, только ref cells 2. Нельзя объявлять новые типы внутри них
Мотивация: безопасное деление type Result = | Success of float | Div. By. Zero let div x y = match y with | 0. -> Div. By. Zero | _ -> Success(x / y)
Посчитаем сопротивление трёх параллельно соединённых резисторов: let resistance r 1 r 2 r 3 = let step 1 = div 1. r 1 match step 1 with | Div. By. Zero -> Div. By. Zero | Success x -> let step 2 = div 1. r 2 match step 2 with | Div. By. Zero -> Div. By. Zero | Success y -> let step 3 = div 1. r 3 match step 3 with | Div. By. Zero -> Div. By. Zero | Success z -> let final = div 1. (x + y + z) final
Этот код ужасен. Давайте представим, как бы он выглядел в идеале: let resistance r 1 r 2 r 3 = let_with_check x = div 1. r 1 let_with_check y = div 1. r 2 let_with_check z = div 1. r 3 return_with_check 1. 0 (x + y + z) Но мы не можем создать такую функцию let_with_check — F# не имеет конструкций, позволяющих прервать вычисление на середине (вроде break).
Зато мы можем воспользоваться continuationpassing style: let_with_check result cont = match result with | Div. By. Zero -> Div. By. Zero | Success x -> cont x cont — это «остаток вычисления» , он будет выполнен только если деление вернуло число.
let resistance' r 1 r 2 r 3 = let_with_check (div 1. r 1) (fun x -> let_with_check (div 1. r 2) (fun y -> let_with_check (div 1. r 3) (fun z -> div 1. (x + y + z)))) В другом форматировании: let resistance'' r 1 r 2 r 3 = let_with_check (div 1. r 1) (fun x -> let_with_check (div 1. r 2) (fun y -> let_with_check (div 1. r 3) (fun z -> div 1. (x + y + z))))
На начальном уровне мы можем воспринимать workflows как подобные синтаксические преобразования. Вызов computation expression в общей форме выглядит так: comp_expr_builder_instance { //code }
Зададим computation expression builder для нашего примера и создадим его экземпляр: type Safe. Builder () = //x : Result //cont : (float -> Result) member this. Bind (x, cont) = match x with | Div. By. Zero -> Div. By. Zero | Success x -> cont x member this. Return (x : 'a) = x let safe = Safe. Builder()
Теперь мы можем легко переписать наше вычисление следующим образом: let resistance. Safe r 1 r 2 r 3 = safe { let! x = div 1. r 1 let! y = div 1. r 2 let! z = div 1. r 3 return div 1. (x + y + z) }
На самом деле let! и return — это просто синтаксический сахар для вызова методов Bind и Return нашего класса-builder’a. Без «сахара» получим: let tr r 1 r 2 r 3 = safe. Bind( (div 1. r 1), (fun x -> safe. Bind( (div 1. r 2), (fun y -> safe. Bind( (div 1. r 3), (fun z -> safe. Return( div 1. (x + y + z))))
В workflow можно определить методы, которые будут привязаны к другим операторам, вроде yield, yield! и return! или даже просто к последовательности операций — «эквивалентно» expr — терминал «cexpr» — нетерминал Полный список в спецификации F#
member Bind: M<'a> * ('a -> M<'b>) -> M<'b> let! pat = expr in cexpr b. Bind (expr, (fun pat -> «cexpr» )) //Bind: //M
member Using: 'a * ('a -> M<'b>) -> M<'b> when 'a : > System. IDisposable use pat = expr in cexpr b. Using (expr, (fun pat -> «cexpr» )) use! pat = expr in cexpr b. Bind (expr, (fun x -> b. Using (x, fun pat -> «cexpr» )))
member Return: 'a -> M<'a> return expr b. Return expr member Return. From: 'a -> M<'a> return! expr b. Return. From expr
member Yield: 'a -> M<'a> yield expr b. Yield expr member Yield. From: seq<'a> -> M<'a> yield! expr b. Yield. From expr
if expr then cexpr 1 else cexpr 2 if expr then «cexpr 1» else «cexpr 2» Но если нет ветви else, то: member Zero: unit -> M
member Combine: M
member While: (unit -> bool) * M<'a> -> M<'a> while expr do cexpr b. While ((fun () -> expr), b. Delay (fun () -> «cexpr» ))
member For: seq<'a> * ('a -> M<'b>) -> M<'b> for pat in expr do cexpr b. For (expr, (fun pat -> «cexpr» ))
member Try. With: M<'a> * (exceptn -> M<'a>) -> M<'a> try expr with patt -> cexpr b. Try. With(expr, (fun v -> match v with | (patt: exn) -> «cexpr» | _ -> reraise exn))
member Try. Finally: M<'a> * (unit -> unit) -> M<'a> try expr finally cexpr b. Try. Finally(expr, (fun () -> «cexpr» ))
Пример: вычисления с округлением Создадим параметризованное вычислительное выражение для представления вычислений с округлениями до определённого количества цифр: type Rounding. Workflow (sig. Digits : int) = let round (x : float) = Math. Round(x, sig. Digits) member this. Bind(x, cont) = let result = round x cont result member this. Return x = round x
let round sig. Digits = new Rounding. Workflow(sig. Digits) let rounded. Calculation = round 4 { let! x = Math. PI / 2. let! y = 0. 5 / 7. return x * y }
Фактически, workflows позволяют нам выполнять некий промежуточный код между шагами вычисления.
Сайд-эффекты let rounded. Calculation = round 4 { let! x = Math. PI / 2. . printfn "%f" z //сайд-эффект return x * y * z } Сайд-эффект произойдёт только при конструировании значения в первый раз. Если мы хотим, чтобы сайд-эффект происходил при каждом вызове rounded. Calculation, мы должны использовать метод Delay().
type Rounding. Workflow' (sig. Digits : int) = let round. . . member this. Bind(x, cont) = let result = round x cont result member this. Return x = round x member this. Delay f = fun x -> this. Bind(this. Return x, f) //или: member this. Delay f = fun () -> f ()
Теперь сайд-эффект будет происходить при каждом вызове значения (которое теперь будет функцией), а не при его создании: let round' sig = new Rounding. Workflow'(sig) let rnded. Calc' = round' 4 { let! x = Math. PI / 2. . . . printfn "%f" z //сайд-эффект не происходит return x * y * z } let x = rnded. Calc' () //сайд-эффект происходит let y = rnded. Calc' () //сайд-эффект происходит
Stack Workflow Мы можем определить выч. выражение для того, чтобы конструировать стеки, аналогично тому, как работают sequence expressions: let s = stack { for x in 0. . 10 -> x yield 12 yield! [50. . 60] try yield 1 / 0 with | _ -> yield 0 }
В подобных «генераторных» выч. выражениях мы можем не определять Bind и Return — они не имеют особого смысла. Зато одним из основных методов становятся Yield и Yield. From. Наконец, мы можем использовать ad-hoc полиморфизм: type Stack. Expressions. Builder() = member this. Yield x = new Stack<_>([x]) member this. Yield. From (s : 'a seq) = new Stack<_>(s) //чтобы не нарушить порядок member this. Yield. From (s : Stack<_>) = s
Чтобы иметь возможность записать whileциклы, мы будем производить всё вычисление в конце построения выражения, использую метод Run: member this. Delay f = f //вычисляем всё в конце member x. Run f = f() Эти методы будут использованы так: let b = builder-expr in b. Run (b. Delay(fun () -> «cexpr» ))
Метод для комбинации двух выражений: member this. Combine(s 1 : Stack<_>, s 2 : unit -> Stack<_>) = let s 2' = s 2(). To. Array() |> Array. rev for elem in s 2' do s 1. Push elem s 1 cexpr 2 b. Combine ( «cexpr 1» , b. Delay(fun () -> «cexpr 2» ))
Теперь запишем метод для while: member this. While(p : unit -> bool, body : unit -> Stack<_>) = if p() then this. Combine( body(), fun () -> this. While(p, body)) else new Stack<_>() while expr do cexpr b. While ((fun () -> expr), b. Delay (fun () -> «cexpr» ))
Остальные методы определяются тривиально: member this. Zero () = new Stack<_>() member this. Try. With(try. Block : unit -> Stack<_>, handler : exn -> Stack<_>) = try. Block() with | e -> handler e member this. Try. Finally(try. Block : unit -> Stack<_>, finalizer : unit -> unit) = try. Block() finally finalizer() member this. For(coll : 'a seq, body : ('a -> Stack<_>)) = let s = new Stack<_>() for elem in coll do for generated in (body elem) do s. Push generated s
Теперь мы можем использовать всё подмножество F#, доступное внутри вычислительных выражений в наших stack expressions: let rec Stack. Count n = stack { if n > 0 then yield! (Stack. Count (n - 1)) }
State workflow Рассмотрим задачу нумерации листьев бинарного дерева: type Bin. Tree<'a> = | Leaf of 'a | Node of Bin. Tree<'a> * Bin. Tree<'a> let tree = Node( Leaf "Pikachu", Leaf "Raichu"), Leaf "Psyduck"), . . . ))
Branch: Leaf: "Pikachu" Leaf: "Raichu" Leaf: "Psyduck" Branch: Leaf: "Bulbasaur" Branch: Leaf: "Slowpoke" Leaf: "Slowbro"
Мы хотим получить такое дерево: Branch: Leaf: ("Pikachu", 0) Leaf: ("Raichu", 1) Leaf: ("Psyduck", 2) Branch: Leaf: ("Bulbasaur", 3) Branch: Leaf: ("Slowpoke", 4) Leaf: ("Slowbro", 5)
Можно просто использовать изменяемое глобальное состояние: let label tree = let label = ref -1 let rec helper = function | Leaf v -> label : = !label + 1 Leaf (v, !label) | Node(l, r) -> Node(helper l, helper r) helper tree
Чтобы избежать проблем, связанных с подобными сайд-эффектами, воспользуемся функциональным подходом, явно передавая состояние в аргументах: let label' tree = let rec helper state = function | Leaf v -> state + 1, Leaf (v, state) | Node(l, r) -> let state. L, l' = helper state l let state. R, r' = helper state. L r //(*) state. L, Node(l', r') helper 0 tree
Для того, чтобы выразить функцию, зависящую от состояния, мы меняем её тип с Bin. Tree<'a> -> Bin. Tree<'a, int> на 'state -> Bin. Tree<'a> -> Bin. Tree<'a, 'b> чтобы менять состояние: 'state -> Bin. Tree<'a> -> 'state * Bin. Tree<'a, 'b>
Проблема заключается в том, что передавать состояние непосредственно — очень неудобно и громоздко. В идеале этот код выглядел бы так: let rec label. Monadic tree = state { match tree with | Leaf v -> let! state = get. State do! set. State (state + 1) return Leaf(v, state) | Node(l, r) -> let! l' = label. Monadic l let! r' = label. Monadic r return Node(l', r') }
Монадический тип: ('state -> 'res * 'state) т. е. функция, принимающая некое состояние и возвращающая результат и новое состояние. Тогда let! будет действовать так: let! res = f. Monadic args cont Bind(f. Monadic args, (fun res -> cont)) Bind : ('state -> 'res * 'state) * ('res -> ('state -> 'res 1 * 'state)) -> ('state -> 'res 1 * 'state)
type State. Builder() = member this. Bind(f, cont) = (fun init. State -> //1 let res, new. State = f init. State //2 cont res new. State) //3 1 — возвращаем функцию, зависящую от состояния, возвращающую новое состояние и результат 2 — считаем результат выражения и новое состояние 3 — вызываем континуацию с результатом и новым состояинем let! res = f. . . //cont = (fun res ->. . . )
return просто создаёт ф-цию, возвращающую заданный результат при любом состоянии: member this. Return res = (fun state -> res, state) //return res Return : : 'res -> ('state -> 'res * 'state)
Неявная передача состояния: let! left' = f left let! right' = f right. . . Bind(f left, (fun left' -> Bind(f right, (fun right' -> . . . )))) (fun init. State -> let res, new. State = f left init. State (fun left' ->. . . ) res new. State)
Функция получения состояния: let get. State = (fun state -> state, state) Функция установки нового состояния: let set. State new. State = (fun _ -> (), new. State)
Без «сахара» : let! state = get. State do! set. State (state + 1) return Leaf(v, state) Bind(get. State, (fun state -> Bind(set. State (state + 1), (fun () -> Return Leaf(v, state))))) Bind(f, cont) = (fun init. State -> let res, new. State = f init. State cont res new. State)
Задание Реализуйте метод для выполнения оператора use в Stack. Expressions. Builder ‘e: member Using : resource: 'a when 'a : > IDisposable * cont: ('a -> Stack<_>) -> Stack<_>
Тест: let stack. From. Resource = stack { use reader = new Stream. Reader(@"C: . . . ") while not <| reader. End. Of. Stream do yield int <| reader. Read. Line() }
Workflows vs. Monads Выч. выражения аналогичны т. е. монадам из языка Haskell. Монада — это тип M<'a> и две операции: bind : M<'T> -> ('T -> M<'U>) -> M<'U> return : 'T -> M<'T> Эти операции аналогичны let! и return в выч. выражениях.
Эти операции должны удовлетворять 3 аксиомам. return аналогично нулевому элементу: bind (return x) f ≡ f x bind m return ≡ m Последовательное связывание с двумя фциями аналогично связыванию с одной функцией, полученной из них: bind (bind m f) g ≡ bind m (λx -> (bind (f x) g))
Если ввести оператор >=> (композиция для монад, оператор композиции Клейсли): f >=> g = fun x -> monad { let! y = f x return g y } >=> : (‘a -> m ‘b) -> (‘b -> m ‘c) -> (‘a -> m c) то аксиомы запишутся так: 1. «Left identity» : return >=> f ≡ f 2. «Right identity» : f >=> return ≡ f 3. «Ассоциативность» : (f >=> g) >=> h ≡ f >=> (g >=> h)
Тем не менее, workflows отличаются от монад тем, что: • Они могут содержать в себе сайд-эффекты, не выраженные в типах • Могут быть скомбинированы с quotations • Могут выражать вычисления, генерирующие несколько результатов (моноиды), такие, как seq/list/array/stack. . . expressions. Они обычно содержат методы для yield и yield! вместо return и return! и часто не имеют метода для let!.