FAQ:为什么可变类型不是不可变类型的子类型,反之亦然?
摘要
本文解释了为什么在编程语言中可变数据类型不能是不可变类型的子类型,使用里氏替换原则(Liskov substitution principle)和配对作为例子,以突出类型安全性和不可变性契约。
<p><a href="https://lobste.rs/s/lzhzsx/faq_why_isn_t_mutable_subtype_immutable">评论</a></p>
查看缓存全文
缓存时间: 2026/09/18 15:51
# 常见问题:为什么可变类型不是不可变类型的子类型,反之亦然?
来源:https://crumbles.blog/posts/2026-09-17-immutable-mutable.html
> 我仍记得第一次了解不可变性时的顿悟时刻,它彻底改变了一切。
—丹尼斯·德弗雷恩 (https://ruby.social/@denis/117280604932132961)
在各种编程语言论坛中,时常会讨论为何某些语言不将数据结构的可变版本与不可变版本设计为彼此的子类型或超类型。虽然技术上并非不可能实现,但这种做法在形式逻辑上并不正确,并且至少会损失语言通常能提供的部分类型检查保证。
要理解为何不可行,必须回顾子类型的定义——即里斯科夫替换原则 (https://en.wikipedia.org/wiki/Liskov_substitution_principle):若类型*S*的值能在所有期待类型*T*值的上下文中使用,则*S*是*T*的子类型。
如同处理形式化问题的惯例,这个定义需要严格解读。*"所有"*确实指*"所有"*,而非*"大多数"*。(你可能学过的替换原则仅仅是面向对象类的*推荐*设计模式,但严格来说,真正的子类型*必须*满足此标准。)支持子类型的静态类型系统必须在类型检查时为你证明这一点。
为说明这点,我们以最简单的复合数据结构——简单的序对——为例。以下是不可变版本的操作:
`\(cons a d\)`构造包含a和d的新序对并返回
`\(car p\)`返回构造序对p时提供的a值
`\(cdr p\)`返回构造序对p时提供的d值
仅此而已!
可变版本新增两个操作:
`\(set\-car\! p a\)`改变序对p中a的值
`\(set\-cdr\! p d\)`改变序对p中d的值
(以及新的构造函数,下文将涉及。)
显然,当需要可变序对时,无法使用不可变序对。需要可变序对的代码可能会调用上述两个操作,但这些操作在不可变序对中未定义,从而导致类型错误。
但为何不能反向进行?不可变序对提供的所有操作也都存在于可变序对中,因此看似可以在需要不可变序对的场合使用可变序对。
原因更为微妙:替换原则不仅涵盖类型提供的操作(方法),还包括这些操作隐含的契约。
当我们对不可变序对调用`car`或`cdr`时,可以依赖一个契约:每次调用该序对时结果始终相同。这意味着我们可以安全地基于序对内容计算哈希值,将其存储在其他数据结构中,并确信后续重新计算检索时结果不变。(换言之,不可变性是哈希整合 (https://en.wikipedia.org/wiki/Hash_consing) 的前提条件!)
因此,不可变序对与可变序对必须是完全不同的类型:
`\(icons a d\)`构造包含a和d的新不可变序对并返回
`\(icar i\)`返回构造不可变序对i时提供的a值
`\(icdr i\)`返回构造不可变序对i时提供的d值
`\(mcons a d\)`构造包含a和d的新可变序对并返回
`\(mcar m\)`返回构造可变序对m时提供的a值
`\(mcdr m\)`返回构造可变序对m时提供的d值
`\(set\-mcar\! m a\)`改变可变序对m中a的值
`\(set\-mcdr\! m d\)`改变可变序对m中d的值
若i是可变序对或m是不可变序对,则属于类型错误。
## 反对意见:但我并不修改它,且我的用例确实不需要不可变性契约
由于这两种序对类型不构成子类型层次,它们必须是完全独立的类型,并定义各自的操作集。
幸运的是,许多语言提供了特设多态 (https://en.wikipedia.org/wiki/Ad_hoc_polymorphism) 机制,允许在不形成层次结构的多个类型上定义相同操作。作为Schemer,我认为在动态类型环境中这是个坏主意,因为它使数据类型流动的推理变得极其复杂,从而更易出错。实际上,无论动态或静态类型语言,大多提供了此类机制。
先看静态类型的情况。Wadler与Blott (https://doi.org/10.1145/75277.75283) 引入了形式化推理特设多态并确保类型检查可靠性的机制。在其术语中,可变与不可变序对是不同类型,但都可属于一个公共的序对类型类,该类型类包含前述的`car`与`cdr`操作。在可变序对上,这些操作对应底层的`mcar`与`mcdr`;在不可变序对上,则对应`icar`与`icdr`。
这在形式上仍然可靠,因为序对类型类定义的新契约未涉及可变性。在正确的类型类实现中,类型系统会阻止你在函数方法中使用修改器——该函数对输入类型的定义仅要求是*某种*序对(可变或不可变)。它不会阻止你使用`car`和`cdr`操作并期望其不可变——但这允许你明确选择两种粒度,根据函数实际需求的契约,将输入类型声明为可变序对、不可变序对或任意一种。子类型关系仅允许单向兼容:你可以声明函数接受不可变序对,但可能错误地隐含包含可变序对;或者反之,声明接受可变序对却错误包含不可变序对;但无法一致地排除任一类型(否则将违反替换原则)。
类似类型类的特性存在于多种静态类型语言中,通常称为接口、特质或角色。然而现实中的类型系统在检查严格性上差异很大。
在动态类型面向对象语言中,这通常表现为鸭子类型 (https://en.wikipedia.org/wiki/Duck_typing):我们在多个不同类型上定义同名方法,由运行时类型分派处理。通过在调用函数前显式检查修改操作的存在与否,仍可获得类似收益。实践中这种做法很少见——尤其检查修改器的缺失——这也是动态类型语言中特设多态容易引发问题的原因。
相似文章
里氏替换原则远超你的想象
本文探讨了里氏替换原则超越其常见解释的内涵,强调其基于子类型的形式化基础,涉及前置条件、后置条件、不变量和历史属性,并引用了原始研究论文。
揭秘类型(及一些悖论的解惑)
类型理论为编程语言基础增加了不必要的复杂性,并提出基于关系成员的更简单观点。
并发、交互、可变:三者选二
文章探讨了编程语言中并发、交互性和可变性之间的固有权衡,通过Common Lisp、Python、Ruby和Erlang的例子说明没有语言能完全优化这三者。
Lisp 子类型中(对我来说)意外的行为
本文探讨了在 Common Lisp 的 SBCL 中,数组子类型对整数类型有效,但对受限类型如 (unsigned-byte 16) 失败的意外行为,这归因于编译器优化和升级的数组元素类型。
内存安全最棘手的问题
本文讨论了一个基本的内存安全挑战,涉及带标签的联合体,其中指向一个变体的指针在联合体被覆盖为另一个变体后被使用,导致类型混淆。作者还认为,缓冲区溢出是最容易被利用的内存错误,如果采用更好的数组语法本可以缓解。