1
votes

I'm developing an application in OCaml and anticipate the codebase will eventually produce multiple executables. The configuration options for each application are also expected to change.

I'm relatively new to OCaml's module system, but I've tried using them to enforce the structure I want. If there's an entirely different approach that meets these criteria, please share and explain how it's better than alternative approaches (see rough ideas at the end).

  1. Each application must specify its own configuration signature/type.
  2. Each application may only be constructed using an instantiation of the specified configuration.
  3. Configuration construction is handled at the call sites however the caller chooses, so long as the result satisfies the application's specification.
  4. The signatures of each application's t type (and all its functions accepting a t) must not change, even when its configuration specification changes. However, application functions with access to a t automatically have access to everything provided by the configuration specification.

Everything tested using

$ ocaml --version
The OCaml toplevel, version 4.03.0
$ ocamlbuild --version
ocamlbuild 0.9.2
$ ocamlbuild sandbox.byte

Parts that compile

An APPLICATION is a CONFIGURABLE_SYSTEM constructed only by using some internal config type:

module type CONFIGURABLE_SYSTEM = sig
  type t
  type config

  val make : config -> t
end

module type APPLICATION = sig
  include CONFIGURABLE_SYSTEM

  val launch : t -> unit
end

Here's a demo application satisfying the above signatures:

module DemoApp : APPLICATION = struct
  type config = { color : color_scheme }
   and color_scheme = | HighContrast | Chromatic

  type t = (config * string)

  let make cfg = (cfg, "default string state")
  let launch (cfg, _str) =
    match cfg.color with
    | HighContrast -> print_string "Using high contrast settings...\n"
    | Chromatic -> print_string "Using chromatic settings...\n"

  let default_config = { color = HighContrast }
end

Where I'm stuck

Goal: make and launch a DemoApp from a toplevel let () = ....

Problem: Construct a config record from outside the DemoApp module where it's defined. (Notice the let default_config successfully constructs one from inside DemoApp.)

let () =
  let config = (*** What goes here? ***)
  let demo = DemoApp.make config in
  DemoApp.launch demo

Attempt 1

Try the obvious approach with minimal ceremony:

let () =
  let config = { color = HighContrast } in
  ()

Fails with Error: unbound record field color. Not entirely surprising because the color field is in a different module.

Attempt 2

Create an ad-hoc module, open DemoApp inside it, make a config instance there, and then grab it from outside:

let () =
  let config =
    let module M = struct
        open DemoApp
        let config = { color = HighContrast }
      end
    in
    M.config
  in
  ()

Surprisingly, this also fails with Error: unbound record field color.

Attempt 3

Grit my teeth and define a top-level type that should only ever be used as a DemoApp config type.

type demo_config =
  {
    color : color_scheme
  }
 and color_scheme = | HighContrast | Chromatic

Redefine DemoApp's config type to use it:

Module DemoApp : APPLICATION = struct
  type config = demo_config
  ... (* everything else is the same as above *)
end

...and give it a try:

let () =
  let cfg = { color = HighContrast } in
  let demo = DemoApp.make cfg in
  ()

Error: This expression has type demo_config but an expression was expected of type DemoApp.config

Ouch. The module system doesn't identify DemoApp.config with demo_config even though the former is defined as the latter. Is this because the CONFIGURABLE_SYSTEM.config, and in turn APPLICATION.config, are abstract and DemoApp produces an APPLICATION, meaning its config definition is hidden? If so, is there a way to enforce all the constraints imposed by APPLICATION on DemoApp, except hiding the concrete config type?

If this is a dead end

...here are some other strategies I haven't experimented with enough yet, but might work:

  1. Functors: each APPLICATION struct, like DemoApp, is parameterized by a module, ideally a signature, representing the configuration spec. Callers define their own module structs satisfying the application's config spec use it to create their own specialized application struct. Then they make an instance of the config, and pass it to the specialized application struct's constructor to make their configured application.
  2. First-class modules: make accepts any module satisfying a configuration signature. This signature must be abstract in the CONFIGURABLE_SYSTEM, and concretely defined by each APPLICATION struct. Callers of the application constructor must also have a way to construct a module satisfying the configuration signature (and consequently, defining concrete configuration settings).
  3. Row polymorphism: I've read a little about how the object system supports row polymorphism. Maybe each application's config type could be defined as a row polymorphic object signature, and make callers could pass any object which has at least the fields defined in that signature. I'm less thrilled about this approach because it could encourage a universal configuration across all applications, and lead to weird naming/interpretation collisions across the various application configurations (e.g. app1's config demands an x field that's an int and app2's config expects an x : string, or worse they expect the same names/types but interpret them differently).
1

1 Answers

2
votes

Your issue is here:

module DemoApp : APPLICATION = struct

By this module type annotation, you constraint the type of DemoApp. This means that t and config are abstract (and that colorscheme is hidden), since that's how they are declared in APPLICATION.

You want to ensure that DemoApp respects the APPLICATION signature while still exposing the actual datatype. You can simply remove the annotation, and it'll work fine.

In the .mli, you would have something like that :

module DemoApp : sig
  type config = { color : color_scheme }
   and color_scheme = | HighContrast | Chromatic
  type t = (config * string)

  (* include APPLICATION while style exposing the concrete types. *)
  include APPLICATION with type t := t and type config := config
end

Note that what you desire is named "transparent ascription". As you discovered, the usual module type ascription hide the implementation of types in the module (it's "opaque"). A transparent ascription would not.