Skip to content

Latest commit

History

History
248 lines (197 loc) 路 7.54 KB

File metadata and controls

248 lines (197 loc) 路 7.54 KB

Interpreter Pattern

In essence, build your programs as values that describe what should be done, and let someone else define how to execute those descriptions.

It's a very useful pattern to layer the design of your libraries and applications.

For example, when I was writing HttpKit I found very useful to think of a Server as a concrete piece of data, a value really, rather than something that was being executed.

Other libraries such as lwt, http/af, cmdliner, or markup.ml do this successfully, allowing you to use them without committing to a particular runtime (such as lwt, or async, or whatever else you want to use).

Let's try this with a small TODO List application that uses a Storage module to define the things that can be done to a Todo item:

module Todo : {
  type t;
  let create: (~name: string) => t;
  let empty: unit => t;
};

/** Core module from the ToDo package */
module Storage : {
  type t =
    | Create(Todo.t)
    | Complete(Todo.ID.t)
    | Delete(Todo.ID.t)
    | Update(Todo.t, Todo.t)
    | Chain(t, t);

  let create: Todo.t => t;
  let complete: Todo.t => t;
  let delete: Todo.t => t;
  let update: (Todo.t, ~like: Todo.t) => t;

  /** convenience operator to sequence storage actions */
  let (>>): (t, Todo.t => t) => t;
};

Now that we have the module there, it's inviting the assumption that calling Storage.create will not create a new thing at all anywhere. In fact, it really just returns a value of type Storage.t that describes what should be done.

I've also included a small operator to allow us to chain storage operations threading the same Todo.t in all of them.

let todo = Todo.empty();

let create_and_complete = Storage.(todo |> create >> complete);

let then_update_and_delete =
  Storage.(
    create_and_complete
    >> update(~like=Todo.create(~name="updated name"))
    >> delete
  );

The values we created above merely describe what should happen, but don't actually execute anything.

Now we can implement a module that will actually take this program description and interpret it:

module Async_storage : {
  /* This defines how the storage will actually be executed! */
  let run: Storage.t => Lwt_result.t(unit, error);
};

Voila. Async_storage knows how to take a description of storage operations (a Storage.t) and returns a promise that it will be executed.

A complete program using both of these modules would look like this:

let todo = Todo.empty();
let todo' = Todo.create(~name="Interpret this!");

Storage.(
  todo
  |> create
  >> update(~like=todo')
  >> complete
  >> delete      /** up to here we're just creating the description */
  |> Async_storage.run  /** then we interpret it! */
);

Note that Async_storage could be implemented in a completely separate package, or by a completely different person altogether.

This pattern seems to be a Good Way of layering your modules since it allows you to swap the underlying implementations without needing to change any of the semantics of higher layers.

If you're interested in more general abstractions that solves the same problem, read on.

Composable DSLs with Free Monads

While the DSL described above is perfectly usable, it requires a little bit of work to get better ergonomics. For example, our Chain constructor doesn't really have any semantic meaning and it only serves the purpose of chaining actions together; the same goes for our convenience (>>) operator.

todo
|> create
>> update(~like=todo') /** <-- our custom operator */
>> complete
>> delete

Fortunately, there's an approach that takes care of generating all that boilerplate for us in a quite elegant way, and gives us a sequencing operator that is not unique to our program but instead has very specific semantics and is very well documented: >>=. This approach involves using Free Monads.

NOTE: For a good read on Free Monads with examples in Haskell have a look at this StackExchange Answer.

Free Monads are a construct that helps us build composable DSLs by giving us a composable structure that we can use to put in whatever we want. You define a type that describes everything you should be able to do, and the Free Monad makes it composable.

For the example above, our type could look like:

module Storage = {
  type t('next) =
    | Get(string, string => 'next)
    | Put(string, string, 'next)
    | End;
};

Where we can see that each action is parametrized by the next action that will occur. Be careful when designing your constructors! That next action could be nothing at all if 'next is unit or it could be another action.

Giving this, we need to define our type as a categorical functor. That is, as something that can be mapped over with a function. We do this to allow the continuations to be composed with other functions, which allows us to execute arbitrary code for each of the steps.

A complete version of our Storage module that defines our core type as a functor, as well as an instance of the Free monad for our core type, and functions to create these values more conveniently, is shown below:

module Storage = {
  /* ... */
  let map = f =>
    fun
    | Get(name, k) => Get(name, x => x |> k |> f)
    | Put(name, value, next) => Put(name, value, f(next))
    | End => End;

  module Free: FreeM.FREE with type f('a) = t('a) =
    FreeM.Make({
      type f('a) = t('a);
      let map = map;
    });

  let get = k => Free.lift(Get(k, x => x));
  let put = (k, v) => Free.lift(Put(k, v, ()));
  let end_ = () => Free.lift(End);
};

With this new module, we can now write expressions of type Storage.Free.t('a) that include arbitrary combinations of steps.

let program : Storage.Free.t(string) =
  put("some key", "some value!")
  >>= (_ => get("some key"))
  >>= (value => put(value, "another value!"))
  >>= (_ => get("some value!"));

And naturally, if we define a function that will take a Storage.Free.t('a) we can recursive into the values and execute all of those commands in the right sequence.

For example, let's say we will have an in-memory version of our Storage, using a Hash table as storage.

module In_memory_runner : {
  let run: Storage.Free.t('a) => unit;
} = {
  open Storage;

  module DB =
    Hashtbl.Make({
      type t = string;
      let equal = String.equal;
      let hash = Hashtbl.hash;
    });

  let db: DB.t(string) = DB.create(1024);

  let rec run: Storage.Free.t('a) => unit =
    fun
    | Free(Get(key, k)) => DB.find(db, key) |> k |> run
    | Free(Put(key, value, k)) => {
        DB.add(db, key, value);
        run(k);
      }
    | Free(End)
    | Return(_) => ();
};

This means executing the program we defined above is as easy as calling the run function with the program value.

let result : string = program |> In_memory_runner.run;

And we can define any number of runners that do different things with our program:

  • Execute it against production databases
  • Execute it in memory for test purposes
  • Log it or dump it as a series of commands for inspection and debugging
  • Optimize it using custom heuristics

One of the biggest drawbacks of the Free Monad approach to the Interpreter Pattern is that the structure of the program is not static and rather has to be discovered by executing the continuations.