Monday, December 3, 2018

Hooray for F#

Hooray!

I think I'm beginning to grok F# computation expressions. I realized recently that an abstraction I've been trying to create--generalized input/output so you can write the same algorithm and execute it both synchronously (a la Console.ReadLine/WriteLine) and in an event loop like React/Elmish--is really just a form of coroutine. Put it together with a packrat parser and you wind up with something like this:

// types for test scenario
type GetNumber = Query of string
type Confirmation = Query of string

type InteractionQuery =
    | Number of GetNumber
    | Confirmation of Confirmation

module Recognizer =
    open Packrat
    let (|Number|_|) = (|Int|_|)
    let (|Bool|_|) = function
        | Word(AnyCase("y" | "yes" | "true" | "t"), ctx) -> Some(true, ctx)
        | Word(AnyCase("n" | "no" | "false" | "f"), ctx) -> Some(false, ctx)
        | _ -> None

module Query =
    open Packrat

    let tryParse recognizer arg =
        match ParseArgs.Init arg |> recognizer with
        | Some(v, End) -> Some v
        | _ -> None

    let confirm txt =
        InteractionQuery.Confirmation(Confirmation.Query txt), (tryParse Recognizer.``|Bool|_|``)
    let getNumber txt =
        InteractionQuery.Number(GetNumber.Query txt), (tryParse Recognizer.``|Number|_|``)

#nowarn "40" // recursive getBurgers is fine
[<Theory>]
[<InlineData(4,true,0,"That will be $8.00 please")>]
[<InlineData(4,true,3,"Thanks! That will be $11.00 please")>]
[<InlineData(3,false,3,"Thanks! That will be $6.00 please")>]
let ``Simulated user interaction``(burgers, getFries, tip, expected) =

    let interaction = InteractionBuilder<string, InteractionQuery>()
    let rec getBurgers : Interactive<_,_,_> =
        interaction {
            let! burger = Query.confirm "Would you like a burger?"
            if burger then
                let! fries = Query.confirm "Would you like fries with that?"
                let price = 1 + (if fries then 1 else 0)
                let! more = getBurgers
                return price + more
            else
                return 0
        }
    let getOrder: Eventual<_, InteractionQuery, _> =
        interaction {
            let! price = getBurgers
            let! tip = Query.confirm "Would you like to leave a tip?"
            if tip then
                let! tip = Query.getNumber "How much?"
                return sprintf "Thanks! That will be $%d.00 please" (tip + price)
            else
                return sprintf "That will be $%d.00 please" price
        }
    let mutable burgerCount = 0
    let question = function
        | Confirmation(Confirmation.Query txt) ->
            if txt.Contains "burger" then
                if burgerCount < burgers then
                    burgerCount <- burgerCount + 1
                    "yes"
                else
                    "no"
            elif txt.Contains "tip" && tip > 0 then
                "yes"
            elif txt.Contains "fries" && getFries then
                "yes"
            else
                "no"
        | Number(GetNumber.Query txt) -> tip.ToString() // must always answer question by typing text
    let resolve =
        let rec resolve monad =
            match monad with
            | Final(v) -> v
            | Intermediate(q,f) as m ->
                let answer = question q
                let m = f answer
                resolve m
        resolve
    Assert.Equal(expected, getOrder |> resolve)

The neat things about this are manyfold:

(1) You can query for numbers (tip amount), yes/no confirmation, or anything else that you want, and it all comes back strongly typed in the context of the algorithm you're executing (getBurgers).

(2) Even though the unit test executes the workflow synchronously via resolve, executing the same logic via React is straightforward: you just take the Intermediate and render the question (q) via React, and dispatch the answer (in string form) back to f. So you can write your business logic without any reference at all to your UI abstractions, and yet it still works in React event loops.

(3) The parser (active patterns in Recognizer) is easy to read and extensible: it looks almost exactly like BNF grammar.

(4) I think it's probably possible to make the parser give you hints about productions that were being matched at the time it ran out of input, so you could give the user hints about what valid inputs they could type next. You could use this in e.g. mobile web development to show autocomplete buttons with the most likely valid responses.

(5) BTW I really like using string format as a canonical form because it's very amenable to pedagogy (teaching the user how they would replicate via text commands the thing you just did on their behalf via the GUI) but you could pick a different canonical form if you wanted to, as long as it's something your event loop knows how to supply.

I'm really happy because I've been working on this problem on and off for probably at least a year, and I finally have a design pattern for user interaction that actually feels _clean_. And in the process I corrected a lot of my misconceptions about computation expressions and what they are good for, and now I have a fairly compelling example to show people of why programming in F# is better/easier/cleaner than C#. :)

~B.C.

 --

Doubtless some of the arguments developed here will prove oversimplified, or merely false. They are certainly controversial, even among my colleagues in economic history. But far better such error than the usual dreary academic sins, which now seem to define so much writing in the humanities, of willful obfuscation and jargon-laden vacuity. As Darwin himself noted, "false views, if supported by some evidence, do little harm, for every one takes a salutary pleasure in proving their falseness: and when this is done, one path towards error is closed and the road to truth is often at the same time opened."[Darwin, 1998, 629] Thus my hope is that, even if the book is wrong in parts, it will be clearly and productively wrong, leading us toward the light. -Gregory Clark, Preface to Farewell to Alms

No comments: