Experiment 07

13 minute read

The second post in the domain specific language series demonstrates how you can use F# computation expressions to create an embedded language.

Introduction

Please read the first post in this series before continuing.

This post demonstrates how to create a custom computation expression to capture data. You may have already used computation expressions without knowing it - seq {} and async {} are both built using computation expressions. Computation expressions provide users with a way to extend F#. This ability is most similar to macros or metaprogramming abilities of other languages (e.g. LISP, Rust, Ruby, Template Haskell), but I find computation expressions more straightforward to understand and use. In this post, we will see how to create three kinds of computation expressions to model our data.

Our first example uses computation expressions to create a concise record syntax.

let trades = 
  trade{
    Buy 4 MSFT 258.32 AllOrNone
    Sell 3 META 158.71 AllOrNone
    Sell 6 GOOGL 106.08 AllOrNone
  }

Data model #2a (Concise record syntax)

The expression above creates the same list of Trade records as in the first post without having to specify the record’s field names. We might choose this data model if we want a concise record syntax.

We accomplish this syntax by creating a custom computation expression. First, define a TradeBuilder class and provide an implementation for Yield1. Then implement two custom operations (tagged with the CustomOperation attribute). Due to the domain and record layout, I decided it was natural to use Buy and Sell as keywords to create the record. Each method takes 5 parameters: the first argument captures trades that were created above the current trade but within the same computation expression; it has type seq<Trade>. The next 4 parameters match those of the record (except price, which takes a float and is converted to decimal).

module Trade = 

  type TradeBuilder() =

      member _.Yield _ = []

      [<CustomOperation("Buy")>]
      member _.Buy (previous: seq<Trade>, numShares:int, ticker:Stocks, price:float, allOrNone:Portion) = 
        [yield! previous
         yield {buyOrSell=Buy; ticker=ticker;numShares=numShares;price=decimal(price);allOrNone=(allOrNone=AllOrNone)}]

      [<CustomOperation("Sell")>]
      member _.Sell (previous: seq<Trade>, numShares:int, ticker:Stocks, price:float, allOrNone:Portion) = 
        [yield! previous
         yield {buyOrSell=Sell; ticker=ticker;numShares=numShares;price=decimal(price);allOrNone=(allOrNone=AllOrNone)}]

Before we can use the computation expression, we must initialize it with:

let trade = Trade.TradeBuilder()

let trades = 
  trade{
    Buy 4 MSFT 258.32 AllOrNone
    Sell 3 META 158.71 AllOrNone
    Sell 6 GOOGL 106.08 AllOrNone
  }

Then if we run Trade.tradeMany trades we get the correct output:

You just purchased 4 shares of MSFT for a total cost of $1,033.28.
You just sold 3 shares of META for a total earnings of $476.13.
You just sold 6 shares of GOOGL for a total earnings of $636.48.
-------------------------------------------------------------------
You just executed a series of trades that earned you $79.33.

Data model #2b (Concise record syntax with placeholders)

Our first attempt at a data model has a serious flaw - without specifying the field names it could be very easy to forget to include a field or put it in the wrong order. Imagine a record with 5 fields all the same type (e.g. decimal), how would you keep them straight? In some domains it may be appropriate to add a few placeholder keywords to help keep things straight. In this example, the following probably reads much better to domain experts:

let trades = 
  trade{
    Buy 4 SharesOf MSFT At 258.32 AllOrNone
    Sell 3 SharesOf META At 158.71 AllOrNone
    Sell 6 SharesOf GOOGL At 106.08 AllOrNone
  }

The SharesOf keyword tells you 2 things - the previous parameter was the number of shares and the following parameter is the stock ticker. The At keyword separates the price constraints for the trade. This is still much more concise than specifying all the field names and it improves readability. To update the TradeBuilder code we need to define two new types (SharesOf and At) and add them to the parameter list of each method.

type SharesOf = SharesOf
type At = At

module Trade = 

  type TradeBuilder() =

      member _.Yield _ = []

      [<CustomOperation("Buy")>]
      member _.Buy (previous: seq<Trade>, numShares:int, sharesOf:SharesOf, ticker:Stocks, at:At, price:float, allOrNone:Portion) = 
        [yield! previous
         yield {buyOrSell=Buy; ticker=ticker;numShares=numShares;price=decimal(price);allOrNone=(allOrNone=AllOrNone)}]

      [<CustomOperation("Sell")>]
      member _.Sell (previous: seq<Trade>, numShares:int, sharesOf:SharesOf, ticker:Stocks, at:At, price:float, allOrNone:Portion) = 
        [yield! previous
         yield {buyOrSell=Sell; ticker=ticker;numShares=numShares;price=decimal(price);allOrNone=(allOrNone=AllOrNone)}]
        

Data model #2c (Fluent record syntax)

The final computation expression uses the Builder Design Pattern and a Fluent expression style to create trades. This approach is most useful when you can set default values for everything and then specify a subset of fields to update (similar to the {default with ...} record syntax). It also provides a way to set fields in any order. But unlike the previous two computation expressions, this approach only creates a single trade per expression.

let trades = 
  [
    trade {
      Buy 4
      SharesOf MSFT
      At 258.32
      AllOrNone
    };
    //AllOrNone optional
    trade {
      Sell 3
      SharesOf META
      At 158.71      
    };
    // order-independent
    trade {
      AllOrNone
      At 106.08      
      SharesOf GOOGL
      Sell 6
    };
  ]
        

As you might have guessed, the code for this data model is significantly different. Yield now provides the default record and we must specify custom operations for Buy, Sell, SharesOf, At, AllOrNone, and Partial. Each method takes the current trade and updates the corresponding field.

module Trade = 

  let baseTrade = {buyOrSell = Buy; ticker=MSFT; numShares=0; price=0.0m; allOrNone=true}

  type TradeBuilder() =

      member _.Yield _ = baseTrade

      [<CustomOperation("Buy")>]
      member _.Buy (trade: Trade, input: int) = {trade with numShares = input; buyOrSell = Buy}

      [<CustomOperation("Sell")>]
      member _.Sell (trade: Trade, input: int) = {trade with numShares = input; buyOrSell = Sell}

      [<CustomOperation("SharesOf")>]
      member _.Ticker (trade: Trade, input: Stocks) = {trade with ticker = input}

      [<CustomOperation("At")>]
      member _.Price (trade: Trade, input: float) = {trade with price = decimal(input)}

      [<CustomOperation("AllOrNone")>]
      member _.AllOrNone (trade: Trade) = {trade with allOrNone = true}
    
      [<CustomOperation("Partial")>]
      member _.Partial (trade: Trade) = {trade with allOrNone = false}
        

The code listing for this series can be found here.

Some other examples/references

In my opinion, custom computational expressions are an underutilized feature of F#. They are much more flexible/powerful than I am showing here so if you would like learn more please check out the following resources that use custom computation expressions.

Computation expressions workshop

Computation Expressions Explained - Youtube

Lego Mindstorms DSL

FsHttp CE

Next

In the final post of this series we will learn about the difference between an internal and external domain specific language and use FParsec to load records after compile time!

Footnotes

  1. https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions#creating-a-new-type-of-computation-expression 

Tags: , ,

Updated: