开发者详解模块系统与类型类的区别与相似之处
来源标题:Axiomitisation of Scienctific Notations for Programming Languages
针对长期存在的模块系统(如 OCaml)与类型类(如 Haskell、Rust)概念混淆问题,有开发者撰文进行澄清。文章指出,类型类的核心目标是提供特设多态(即运算符重载),而模块系统的核心目标是提供模块化抽象,如依赖注入与信息隐藏。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
很长一段时间以来,我注意到人们对模块系统(如 OCaml 等语言中的)与 typeclass(如 Haskell 或 Rust 等语言中的)之间的差异和相似之处存在许多持续的困惑。本文试图澄清这些困惑。
typeclass 作为一种语言构造,其主要目标是提供 ad-hoc 多态。这也被称为运算符重载或基于类型的分发。ad-hoc 多态的核心意义在于作为一种小规模编程的便利特性:它让你可以跨类型复用相同的标识符。例如,我可能想用 + 符号同时表示整数加法和浮点数向量加法(重要的是,向量类型可能定义在某个随机的库中,因此我们无法在语言层面硬编码这种行为)。
模块系统作为一种语言构造,其主要目标是提供模块化抽象。这一特性的某些部分在更广泛的软件行业中已有名称,比如依赖注入、封装和信息隐藏。模块化抽象的核心意义在于通过让你显式地表达程序的分解方式,从而实现更高效的大规模编程。它为你提供了一种组合式的方式,将程序递归地分解为各个组件。
我认为导致困惑的主要因素有:
然而,尽管在许多情况下可以进行模拟,但通常并非最优方案。
typeclass 背后的动机基本上是:如果你要重载一个符号,它在“道义上”应该表示相同的含义。例如,符号 + 表示抽象的加法概念,我们可以为不同的类型提供不同的实现。这与 C++ 等情况形成对比——在 C++ 中,<< 根据类型的不同可以表示位移或流重定向,二者毫无共同之处。这里的目标是让程序的含义更容易理解(相对于 C++ 那种无原则的做法),同时通过减少符号数量来降低认知负担。我们可以学习少数几个适用于抽象结构的符号,比如 + 或 >>=,并在各种不同的具体结构上使用它们。
如果我们想更严肃地对待这个问题,还需要考虑语义。我们如何确保 + 确实表示抽象的加法运算?我们可以为 typeclass 添加定律。例如,我们可以要求加法必须满足 (a+b)+c=a+(b+c)(a + b) + c = a + (b + c)(a+b)+c=a+(b+c)(尽管这对浮点数并不成立),然后使用 property test 或证明见证来检查新的实现确实满足该定律。一般来说,每个 typeclass 都可以附带一些语义描述,理想情况下以测试套件或证明义务的形式进行机器检查。
作为参考,以下是 Haskell 中 Num typeclass 的定义,取自 ghc。注意它给出了一些定律,但在这个例子中它们只是约定俗成的。例如,浮点乘法不满足结合律,但它仍然拥有 Num 实例。
-- | Basic numeric class.
--
-- The Haskell Report defines no laws for 'Num'. However, @('+')@ and @('*')@ are
-- customarily expected to define a ring and have the following properties:
--
-- [__Associativity of @('+')@__]: @(x + y) + z@ = @x + (y + z)@
-- [__Commutativity of @('+')@__]: @x + y@ = @y + x@
-- [__@'fromInteger' 0@ is the additive identity__]: @x + fromInteger 0@ = @x@
-- [__'negate' gives the additive inverse__]: @x + negate x@ = @fromInteger 0@
-- [__Associativity of @('*')@__]: @(x * y) * z@ = @x * (y * z)@
-- [__@'fromInteger' 1@ is the multiplicative identity__]:
-- @x * fromInteger 1@ = @x@ and @fromInteger 1 * x@ = @x@
-- [__Distributivity of @('*')@ with respect to @('+')@__]:
-- @a * (b + c)@ = @(a * b) + (a * c)@ and @(b + c) * a@ = @(b * a) + (c * a)@
-- [__Coherence with 'toInteger'__]: if the type also implements 'GHC.Real.Integral', then
-- 'fromInteger' is a left inverse for 'GHC.Internal.Real.toInteger', i.e. @fromInteger (toInteger i) == i@
class Num a where
(+), (-), (*) :: a -> a -> a
-- | Unary negation.
negate :: a -> a
-- | Absolute value.
abs :: a -> a
-- | Sign of a number.
-- The functions 'abs' and 'signum' should satisfy the law:
--
-- > abs x * signum x == x
--
-- For real numbers, the 'signum' is either @-1@ (negative), @0@ (zero)
-- or @1@ (positive).
signum :: a -> a
-- | Conversion from an 'Integer'.
-- An integer literal represents the application of the function
-- 'fromInteger' to the appropriate value of type 'Integer',
-- so such literals have type @('Num' a) => a@.
fromInteger :: Integer -> a
x - y = x + negate y
negate x = 0 - x
为何与何为:模块
模块的意义在于让你能够以符合人体工程学的方式对程序的各个部分进行抽象。为此,我们将程序拆分为多个部分(“模块”、“结构”),然后显式指定它们如何组合在一起。重要的是,我们可以通过让每个模块对与它交互的其他模块隐藏自身的某些部分(封装!),来减少推理整个程序所需的总工作量。
这对输出而言是好的,但我们还需要考虑对输入进行抽象。为此,我们可以创建参数化模块(由于历史原因,这些通常被称为 functor),它们本身不是模块,但在用其他某个(些)模块实例化时会产出一个模块。我们可以用哪些模块来实例化呢?所有符合特定接口的模块(在文献中这些接口通常被称为 signature)!
同样,如果我们严肃对待这件事,我们可能会给接口附加一些语义,比如约定俗成的定律、测试套件或证明义务。毕竟,用模块化分解来组织程序的最常见原因之一就是提高测试的便利性。识别重要的接口并在接口层面进行测试,是一种以低成本获得高覆盖率的非常有效的方式。
(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。