函数错位的探索
摘要
本文探讨了如何使用去函数化将函数嵌入纯数据中,讨论了Haskell中的类型类和一个玩具级的WASM后端示例。
<p><a href="https://lobste.rs/s/y2n9bq/sticking_functions_where_they_don_t">评论</a></p>
查看缓存全文
缓存时间: 2026/09/14 13:02
# 把函数塞进不该出现的地方
来源:https://blog.veritates.love/functions-as-data
纯粹数据意味着什么?我们究竟该如何把函数塞进数据中?
对我而言,*纯粹数据*指满足或可实现以下特性的类型:
- `Show`,可序列化整个内部结构。
- `NFData`(https://hackage-content.haskell.org/package/deepseq-1.5.2.0/docs/Control-DeepSeq.html#t:NFData),确保结构被完全求值(强制求值所有惰性thunk)。关于函数的NFData注:曾有一些(https://github.com/haskell/deepseq/issues/16)争论(https://github.com/haskell/deepseq/issues/111),讨论是否应保留函数的浅层WHNF实例作为规避笨拙代码的变通方案。本文提供了潜在的解决方案!语义上你希望`NFData`能规范闭包,即帮助函数实现运作的所有残留数据。但闭包的部分作用正是隐藏这些数据——你根本不知道它包含什么类型!除非所有数据都可规范化,否则这些数据很可能无法归一化,而且……你可能不喜欢它带来的副作用。
- `Eq`,用于比较值。
- `Ord`,用于排序值——不必然具有语义相关性。语义通常更偏好偏序关系。可以简单理解为`Set`和`Map`用来维护键的类型类。实现了`Eq`却无法实现`Ord`的类型极为罕见:存在类型,或由运行时实现的类型(如指针相等性/引用同一性——参见`Eq (IORef t)`示例)。
- 或许还有`Read`,但后文将说明其为何与本故事关系不大。
遵循这些接口契约的类型不会隐藏任何意外。它完全可读、简单、扁平。无论是代数数据类型(ADT)、字节串还是任意大自然数——其表示形式都无关紧要。它就是纯粹的数据。
不幸的是,这也意味着它不够*有趣*。你可以为数据添加可扩展性,但可扩展的*行为*更难实现。你可以教导所有消费该数据的代码如何处理这些新数据形态,甚至可能提供行为查找注册表——只要它们存在于运行时。但你无法在数据中嵌入函数。理论上不行,实践中也不行。好吧,某些运行时(https://blog.veritates.love/pickling.html#functions-builtins-bootstrapping)可以将函数序列化为字节码或语法树,但这在Haskell中不可行,且最好避免使用。
但真的做不到吗?
### 起源故事:玩具WASM后端
我一直在尝试制作一个编译为WASM的简单函数式编程语言,并使用垃圾回收类型。(WASM GC提供了带垃圾回收引用的结构体和数组,通过`externref`与宿主运行时集成实现跨边界引用。虽然我将来也可能尝试自制GC,但特别对于原型开发,值得体验WASM GC。)
我*极其*希望语言中的类型能自由选择其在类型层面和代码层面的编码方式。例如,表面语言提供了数组、文本和ADT的基本语法,但我们应能将枚举编码为`i8`或`i31`(`i31`比`i32`节省一个比特,可与GC指针同域存储)。或许我们还想将位数组智能地编码为字节数组`\(array i8\)`。
我不想维护自定义类型的注册表。我不希望代码生成被特化类型和编译知识污染。基础代码生成应保持简洁纯粹。
但同时我也不想放弃表达式作为纯粹数据的特性——我希望能检查、比较AST(抽象语法树),或将它们用于属性测试等场景。
我该怎么办?
### 存在类型模式
让我们通过去函数化(defunctionalization)形式,将函数“走私”到原本纯粹的数据中!
类型类的魔力在于它们提供全局且*一致*的字典,以类型为键。由于全局且一致,字典本身不包含数据:理论上,你只需知道其类型即可。
而最棒的是?类型类字典天生就包含函数。
`NFData`对类型类字典不成问题:全局类型类字典中不会有意外的thunk或bottom值。`Eq`甚至`Ord`已为`TypeRep`(https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Data-Typeable.html#t:TypeRep)提供——即`Typeable`类型的运行时表示(自动派生)。`Show`也不成问题(但`Read`会是问题)。
首先我们创建一个类型类同义名,捆绑所需的优秀特性。
```
-- 为存在类型隐藏数据准备的良好属性
type Existentiable hidden = (Typeable hidden, NFData hidden, Ord hidden, Show hidden)
```
接下来我们创建一个类型类,其中包含要“走私”的函数。比如这个用于注入任意外部语义的类:
```
class Existentiable sem => Semantics sem where
semTyp :: sem -> STyp
semCode :: sem -> WASM
semEval :: sem -> [Expr] -> Map Name Expr -> Maybe Expr
semExprs :: sem -> [Expr]
semEffects :: sem -> Effects
```
语义类型可以是捕获我们所需行为的任何数据类型。一旦获得该数据,我们就能投影出关心的行为:它的类型、某种代码生成方式、可能的求值模拟,以及用于静态分析的副作用说明。
这些本质上是编译器的钩子,是我们可扩展注入任意新行为的位置。
然后我们创建一个存在类型,将任意此类数据及其对应的类型类字典(包括`Typeable`、`Eq`等)包装起来,直到实际需要的`Semantics`。
```
data ForeignSemantics = forall sem. Semantics sem => ForeignSemantics sem
```
注意所有类型类方法都是投影式的(接收`sem`但不生成它),因此我们也能为这个抽象的`ForeignSemantics`存在类型实现`Semantics`:
```
instance Semantics ForeignSemantics where
semTyp (ForeignSemantics sem) = semTyp sem
semCode (ForeignSemantics sem) = semCode sem
semEval (ForeignSemantics sem) = semEval sem
semExprs (ForeignSemantics sem) = semExprs sem
semEffects (ForeignSemantics sem) = semEffects sem
```
为此,我们需要实现`Existentiable`的组成部分。
`Show`很简单,解包后应用`show`。`NFData`同样直接转发给包装类型的`NFData`——字典虽包含函数,但如前所述无需归一化:它只是全局类型类字典。
`Eq`和`Ord`需要比较两个值中`Typeable sem`打包的运行时类型反射。当然,`Typeable ForeignSemantics`自动派生——感谢GHC的神奇仙尘。
```
instance Show ForeignSemantics where
show (ForeignSemantics spec) = show spec
instance NFData ForeignSemantics where
rnf (ForeignSemantics spec) = rnf spec
instance Eq ForeignSemantics where
ForeignSemantics spec0 == ForeignSemantics spec2
| Just spec1 <- Typeable.cast spec0 = spec1 == spec2
| otherwise = False
instance Ord ForeignSemantics where
ForeignSemantics spec0 `compare` ForeignSemantics spec2
| Just spec1 <- Typeable.cast spec0 = spec1 `compare` spec2
| otherwise = Typeable.typeOf spec0 `compare` Typeable.typeOf spec2
```
现在我们可以在表达式主AST或任何有用的地方使用`ForeignSemantics`,而不会破坏`Show`、`NFData`、`Eq`或`Ord`的派生。
```
-- 表达式AST
data Expr
-- 字面量
= EF64 !Double
| EF32 !Float
| EU64 !Word64
| EU32 !Word32
| ES64 !Int64
| ES32 !Int32
| ETxt !Text
-- let表达式:按顺序绑定多个名称(非递归let)
| ELet [(Expr, Bind)] Expr
-- 变量引用
| EVar STyp !Name
-- n元lambda表达式
| EFun STyp [Bind] Expr
-- 函数应用(n元)
| EApp Expr [Expr]
.
.
.
-- 外部代码
| EForeign ForeignSemantics
deriving stock (Generic, Show, Eq, Ord)
deriving anyclass (NFData)
```
最后我们可以提供便捷的模式同义词。由于我们将`Typeable`与该存在类型捆绑,可以将其与特定类型比较,使用`Typeable\.cast`(https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Data-Typeable.html#v:cast)检查是否为该类型。(这需要视图模式,虽非我所喜,但在此场景极为方便。)该模式同义词现可用于解构具体类型。
为方便起见,我们也可为AST类型重复该模式。由于每个`Expr`构造子都以`E`开头,可命名为`ESem`。
```
pattern Sem :: Semantics sem => Semantics sem => sem -> ForeignSemantics
pattern Sem sem <- ForeignSemantics (Typeable.cast -> Just (sem :: sem)) where
Sem sem = ForeignSemantics sem
pattern ESem :: Semantics sem => Semantics sem => sem -> Expr
pattern ESem sem = EForeign (Sem sem)
```
这很棒,库自身可以提供实例,需要时仍能具体匹配。例如,`STyp`是另一个合成类型的存在类型。
现在我们可以创建实例,它们会自动生效。外部调用的最小数据类型包含其类型、优化可能需要的副作用信息,以及要调用的WASM函数或导入名称。
```
data ForeignCall = ForeignCall STyp Effects Name
deriving stock (Eq, Ord, Show, Generic)
deriving anyclass (NFData)
instance Semantics ForeignCall where
semTyp (ForeignCall t _ _) = t
semCode (ForeignCall _ _ name) = tell $ SEXP "call" [ wasmID name ]
semEval _ _ _ = Nothing
semExprs _ = []
semEffects (ForeignCall _ ef _) = ef
```
瞧,它自动接入编译器,我们可以通过魔法表达式`ESem \(ForeignCall \(itsType\) mempty "functionToCall"\)`召唤任何函数。
需特别注意,这是我们真正需要类型类约束存在类型的少数情况之一。通常你会说`data Showable = forall t. Show t => Showable t`等同于`String`(或其他类型),因为通过`show`转换为`String`是对存在类型`t`值能做的全部操作。但因为我们想在其中将函数作为数据,需要保持类型类字典抽象,并保留底层数据类型。
此外,你在此处从事实际工作。必须为每个类型类实例决定其行为背后数据的本质。你正在设计所需函数的闭包,并称其为`sem`类型。这本就是去函数化工作的核心部分。
但你无需担心去函数化的可扩展性问题;无需考虑作为库还是库消费者的立场。Haskell已处理好一切。这只是另一个类型类,而类型类机制无比强大。
你只是得不到实例的漂亮枚举列表,而实现`Read`那样的反序列化需要它。你受限于与此开放世界视图兼容的方法。但这对编译器钩子等场景来说恰到好处。
### 结论
去函数化通常要么是经过深思熟虑的、极其显式的设计选择,要么在闭源代码库外部执行——那时所有函数都是已知的。
这种去函数化形式是可扩展的:你可以编写包含某些实例的Haskell库,用户可以带来自己的实例,无需任何协调。
你可以为代码或数据的任何部分添加可扩展行为,而不会过多损害数据的本质,无需维护扩展注册表,也无需预测用户(或未来的你)可能想用它做什么。
确定实例所需函数的核心数据需要付出努力,但希望这不会过于繁重,且能提供清晰性。这比其他方案更优。
遗憾的是,目前无法自动序列化和反序列化这些存在类型字典。我曾期望GHC\.Compact(https://hackage-content.haskell.org/package/ghc-compact-0.1.0.0/docs/GHC-Compact.html)能做到,但它未为类型类字典预留特殊处理。它确实说明:
> 序列化数据只能由创建它的完全相同的二进制文件反序列化,但可以在反序列化前无限期存储。
理论上,如果你真想玩火,甚至可以包含函数和闭包。相同二进制,相同函数。更安全的做法是,二进制文件可嵌入规范的`TypeRep -> Dict`表(即字典组装器),以便反序列化时查找实例。但GHC运行时不支持此功能。
相似文章
hica中的函数式编程
hica语言中函数式编程的介绍,涵盖表达式、不可变性、纯函数、闭包以及高阶函数(如map、filter和fold)。
Data types à la carte (2008)
本文提出了一种从独立组件组合数据类型和函数的技术,并将该方法扩展到结合自由单子,从而实现了对Haskell的IO单子的模块化结构。
Haskell中的数据导向编程(SICP 2.4.3)
本文展示了在Haskell中实现数据导向编程来处理复数运算,遵循SICP 2.4.3的方法,避免在添加新表示时修改通用函数。
从第一原理看函数式编程,第1部分——动机
本文从第一原理介绍函数式编程,涵盖函数的数学定义及编程语言范式的分类。这是面向命令式编程者系列文章的第一部分。
底层Haskell:在Haskell/GHC中模拟内联汇编的邪道方法
本文探讨了在Haskell/GHC中模拟内联汇编的技术,用于调用晦涩的CPU指令以及从外部函数高效返回多个值,并使用了扩宽乘法和无进位乘法等示例。