函数式编程14:最小Scala桥接与类型类
Optional 与 List 都能 map,但 Java 方法通常分别写成 Optional<A> 和 List<A>。若希望同一个算法只要求“能映射”,就需要把外层结构也当成参数。Scala 的 F[_] 用于表达这一层抽象;它与接受一个函数作为参数的高阶函数不是同一个概念。
本章只引入后续组合章节需要的 Scala 3 语法:函数类型、enum、模式匹配、泛型、given/using、类型构造器和最小 for 表达式。实验固定 Scala 3.3.7、Scala CLI 1.9.1 与 JDK 21,不依赖 Cats。目标是能读懂并实现一个小型能力接口,不要求先掌握宏、复杂型变或整个 Scala 生态。
函数值仍然是值
Int => Int 表示接收整数、返回整数的函数类型。n => n * 125 是一个函数值,val 给它绑定名字。函数体在被调用时执行,写出 lambda 并不会自动执行其中的乘法。
1 | |
def cents(n: Int): Int = n * 125 则定义方法。方法可以在需要函数的位置被适配,但读代码时仍应分清声明形式。当前例子显式写函数类型,是为了让输入与输出关系可见;省略类型之后编译器的推导不会改变乘法的业务含义。
val 限制绑定重新赋值,不保证引用对象内部不可变。一个 val 指向可变缓冲区,缓冲区仍可以增加元素。后续实验有时用它记录执行事件,那是观察工具;不能看到函数式风格的语法,就认定回调必然纯。
Scala 3 的缩进定义代码块范围。冒号后面的成员增加缩进,方法体超过一行也增加缩进。从 Java 转来时,排错可以先确认块结构,再看类型。意外减少缩进,可能把方法体内表达式移到外层初始化位置。
enum 把有限分支放在一起
本章的小状态只有草稿与已付款。带参数的分支保存数据,匹配时可以取出它:
1 | |
s"paid:$r" 是字符串插值,r 来自当前分支的绑定。这个模式匹配不需要把 Stage 强制转换成 Paid,也不会在 Draft 中产生一份虚假的 receipt。它承接前两章的和类型结构,只是换成 Scala 语法。
这里没有检验空白收据,所以 Stage.Paid(“”) 仍能创建。这一章的任务是编译语法与能力接口,领域验证继续沿用前面章节的边界原则。若要把这个 Stage 当作已验证订单,需要补工厂或强值类型,不能把学习语法时的简化忘在公共接口里。
带参数的 Paid 可以有很多不同值;enum 分支数有限不等于全部数据值有限。泛型 enum 还可以让某个分支携带 A,但本章不需要先引入协变等额外规则。对接下来的 Option/List 泛化,理解一个普通类型参数就足够。
外部能力对象与接口继承
假设整数与订单状态都需要显示成一段教学文本。它们不用共同继承某个带 render 方法的父类。可以定义一个独立能力对象,接收被显示的数据:
1 | |
Render[A] 是“能够显示 A 的对象”的类型。A 自己不需要实现 Render。display 同时获得一个 A 和一个适用于 A 的行为对象,这种组织方式通常称为类型类。using 使第二组参数可以由编译器寻找并传递;去掉这种语法,显式传入普通对象也能表达相同设计。Scala 3 Type Classes
给已有 Int 增加显示规则,不需要修改标准库整数类型。给 Stage 增加另一种审计表示,也不必把所有格式都变成 Stage 成员。这对数据所有权不在当前模块、或同一数据有多种使用策略的场景很方便。
它也不等于任意对象都能自动显示。通用 display 要求 Render[A],缺少这份对象时调用不能通过类型检查。若为了消除报错而改用 a.toString,就已经换了契约:专门的显示能力被任意调试字符串替代。便利不应以静默取消约束为代价。
given 定义候选,using 表达需求
1 | |
实验调用 display(7) 与 display(Stage.Paid(“r1”)),分别得到 7 与 paid:r1。两个实例实现同一个接口,但适用于不同数据类型。编译器帮助传递正确的对象,运行时仍然执行普通方法调用,并没有依据字符串类型名动态查找处理器。
given 的存在也不保证实现正确。一个 Render[Int] 总返回空串,仍然符合方法签名。输出是否符合协议,必须用样例或性质检验。对类型类的学习应同时保留“实例能找到”和“实例行为正确”两条检查线。
同一种 A 可以有多种行为策略。若业务需要紧凑文本与审计文本,应给实例命名,并在调用点显式选择 using compact 或 using audit。不要让一个无关 import 偶然决定外部协议。当前实验每种类型只设置一个候选,以便集中观察最小调用路径。
summon[Render[Int]] 可以取得编译器找到的能力对象。后续章节使用 summon 调用 Monoid、Functor 等实例时,含义相同:取得已有的证据参数或可见实例,不是创建一个拥有额外运行时能力的容器。
把完整类型与类型构造器分开
Int 是完整类型,List[Int] 也是完整类型。List 还需要一个类型参数,才能组成 List[A]。这类接受类型参数形成类型的结构称为类型构造器。F[_] 声明 F 需要一个类型参数,允许它被替换为 List 或 Option。
1 | |
这段签名保留三个关系:输入和输出使用同一个外层 F;输入元素类型是 A;回调把 A 变成 B,因此输出是 F[B]。把 fa 改成 Any 会丢掉这些关联,程序只能在运行时猜类型。高阶类型抽象增加参数层次,并没有删除静态约束。
Mapping[Option] 的实例知道怎样处理 Some 与 None;Mapping[List] 的实例知道怎样遍历零个或多个元素。这里暂时委托标准库 map,下一章之后再讨论实例需要满足的规律。仅仅写出 F[_] 不会为某个类型自动生成 map,实现与定律是额外责任。
高阶函数作用在值层,比如接收 Int => String;高阶类型参数作用在类型层,比如接收 Option 形成 Option[Int]。两者能够在同一签名出现。Mapping 的 map 既使用了类型构造器 F,也接收函数值 f,不能把两种“高阶”混成一种语言技巧。
同一个算法使用两种外层结构
1 | |
label 不知道输入究竟有几个元素,也不决定缺席如何表示。它只声明每个可被映射的整数如何变成文本。实验结果分别是 Some(“qty:2”) 与 List(“qty:2”,“qty:3”),具体外层行为由实例提供。
函数参数显式写成 label[Option] 有助于刚开始阅读时确认 F。熟悉类型推导后可以省略某些类型参数,但不应把省略后的代码当作更强的抽象。推导失败时,先补全 F、A、B 看看哪一层不匹配,通常比随机添加转换更有效。
Java 缺少直接对应这种 F[_] 参数的语法,不意味着 Optional 或 List 不能分别实现正确组合。可以写具体方法,也可以借助更复杂编码模拟类型构造器,但那会增加教学噪声。本系列此处切换 Scala,是为了把共同签名直接写出来,并非证明另一种语言不能进行函数式设计。
for 表达式先按展开式理解
1 | |
这份没有守卫、没有模式过滤的最小 for,等价于所示 map/flatMap 嵌套。实验断言二者都得到 Some(250)。for 提供语法组织,不定义一种新的错误累积策略,也不会把同步 Option 转成并发任务。
两个输入在业务上独立,当前为了讲解语法仍使用 flatMap 写法。第 17 篇会为独立输入推导 Applicative 组合;第 19–20 篇解释依赖组合与 Monad。不能从某段代码写了 for 就推导它必须依赖前值,更不能把 for 本身当成 Monad 定义。
实例选择也是程序行为的一部分
Render[Int] 表明存在一种把整数变成文本的能力,但没有保证所有地方都应该使用同一种格式。普通十进制展示、货币格式、调试表示可能各有用途。若把它们都作为同一作用域中的隐式候选,调用点会变得模糊;更清楚的做法是用不同能力名称,或者在需要特殊策略时显式传入实例。
给定实例的选择发生在类型检查阶段,不是运行时按照值的大小搜索。display 收到整数之后,不会因为它碰巧代表金额就自动切换成货币格式;金额含义若要影响选择,需要体现在类型或明确参数中。这也是业务包装类型有价值的原因:它不仅防止混用,还能让能力需求更准确。
扩展一个不能修改源码的类型时,外部能力对象尤其方便。可以为它提供 Render,而不要求该类型继承新的接口。但“可以扩展”不意味着任意实例都值得全局导入。实例会影响泛型算法的含义,应该放在可解释的作用域内,避免一个宽泛导入悄悄改变项目不同位置的策略。
与 Java 对照时,可以把 using 参数先理解成编译器协助传递的普通依赖。手动传入一个策略对象的思路并没有消失,只是 Scala 提供了声明需求与寻找候选的语法。这样理解后,调试缺失实例时可以依次检查需要的类型、当前可见候选,以及候选是否满足完整参数,而不必将它当成隐藏的全局注册中心。
本章的 Mapping 不提供空值构造、合并或依赖步骤,因此泛型 label 只能映射已经存在的结构。若后续算法开始需要创建新的上下文,就应改变能力约束,而不是在函数里用强制转换猜测 F 是 List 还是 Option。高阶类型的作用是描述结构参数,不是绕过具体类型检查。
本章还没有讨论协变、逆变、扩展方法优先级或复杂给定实例推导。先把函数值、完整类型、类型构造器和能力参数对应清楚,就足以阅读随后几篇的最小接口。遇到更复杂语法时,应查看具体签名,而不是根据缩进形式推断它与已学操作完全相同。
编译正反例与运行
1 | |
源码使用 scala.compiletime.testing.typeChecks 在编译阶段检查字面量片段。正对照是 Int 到 String 的合法函数;三个负例分别是函数结果类型错误、缺少 using 实例,以及把完整类型 Int 放进需要类型构造器的位置。返回 false 的预期由运行断言核对,实际片段的类型检查发生在编译时。
公共运行器隔离编译缓存,结果证据保存 Scala/JDK 命令及所有 PASS 标记。这里不声称验证了全部实例搜索优先级;实验只覆盖本章的局部声明与明确负例。
Scala Typeclass 与组合实例进一步解释递归实例与多策略,高阶类型与组合定律覆盖类型 lambda。本章承担切换语言所需的最小阅读路径,不要求把这些扩展机制全部学完才能继续。
手算与修改练习
类型题:Mapping[Option]、Option[Int]、Int => String、Mapping[List] 中,哪些是完整类型,哪些表达函数?它们全部是完整类型,其中第三项是函数类型;单独的 Option 与 List 才是此处的一参数类型构造器。再写出 label[List] 的输入输出:List[Int] 到 List[String],并附带 Mapping[List] 参数。
修改题:定义 Box[A](value:A) 及 Mapping[Box],让原有 label 不改一行就处理 Box(4),断言得到 Box(“qty:4”)。再暂时移除实例,确认调用无法通过类型检查。不要在 label 内增加针对 Box 的模式分支,因为那会取消它仅依赖能力接口的约束。
