Как решить, следует ли параметризовать на уровне типов или на уровне модулей при разработке модулей?
Я работаю над глубоким пониманием модулей в стиле ML: я думаю, что концепция важна, и мне нравится то, что они поощряют. Я только сейчас обнаруживаю напряжение, которое может возникнуть между параметрическими типами и параметрическими модулями. Я ищу инструменты для размышления по этому вопросу, которые помогут мне принимать умные решения по проектированию при разработке моих программ.
Сначала я постараюсь описать свой вопрос в целом. Затем я приведу конкретный пример из учебного проекта, над которым я работаю. Наконец, я вернусь к общему вопросу, чтобы подвести его к сути.
(Мне жаль, что я еще не знаю достаточно, чтобы поставить этот вопрос более кратко.)
В общих чертах, напряжение, которое я обнаружил, заключается в следующем: функции наиболее гибки и открыты для самого широкого повторного использования, когда мы предоставляем им сигнатуры параметрического типа (где это уместно). Однако модули являются наиболее гибкими и открыты для самого широкого повторного использования, когда мы запечатываем параметризацию функций внутри модуля и вместо этого параметризируем весь модуль для данного типа.
Готовый пример этой разницы можно найти в сравнении модулей, которые реализуют LIST
подпись с теми, которые реализуют ORD_SET
, Модуль List:LIST
предоставляет множество полезных функций, параметризованных для любого типа. Как только мы определили или загрузили List
Модуль, мы можем легко применять любые функции, которые он предоставляет, чтобы создавать, манипулировать или проверять списки любого типа. Например, если мы работаем как со строками, так и с целыми числами, мы можем использовать один и тот же модуль для создания и манипулирования значениями обоих типов:
val strList = List.@ (["a","b"], ["c","d"])
val intList = List.@ ([1,2,3,4], [5,6,7,8])
С другой стороны, если мы хотим иметь дело с упорядоченными множествами, дело обстоит иначе: упорядоченные множества требуют, чтобы отношение упорядочения сохранялось над всеми их элементами, и не может быть единой конкретной функции. compare : 'a * 'a -> order
производя это отношение для каждого типа. Следовательно, нам нужен другой модуль, удовлетворяющий ORD_SET
подпись для каждого типа мы хотим поместить в упорядоченные наборы. Таким образом, чтобы создать или управлять упорядоченными наборами строк и целых чисел, мы должны реализовать различные модули для каждого типа [1]:
structure IntOrdSet = BinarySetFn ( type ord_key = int
val compare = Int.compare )
structure StrOrdSet = BinarySetFn ( type ord_key = string
val compare = String.compare )
И тогда мы должны использовать функцию подбора из соответствующего модуля, когда мы хотим работать с данным типом:
val strSet = StrOrdSet.fromList ["a","b","c"]
val intSet = IntOrdSet.fromList [1,2,3,4,5,6]
Здесь есть довольно простой компромисс: LIST
модули предоставляют функции, которые варьируются по вашему желанию, но они не могут использовать какие-либо отношения между значениями какого-либо конкретного типа; ORD_SET
Модули предоставляют функции, которые обязательно ограничены типом, указанным в параметре functors, но с помощью той же самой параметризации они способны включать конкретную информацию о внутренней структуре и отношениях их целевых типов.
Легко представить себе случаи, когда мы хотели бы разработать альтернативное семейство модулей списков, используя функторы для параметризации типов и других значений, чтобы предоставить списковые типы данных с более сложной структурой: например, чтобы указать тип данных для упорядоченного списка или для представления списков с использованием самобалансирующихся бинарных деревьев поиска.
При создании модуля, я думаю, также довольно легко распознать, когда он сможет предоставлять полиморфные функции и когда его нужно будет параметризировать для некоторых типов. Что мне кажется более сложным, так это выяснить, от каких модулей вы должны зависеть, работая над чем-то еще ниже.
В общих чертах, мой вопрос заключается в следующем: когда я проектирую систему различных модулей, как я могу выяснить, следует ли проектировать модули, предоставляющие полиморфные функции или модули, созданные с использованием функторов, параметризованных по типам и значениям?
Я надеюсь проиллюстрировать дилемму и то, почему она имеет значение, на следующем примере, взятом из игрушечного проекта, над которым я работаю.
у меня есть functor PostFix (ST:STACK) : CALCULATOR_SYNTAX
, Это берет реализацию структуры данных стека и создает синтаксический анализатор, который считывает конкретную нотацию постфикса ("обратная полировка") в абстрактный синтаксис (который будет оценен модулем калькулятора в нисходящем направлении) и наоборот. Теперь я использовал стандартный интерфейс стека, который обеспечивает полиморфный тип стека и количество функций для работы с ним:
signature STACK =
sig
type 'a stack
exception EmptyStack
val empty : 'a stack
val isEmpty : 'a stack -> bool
val push : ('a * 'a stack) -> 'a stack
val pop : 'a stack -> 'a stack
val top : 'a stack -> 'a
val popTop : 'a stack -> 'a stack * 'a
end
Это прекрасно работает и дает мне некоторую гибкость, так как я могу использовать стек на основе списка, векторный стек или что-то еще. Но, скажем, я хочу добавить простую функцию регистрации в модуль стека, чтобы каждый раз, когда элемент помещался в стек или извлекался из него, он выводил текущее состояние стека. Теперь мне понадобится fun toString : 'a -> string
для типа, собранного стеком, и это, как я понимаю, не может быть включено в STACK
модуль. Теперь мне нужно запечатать тип в модуль и параметризировать модуль по типу, собранному в стеке, и toString
функция, которая позволит мне создать печатное представление собранного типа. Так что мне нужно что-то вроде
functor StackFn (type t
val toString: t -> string ) =
struct
...
end
и это не будет производить модуль, соответствующий STACK
подпись, так как она не обеспечивает полиморфный тип. Таким образом, я должен изменить подпись, необходимую для PostFix
функтор. Если у меня много других модулей, я должен также поменять их все. Это может быть неудобно, но реальная проблема заключается в том, что я больше не могу использовать свой простой, основанный на списке, или векторный STACK
модули в PostFix
функтор, когда я не хочу войти. Теперь, похоже, мне нужно вернуться и переписать эти модули, чтобы они также были запечатаны.
Итак, чтобы вернуться, расширить и (к счастью) закончить мой вопрос:
- Есть ли способ указать сигнатуру модулей, созданных
StackFn
так что они в конечном итоге как "особые случаи"STACK
? - В качестве альтернативы, есть ли способ написания подписи для
PostFix
модуль, который позволит как модули, произведенныеStackFn
и те, которые удовлетворяютSTACK
? - Вообще говоря, есть ли способ размышления об отношениях между модулями, которые помогут мне уловить / предвидеть подобные вещи в будущем?
(Если вы читали это далеко. Большое спасибо!)
1 ответ
Как вы обнаружили, в SML и OCaml существует противоречие между параметрическим полиморфизмом и функторами / модулями. В основном это связано с "двухъязыковой" природой модулей и отсутствием специального полиморфизма. 1ML и модульные имплициты предоставляют разные решения этой проблемы. Первый, объединяя два вида параметризма, позже позволяя при необходимости вызывать некий специальный полиморфизм.
Вернемся к практическим соображениям. С помощью функторов довольно легко (но многословно / досадно) мономорфизировать данную структуру данных. Вот пример (в OCaml). При этом вы все еще можете писать общие реализации и специализировать их позже (предоставляя функцию печати).
module type POLYSTACK = sig
type 'a stack
exception EmptyStack
val empty : 'a stack
val isEmpty : 'a stack -> bool
val push : ('a * 'a stack) -> 'a stack
val pop : 'a stack -> 'a stack
val top : 'a stack -> 'a
val popTop : 'a stack -> 'a stack * 'a
val toString : ('a -> string) -> 'a stack -> string
end
module type STACK = sig
type elt
type t
exception EmptyStack
val empty : t
val isEmpty : t -> bool
val push : (elt * t) -> t
val pop : t -> t
val top : t -> elt
val popTop : t -> t * elt
val toString : t -> string
end
module type PRINTABLE = sig
type t
val toString : t -> string
end
module Make (E : PRINTABLE) (S : POLYSTACK)
: STACK with type elt = E.t and type t = E.t S.stack
= struct
type elt = E.t
type t = E.t S.stack
include S
let toString = S.toString E.toString
end
module AddLogging (S : STACK)
: STACK with type elt = S.elt and type t = S.t
= struct
include S
let push (x, s) =
let s' = S.push (x, s) in print_string (toString s') ; s'
end