Haskell中的数据导向编程(SICP 2.4.3)
摘要
本文展示了在Haskell中实现数据导向编程来处理复数运算,遵循SICP 2.4.3的方法,避免在添加新表示时修改通用函数。
<p><a href="https://lobste.rs/s/shajxh/data_directed_programming_haskell_sicp2">评论</a></p>
查看缓存全文
缓存时间: 2026/07/12 10:49
# Haskell 中的数据导向编程 (SICP 2.4.3)
来源:https://entropicthoughts.com/sicp-2-4-data-directed-programming-in-haskell
sicp-2-4-data-directed-programming-in-haskell.jpg
我有一本《SICP》,也就是俗称的*《魔法书》*。¹¹*《计算机程序的构造与解释》*;Abelson 与 Sussman 合著;MIT Press 出版;1996 年版。这本书广受赞誉,但我没有时间通读全部内容。相反,我会偶尔跳进那些看起来有趣的章节。上周,我们探讨了 Haskell 中的带标签数据 (https://entropicthoughts.com/sicp-2-4-tagged-data-in-haskell)。SICP 的作者们并不认为这是最佳方案,因此他们转向了数据导向编程。我们也照此办理。
## 复数有四个运算 (https://entropicthoughts.com/sicp-2-4-data-directed-programming-in-haskell#complex-numbers-have-four-operations)
复数既可以用直角坐标形式存储——包含实部和虚部,也可以用极坐标形式存储——包含模和幅角。无论复数以何种方式存储,我们都希望能够查询它的这四个量:
- 复数直角坐标形式的实部坐标。
- 直角坐标形式的虚部坐标。
- 极坐标形式的模。
- 极坐标形式的幅角。
上次我们在一个*带标签的*值之上实现了这些操作。要返回上面的四个坐标之一,函数会检查标签,以便根据具体的表示形式来满足调用者的需求。这种方法可行,但每当我们需要添加一种新的表示形式时,就必须修改所有现有的四个操作。
为了解决这个问题,Abelson 和 Sussman 提出了一种数据导向的方法。我们不会像上一篇文章那样从 Lisp 最底层开始,而是从中间某个位置切入——那时我们已经使用编译器识别的元组和字符串来标记我们的类型。这意味着我们将从以下代码开始:
In\[1\]:
``
attach_tag tag contents = (tag, contents)
type_tag (tag, _) = tag
contents (_, value) = value
is_rectangular z = type_tag z == "rectangular"
is_polar z = type_tag z == "polar"
make_rectangular re im = (attach_tag "rectangular" (re, im))
make_polar r a = (attach_tag "polar" (r, a))
``
在书中,作者使用了两个神奇的函数`get`和`put`来从隐式声明的操作表中存储和检索操作。以下是我们 Haskell 代码中这两个函数的样子:
In\[2\]:
``
put op tag fn =
State.modify (Map.insert (op, tag) fn)
get op tag =
State.gets (Map.lookup (op, tag))
``
我们将像 SICP 中的 Lisp 代码一样,使用它们来安装直角坐标表示形式的操作。
In\[3\]:
``
install_rectangular =
let
real_part (re, _) = re
imag_part (_, im) = im
magnitude (re, im) = sqrt (re^2 + im^2)
angle (re, im) = atan2 im re
in do
put "real_part" "rectangular" real_part
put "imag_part" "rectangular" imag_part
put "magnitude" "rectangular" magnitude
put "angle" "rectangular" angle
``
我们对极坐标表示形式做同样的操作。
In\[4\]:
``
install_polar =
let
real_part (r, a) = r * cos a
imag_part (r, a) = r * sin a
magnitude (r, _) = r
angle (_, a) = a
in do
put "real_part" "polar" real_part
put "imag_part" "polar" imag_part
put "magnitude" "polar" magnitude
put "angle" "polar" angle
``
我们从 SICP 中重新实现了`apply_generic`函数,以便从这个表中选取正确的操作。
In\[5\]:
``
apply_generic op arg = do
value <- get op (type_tag arg)
pure $ case value of
Just fn -> fn (contents arg)
Nothing -> error "No method for these types."
``
这个操作可以用来实现通用的坐标提取函数。
In\[6\]:
``
real_part z = apply_generic "real_part" z
imag_part z = apply_generic "imag_part" z
magnitude z = apply_generic "magnitude" z
angle z = apply_generic "angle" z
``
然后,由于我们在用 Haskell 编写,复数的算术运算会显得有些笨拙。别担心,我们很快就会改进。
In\[7\]:
``
add_complex za zb = do
za_re <- real_part za
za_im <- imag_part za
zb_re <- real_part zb
zb_im <- imag_part zb
pure $ make_rectangular (za_re + zb_re) (za_im + zb_im)
mul_complex za zb = do
za_r <- magnitude za
za_a <- angle za
zb_r <- magnitude zb
zb_a <- angle zb
pure $ make_polar (za_r * zb_r) (za_a + zb_a)
``
要使用这段代码进行计算,我们需要将其作为有状态的计算来运行——首先安装操作,然后执行计算。它可能看起来像这样。
In\[8\]:
``
main = flip State.evalStateT Map.empty $ do
install_rectangular
install_polar
zc <- add_complex (make_polar 4 0.3) (make_rectangular 3 2)
s <- show_complex zc
liftIO (putStrLn s)
``
太棒了!我们可以做到 Lisp 能做到的事情。
## 转向纯表 (https://entropicthoughts.com/sicp-2-4-data-directed-programming-in-haskell#moving-to-pure-tables)
虽然这可能看起来像是倒退了一步,但我们将摒弃隐式的操作表,转而显式地指定它。我们不再使用会修改操作表的安装函数,而是让每个安装函数返回它们所负责的那部分表。
In\[9\]:
``
rectangular =
let
real_part (re, _) = re
imag_part (_, im) = im
magnitude (re, im) = sqrt (re^2 + im^2)
angle (re, im) = atan2 im re
in
Map.fromList
[ (("real_part", "rectangular"), real_part)
, (("imag_part", "rectangular"), imag_part)
, (("magnitude", "rectangular"), magnitude)
, (("angle", "rectangular"), angle)
]
polar =
let
real_part (r, a) = r * cos a
imag_part (r, a) = r * sin a
magnitude (r, _) = r
angle (_, a) = a
in
Map.fromList
[ (("real_part", "polar"), real_part)
, (("imag_part", "polar"), imag_part)
, (("magnitude", "polar"), magnitude)
, (("angle", "polar"), angle)
]
``
当我们修改`apply_generic`函数,使其将表作为显式参数时,我们也必须更新其他泛型方法。
In\[10\]:
``
apply_generic table op arg = do
case Map.lookup (op, type_tag arg) table of
Just fn -> fn (contents arg)
Nothing -> error "No method for these types."
real_part table z = apply_generic table "real_part" z
imag_part table z = apply_generic table "imag_part" z
magnitude table z = apply_generic table "magnitude" z
angle table z = apply_generic table "angle" z
``
最后,我们也要对算术运算做同样的处理。
In\[11\]:
``
add_complex table za zb =
make_rectangular
(real_part table za + real_part table zb)
(imag_part table za + imag_part table zb)
mul_complex table za zb =
make_polar
(magnitude table za * magnitude table zb)
(angle table za + angle table zb)
``
现在我们可以完全纯地使用这些东西进行计算,没有任何隐式状态。在开始之前,我们将每种类型的表合并起来。
In\[12\]:
``
main =
let
table = rectangular <> polar
za = make_polar 4 0.3
zb = make_rectangular 3 2
zc = add_complex table za zb
in
putStrLn (show_complex table zc)
``
显式的表引用让代码稍微啰嗦了一些,但它们为我们将要使用的下一个语言特性提供了一个很好的阶梯。
## 语言本身也支持这一点 (https://entropicthoughts.com/sicp-2-4-data-directed-programming-in-haskell#the-language-supports-this-too)
通过显式的表引用,我们实际上构造了一个*类型类*。我们可以正式地定义它,以便让编译器知道我们在做什么。
In\[13\]:
``
class Complex z where
real_part :: z -> Double
imag_part :: z -> Double
magnitude :: z -> Double
angle :: z -> Double
``
然后我们必须为复数的两种表示定义自定义类型。首先,我们定义直角坐标存储的类型,并为它实现类型类的泛型方法。
In\[14\]:
``
data Rectangular = Rectangular Double Double
instance Complex Rectangular where
real_part (Rectangular re _) = re
imag_part (Rectangular _ im) = im
magnitude (Rectangular re im) = sqrt (re^2 + im^2)
angle (Rectangular re im) = atan2 im re
``
然后我们对极坐标做同样的处理。
In\[15\]:
``
data Polar = Polar Double Double
instance Complex Polar where
real_part (Polar r a) = r * cos a
imag_part (Polar r a) = r * sin a
magnitude (Polar r _) = r
angle (Polar _ a) = a
``
在上一篇文章中,`Rectangular`和`Polar`是同一个类型`Complex`的两个构造器。而在这里,`Rectangular`和`Polar`是两个完全不同的类型。编译器眼中它们唯一的共同点就是都实现了`Complex`接口。
现在我们已经接入了语言特性,可以移除所有其他代码,并用这些函数替换算术运算。
In\[16\]:
``
add_complex za zb =
Rectangular
(real_part za + real_part zb)
(imag_part za + imag_part zb)
mul_complex za zb =
Polar
(magnitude za * magnitude zb)
(angle za + angle zb)
``
干净利落!
## 将不同的表示视为相同的类型 (https://entropicthoughts.com/sicp-2-4-data-directed-programming-in-haskell#treating-different-representations-as-the-same)
这种在 Haskell 中的表示方式的缺点是,由于`Rectangular`和`Polar`被认为是不同的类型,我们无法创建包含两种表示的同构数据结构,比如列表。换句话说,即使我们只关心列表中的元素作为通用的复数,这也会导致类型错误:
In\[17\]:
``
let
za = Polar 4 0.3
zb = Rectangular 3 2
zs = [za, zb] -- ! 无法匹配期望的类型 ...
in
foldl1 add_complex zs
``
作为 Haskell 初学者,这个问题曾困扰我很久。幸运的是,Haskell 在这方面也有一个非常干净的解决方案。我们可以创建所谓的*存在量化类型*来承载这样的概念:“这是一个我们对其一无所知的值,只知道它是一个复数。”
In\[18\]:
``
data AnyComplex = forall z. Complex z => AnyComplex z
``
为这个类型实现复数类型的泛型方法,就像将操作分派给底层的值一样简单。我们可以这样做,因为虽然我们不知道底层值的具体类型,但我们知道它支持这些操作。
In\[19\]:
``
instance Complex AnyComplex where
real_part (AnyComplex z) = real_part z
imag_part (AnyComplex z) = imag_part z
magnitude (AnyComplex z) = magnitude z
angle (AnyComplex z) = angle z
``
现在我们可以创建那个列表,并将其作为复数列表来进行操作:
In\[20\]:
``
let
za = Polar 4 0.3
zb = Rectangular 3 2
zs = [AnyComplex za, AnyComplex zb]
in
foldl1 add_complex zs
``
诀窍在于`AnyComplex`包装器没有类型参数,因为它将具体的类型隐藏在了内部。这意味着任何两个`AnyComplex`类型的值在编译器看来都是一样的,即使它们底层是不同类型的值。
## 让返回值变得泛型化 (https://entropicthoughts.com/sicp-2-4-data-directed-programming-in-haskell#making-return-values-generic)
有一件事是 Abelson 和 Sussman 在 SICP 中从未做过的,但现在我们有了类型类机制,做起来就很容易了:让算术函数的*返回值*变得泛型化。目前,如果我们调用`add_complex`,我们得到的复数将以直角坐标形式存储。也许我们不希望这样。
为了避免这种情况,我们可以在类型类定义中添加泛型的构造函数。这些函数可以让我们从直角坐标构造存储在极坐标表示中的复数,反之亦然。
In\[21\]:
``
class Complex z where
--------------- >8 -----
fromRectangular :: Double -> Double -> z
fromPolar :: Double -> Double -> z
``
我们为直角坐标类型和极坐标类型都实现这些函数。
In\[22\]:
``
instance Complex Rectangular where
--------------- >8 -----
fromRectangular re im = Rectangular re im
fromPolar r a =
Rectangular (r * cos a) (r * sin a)
instance Complex Polar where
--------------- >8 -----
fromPolar r a = Polar r a
fromRectangular re im =
Polar (sqrt (re^2 + im^2)) (atan2 im re)
``
我们也要为`AnyComplex`包装器实现它们。对于这个包装器,我们可以自由选择——`fromRectangular`使用`Rectangular`表示,`fromPolar`类似。
In\[23\]:
``
instance Complex AnyComplex where
--------------- >8 -----
fromRectangular re im = AnyComplex (Rectangular re im)
fromPolar r a = AnyComplex (Polar r a)
``
现在我们可以使用这些新的泛型转换来实现算术函数。
In\[24\]:
``
add_complex za zb =
fromRectangular
(real_part za + real_part zb)
(imag_part za + imag_part zb)
mul_complex za zb =
fromPolar
(magnitude za * magnitude zb)
(angle za + angle zb)
``
这让*调用方*代码可以决定他们想要返回哪种表示。如果他们希望结果以直角坐标形式存储,可以这样写:
In\[25\]:
``
zc :: Rectangular = add_complex za zb
``
但如果他们更希望得到极坐标表示,那就这样请求:
In\[26\]:
``
zc :: Polar = add_complex za zb
``
如果他们希望结果是一个`AnyComplex`值,那也是可以的!这是一个支持返回所有表示的函数。这非常酷。
## 类型类本质上是函数字典 (https://entropicthoughts.com/sicp-2-4-data-directed-programming-in-haskell#type-classes-are-secretly-dictionaries-of-functions)
类型类在幕后发生的事情,实际上与我们手动实现操作字典时非常相似。如果我们思考一下在那段代码中`add_complex`的类型会是什么,我们会得到类似这样的结果:
In\[27\]:
``
add_complex
:: Map (Op_Name, Type_tag) (Complex -> Double)
-> Complex
-> Complex
-> Complex
add_complex table za zb =
make_rectangular
(real_part table za + real_part table zb)
(imag_part table za + imag_part table zb)
``
第一个参数,即字典,携带了接口的实现,使得我们可以操作那些参数,无论它们如何存储。在基于类型类的版本中,我们可以将其视为具有如下的类型签名:
In\[28\]:
``
add_complex :: Complex z => z -> z -> z
add_complex za zb =
fromRectangular
(real_part za + real_part zb)
(imag_part za + imag_part zb)
``
双箭头前的第一个类型类约束隐式地携带了那个操作字典。但实际上,这是同一回事。
## 一切进展顺利 (https://entropicthoughts.com/sicp-2-4-data-directed-programming-in-haskell#things-went-well)
因此,实现这一功能所需的约 80 行 Lisp 代码被缩减为仅需约 35 行使用类型类的代码。如果算上额外的泛型返回值功能(Lisp 代码不支持这一点),那也就是 45 行。
除了代码更短之外,我们还将错误转化为了编译时错误,而不是深夜的警报。编译器精确地知道类型类字典中有哪些方法,也精确地知道哪些值实现了该类型类,因此我们根本无法意外地调用一个不存在的方法,或者试图对一个不支持该操作的值调用该方法。
有一件事是显式字典支持而类型类不支持的:动态扩展可用的泛型操作集合。类型类的操作集在编译时是固定的²² 出于安全性、性能等方面的考虑。扩展类型类的典型解决方案是创建带有扩展的新类型类,然后在需要扩展方法集的调用点处要求使用这些新类型类。这并非一个重大的实践
相似文章
Data types à la carte (2008)
本文提出了一种从独立组件组合数据类型和函数的技术,并将该方法扩展到结合自由单子,从而实现了对Haskell的IO单子的模块化结构。
Haskell 中的数字电路模拟器
一篇博文,探讨如何使用 Haskell 实现数字电路模拟器,遵循《计算机程序的构造和解释》(SICP)中的方法,使用可变状态和 IORef。
底层Haskell:在Haskell/GHC中模拟内联汇编的邪道方法
本文探讨了在Haskell/GHC中模拟内联汇编的技术,用于调用晦涩的CPU指令以及从外部函数高效返回多个值,并使用了扩宽乘法和无进位乘法等示例。
Haskell中的Profunctor装备
这篇博客文章提供了一个用Haskell实现的Profunctor装备的玩具实现,包括自然变换和组合,旨在让范畴论概念对程序员来说更易于理解。
借用生物学家的方法来更快速地编译Haskell
本文探讨了GHC中最优的ApplicativeDo调度问题(该功能因性能缓慢默认关闭),并将其与RNA折叠中使用的动态规划算法进行类比,以改善编译器的性能。