让不可能状态成为不可能:借助函数式依赖注入实现类型安全的领域建模

PHP教程 2025-08-19

大多数应用程序并非因算法复杂而失败,而是因为我们的模型允许存在“在业务领域中毫无意义”的状态。比如“没有邮箱却已验证的用户”“既已发货又已取消的订单”“总和小于 0”“既关闭又激活的模态对话框”——这些状态从一开始就应该是不可能存在的。

在所有难以追踪的 Bug 中,最耗时的莫过于那种看到应用状态后会疑惑“这按理说不可能发生”的情况。

  • Richard Feldman,elm-conf 2016

这正是类型化函数式语言(如 Elm、Haskell、F# 等)的优势所在。它们为我们提供了直接在类型系统中表达业务规则的工具。最终效果是:当我们试图表示非法状态时,编译器会直接拒绝构建代码。简而言之,就是“让不可能的状态变得不可存在”。

如果“无运行时异常”听起来很有吸引力,那么“运行时无不可能状态”肯定更棒!?

丰富类型:可落地的活文档

Scott Wlaschin 在《Domain Modeling Made Functional》(《函数式领域建模》)中提出一个重要观点:优秀的领域类型应足够清晰,即使是没有编程背景的业务专家,也能读懂并识别出熟悉的概念和规则。换句话说,精心设计的丰富类型本身就是“活文档”,而且编译器会强制保障其有效性。

这同时也是类型化函数式语言最有力的优势之一:它们能让“精准表达业务领域”变得自然,在编译阶段就能快速反馈问题,还能让开发人员与业务专家围绕正确的概念更紧密地协作。

解析而非验证(Parse, Don’t Validate)

与其在代码中零散地“验证”数据,不如在将数据传入领域层之前做一件事:把原始数据解析成“丰富且类型安全的领域值”。从这一步开始,系统其余部分处理的都是“已确保有效”的值。Alexis King(Lexi Lambda)在《Parse, don’t validate》一文中,对这一原则做了极佳的阐释。

以下是 Elm 语言中的示例——分别定义“非空字符串”和“邮箱”:

module Domain exposing (NonEmptyString, Email, nonEmpty, email)  type NonEmptyString     = NonEmptyString String -- 注意:构造函数不对外暴露  nonEmpty : String -> Result String NonEmptyString nonEmpty s =     if String.length s > 0 then         Ok (NonEmptyString s)     else         Err "Cannot be empty"  type Email     = Email String  email : String -> Result String Email email s =     if String.contains "@" s then         Ok (Email s)     else         Err "Invalid email"

核心要点:解析完成后,领域层中不会存在“空字符串”或“非法邮箱”。这些非法值只能在“边界代码”(如接收外部输入的代码)中以错误形式存在,而不会出现在系统其他部分。而且由于值是不可变的,在后续程序流程中也不会被意外“污染”。

求和类型:状态的唯一真相来源

与其用零散的布尔值表示状态,不如用显式的联合/求和类型(union/sum type)来定义所有可能的状态。这正是“让不可能状态不可存在”的核心思想:

type Session     = Anonymous     | Authenticated User  -- 不可能出现“部分登录”的用户

另一个例子是异步加载场景(避免使用可能相互矛盾的 isLoading、error、data 等布尔值):

type RemoteData error value     = NotAsked     | Loading     | Success value     | Failure error

在这里,每个状态都是“互斥且完整”的——UI 逻辑因此变得更简单,也更安全。

函数式依赖注入:以部分应用为核心的架构

现在,领域的状态空间已通过类型得到约束,但我们仍需引入“副作用”(如IO操作),同时避免重新引入非法状态。这正是“部分应用”(partial application)的用武之地:我们保持核心逻辑的纯函数特性,将副作用通过“函数注入”的方式推到系统边缘。

这正是“整洁架构”(Clean Architecture)中的依赖倒置原则与函数式编程的完美结合。我们无需注入庞大的接口和对象,而是可以将“函数”作为依赖注入。正如 Scott Wlaschin 在《Domain Modeling Made Functional》中所展示的,“部分应用”在函数式编程中,相当于面向对象中的“依赖注入”。

来看书中的一个工作流程示例(使用 F# 语言):

type ValidateOrder =     CheckProductCodeExists    // 依赖项     -> CheckAddressExists     // 依赖项     -> UnvalidatedOrder       // 输入     -> Result// 输出

核心思路:参数顺序中,“依赖项”在前,“输入”在后,最后是“输出”。这样就能通过“部分应用”来“注入”依赖:

// 通过部分应用“注入”依赖 let validateOrderStep =     validateOrder         checkProductCodeExists  // 注入的依赖项         checkAddressExists      // 注入的依赖项     // 返回:UnvalidatedOrder -> Result

在 Elm 语言中,实现思路类似:

-- 先注入依赖项,再传入输入 type alias CheckProductCodeExists =     ProductCode -> Result String ()  type alias CheckAddressExists =     Address -> Result String ()  type alias ValidateOrder =     CheckProductCodeExists     -> CheckAddressExists     -> UnvalidatedOrder     -> Result String ValidatedOrder  validateOrder : ValidateOrder validateOrder checkProduct checkAddress unvalidated =     Debug.todo "implement"  -- 通过部分应用(柯里化)注入具体依赖项 validateOrderStep : UnvalidatedOrder -> Result String ValidatedOrder validateOrderStep =     validateOrder checkProductCodeExists checkAddressExists

这是一种“无接口的依赖倒置”!我们实现了依赖倒置(函数依赖于抽象而非具体实现),而且能轻松替换不同的实现以适配测试或不同环境。

为何这符合整洁架构?

这种方式以“极简且实用”的形式契合了依赖倒置原则:

  • 高层策略保持纯函数特性,与基础设施无关(例如,ValidateOrder 完全不知道数据库的存在)

  • 双方都依赖“函数类型”而非“具体实现”(例如,CheckProductCodeExists 是函数类型)

  • 抽象保持稳定,实现可灵活变化(为生产环境/测试环境替换不同实现)

与传统的面向对象依赖注入不同,我们无需依赖控制反转(IoC)容器、接口和生命周期管理——类型系统和部分应用就承担了这些复杂工作。

幻影类型:编译时过滤

幻影类型(Phantom Types)能让我们在“无运行时开销”的前提下为值“标记类型”,常用于区分“不应混合的元素”。以下是一个简洁示例:通过类型参数区分“环保汽车”和“高污染汽车”:

type Car fuel     = ElectricCar     | HydrogenCar     | DieselCar  type Green = Green type Polluting = Polluting  electricCar : Car fuel electricCar = ElectricCar  dieselCar : Car Polluting dieselCar = DieselCar  createGreenCarFactory : (data -> List (Car Green)) -> Factory createGreenCarFactory build =     -- 实现逻辑无关;签名已禁止传入柴油车     Debug.todo "..."

核心在于:electricCar 是多态的(类型为 Car fuel),因此在需要“环保车型”时可作为 Green 类型使用;而 dieselCar 被固定为 Polluting 类型,当需要“环保车型”时,编译机会直接拒绝它。

用幻影类型将流程建模为状态机

幻影类型还非常适合建模“步骤顺序必须正确”的流程,且无需创建复杂的中间类型迷宫:

type Step step     = Step Order  type Start = Start type WithTotal = WithTotal type WithQuantity = WithQuantity type Done = Done  start : Order -> Step Start  setTotal : Int -> Step Start -> Step WithTotal adjustQuantityFromTotal : Step WithTotal -> Step Done setQuantity : Int -> Step Start -> Step WithQuantity adjustTotalFromQuantity : Step WithQuantity -> Step Done  done : Step Done -> Order  -- 两种合法流程 flowPrioritizingTotal : Int -> Order -> Order flowPrioritizingTotal total order =     start order         |> setTotal total         |> adjustQuantityFromTotal         |> done  flowPrioritizingQuantity : Int -> Order -> Order flowPrioritizingQuantity quantity order =     start order         |> setQuantity quantity         |> adjustTotalFromQuantity         |> done

这些类型签名能防止“跳过步骤”或“打乱顺序”。这既实现了状态机的优势(有编译期检查),又避免了“中间类型爆炸式增长”的问题。

另一个示例:结合幻影构建器与可扩展记录

构建器(Builder)也能通过类型保障安全性:在生成“最终对象”前,必须按正确顺序完成必要步骤——这需要用到“可扩展记录”(形如 { a | requiredTrait : () } 的记录类型)。特别感谢我的 Elm 领域偶像 Jeroen Engels,他在视频中清晰地介绍了这种模式。

module Button exposing (Button, new, withDisabled, withOnClick, withText, withIcon, toHtml)  type Button constraints msg     = Button (List (Html.Attribute msg)) (List (Html msg))  -- 初始状态:必须选择一种交互方式(onClick 或 disabled) new : Button { needsInteractivity : () } msg new =     Button [] []  withDisabled :     Button { c | needsInteractivity : () } msg     -> Button { c | hasInteractivity : () } msg withDisabled (Button attrs children) =     Button (Html.Attributes.disabled True :: attrs) children  withOnClick :     msg     -> Button { c | needsInteractivity : () } msg     -> Button { c | hasInteractivity : () } msg withOnClick message (Button attrs children) =     Button (Html.Events.onClick message :: attrs) children  withText :     String     -> Button c msg     -> Button { c | hasTextOrIcon : () } msg withText str (Button attrs children) =     Button attrs (Html.text str :: children)  toHtml :     Button { c | hasInteractivity : (), hasTextOrIcon : () } msg     -> Html msg toHtml (Button attrs children) =     Html.button (List.reverse attrs) (List.reverse children)

简单来说,这能确保:在构建按钮前,按钮必须“要么有 onclick 事件,要么处于禁用状态”。无需运行时验证,只需通过“可扩展记录”的“特征标记”来表示流程状态。类型签名会自动完成校验:只有满足这两个要求,才能调用 toHtml 方法。我们可以自由选择步骤顺序,后续也能添加更多“标记”而不影响现有使用者。

“不可能状态”实践清单

  • 用求和类型表示“原本可能用布尔值错误组合的状态”

  • 为“重要字符串”(如邮箱、UUID、非空字符串、非负数值等)创建丰富的领域值(新类型/类型别名)

    • 内部使用“智能构造函数”,不对外暴露类型构造函数

  • 在边界层(IO/HTTP/数据库)解析数据——为系统其余部分提供安全类型

  • 用幻影类型区分“不应混合的子组”

  • 当步骤顺序重要时,将构建流程建模为类型(幻影构建器)

  • 通过部分应用实现函数式依赖注入

测试与编译器辅助

有了强类型和丰富的领域模型,“确保非法状态不可存在”的重任很大程度上会由编译器承担。这减少了手动测试的需求,因为许多错误在编译阶段就已被捕获。

测试对于“确保逻辑符合预期”仍然重要,但编译器帮我们消除了“一大类原本难以定位和修复的错误”。比如,无需测试“用户不能既登录又登出”——类型系统会直接保障这种状态根本不可能存在。

何时值得采用这种方式?

在“健壮性、可预测性、可维护性”至关重要的系统中,这种方式的价值尤为突出。当“错误状态的后果”对用户、业务或安全造成重大影响时,“通过建模让非法状态在编译期就不可存在”的投入是值得的。

就像人们常说的测试原则:在你不希望应用程序失败的地方,就该这么做……

实践中的函数式架构

“丰富类型”与“函数式依赖注入”的结合,为我们提供了一种强大的架构模式:

  1. 领域层:纯函数 + 丰富类型,依赖项以函数参数形式存在

  2. 应用层:通过“部分应用”注入依赖,组合领域层函数

  3. 基础设施层:实现具体的依赖函数(如数据库访问、外部API调用等)

这种模式实现了清晰的职责分离:领域层完全不感知基础设施细节,同时又避免了传统依赖注入框架的复杂性。

总结

类型化函数式语言让“将验证从运行时转移到编译时”变得既可行又自然。借助求和类型、丰富领域值、幻影类型和函数式依赖注入,我们能构建出“根本无法表示非法状态”的模型。

这种方式能让代码更简洁、重构更安全、生产环境错误更少。随着系统复杂度不断提升,“健壮性”的重要性日益凸显,“让不可能状态不可存在”的技术也变得比以往任何时候都更有价值。

此时,编译器成了我们最可靠的伙伴——它从不疲倦,从不错过边缘场景,24小时不间断地保障我们的领域模型始终一致、正确。