5.6. Module Type Constraints

We have extolled the virtues of encapsulation. Now we’re going to do something that might seem counter-intuitive: selectively violate encapsulation.

As a motivating example, here is a module type that represents values that support the usual operations from arithmetic, or more precisely, a field:

module type Field = sig
  type t
  val zero : t
  val one : t
  val ( + ) : t -> t -> t
  val ( * ) : t -> t -> t
  val ( ~- ) : t -> t
  val to_string : t -> string
end
module type Field =
  sig
    type t
    val zero : t
    val one : t
    val ( + ) : t -> t -> t
    val ( * ) : t -> t -> t
    val ( ~- ) : t -> t
    val to_string : t -> string
  end

Recall that we must write ( * ) instead of (*) because the latter would be parsed as beginning a comment. And we write the ~ in (~-) to indicate a unary negation operator.

This is a bit weird of an example. We don’t normally think of numbers as a data structure. But what is a data structure except for a set of values and operations on them? The Field module type makes it clear that’s what we have.

Here is a module that implements that module type:

module IntField : Field = struct
  type t = int
  let zero = 0
  let one = 1
  let ( + ) = Stdlib.( + )
  let ( * ) = Stdlib.( * )
  let ( ~- ) = Stdlib.( ~- )
  let to_string = string_of_int
end
module IntField : Field

Because t is abstract, the toplevel can’t give us good output about what the sum of one and one is:

IntField.(one + one)
- : IntField.t = <abstr>

But we could convert it to a string:

IntField.(one + one |> to_string)
- : string = "2"

We could even install a pretty printer to avoid having to manually call to_string:

let pp_intfield fmt i =
  Format.fprintf fmt "%s" (IntField.to_string i);;

#install_printer pp_intfield;;

IntField.(one + one)
val pp_intfield : Format.formatter -> IntField.t -> unit = <fun>
- : IntField.t = 2

We could implement other kinds of fields, too:

module FloatField : Field = struct
  type t = float
  let zero = 0.
  let one = 1.
  let ( + ) = Stdlib.( +. )
  let ( * ) = Stdlib.( *. )
  let ( ~- ) = Stdlib.( ~-. )
  let to_string = string_of_float
end
module FloatField : Field

Then we’d have to install a printer for it, too:

let pp_floatfield fmt f =
  Format.fprintf fmt "%s" (FloatField.to_string f);;

#install_printer pp_floatfield;;

FloatField.(one + one)
val pp_floatfield : Format.formatter -> FloatField.t -> unit = <fun>
- : FloatField.t = 2.

Was there really a need to make type t abstract in the field examples above? Arguably not. And if it were not abstract, we wouldn’t have to go to the trouble of converting abstract values into strings, or installing printers. Let’s pursue that idea, next.

5.6.1. Specializing Module Types

In the past, we’ve seen that we can leave off the module type annotation, then do a separate check to make sure the structure satisfies the signature:

module IntField = struct
  type t = int
  let zero = 0
  let one = 1
  let ( + ) = Stdlib.( + )
  let ( * ) = Stdlib.( * )
  let ( ~- ) = Stdlib.( ~- )
  let to_string = string_of_int
end

module _ : Field = IntField
module IntField :
  sig
    type t = int
    val zero : int
    val one : int
    val ( + ) : int -> int -> int
    val ( * ) : int -> int -> int
    val ( ~- ) : int -> int
    val to_string : int -> string
  end
IntField.(one + one)
- : int = 2

There’s a more sophisticated way of accomplishing the same goal. We can specialize the Field module type to specify that t must be int or float. We do that by adding a constraint using the with keyword:

module type INT_FIELD = Field with type t = int
module type INT_FIELD =
  sig
    type t = int
    val zero : t
    val one : t
    val ( + ) : t -> t -> t
    val ( * ) : t -> t -> t
    val ( ~- ) : t -> t
    val to_string : t -> string
  end

Note how the INT_FIELD module type now specifies that t and int are the same type. It exposes or shares that fact with the world, so we could call these “sharing constraints.”

Now IntField can be given that module type:

module IntField : INT_FIELD = struct
  type t = int
  let zero = 0
  let one = 1
  let ( + ) = Stdlib.( + )
  let ( * ) = Stdlib.( * )
  let ( ~- ) = Stdlib.( ~- )
  let to_string = string_of_int
end
module IntField : INT_FIELD

And since the equality of t and int is exposed, the toplevel can print values of type t without any help needed from a pretty printer:

IntField.(one + one)
- : IntField.t = 2

Programmers can even mix and match built-in int values with those provided by IntField:

IntField.(1 + one)
- : IntField.t = 2

The same can be done for floats:

module type FLOAT_FIELD = Field with type t = float

module FloatField : FLOAT_FIELD = struct
  type t = float
  let zero = 0.
  let one = 1.
  let ( + ) = Stdlib.( +. )
  let ( * ) = Stdlib.( *. )
  let ( ~- ) = Stdlib.( ~-. )
  let to_string = string_of_float
end
module type FLOAT_FIELD =
  sig
    type t = float
    val zero : t
    val one : t
    val ( + ) : t -> t -> t
    val ( * ) : t -> t -> t
    val ( ~- ) : t -> t
    val to_string : t -> string
  end
module FloatField : FLOAT_FIELD

It turns out there’s no need to separately define INT_FIELD and FLOAT_FIELD. The with keyword can be used as part of the module definition, though the syntax becomes a little harder to read because of the proximity of the two = signs:

module FloatField : Field with type t = float = struct
  type t = float
  let zero = 0.
  let one = 1.
  let ( + ) = Stdlib.( +. )
  let ( * ) = Stdlib.( *. )
  let ( ~- ) = Stdlib.( ~-. )
  let to_string = string_of_float
end
module FloatField :
  sig
    type t = float
    val zero : t
    val one : t
    val ( + ) : t -> t -> t
    val ( * ) : t -> t -> t
    val ( ~- ) : t -> t
    val to_string : t -> string
  end

5.6.2. Constraints

Syntax.

There are two sorts of constraints. One is the sort we saw above, with type equations:

  • T with type x = t, where T is a module type, x is a type name, and t is a type.

The other sort is a module equation, which is syntactic sugar for specifying the equality of all types in the two modules:

  • T with module M = N, where M and N are module names.

Multiple constraints can be added with the and keyword:

  • T with constraint1 and constraint2 and ... constraintN

Static semantics.

The constrained module type T with type x = t is the same as T, except that the declaration of type x inside T is replaced by type x = t. For example, compare the two signatures output below:

module type T = sig type t end
module type U = T with type t = int
module type T = sig type t end
module type U = sig type t = int end

Likewise, T with module M = N is the same as T, except that the any declaration type x inside the module type of M is replaced by type x = N.X. (And the same recursively for any nested modules.) It takes more work to give and understand this example:

module type XY = sig
  type x
  type y
end

module type T = sig
  module A : XY
end

module B = struct
  type x = int
  type y = float
end

module type U = T with module A = B

module C : U = struct
  module A = struct
    type x = int
    type y = float
    let x = 42
  end
end
module type XY = sig type x type y end
module type T = sig module A : XY end
module B : sig type x = int type y = float end
module type U = sig module A : sig type x = int type y = float end end
module C : U

Focus on the output for module type U. Notice that the types of x and y in it have become int and float because of the module A = B constraint. Also notice how modules B and C.A are not the same module; the latter has an extra item x in it. So the syntax module A = B is potentially confusing. The constraint is not specifying that the two modules are the same. Rather, it specifies that all their types are constrained to be equal.

Dynamic semantics.

There are no dynamic semantics for constraints, because they are only for type checking.