Scala 函数式编程
Scala 的函数式特性很容易被讲成 map、flatMap、Monad 的名词清单。更好的入口是一个朴素事实:函数本身可以是值。一旦函数能被传递、返回和组合,很多“算子”只是对计算管道的命名。
Lambda 让行为成为参数
假设我们有一组订单,想筛出已支付订单再提取金额:
orders
.filter(_.paid)
.map(_.amount)
_.paid 与 _.amount 都是 lambda 的简写:前者把 Order => Boolean 交给 filter,后者把 Order => Money 交给 map。集合 API 之所以强大,不是因为它有很多方法,而是因为“遍历策略”由集合实现,“每个元素如何处理”由调用者提供。
这也是高阶函数的核心分工:把稳定的控制流封装起来,把变化的业务行为暴露成函数参数。
先说 context 与 effect:值之外,计算还带着什么规则
先不用把 F[A] 当成高深符号。它只是说:我不只有一个 A,还带着一层上下文;这层上下文决定“有值时怎样继续、没有值/失败时怎么办、什么时候执行”。
| 形式 | A 之外携带的规则 | 例子 |
|---|---|---|
Option[A] | 值可能不存在 | 查不到用户时不再继续取团队 |
Either[Error, A] | 值可能失败且带错误原因 | 参数校验失败直接走错误分支 |
Validated[Errors, A] | 值可能无效,且可收集多个错误 | 一次返回所有数据库配置错误 |
IO[A] | 计算稍后执行,可能失败、取消、占用资源 | 查询数据库、调用 HTTP、读文件 |
effect 可以理解为一类更具体的 context:它不只是装着一个结果,还描述了将来如何执行有副作用的计算。IO[User] 不是已经查到的用户,而是一段“执行后可能得到用户、也可能失败或取消”的程序。这样,业务逻辑不必在每一行都重复处理连接丢失、超时或资源关闭;这些规则由 IO、Resource 和最外层的错误处理来承接。
这不意味着异常和空值从此消失。更准确地说,是把它们从每个纯业务函数的控制流中移走,放到类型、基础设施实现和明确的边界处理处。理解这层分工后,再看 Functor、Applicative、Monad 就是在问:我手里的 F[A],下一步怎样组合?
Functor:map 是“保持外壳,只变内容”
map 适用于输入与输出都有同一种上下文的场景:List[A] → List[B]、Option[A] → Option[B]、Future[A] → Future[B]。它不负责创造或消除上下文,只把其中的 A 变成 B。
Option("42").map(_.toInt) // Option[Int]
空值、多个值、异步结果这些差异都留在“外壳”里。这个统一视角比记忆各个集合的 API 更重要。
从类型角度看,Functor 给出的正是这一种能力:F[A] 和 A => B 组合,仍得到 F[B]。F 可以是 Option、List、Future,也可以是一个函数本身。关键不是 F 像不像容器,而是它能否在不拆开上下文规则的前提下变换其中的值。
Functor 本身不保证 lambda 一定是纯函数;但它最有价值的用法,是把纯业务变换放进 map。例如数据库层已经给出 IO[Option[User]],把用户转成展示模型并不需要知道连接是否断开:
def toProfile(user: User): UserProfile =
UserProfile(user.id, user.name.trim, user.roles.sorted)
val profile: IO[Option[UserProfile]] =
repo.find(userId).map(_.map(toProfile))
toProfile 只处理一个确定的 User,容易测试和推理;IO 负责查询何时执行、数据库异常怎样传播,Option 负责“用户不存在”时不调用 toProfile。这不是忽略 corner case,而是让每一层只负责自己的 corner case。
Applicative:并排组合彼此独立的上下文
有时我们不是把一个值改成另一个值,而是已经有几个彼此独立的“带外壳的值”,想一起构造结果。比如主机和端口分别来自两个可能缺失的配置项:
import cats.syntax.all._
case class Endpoint(host: String, port: Int)
val endpoint: Option[Endpoint] =
(Option("db.internal"), Option(5432)).mapN(Endpoint.apply)
map 只能先处理一个 Option[A];而 mapN(底层可理解为 ap)能把 F[A => B] 应用于 F[A],再把多参数函数逐个接上其他 F[...]。因此 Applicative 的能力是:函数和参数都带着同一种上下文,仍能在上下文内完成组合。
它尤其适合彼此没有依赖的输入。上例中端口并不依赖主机名;若换成 Validated,同样的 Applicative 组合甚至可以收集多个独立配置错误。具体的失败语义由 F 决定,组合代码不需要手写一串空值判断。
数据库配置正好能体现这点。host、port、用户名、密码的读取与校验彼此独立;不应因为 host 写错,就放弃告诉使用者 port 也不合法:
case class DbConfig(host: String, port: Int, user: String)
val config: ValidatedNel[ConfigError, DbConfig] =
(readHost, readPort, readUser).mapN(DbConfig.apply)
这里 DbConfig.apply 是普通的三参数函数,mapN 将它提升到 Validated 的上下文中,再把三个带校验结果的参数喂给它。可以把 Applicative 看作“把函数也当成组合子”:函数不必先拿到裸值,照样能在 F[...] 中组装结果。它关心的是独立输入怎样合并,不关心某个输入的值如何决定下一个请求。普通 mapN 也不承诺并行;若 F 支持并发,例如 Cats Effect 的 IO,应显式用 parMapN 表达可以并行的意图。
Monad:flatMap 让下一步本身也带外壳
真实程序的下一步常常会失败、为空或异步:
def findUser(id: UserId): Option[User]
def findTeam(u: User): Option[Team]
findUser(id).flatMap(findTeam)
如果用 map,结果会是 Option[Option[Team]];flatMap 将嵌套的同类上下文压平。for-comprehension 只是这组操作更接近日常控制流的语法:
for {
user <- findUser(id)
team <- findTeam(user)
} yield team
它的价值不是少写括号,而是让“失败/为空的传播规则”由 Option 决定,而不是散落在业务代码的 if 中。
这就是 Monad 比 Applicative 多出的那一步:它接收 A => F[B],而不是普通的 A => B。也就是说,下一步可以依赖上一步解出的真实值,并自行决定产生什么上下文。findTeam 必须先拿到 User 才知道去查谁,因此应使用 flatMap;主机名与端口可以同时取得,因此更像 Applicative。
用铁路理解 Monad:成功继续,失败自动绕开后续步骤
如果 Option 的“空”还不够直观,可以把 Either[Error, A] 想成两条铁轨:绿色轨道携带成功值,红色轨道携带错误。一个普通函数 A => B 只是在同一条轨道上改造车厢;而一个可能失败的步骤 A => Either[Error, B],会根据输入把列车送到成功或失败轨道。

