STATION II · 干流CH 06里程 06/20

类型类:开放世界的接口

第 3 章欠了一笔账::t 42 返回的 Num a => a 里,那个 => 到底是什么?这一章还账。类型类(type class)是 Haskell 对「接口」问题的回答,1989 年由 Wadler 和 Blott 在一篇论文里提出,后来被 Rust 抄成 trait、被 Swift 抄成 protocol、被 Scala 抄成 given/using——它可能是 Haskell 输出下游最成功的单项发明。而它和你写了十年的 interface,有三个根本性的不同。

class / instancederiving字典传递trait 的出处

夹在中间的需求:有条件的多态

写一个「判断元素是否在列表里」。第 3 章的参数化多态帮不上忙:elem :: a -> [a] -> Bool 的函数体里,a 是黑盒,连 == 都不能用——多态太严。写死 Int -> [Int] -> Bool?那 String 版、Bool 版全要复制粘贴——具体太死。我们要的是中间态:「a 可以是任何类型,只要它会判等」。这就是约束:

elem :: Eq a => a -> [a] -> Bool
--      ↑ 读作:对任何实现了 Eq 的类型 a

Eq 是个类型类。它的定义朴素得让人失望:

class Eq a where
  (==) :: a -> a -> Bool
  (/=) :: a -> a -> Bool
  x /= y = not (x == y)     -- 默认实现:定义了 == 就白送 /=

-- 让自己的类型加入 Eq:写一个 instance
data Traffic = Red | Yellow | Green

instance Eq Traffic where
  Red    == Red    = True
  Yellow == Yellow = True
  Green  == Green  = True
  _      == _      = False

class 声明一组「会员必须提供的函数」,instance 是某个类型的入会申请书。到这里看起来和 interface 没差多少——差别在三个细节里,每个都值得单独看。

差异一:事后补票

Java 的类必须出生时决定实现哪些接口;别人写的类没实现 Comparable,你只能包一层 Comparator 到处传。类型类的 instance 是独立的顶层声明——类型是别人的、类是别人的,你照样可以在自己的代码里让它们结合:

-- 第三方库的类型 + 标准库的类,我来撮合:
instance Semigroup ThirdPartyConfig where
  a <> b = mergeConfig a b

这叫 retroactive instance(追溯实例)。开放世界由此而来:类型的能力不在定义时封死,而是随生态生长。Kotlin 的扩展函数解决了一半(能加方法),但扩展函数不能实现接口,所以「给别人的类型补上多态能力」这件事,Kotlin 至今做不到(KEEP-87 提案讨论了很多年)。Rust 的 trait、Swift 的 protocol extension 则是全盘引进。

差异二:返回位置的多态

interface 的分派靠接收者:obj.method(),运行时看 obj 是谁。这意味着多态只能长在参数位置。类型类的分派靠类型本身,于是出现了 interface 世界里不存在的物种——按返回值分派:

λ> read "42" :: Int
42
λ> read "42" :: Double
42.0
λ> minBound :: Int
-9223372036854775808
λ> minBound :: Char
'\NUL'

read :: Read a => String -> a,同一个函数,你要什么类型它就解析成什么;minBound 干脆连参数都没有,纯靠期望的返回类型选实现。用 interface 的脑子想一下就知道这有多不可能:没有接收者,你让 JVM 往哪张虚表上查?

差异三:分派发生在编译期——字典传递

第三个差异藏在实现里。interface 的多态是运行时的:对象头里有指针,指向虚表,调用时查表。类型类没有「对象头」——Haskell 的 Int 就是光秃秃的整数,没地方藏指针。GHC 的做法叫字典传递(dictionary passing):把约束 Eq a => 编译成一个额外的隐藏参数,参数的值是一本装满函数的「字典」。透视台里逐步看:

要点重述一遍:elem :: Eq a => a -> [a] -> Bool 编译后真身是 elem :: EqDict a -> a -> [a] -> Bool。调用点的类型编译期已知,该塞哪本字典也就编译期确定——运行时没有查表、没有反射,还常常能整本内联(specialization),多态归零开销。这套「能力即参数」的思想后来在 Scala 里叫 implicit/given,在 Rust 里是 trait 的单态化。

deriving:白嫖标准能力

给每个类型手写 Eq 的样板代码?编译器替你写:

data Traffic = Red | Yellow | Green
  deriving (Eq, Ord, Show, Enum, Bounded)
λ> Red < Green            -- Ord:按声明顺序
True
λ> show Yellow             -- Show:转字符串
"Yellow"
λ> [minBound .. maxBound] :: [Traffic]   -- Enum + Bounded 联手
[Red,Yellow,Green]

一行 deriving,五种能力。最后那行是个实用成语:「枚举出一个类型的所有值」,写测试时格外顺手。常用类型类先混脸熟,遇到再回来查:

类型类会员必会近亲
Eq==、/=equals / == 重载
Ordcompare、<、max…Comparable
Show / Readshow / readtoString / 反向解析
Num+、*、abs、fromInteger…数字运算符重载
Enum / Boundedsucc、pred / minBound、maxBoundenum values()
Semigroup / Monoid<>(可结合的拼接)/ mempty「可累加」抽象,fold 的好搭档

