devxlogo

Working with DSLs and Computation Expressions in F#

Working with DSLs and Computation Expressions in F#

f you develop UI applications in F#, you probably miss the familiar Visual Studio form editor that comes with C# or VB. This is due to a simple reason: there are no partial classes in F#. This is precisely what the code generator behind the scenes would need to synthesize code for your design forms. While the F# team is working hard to find the best approach to support partial classes and bring the Visual Studio integration up to par with that of mainstream front-ends, there are a number of things you can do to bypass this soon-to-disappear annoyance.

For one, you can set up a multi-project solution and implement the UI as a C# or VB UI project, and code the rest of the application logic in F#. This works so well that it is hard to argue that you could do better unless you had in turn the ability to design the UI with F# code behind. But after you start making incremental changes to your UI project, various small issues start to crop up. One such issue is the accessibility of certain UI elements that you need for wiring event handlers; these need to be manually made public in your UI library project.

In this article, you will see two different approaches for making your UI coding easier and still retain all your application code in F#. In the first approach, you will develop a DSL to describe UI elements and their layout, and then implement a tool that generates F# code from this specification. While this has a sizeable gain over writing ordinary UI code in F# it also has some drawbacks: any errors you make are not discovered until you generated F# code and tried to compile it.

In the second part of the article, to remedy the problems in the first approach, you need to develop a computation expression notation for building UI code directly in F#. Besides retaining a succinct declarative style, this also has the added benefit of all code being in F#—so you get type safety and the discovery of errors is instantaneous.

Notepad: A Simple WinForms Application

 
Figure 1. Notepad Application: Here is the UI of a simple Notepad application.

Figure 1 shows the UI of a simple Notepad application—basically a form with a menu bar—and an editor control that allows the user to edit text. Fundamentally, even the most complex UIs are built using the very same forms and a breadth of user controls. However, building up the hierarchy of these controls in code is repetitious and error-prone. Consider the following simple example:

#lightopen Systemopen System.Windows.Forms[]let _ =    let form = new Form(Text="Example", Width=400, Height=300)    new Button(Text="Click me...") |> form.Controls.Add    new Button(Text="And me...", Left=80) |> form.Controls.Add    form |> Application.Run

Here, even though you exploit the handy F# shorthand notation for creating a form, and two buttons, and initialize their key properties in one step, you have to manually nest the controls to build the main form before you can run it as an application. This extra bit of work is more apparent and tedious if you had to build a larger collection of controls, say a menu bar with a handful of main menu items and their nested sub menus. Wouldn’t it be nice to declaratively express the parent-child relationship that exists between these UI controls? Well, that is not that difficult as you will see shortly.

Part 1: Formgen, A DSL for Declaring UIs

If you had a tool to declare and generate your UI layer, you want it to support the following:

  • The ability to name certain controls
  • The ability to set control properties
  • The ability to nest controls into other controls (containers)

Listing 1 shows an example (Notepad.form) of a DSL that is able to support all the above.

This snippet defines two forms, an empty one (AboutForm) and a more complex main form (MainForm). In this DSL, you can introduce a new control by the plus (+) symbol, give its type (for instance, Form) and name (for instance, MainForm) followed by an optional set of constructor arguments (for instance, mFile(“&File”)), and an optional set of property assignments in the form of (for instance, -Width 400). Property values can be of the usual types, with a “code” type being something extra—this can be given inside brackets as a string (for example, look at the shortcut properties of most menu items). Child controls can be nested inside the block delimited by braces, with an optional with prefix that lets you designate the parent control property to which the child nodes are added (this property should support the Add method to add a new item). By default, this property is assumed to be Controls.

Occasionally, you need the ability to specify that certain child controls are assigned to a different property of the parent control—this you can do with an optional as suffix after giving the control’s name. For example, the MainForm form has two child controls: a MainMenu control that is added as the form’s Menu, and a Panel that is added to the form’s Controls list.

Each control in this DSL is named, but there are times when a given name is irrelevant. For example, above you used the same name for separator menu items. By using +: to introduce a new control, you can essentially cause it not to be exposed, thus its name becomes irrelevant.

Furthermore, as you might have spotted, the Click event handler for the exit menu item: by using ++ you can introduce a new event handler for the parent control and specify its code as a string.

Without further due on the syntax, you can proceed to create an AST definition to hold the necessary information for the above DSL. Your FormType.fs file looks like the following:

