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).
- Each application must specify its own configuration signature/type.
- Each application may only be constructed using an instantiation of the specified configuration.
- Configuration construction is handled at the call sites however the caller chooses, so long as the result satisfies the application's specification.
- The signatures of each application's
ttype (and all its functions accepting at) must not change, even when its configuration specification changes. However, application functions with access to atautomatically 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:
- Functors: each
APPLICATIONstruct, likeDemoApp, 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. - First-class modules:
makeaccepts any module satisfying a configuration signature. This signature must be abstract in theCONFIGURABLE_SYSTEM, and concretely defined by eachAPPLICATIONstruct. Callers of the application constructor must also have a way to construct a module satisfying the configuration signature (and consequently, defining concrete configuration settings). - Row polymorphism: I've read a little about how the object system supports row polymorphism. Maybe each application's
configtype could be defined as a row polymorphic object signature, andmakecallers 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 anxfield that's anintand app2's config expects anx : string, or worse they expect the same names/types but interpret them differently).