真实代码:自己写一个类型类

体感最好的学法是造一个。假设团队规范要求所有实体都能输出结构化日志字段:

class Loggable a where
  logTag    :: a -> String
  logFields :: a -> [(String, String)]
  logLine   :: a -> String                     -- 默认实现:会员可白嫖
  logLine x = logTag x ++ " " ++
              concatMap (\(k,v) -> k ++ "=" ++ v ++ " ") (logFields x)

instance Loggable Order where
  logTag _    = "order"
  logFields o = [("no", show (orderNo o)), ("total", show (total o))]

instance Loggable a => Loggable [a] where     -- 实例可以有条件!
  logTag _    = "batch"
  logFields   = concatMap logFields

三个看点。默认实现让接口进化不炸会员:后加的 logLine 带默认体,老 instance 不用改。条件实例(最后一段)是 interface 没有的火力:「只要 a 可记录,a 的列表就自动可记录」——一条声明覆盖无穷多类型,还会递归组合([[Order]] 也自动可记录)。约束即文档:下游函数写 audit :: Loggable a => a -> IO (),一眼看清它对参数的全部要求。

类型类之间还能声明依赖——超类约束(superclass):

class Eq a => Ord a where ...
-- 读作:想入 Ord 会,先得是 Eq 会员

这保证任何能比大小的类型必然能判等,下游写 Ord a => 就白拿 == 可用。注意方向:这不是 OO 继承(Ord 不「是一种」Eq,而是「依赖」Eq),更像 Gradle 里声明依赖项——能力清单的组合,而不是分类学的树。

▸ 下游对照 · 认领你语言里的碎片

Kotlin 里类型类被拆成了碎片:运算符重载(operator fun plus)≈ Num 的一角;扩展函数 ≈ 追溯加方法但没有多态;Comparable/Comparator ≈ Ord 的两种残影。Java 的 Comparator 尤其值得玩味:它其实就是一本手动传递的 Ord 字典——你早就会字典传递了,只是每次都手传。Rust 用户则会全程点头:trait、impl、derive、约束语法 T: Eq,一比一对应,论文都引的同一篇。

表里最后一行的 Semigroup/Monoid 值得多给三行,因为它和第 5 章的 fold 是天作之合。Monoid 的会员条件是「有一个可结合的拼接操作 <>,外加一个单位元 mempty」——列表(拼接/空表)、字符串、Sum Int(加法/0)、Map(合并/空表)全是会员。于是标准库能提供一个终极折叠:

λ> mconcat ["水文", "测绘", "志"]
"水文测绘志"
λ> mconcat [Just [1], Nothing, Just [2,3]]
Just [1,2,3]
λ> foldMap show [1,2,3]     -- 先逐个变换,再 Monoid 拼接
"123"

「知道怎么拼、知道空值是什么,折叠就是免费的」——这就是抽象的复利:第 5 章的 fold 提供骨架,本章的类型类提供能力标签,两者相乘,一个 foldMap 通吃一切可拼接物。你在 Kotlin 里给 reduce 传 initial + operation 时,传的其实就是一份手工 Monoid。

⚠ 暗礁 · 两个社区级注意事项

其一,orphan instance(孤儿实例):类型不是你的、类也不是你的,instance 却写在你这里——一旦两个库对同一对组合各写一份,链接时就打架。惯例:instance 应该和类型或类住在同一个包里。其二,类型类不是继承树:它没有「子类型」概念,Num 不是 Eq 的子类(尽管有超类约束的机制),不要把 OO 的 is-a 层级脑模套上来——类型类之间是「能力清单」的关系,更接近组合而非继承。

◆ 水文记录 · 42 的完整答案

现在能还清第 3 章的账了:42 :: Num a => a 意味着字面量 42 会被编译成 fromInteger 42,其中 fromInteger 是 Num 类的成员。也就是说数字字面量本身是多态的:上下文要 Int 它就是 Int,要 Double 就是 Double,你自定义的 Money 类型只要 instance Num,字面量直接可用。这也是 Haskell 做嵌入式 DSL 特别顺手的原因之一。

✚ 动手泅渡

1. 不查资料,写出 lookup :: Eq a => a -> [(a, b)] -> Maybe b 的实现(键值对列表里找键)。为什么约束是 Eq a 而 b 没有约束?

2. 在推断台(第 3 章)输入 \x xs -> elem x xs 之类的表达式时它省略了约束——现在你知道真 GHC 会补上什么了。写出 \x y -> x < y || x == y 在真 GHC 里的完整签名。

3. 设计题:log 系统想让「任何能序列化成一行文本的东西」都可记录。用类型类怎么建模?用 Kotlin interface 建模会在哪种调用点卡住?(提示:第三方类型。)

4. 思考:Comparator.comparing(User::getAge) 对应类型类世界的什么操作?(提示:字典是值,值就能被构造和变换。)

5. 给上文的 Loggable 写一个条件实例 instance (Loggable a, Loggable b) => Loggable (a, b),让「一对实体」也能记日志。logFields 怎么合并两边?