Overview

Large software systems rely on modular design — dividing programs into components that can be developed, tested, and compiled independently.
Programming languages formalize this through modules, signatures, and separate compilation.

Together, these mechanisms enable:

  • Encapsulation — hiding implementation details.
  • Abstraction — exposing only conceptual interfaces.
  • Reusability — compiling and linking components without re-analyzing the whole system.
  • Safety — type checking across module boundaries.

Note

The module system generalizes the idea of scope from single files to entire components.
Where functions provide control abstraction, modules provide data and structural abstraction.


Modules

A module is a named collection of related definitions — values, types, functions, and submodules.
It is both a namespace and a compilation unit.

Example (OCaml-style)

module Stack = struct
  type t = int list
  let empty = []
  let push x s = x :: s
  let pop = function
    | [] -> failwith "Empty stack"
    | x :: xs -> (x, xs)
end
  • Stack.t defines the abstract type of stacks.

  • Functions push, pop, and empty belong to the module’s namespace.

  • Access via Stack.push, Stack.pop, etc.

Tip

Modules can group any collection of types and functions — not just ADTs.
They act as logical boundaries for cohesion and reuse.


Signatures — The Interface Layer

A signature specifies what a module exposes: the types and values that form its public contract.
It hides private details, ensuring clients depend only on the interface, not the implementation.

Example

module type STACK = sig
  type t
  val empty : t
  val push : int -> t -> t
  val pop : t -> int * t
end

Key Points

  • Abstract types: type t hides its concrete definition.

  • Exposed operations: define how users interact with the abstract type.

  • Interface stability: as long as the signature stays the same, implementation can change freely.

Matching Implementation to Signature

module Stack : STACK = struct
  type t = int list
  let empty = []
  let push x s = x :: s
  let pop s = (List.hd s, List.tl s)
end

Here, the compiler ensures the implementation matches the signature — every declared value and type must be defined with the correct type.

Note

In ML-family languages, signature matching is statically verified, not at runtime.
This is essential for separate compilation.


Abstraction and Encapsulation

Signatures create abstraction barriers: the user knows how to use a module but not how it works internally.

  • Internal types, helper functions, and optimizations remain private.

  • The compiler enforces the interface boundary.

Example:

module Counter : sig
  type t
  val new : unit -> t
  val inc : t -> unit
  val get : t -> int
end = struct
  type t = int ref
  let new () = ref 0
  let inc c = c := !c + 1
  let get c = !c
end

Here, users cannot access the underlying int ref.
They interact only through new, inc, and get.

Warning

Exposing concrete types accidentally (e.g., type t = int list instead of type t) breaks encapsulation and ties clients to internal design choices.


Functors — Parameterized Modules

Modules can be parameterized by other modules — analogous to functions operating on modules instead of values.

Example

module type STACK = sig
  type t
  val empty : t
  val push : int -> t -> t
  val pop : t -> int * t
end
 
module StackArray () : STACK = struct
  type t = int list
  let empty = []
  let push x s = x :: s
  let pop s = (List.hd s, List.tl s)
end

Or, more generally:

functor MakeStack (Element : sig type t end) : sig
  type t
  val empty : t
  val push : Element.t -> t -> t
  val pop : t -> Element.t * t
end = struct
  type t = Element.t list
  let empty = []
  let push x s = x :: s
  let pop s = (List.hd s, List.tl s)
end

Tip

Functors allow generic programming without inheritance — each instantiation produces a specialized module.


Separate Compilation

With modules and signatures, compilers can check and compile each module independently:

  1. Parse and type-check against its signature.

  2. Generate compiled code (.cmo, .o, or equivalent).

  3. Link precompiled modules later.

Example Flow

stack.ml
stack.mli
main.ml

Steps:

  1. Compile interface: ocamlc -c stack.mlistack.cmi

  2. Compile implementation: ocamlc -c stack.ml

  3. Compile main: ocamlc -c main.ml

  4. Link: ocamlc -o app stack.cmo main.cmo

Note

The .mli file (signature) is compiled first — guaranteeing other modules can compile against it without needing implementation details.


Benefits of Separate Compilation

BenefitDescription
ScalabilityLarge projects compile faster — only changed modules rebuild.
SafetyType-checked module boundaries prevent interface mismatches.
EncapsulationPrivate data remains hidden, protecting invariants.
ReusabilityCommon libraries are linked, not recompiled.

Tip

Separate compilation is foundational for modular systems like Standard ML, OCaml, Ada, and Modula-3, and conceptually underpins package systems in languages like Rust and Go.


Common Pitfalls

Warning

  • Leaky abstraction: exposing concrete types or constructors in a public signature.

  • Circular dependencies: modules referring to each other without explicit functorization.

  • Incomplete signatures: missing declarations prevent consistent type-checking.

  • Name collisions: improper namespace scoping leads to ambiguous symbols.


Diagram Concepts

  • module_interface_boundary.svg:
    Two boxes — Implementation and Signature — with arrows showing exported identifiers and hidden internals.
    Highlight that clients depend only on the interface layer.

  • separate_compilation_pipeline.svg:
    Modules A, B, and Main each compile independently to object code.
    Final link step combines compiled artifacts using consistent type signatures.


See also