Модули и сигнатуры

Знакомимся с модульной системой OCaml — одной из мощнейших в индустрии — и её основой: модулями и сигнатурами.

Модуль группирует типы и значения в единое пространство имён; сигнатура задаёт его публичный интерфейс, скрывая детали реализации.

Модульная система — то, чем OCaml особенно гордится. Если в большинстве языков модуль — это просто файл с функциями, то в OCaml модули сами по себе — мощный язык: их можно параметризовать, ограничивать интерфейсами и комбинировать.

Модуль как пространство имён

Каждый файл foo.ml автоматически становится модулем Foo. Можно определять модули и явно:

module Stack = struct
  type 'a t = 'a list
  let empty = []
  let push x s = x :: s
  let pop = function
    | [] -> None
    | x :: rest -> Some (x, rest)
end

let s = Stack.push 1 (Stack.push 2 Stack.empty)

Снаружи к определениям обращаются через точку: Stack.push. Принято называть основной тип модуля просто t (тогда снаружи он читается как Stack.t).

Сигнатура: интерфейс модуля

module type STACK = sig
  type 'a t
  val empty : 'a t
  val push : 'a -> 'a t -> 'a t
  val pop : 'a t -> ('a * 'a t) option
end

Здесь type 'a t объявлен абстрактно — без правой части. Снаружи никто не знает, что Stack.t — это список. Можно поменять реализацию на массив или дерево, и код-пользователь не сломается.

Инкапсуляция через ограничение

module Stack : STACK = struct
  type 'a t = 'a list
  let empty = []
  let push x s = x :: s
  let pop = function [] -> None | x :: r -> Some (x, r)
end

(* Теперь это ОШИБКА — тип t абстрактен снаружи: *)
(* let bad = List.length (Stack.push 1 Stack.empty) *)

Поскольку t абстрактен, нельзя обращаться со стеком как со списком — только через push/pop. Это инкапсуляция, гарантированная типами.

Как работает под капотом

Абстрактные типы существуют только на этапе компиляции. В рантайме Stack.t — это всё тот же список без обёртки, поэтому абстракция бесплатна. Сигнатура — это контракт, который проверяет тайпчекер: он сопоставляет реализацию с интерфейсом и стирает всё, что не входит в сигнатуру, из видимости. Файлы .mli играют ту же роль: stack.mli задаёт публичный интерфейс для stack.ml.

Частые ошибки

  • Раскрывать тип t в сигнатуре без нужды. Если написать type 'a t = 'a list в sig, инкапсуляция теряется.
  • Путать struct/end и sig/end. struct — реализация, sig — интерфейс.
  • Дублировать сигнатуру и в .mli, и инлайн. Обычно достаточно одного места.

Итоги

  • Модуль (struct ... end или файл) группирует типы и значения в пространство имён.
  • Сигнатура (sig ... end или .mli) задаёт публичный интерфейс.
  • Абстрактный тип type t без определения скрывает реализацию без накладных расходов.
Проверьте себя
1. Что делает абстрактный тип `type 'a t` (без правой части) в сигнатуре?
AДелает тип изменяемым
BСкрывает реализацию: снаружи неизвестно, что это за тип
CЗапрещает использовать модуль
DУскоряет код
2. В чём разница между struct/end и sig/end?
AНикакой
Bstruct — реализация модуля, sig — его интерфейс (тип модуля)
Cstruct для типов, sig для функций
Dsig быстрее
3. Каковы накладные расходы абстрактных типов в рантайме?
AЗначительные, из-за обёрток
BНулевые: абстракция существует только при компиляции и стирается
CЗависят от размера данных
DУдваивают память