5.7. 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 addition and multiplication operations from arithmetic, or more precisely, a ring:

module type Ring = sig
  type t
  val zero : t
  val one : t
  val ( + ) : t -> t -> t
  val ( * ) : t -> t -> t
  val ( ~- ) : t -> t  (* additive inverse *)
  val to_string : t -> string
end
module type Ring =
  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 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 Ring module type makes it clear that’s what we have.

Here is a module that implements that module type:

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

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

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

But we could convert it to a string:

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

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

let pp_intring fmt i =
  Format.fprintf fmt "%s" (IntRing.to_string i);;

#install_printer pp_intring;;

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

We could implement other kinds of rings, too:

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

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

let pp_floatring fmt f =
  Format.fprintf fmt "%s" (FloatRing.to_string f);;

#install_printer pp_floatring;;

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

Was there really a need to make type t abstract in the ring 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.7.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 IntRing = struct
  type t = int
  let zero = 0
  let one = 1
  let ( + ) = Stdlib.( + )
  let ( * ) = Stdlib.( * )
  let ( ~- ) = Stdlib.( ~- )
  let to_string = string_of_int
end

module _ : Ring = IntRing
module IntRing :
  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
IntRing.(one + one)
- : int = 2

There’s a more sophisticated way of accomplishing the same goal. We can specialize the Ring 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_RING = Ring with type t = int
module type INT_RING =
  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_RING 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 IntRing can be given that module type:

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

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:

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

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

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

The same can be done for floats:

module type FLOAT_RING = Ring with type t = float

module FloatRing : FLOAT_RING = 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_RING =
  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 FloatRing : FLOAT_RING

It turns out there’s no need to separately define INT_RING and FLOAT_RING. 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 FloatRing : Ring 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 FloatRing :
  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.7.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.