#lightnamespace IntelliFactory.Tools.FormGenmodule Types =    type comp =    | Component     of bool * string * (string * par list) * string option * prop list * string * comp list    | EventHandler  of string * string    and par =    | IntPar        of int    | FloatPar      of float    | StringPar     of string    | BoolPar       of bool    and prop =    | IntProp       of string * int    | FloatProp     of string * float    | StringProp    of string * string    | BoolProp      of string * bool    | CodeProp      of string * string

Here, the comp type defines a component or an event handler. A Component carries a Boolean flag that determines whether its name is exported, its type as a string, its name, and a list of constructor parameters (defined as par objects), an optional name for the as clause, a list of properties (defined as prop objects), the name of the container property to which child controls are added, and a list of child controls and event handlers. Event handlers have a name and a code block defined as a string.

The lexer (FormLexer.fsl) follows a “typical” FsLex definition, except that you added the ability to lex comments and strings with proper escape characters. You can easily add position information also to each token to recover from later semantic errors with an exact position; this was not added in the implementation as shown in Listing 2.

The parser (FormParser.fsy) is equally straightforward and without much explanation you can find it in Listing 3.

At this point, all you have left is a pretty printer that outputs F# code for an AST in your DSL. Here is FormPrint.fs:

#lightnamespace IntelliFactory.Tools.FormGenmodule Print =    open System.IO    open Types    exception DuplicateName of string    let rec collect_exports env = function    | Component (export, ty, (id, pars), _, props, _, comps) ->        let env' =            if export then                if Set.exists (fun (id', _) -> id = id') env then                    raise (DuplicateName id)                Set.add (id, ty) env            else                env        List.fold_left (fun env comp ->            collect_exports env comp) env' comps    | EventHandler _ ->        env

Here, collect_exports takes a component, returns all exported names from it and all of its child nodes, and rejects any duplicate names. This is because for each top-level component, you generate a record type with the exported names as labels. The rest of the file contains various functions for pretty printing (see Listing 4).

Now you can tie it all together: in the main F# module (formgen.fs) call the lexer/parser with an input file, and pretty print the result into an F# code file (see Listing 5).

 
Figure 2. References: Your F# project should contain all of the files and references shown here.

For this, your F# project should contain all of the above files and references as shown in Figure 2. To obtain the lexer and parser implementation (FormLexer.fs and FormParser.fs, respectively) from FormLexer.fsl and FormParser.fsy you need to run:

fsyacc FormParser.fsyfslex FormLexer.fsl

Both fsyacc and fslex are in the F# distribution (in the bin folder).

This completes the formgen tool. To test it, take the Notepad snippet you saw earlier and run the formgen tool on it to generate Notepad.fs. Then add this file to a new F# project, add references to System.Drawing and System.Windows.Forms, and create a new F# file with the following:

#lightopen System.Windows.Formsopen System[]let _ =   let form = Notepad.CreateMainForm ()   form.mAbout.Click.Add (fun _ -> Notepad.CreateAboutForm().AboutForm.Show())   form.MainForm |> Application.Run

This code uses the generated Notepad module to create the main and about forms, and shows how to register an event handler for displaying the about box. You can add further event handlers to fill the Notepad UI with full functionality in a similar fashion.

Part 2: Using Computation Expressions to Model Building UIs

While the formgen tool can save you significant time when building complex UIs, it suffers from providing no feedback on correctness until the generated file is compiled. Ideally, it is best to retain the same shortness and declarative style of definition but as F# code. That way, semantic errors would surface as you type the form definition in Visual Studio and you can call IntelliSense for help, etc.

If you study the formgen generated code, you can see that most of the gain you get by using it is in generating the code for adding child controls to parent containers. You can use computation expressions in a clever way to get the same benefit, say by overriding the let! construct in your own UI control builder objects. So it would look something like:

let form =    formM()        { let! panel1 = new Panel()          let! panel2 = new Panel()          return () }

Here you have a form builder object (an instance of the formM type) and each let! binding creates a new child control. You can then build similar builder objects for other controls, encapsulating the details of what property is assigned when a new child is added, etc.

Computation Builders

F# computation expressions are a great way to add domain specific functionality to your F# code. By defining a given code block as a computation expression, you allow the code inside that block to be governed by a computation builder object. This is performed by the F# compiler automatically de-sugaring each F# construct in the code block to calling certain methods in your computation builder object. You can define computation builders as normal class types and define how each F# construct is handled by adding the appropriate de-sugared members. Below is a list of F# constructs (defined as cexp, and exp being ordinary F# expressions) and their de-sugared counterparts:

