Experiment 07
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.
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 Yield
1. 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
).
Before we can use the computation expression, we must initialize it with:
Then if we run Trade.tradeMany trades
we get the correct output:
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:
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.
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.
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.
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
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
-
https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions#creating-a-new-type-of-computation-expression ↩