为什么可变类型不是不可变类型的子类型,反之亦然?
Why isn't mutable a subtype of immutable, or vice versa?

原始链接: https://crumbles.blog/posts/2026-09-17-immutable-mutable.html

编程语言通常会避免将同一数据结构的可变版本和不可变版本视为子类型,因为这违反了里氏替换原则。虽然直觉上可变配对似乎可以作为不可变配对使用,但它们隐含的契约有着本质区别。不可变配对保证其内容保持不变,从而支持哈希一致性(hash consing)等可靠的操作。如果替换为可变配对,该契约可能会被打破,进而导致类型系统旨在强制执行的保证失效。 由于它们无法安全地共存于子类型层级中,可变和不可变数据结构必须作为不同的类型存在。为了在不强制建立错误层级的情况下允许共享操作(如 `car` 或 `cdr`),编程语言使用了特设多态——通过类型类、接口或特征来实现。这些机制允许开发者定义共同行为,同时保持各类型特定契约的完整性。相比之下,动态类型语言通常依赖“鸭子类型”,这种方式往往无法顾及这些细微的契约差异,从而在意外出现修改器时导致错误。归根结底,区分可变与不可变类型对于维护可预测且健壮的软件行为至关重要。

Hacker News 最新 | 往期 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 为什么可变类型不是不可变类型的子类型,反之亦然? ( crumbles.blog ) 5 分 由 ibobev 发布于 2 小时前 | 隐藏 | 往期 | 收藏 | 2 条评论 帮助 gus_massa 30 分钟前 | 下一条 [–] 是的,我在 Racket 中做一些优化时也思考过这个问题。这比看起来要难,可能涉及协变或逆变类型,我放弃了。 回复 comrade1234 19 分钟前 | 上一条 [–] 在某些语言中,NSMutableArray 不是 NSArray 的子类吗? 回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

I remember the moment when I learned about immutability. It changed everything.

Denis Defreyne

Periodically, in various programming language forums, the discussion comes up of why a certain language doesn’t provide the immutable and mutable variants of some data structure as subtypes or supertypes of one another. Now, it’s not impossible to do this, but it’s actually not formally correct to do so, and by doing so you’ll lose at least some of the type checking guarantees your language can usually make for you.

To understand why this doesn’t work, you have to remember the definition of a subtype. Namely, Liskov’s subsitution principle: a type S is a subtype of T if a value of type S can be used in every context where a value of type T is expected.

As usual when dealing with formal matters, this definition is strictly interpreted. Every really does mean every, not just most. (You might have learned the substitution principle as a mere recommended design pattern for OO classes, but formally speaking a true subtype has to fulfil this criterion.) A static type system which supports subtyping will have to prove this for you in order to pass your program through its type checker.

To illustrate this, let’s take the simplest compound data structure imaginable: the humble pair. Here are our operations on an immutable version.

(cons a d) construct a new pair containing a and d and return it
(car p) return the value of a provided when the pair p was constructed
(cdr p) return the value of d provided when the pair p was constructed

That’s it!

Our mutable variant adds two new operations:

(set-car! p a) change the value of a within the pair p
(set-cdr! p d) change the value of d within the pair p

(And a new constructor, but we’ll deal with that below.)

Now, it should be obvious that an immutable pair can’t be provided where a mutable pair is expected. A place that needs a mutable pair will presumably try to use of these two operations on it, which aren’t defined on an immutable pair, so there will be a typing error.

But why couldn’t it be the other way around? All of the operations provided provided by an immutable pair are also provided by a mutable pair, so it seems like we should be able to use a mutable pair wherever an immutable pair is expected.

The reason is more subtle. The substitution principle extends beyond the set of operations (methods) a type provides to the implicit contract which comes with those operations.

When we take the car or cdr of an immutable pair, we can depend on a contract which says the result will always be the same every time we call it on that pair. This contract means that we can, for example, safely calculate the hash value of the pair based on its contents, store it away in another data structure, and know that it won’t be different when we recalculate it later to try to retrieve it. (In other words, immutability is a prerequisite for hash consing!)

Because of this, immutable and mutable pairs have to be completely different types:

(icons a d) construct a new immutable pair containing a and d and return it
(icar i) return the value of a provided when the immutable pair i was constructed
(icdr i) return the value of d provided when the immutable pair i was constructed
(mcons a d) construct a new mutable pair containing a and d and return it
(mcar m) return the value of a provided when the mutable pair m was constructed
(mcdr m) return the value of d provided when the mutable pair m was constructed
(set-mcar! m a) change the value of a within the mutable pair m
(set-mcdr! m d) change the value of d within the mutable pair m

It’s a typing error if an i is a mutable pair or m is an immutable pair.

Because the two types of pair don’t form a subtype hierarchy, they have to be completely separate types and have separate sets of operations defined on them.

Fortunately, many languages offer one or another mechanism for ad hoc polymorphism, where the same operations can be defined on multiple types even if they don’t form a hierarchy. As a Schemer, I tend to think that this a bad idea in a dynamically typed context, because it makes the reasoning you have to do about the flow of data types vastly more complicated, and thus more difficult to get right. In practice, most languages do offer some mechanism for this, whether dynamically typed or statically typed.

Let’s consider the statically typed case first. Wadler and Blott introduced a mechanism for formally reasoning about ad hoc polymorphism and ensuring the type checker can actually prove it sound. In their terminology, mutable and immutable pairs are different types, but both can belong to a common pair type class whose operations are the original car and cdr we defined above. On mutable pairs, these refer to the underlying mcar and mcdr operations, and on immutable pairs to the icar and icdr operations.

This is still formally sound because the pair type class defines a new contract that says nothing about mutability. In a proper implementation of type classes, the type system will stop you trying to use the mutators in a method where the most you defined about the input type to your function is that they are some kind of pair, mutable or immutable. It won’t prevent you from using the car and cdr operations expecting them to be immutable when they might not be – but it does let you choose the granularity explicitly both ways, declaring the input type to your function as either a mutable pair or immutable pair or either, depending on the contract your function actually expects. A subtype relationship would only allow one way but not the other: you could declare your function as allowing immutable pairs, but potentially incorrectly implicitly including mutable pairs too; or, if it were the other way around, as allowing mutable pairs but potentially incorrectly including immutable ones; but one couldn’t consistently exclude either type (without violating the substitution principle).

Things akin to type classes are available in several statically typed languages, where they’re often called interfaces or traits or roles. However, real world type systems vary greatly in how strictly they enforce the checking.

In dynamically typed, object-oriented languages, this usually takes the form of duck typing where we simply define methods with the same name on multiple different types and let run-time type dispatch do the work. We can still get the benefits by adding explicit check for the presence or absence of the mutation operations before allowing a function to be called. In practice, it’s pretty unusual to do this – especially checking for the absence of mutators – and this is why ad hoc polymorphism in dynamically typed languages tends to invite problems.

联系我们 contact @ memedata.com