flatMap 做的事就是把这些“会分叉的步骤”接起来。它不要求每个业务函数都重复判断错误;当前一步已经在失败轨道时,它跳过后续函数,原样把错误传到结尾。拿创建订单为例:
type Result[A] = Either[DomainError, A]
def validate(cmd: CreateOrder): Result[ValidatedOrder]
def save(order: ValidatedOrder): Result[Order]
def notify(order: Order): Result[Unit]
val created: Result[Unit] =
validate(cmd)
.flatMap(save)
.flatMap(notify)
这段代码的重点不是 Either 的语法,而是控制流的归属:validate 失败时,save 和 notify 不会执行;save 失败时,notify 不会执行。每个业务步骤只说明自己的成功值或错误,flatMap 统一负责短路规则。for-comprehension 则是这条流水线更易读的写法。

因此,不必先从范畴论或复杂的定律背诵去理解 Monad。先抓住这个实用定义已经足够:当每一步都可能带着同一种上下文继续或失败,而下一步又依赖上一步结果时,用 flatMap 把控制流交给这个上下文。 对 Either 来说它是失败短路;对 Future/IO 来说它还会决定异步或 effect 的后续如何衔接。
数据库业务流程往往正是 Monad 的场景:只有查到用户,才知道该查哪一份权限;只有权限允许,才值得创建订单。每个基础设施动作返回 IO[...],后续动作依赖前一步的结果:
def placeOrder(cmd: CreateOrder): IO[Order] =
repo.findUser(cmd.userId).flatMap {
case None => IO.raiseError(UserNotFound(cmd.userId))
case Some(user) =>
permission.check(user, cmd.productId).flatMap { _ =>
repo.insert(Order.from(cmd, user))
}
}
这里 flatMap 并没有神奇地修复数据库连接丢失:repo.findUser 或 repo.insert 失败时,IO 会把失败带到外层,由调用边界选择记录、重试、转换成 API 错误或终止请求。它解决的是另一件同样重要的事:成功路径上的依赖顺序,以及失败时不继续执行不该执行的写入。连接的取得与释放则应放在 Resource,而不是藏在 placeOrder 的纯业务判断里。
这三种抽象到底省掉了什么
Functor、Applicative、Monad 不是为了把普通程序包装成难懂的名词;它们把反复出现的“外壳规则”提取出来,让业务代码只写值的变化与步骤的依赖关系。
- Functor /
map:统一“成功时变换值、缺失/失败时原样传递”的样板代码。业务只写A => B。 - Applicative /
mapN:统一组合相互独立的输入。它可以让Option一起缺失,也让Validated收集多个配置错误;至于是否并行,取决于具体 effect,不能把 Applicative 自动等同于并发。 - Monad /
flatMap:统一“根据上一步结果决定下一步”的顺序、短路与上下文传递。业务只写每个局部步骤。
一个很简单的选择方法是看函数的返回类型:若下一步返回普通 B,先考虑 map;若要把几个独立的 F[...] 合在一起,考虑 mapN;若下一步返回 F[B] 且依赖当前值,使用 flatMap。这比先问“它是不是 Monad”更接近日常编程。
可以把三者临时记成一张能力表。它不是为了贴标签,而是帮助选择组合方式:
| 抽象 | 组合的函数 | Scala/Cats 中常见操作 | 适合的问题 |
|---|---|---|---|
| Functor | A => B | map | 只改变上下文中的值 |
| Applicative | F[A => B] 与 F[A] | ap、mapN | 组合相互独立的上下文值 |
| Monad | A => F[B] | flatMap、for-comprehension | 下一步依赖上一步结果,且也带上下文 |
ADT:先让状态有限,才能写对分支
Scala 的 sealed trait 与 case class 适合表达有限状态,例如支付结果:
sealed trait Payment
case object Pending extends Payment
case class Succeeded(id: String) extends Payment
case class Failed(reason: String) extends Payment
pattern matching 会迫使调用者考虑完整分支。比起用 status: String 加一串约定,ADT 将状态集合和每种状态携带的数据放进类型系统。
Effect:不要在定义函数时就做事
纯函数返回的值可以立即推理;但数据库、网络、时间和并发都有副作用。Cats Effect 的 IO[A] 不是“已经拿到的 A”,而是“描述一个将来可能产生 A、也可能失败或被取消的程序”。这使得组装与执行分离:
def load(id: Id): IO[User] =
client.get(id).flatMap(decode)
flatMap 在这里不只处理值,也串起了失败、取消和资源生命周期。resource、bracket 等抽象进一步保证连接和文件在成功、失败、取消三条路径上都能释放。Cats Effect 将并发单元称为 fiber;它轻量,但仍需要结构化地等待、取消和处理失败。
从 Lambda 到 Effect 的主线其实没有断:先是把局部行为变成值,再把带上下文的计算也变成值,最后让组合规则替代手写控制流。
References
Notion 相关零散笔记
- Scala
- Scala3
- Function
- DSL
- tagless-final
- Category theory
- Cats effect
- ZIO
- Actor