类型、别名与边界
名字、边界与类型的身份
“Die Grenzen meiner Sprache bedeuten die Grenzen meiner Welt.” — Ludwig Wittgenstein
“语言是思维的边界”,类型是业务的边界。
比如一个很普通的问题:
UserId 和 String 是不是同一个类型?
如果只是为了让代码更好读,我们可能会说:给 String 起个名字吧,叫 UserId。这样函数签名里出现 UserId,读的人马上知道这里不是随便一段文本。
但如果我们想让编译器能够区分呢?
比如 UserId、OrderId、Email 底层都可以是字符串,但它们不应该被混用。这时候“起名字”就不够了。名字要开始变成边界,甚至变成一种小小的封装。
所以类型别名这件事可以分成两层来看:
第一层是类型别名:给已有类型换一个名字。
第二层是不透明类型 / newtype / defined type 这一类东西:底层表示可能没变,但外面看起来已经不是同一个类型了。
前者解决的是表达问题,后者解决的是约束问题。
类型别名:名字变了,类型没变
类型别名最朴素的动机,是让类型签名更像人在说话。
String -> UserId
Map[String, List[Int]] -> Index
Result<T, io::Error> -> io::Result<T>
它不一定让类型系统变得更强,但会让代码少一点噪声。问题在于:别名毕竟只是别名,它通常不会创造新的类型身份。
Go
Go 里这个区别很直观,因为它把两种写法摆在了你面前。
type UserId = string
这叫 type alias。UserId 和 string 是同一个类型,只是多了一个名字。
type UserId = string
func FindUser(id string) {}
func main() {
var id UserId = "u_123"
FindUser(id) // ok
}
这段代码里,id 虽然标成了 UserId,但传给需要 string 的函数没有任何问题。因为在类型系统眼里,它们并没有分开。
所以 Go 的 alias 更像是“给已有类型开了一个入口”。它适合做重构、迁移、兼容旧包名,也适合把复杂类型写短一点。
比如:
type StringSet = map[string]bool
这会让代码看起来更有意图,但它不会阻止你把普通的 map[string]bool 当成 StringSet 用。这里没有新的类型边界。
Go 真正会创造新类型的是另一种写法:
type UserId string
这个后面再说。Go 在这里有意思的地方正是:差一个 =,语义就从“名字”变成了“类型”。
Rust
Rust 里也有 type alias。
type UserId = String;
它同样不创造新类型。
type UserId = String;
fn find_user(id: String) {}
fn main() {
let id: UserId = String::from("u_123");
find_user(id); // ok
}
UserId 只是 String 的另一个名字。函数要的是 String,你给它 UserId,编译器不会觉得这是两种东西。
Rust 里的别名很常见,尤其适合处理长类型。
type Thunk = Box<dyn Fn() + Send + 'static>;
或者给 Result 填好错误类型:
type DbResult<T> = Result<T, DbError>;
这类别名很好用,因为 Rust 的类型签名经常会越长越认真。别名能把重复的结构折起来,让 API 暴露的是一个更有业务含义的名字。
但它的边界仍然很薄。DbResult<T> 不是一种新的 Result,UserId 也不是一种新的 String。你只是给一个类型表达式贴了标签。
Scala
Scala 的 type alias 更像是“给类型表达式起名”。
type UserId = String
type Index = Map[String, List[Int]]
type Handler[A] = A => Either[String, Unit]
Scala 的类型表达式本来就可以很丰富:函数类型、泛型类型、路径依赖类型、结构化的类型组合。于是 alias 的价值也很自然:别让签名把人淹了。
比如:
type UserId = String
def findUser(id: String): Unit = ()
val id: UserId = "u_123"
findUser(id) // ok
这里 UserId 依然没有变成一个新的类型。它只是让读者知道:这个字符串在当前语境里扮演的是用户 ID。
Scala 里 alias 的味道会比 Go/Rust 更“类型语言”一点,因为它不只是给基本类型起小名,还经常用来给复杂的抽象形状命名。
type Result[A] = Either[DomainError, A]
type Reader[Env, A] = Env => A
这很舒服,但也要记住:舒服不等于安全。type UserId = String 不能阻止你把任何字符串传进去。它让代码更会说话,却没有让编译器更严格。
不透明类型:名字开始变成边界
如果只是为了可读性,类型别名已经够了。
但很多时候,我们想表达的是另一件事:
UserId 的底层可以是 String,
但外部代码不应该把任意 String 当成 UserId。
这时候我们需要的就不只是名字,而是边界。
这个边界在不同语言里长得不一样。Scala 3 有很直接的 opaque type;Rust 常用 newtype pattern;Go 则用 defined type 给已有底层类型造一个新的命名类型。
它们不是完全同一种机制,但都在回答同一个问题:我能不能在不改变底层表示的情况下,让类型系统多认出一个概念?
Go
Go 的这行代码不是别名:
type UserId string
它定义了一个新的类型。底层类型是 string,但 UserId 和 string 不再是同一个类型。
type UserId string
func FindUser(id UserId) {}
func main() {
var raw string = "u_123"
FindUser(raw) // compile error
FindUser(UserId(raw)) // ok
}
这个显式转换很重要。它像是在代码里留下了一道小门:你可以从 string 走到 UserId,但你得说清楚你正在这么做。
这个东西很适合表达领域概念。
type UserId string
type OrderId string
func LoadUser(id UserId) {}
func LoadOrder(id OrderId) {}
现在 UserId 和 OrderId 底层都是 string,但它们不会被随手混在一起。
var userId UserId = "u_123"
var orderId OrderId = "o_456"
LoadUser(orderId) // compile error
LoadOrder(userId) // compile error
这已经有了 newtype 的味道。不过 Go 的 defined type 不是“完全封装”的包装类型,它仍然有一个明确的 underlying type,也保留了很多底层类型可用的操作。
比如:
type UserId string
func (id UserId) IsEmpty() bool {
return id == ""
}
你可以给它挂方法,这样 UserId 就不只是一个字符串别名,而是一个带行为的小类型。
Go 这里的设计很简洁:直接给你一个简单、直接、够用的类型边界。
Rust
Rust 的 type UserId = String 不会创造边界。要创造边界,常见做法是 newtype pattern。
struct UserId(String);
这是真的新类型。
struct UserId(String);
struct OrderId(String);
fn load_user(id: UserId) {}
fn load_order(id: OrderId) {}
fn main() {
let user_id = UserId(String::from("u_123"));
let order_id = OrderId(String::from("o_456"));
load_user(order_id); // compile error
load_order(user_id); // compile error
}
UserId 里面包着一个 String,但它不是 String。这个区别非常 Rust:类型边界是真边界,编译器会认真对待。
如果你希望外部不能随便构造它,可以把字段设为私有,然后提供构造函数。
pub struct UserId(String);
impl UserId {
pub fn new(value: String) -> Option<Self> {
if value.starts_with("u_") {
Some(Self(value))
} else {
None
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
这里边界就不只是“不能把 OrderId 传给 UserId”了,还可以顺手放进校验规则。只有满足规则的字符串,才能被抬升成 UserId。
Rust 的代价也很清楚:你造了新类型,就要给它补实现。
需要打印?实现 Display。
需要比较?派生 PartialEq、Eq。
需要排序、哈希、序列化?继续加。
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UserId(String);
这正是 Rust 的风格:它让边界非常明确,但不会假装这个边界没有成本。你想要一个新类型,就要把它作为新类型认真对待。
Scala
Scala 3 的 opaque type 是本节“不透明类型”这个词的来源。
object domain:
opaque type UserId = String
object UserId:
def apply(value: String): UserId = value
extension (id: UserId)
def value: String = id
在 domain 这个定义作用域内部,UserId 知道自己底层是 String。所以 apply 可以直接返回 value,id.value 也可以直接把它当成
String 取出来。
但出了这个作用域,事情就变了。
import domain.*
val id: UserId = UserId("u_123")
val raw: String = id // compile error
val id2: UserId = "u_456" // compile error
val raw2: String = id.value // ok
这就是 opaque 的意思:底层表示没有消失,但它不暴露给外部。
它不像普通 type alias 那样完全透明:
type UserId = String
也不像 case class 那样一定要在运行时多包一层:
case class UserId(value: String)
opaque type 更像是在编译期立了一道墙:墙里面知道它是 String,墙外面只知道它是 UserId。这个设计非常适合表达领域模型里的轻量类型。
比如同样是 Int,可以分成不同语义:
object Access:
opaque type Permissions = Int
opaque type PermissionChoice = Int
它们底层都可以是 Int,但外部看是两个不同概念。你可以只暴露允许的操作,不把 Int 的所有能力原样放出去。
这点很关键。opaque type 不只是“让 String 看起来不像 String”。它真正有用的地方在于:你可以重新设计这个类型对外提供的
API。
横向看:名字、身份和边界
三门语言放在一起看,会发现它们的差别不是语法差别,而是类型身份的处理方式不同。
Go:
type UserId = string // alias,同一个类型
type UserId string // defined type,新类型,底层是 string
Rust:
type UserId = String // alias,同一个类型
struct UserId(String) // newtype,新类型,包装 String
Scala:
type UserId = String // alias,同一个类型
opaque type UserId = String // 外部不透明,内部透明
如果只是消除重复、改善表达,alias 很好。
这个类型太长了,给它起个名字。
如果要防止概念混用,就要更进一步。
这个值虽然底层是字符串,但它不是任意字符串。
这时 Go 会说:定义一个新的 defined type。
Rust 会说:包一层 newtype,然后把 API 写清楚。
Scala 3 会说:用 opaque type,让内部和外部看到不同的类型事实。
我挺喜欢这个角度,这是在看一个更本质的问题:
类型到底只是值的形状,
还是程序员给概念画出来的边界?
类型别名站在前一边。它说:这里有个已有类型,我给它一个更合适的名字。
不透明类型、新类型、defined type 站在后一边。它们说:这里有个概念,哪怕它底层和另一个东西长得一样,也不应该被随便混用。
这两种能力都重要。别名让代码可读,不透明边界让代码更难写错。
很多时候,我们真正想要的不是更复杂的类型系统,而是更准确地表达一句话:
它底层是什么,不等于它在业务里是什么。