8.2 基础、复合、函数与用户自定义类型
一旦编译器开始谈“类型”,它就需要一套足够丰富的词汇,既能描述简单值,也能描述更大的程序结构。语言通常先有基础类型,例如整数、布尔、字符、浮点数;然后再通过类型构造器组合出更复杂的类型,例如数组、元组、记录、函数、可选值、引用、类等等。
关键点在于:复合类型不是把若干基础类型随便堆在一起。组合方式本身就会改变语义。
| 类别 | 例子 | 关键点 |
|---|---|---|
| 基础类型 | int、bool | 原子值类别 |
| 数组 | int[] | 元素类型,常还带长度/可变性规则 |
| 元组 | (int, bool) | 位置与个数 |
| 记录 | { value: int, ok: bool } | 字段名与字段类型 |
| 函数 | (int) -> bool | 参数类型与返回类型 |
| 用户自定义命名类型 | UserId | 声明出来的身份,而不只是底层表示 |
类型构造器会产生新的含义
语言里也许只有 int 和 bool 这两个基础类型,但下面几种类型做的事情完全不同:
int[]
(int, bool)
{ value: int, ok: bool }
(int) -> bool它们都提到了 int 和 bool,却不能互相替代。
int[]表示“很多个同类整数”。
(int, bool)表示“按位置打包的两个值”。
{ value: int, ok: bool }表示“带名字、角色不同的字段集合”。
(int) -> bool表示“吃进一个 int,产出一个 bool 的可调用值”。
这就是为什么类型检查既要认识原子类型,也要认识构造器。只看“里面出现了哪些基础类型”远远不够。
函数类型是契约,不只是语法糖
函数类型是最重要的复合类型之一,因为它把程序的多个位置连在了一起。最少它要记录参数类型与返回类型:
(int, int) -> int这个签名表达的远不只是“这里有括号”。它告诉检查器:期待几个参数、哪些转换可以接受、以及调用结束后后续表达式可以把结果当成什么类型使用。
更强的类型系统还可能继续追踪异常、副作用(effect)、所有权、可变性、可空性、生命周期等信息。但中心模式不变:函数类型本质上是一份调用方与被调用方之间的契约。
用户自定义类型保护的是“意义”
如果编译器把所有身份类值都当成普通 int,那 UserId、OrderId、Meters 只要底层表示一样,就很容易被混用。这通常不是我们想要的。用户自定义名义类型的价值就在这里:它允许语言明确说,“这些值底层也许像,但语义上不是一回事”。
这就进入了设计取舍:
| 设计选择 | 优点 | 风险 |
|---|---|---|
| 一切都按结构兼容 | 复用方便 | 语义不同的数据可能被混用 |
| 对关键领域值使用名义类型 | 保护更强 | 需要更多显式转换 |
结构形状与声明身份不是一回事
有些值天然偏向“看形状”。例如 Point {x:int, y:int},只要字段齐全,它在不同模块之间往往就很容易复用。还有些值天然偏向“看身份”。例如 UserId 不应该仅仅因为底层也是整数,就被静默地当成 ProductId 使用。
好的类型系统设计通常不会用一条规则硬套所有地方,而是有意识地区分这些场景。
一个对比例子
设想两个模块都定义了:
{ x: int, y: int }如果语言采用结构类型,它们可能天然互通。如果语言采用名义类型,那么即使字段布局一模一样,只要声明来源不同,也可能仍然是两个不同类型。这里没有绝对的优劣。结构兼容偏向复用,名义区分偏向语义保护。