Контракт модуля: скрытые типы и несколько взглядов на реализацию
Ты пишешь отмену действий в редакторе. Пользователь набрал букву, сменил цвет, удалил абзац. При отмене первым должен вернуться абзац, потом цвет, потом буква. История работает как стек.
Коллега меняет хранение истории. Все функции остались на месте, проект собрался. Но теперь первая отмена убирает букву: вместо стека внутри появилась очередь. Проверка типов ничего не заметила.
В уроке про архитектурные принципы контракт уже разделён на форму данных и поведение. Здесь ты проведёшь ещё одну границу: модуль скрывает представление данных, а его контракт выбирает, что разрешено знать потребителю. Один контракт допускает разные реализации. Одна реализация допускает разные контракты.
Три разных смысла слова «интерфейс»
Представь файловый модуль. Он содержит типы файла, каталога и позиции чтения. Функция чтения должна знать, как устроены и файл, и позиция. Внешнему коду эти сведения не нужны.
Здесь важна общая граница вокруг набора типов и операций, а не только список методов одного объекта.
| Понятие | На какой вопрос отвечает |
|---|---|
| Инженерный интерфейс | Что потребитель вправе делать и ожидать? |
Ключевое слово interface | Какую конструкцию конкретного языка ты объявил? |
| Пространство имён | Где искать определение и как избежать совпадения имён? |
namespace History ещё не гарантирует сокрытие представления. interface Stack ещё не описывает порядок извлечения. При этом оба инструмента могут участвовать в хорошем модульном дизайне: название конструкции не решает за тебя, где проходит граница.
В Java такую границу строят через доступность классов, пакеты, фабрики и модули JPMS. В TypeScript через экспорты, замыкания и непрозрачные типы. Мы возьмём OCaml, потому что в нём контракт группы типов и функций можно записать прямо как module type.
Стек без обещания «внутри список»
В OCaml sig ... end описывает публичные объявления, а struct ... end содержит реализацию. Тип с параметром 'a читается как «для элементов типа a».
Сохрани следующий блок в contracts.ml. Все исполняемые OCaml-блоки этого урока, кроме явно ошибочного, дописывай в этот же файл по порядку. Запуск: ocaml contracts.ml. Нужен установленный OCaml, внешних библиотек нет.
(* 1. Контракт и реализация. *)
module type STACK = sig
type 'a t
val empty : 'a t
val is_empty : 'a t -> bool
val push : 'a -> 'a t -> 'a t
val pop : 'a t -> ('a * 'a t) option
end
module ListStack : STACK = struct
type 'a t = 'a list
let empty = []
let is_empty = function [] -> true | _ -> false
let push value stack = value :: stack
let pop = function
| [] -> None
| value :: rest -> Some (value, rest)
end
У STACK есть четыре операции и один абстрактный тип. Строка type 'a t говорит, что такой тип существует, но не сообщает его устройство.
Внутри ListStack компилятор знает равенство t = list. Поэтому push, pop и is_empty работают с одним представлением. Снаружи виден только STACK: запись : STACK скрыла равенство со списком.
Это не секрет от человека, который открыл исходник. Это ограничение зависимостей: обычный типизированный клиент не может опереться на устройство списка. Низкоуровневые обходы системы типов вроде Obj.magic в эту гарантию не входят.
Теперь история редактора:
(* 2. Клиент видит операции, но не представление. *)
let () =
let before = ListStack.push "letter" ListStack.empty in
let after = ListStack.push "color" before in
match ListStack.pop after, ListStack.pop before with
| Some (latest, rest), Some (previous, _) ->
assert (latest = "color");
assert (previous = "letter");
assert (not (ListStack.is_empty rest));
Printf.printf "latest=%s; previous=%s\n" latest previous
| _ -> failwith "unexpected empty history"
Ожидаемая строка: latest=color; previous=letter.
push возвращает новый стек. pop возвращает пару из элемента и оставшегося стека либо None, если элементов нет. Старое значение before не исчезает после создания after.
Такую структуру называют персистентной: прежние версии остаются доступны. Это не запись на диск. В списковой реализации новая вершина ссылается на прежний хвост, поэтому копировать всю историю при каждом push не нужно.
Что граница запрещает
Этот блок намеренно не компилируется. Не добавляй его в рабочий файл:
let forged : int ListStack.t = [1; 2]
Снаружи int list и int ListStack.t не одно и то же. Для создания стека нужны empty и push. Аналогично нельзя сопоставить ListStack.t с шаблоном value :: rest.
Обрати внимание на форму API. empty входит в тот же контракт, что push и pop. Клиенту не нужен отдельный конструктор конкретного класса. И тип результата push совпадает с типом аргумента-стека: он не превращается в неизвестную другую разновидность истории.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.