let! pat = exp in cexp        b.Bind(exp,(fun pat -> «cexp»))let pat = exp in cexp         b.Let(exp,(fun pat -> «cexp»))use pat = exp in cexp         b.Using(exp,(fun pat -> «cexp»))use! pat = cexp in cexp       b.Bind(exp,(fun x->b.Using(x,(fun pat-> «cexp»))))do! exp in cexp               b.Bind(exp,(fun () -> «cexp»)) do exp in cexp                b.Let(exp,(fun () -> «cexp»))for pat in exp do cexp        b.For(exp,(fun pat -> «cexp»))while exp do cexp             b.While((fun ()-> exp),b.Delay(fun ()-> «cexp»))if exp then cexp1 else cexp2  if exp then «cexp1» else «cexp2» if exp then cexp              if exp then «cexp» else b.Zero() cexp1; cexp2                  b.Combine(«cexp1», b.Delay(fun () -> «cexp2»)) return exp                    b.Return(exp)return! exp                   exp

You do not have to provide all of these methods in a computation builder object. For instance, if you use let! in a computation expression for which the builder object does not have a corresponding Bind member you will get an error from the compiler.

Defining Computation Builders for UI Controls

Most UI controls are descendants of the System.Windows.Forms.Control class, so you can create an interface that all computation builder objects that build these Control descendants have to implement:

open System.Windows.Formsopen System.Drawing []type 'a IComposableControl when 'a :> Control () =    abstract Self : 'a    member self.Return (e: unit) = self.Self    member self.Zero () = self.Self    []    member self.Bind(e : Control, body) : 'a =        e |> self.Self.Controls.Add        body e    []    member self.Bind(e: 'b IComposableControl when 'b :> Control, body) : 'a =        e.Self |> self.Self.Controls.Add        body e

This interface contains an abstract Self member that you can use later to fetch the actual UI control that was built using the computation builder. These computation expressions are likely to contain let! and let! bindings so you will need a way to “end” the computation expression with a trailing statement. You don’t want these expressions to return just any value; you want to actually return the UI control object that is managed by the builder object. Hence, the interface also contains an implementation for the Return and Zero members, requiring that you can only return a unit value, which you use to return the managed control itself.

Your interface also contains two overloaded Bind members that correspond to let! constructs, each taking a direct Control descendant or another IComposableControl builder object, and adding them to the Controls collection of the managed UI control.

Armed with this interface you can go ahead and implement various builders for Control descendants. Below you can find three builder classes that build forms, panels and rich text boxes.

type formM() =    inherit IComposableControl
() let c = new Form() override self.Self = c type panelM() = inherit IComposableControl() let c = new Panel() override self.Self = ctype formM() = inherit IComposableControl() let c = new Form() override self.Self = c

Implementing Additional UI Control Builders

There are UI controls that are not Control descendants, and as such they may have a container property different than Controls. One such control is MainMenu and its related MenuItem controls. For these, you create a similar abstraction but wrap the MenuItems container property (see Listing 6).

At this point you can define your Notepad UI as shown in Listing 7.

Speeding Up the Development of UI Code

In this article, you saw two fundamental language-oriented approaches to speed up developing UI code in your F# applications. In the first approach, you defined a DSL, implemented a lexer and parser for it, and wrapped it as a tool to generate F# code from a UI definition in this DSL. The generated files can then be added to your application logic to form a complete application.

In the second approach, you developed various computation builders that manage UI controls and used these builders in nested computation expressions to describe the UI of your application in a succinct and concise way without having to add any code for managing the parent-child relationship between the participating controls. The added benefit of this approach over the first one is that you get instant feedback on your UI’s correctness from the compiler, making it an excellent alternative to building UIs in the absence of a Visual Studio form designer that can output F# code, while keeping the entire application code in F#.

devxblackblue

About Our Editorial Process

At DevX, we’re dedicated to tech entrepreneurship. Our team closely follows industry shifts, new products, AI breakthroughs, technology trends, and funding announcements. Articles undergo thorough editing to ensure accuracy and clarity, reflecting DevX’s style and supporting entrepreneurs in the tech sphere.

See our full editorial policy.

About Our Journalist