重新思考Java设计模式:从面向对象到函数式编程
对于想知道如何将函数式编程与面向对象编程集成或结合的人来说,函数式编程的答案通常是:乌龟叠乌龟,一路向下。这是一句源于理查德·费曼(Richard Feynman)的格言。在他于 1985 年出版的 别闹了,费曼先生! 一书中,他讲述了一次关于宇宙本质的会议经历:听众中有人反驳他说,宇宙是托在一只乌龟背上的。费曼追问这只乌龟又站在什么上面,得到的回答是:“另一只更大的乌龟”。当他得意地继续追问那只更大的乌龟站在什么上面时,那位听众说:“乌龟叠乌龟,一路向下,你骗不了我!”
这个隐喻常常在函数式编程的语境中使用,用来描述由递归原则支配的无限实体序列。同时,它也是函数式编程对那些来自面向对象思维的开发者的回答:“一路向下,全部用函数式”。然而,要想采用更系统的方法将面向对象原则与函数式风格结合起来,还需要更实用的答案,这正是我在这里试图做的。
幸运的是,作为开发者的我们不必重新发明轮子。如今,特别是在 LLM 智能体成为最常见的数字基础设施之后,所有问题几乎都已经解决了。但令我们年轻同事(他们离开 AI 连 48 小时都撑不住)感到惊讶的是,早在 LLM 之前,一种将解决方案匹配到问题上的通用方法就已经存在,那就是设计模式。事实上,面向对象编程提供了一套可重复、经过验证并被形式化的解决方案,称为设计模式。即便你并未意识到,也很可能已经使用过它们。*四人组(Gang of Four)*将这些模式分为三类:
- 行为型模式:处理对象之间的职责与通信。
- 创建型模式:抽象对象的创建/实例化过程。
- 结构型模式:组合对象,使其形成更大或增强后的对象。
让我们从每一类中选取一些最常用的模式,看看如何将它们的面向对象本质与更函数式的方法结合起来。
工厂
工厂(Factory)设计模式属于创建型模式,其目的是在不暴露实现细节的情况下实例化对象。
面向对象方法
下图展示了工厂设计模式的类图:
[LOADING...]
这里的场景很简单:一个 Product 接口由三个类实现:BookProduct、ElectronicProduct 和 FashionProduct。它们可以通过 ProductFactory 类创建,如下所示:
例如,使用这个工厂可以非常容易地创建 BookProduct,同时避免暴露实现细节:
你可能已经注意到,ProductType 枚举定义了这三个类别。如果要引入新产品,就必须修改工厂以反映这一业务变化。工厂与枚举之间的这种相互依赖,使整个方案变得脆弱。为了降低这种脆弱性,我们需要引入一种更函数式的编译期校验。
函数式方法
我们的示例是产品管理系统中一个过度简化的案例。上面展示的工厂实例化了几个参数相同的简单记录。这些完全相同的构造函数让我们可以把工厂直接放进 ProductType 枚举中,这样任何新产品都会自动要求有对应的工厂。Java 的 enum 类型基于常量名,但我们可以为每个常量附加对应的值;或者更好的是,附加一个用于创建各产品的工厂函数。请看:
现在,创建新的 Product 实例更加简单:
既然已经有了专门用于创建实例的方法,公开属性 factory 看起来似乎有些多余。但它提供了一种非常便捷的函数式方式,可以与工厂进一步交互。例如:
如 fp_design_paterns.factory 包中的 TestProductFactory 类所示。
当然,由于我们的产品需要三参数构造函数,而 Java 没有提供带三个输入参数的 BiFunction 等价类,因此你需要自己编写一个 TriFunction 类,如下所示:
你可以这样做;或者,如果你和我一样,更愿意使用可靠的库,那么 Vavr 已经定义了符合需求的 Function3 接口。只需引入以下 Maven 依赖:
如果你需要定义最多 8 个参数的函数,这个库是个不错的选择。然后,你只需在 ProductType 中,把下面这段定义:
替换为:
访问者
访问者(Visitor)设计模式属于行为型模式,其目的是在不需要修改对象层级结构中各个类的情况下,为现有对象层级结构添加新的操作。它是经典的表达式问题的答案:当类型集合保持稳定,而操作集合不断增长时,访问者让你可以低成本地持续添加操作。
我们复用与工厂相同的领域:Product 接口由 BookProduct、ElectronicProduct 和 FashionProduct 实现。为了让访问者模式有意义,每个操作现在都针对不同的产品类型做出不同行为:
- 增值税:图书适用优惠税率
5.5%,其他商品适用标准税率20%。 - 运费:电子产品(易碎、需投保)为
10.00 + 2%的价格;图书为固定3.00;时尚商品为固定5.00。 - 折扣:电子产品
10%,图书5%,时尚商品15%。
面向对象方法
经典的访问者模式依赖于双重分派。每个 Product 接收一个访问者,并回调与自身类型匹配的重载方法:
操作存在于一个通用的访问者中,每种具体类型对应一个 visit 重载:
计算任意产品的增值税,只需应用一个具体的访问者:BigDecimal vat = book.accept(new VatVisitor());
添加新操作(运费、折扣等)只需要实现一个新的 ProductVisitor,因为 Product 的实现类永远不会改变。这与工厂模式的取舍正好相反:工厂让添加新操作变得简单,但添加新产品类型成本更高,因为你必须修改其核心 switch。访问者模式使添加新操作变得容易,却把同样的成本转移到了类型上,因为新增产品类型会迫使所有访问者都要更新。这就是经典的表达式问题:你可以让类型易于添加,或让操作易于添加,但无法两者兼得。下图展示了面向对象实现的类图:
[LOADING...]
函数式方法
现在看一下访问者函数式风格实现的类图:
[LOADING...]
在现代 Java 中,访问者模式的函数式对应物,是对密封类型进行穷尽式模式匹配。我们首先密封层级结构:
然后,一个操作就只是一个基于 switch 的 Function,用于解构每条记录。由于 Product 是密封的,编译器可以证明 switch 是穷尽的——没有 default 分支,没有双重分派,也没有 accept:
作为普通函数,这些操作可以组合:ProductOperations.DISCOUNT.andThen(amount -> "discount=" + amount).apply(fashion);
在经典访问者与纯粹模式匹配之间,还有一个中间步骤:将访问者作为一束函数,每种类型一个 lambda,而不是每种类型一个方法的接口:
这使得操作成为一个可以随时装配的值:
构建器
构建器(Builder)设计模式与工厂一样属于创建型模式,但它解决的是不同的问题。工厂隐藏的是实例化的具体类型,而构建器则是逐步组装一个单一、复杂的对象,将它的构造与表示分离。它是经典的伸缩式构造函数问题的答案:当一个对象有很多参数,其中一些是必填、大多数是可选的,构造函数就会爆炸式地产生大量重载组合。我们的 Product 记录只有三个必填字段,因此不需要构建器。为此,我们引入一个 Order:一个客户订单,将 common 产品聚合为订单项,并添加几个可选属性:优惠券代码、礼品包装标志和自由文本备注。无论采用哪种风格,目标都是同一个不可变值:
面向对象方法
下图展示了面向对象构建器的类图:
[LOADING...]
经典的 GoF 构建器是一个可变的累加器。必填参数提前捕获;可选参数通过流式调用添加,所有调用都返回 this,而 build() 会把累积的状态冻结为不可变的 Order:
构建订单读起来像一句话,而且你只需提到实际需要的部分:
函数式方法
现在看一下函数式风格实现的类图:
[LOADING...]
函数式对应物保持相同的不可变 Order 目标,但丢弃了可变累加器。每个构建步骤都成为一个一等的 UnaryOperator 值,这是一个纯函数,通过返回修改后的副本,将一个不可变 Order 映射到下一个:
由于这些步骤是普通值,它们不是在构建器上被调用,而是用 andThen 组合起来,正如工厂组合它的 factory 函数、访问者组合它的操作一样:
这不仅仅是风格上的差异。在面向对象版本中,一个步骤是一次方法调用,只存在于链式调用的持续期间。在函数式版本中,一个步骤是一个值,可以存储在变量中、传递给其他方法、保存在步骤列表中后续应用,甚至可以重复使用同一个步骤两次:
面向对象构建器在不可变目标之外包装了一个有状态对象,而函数式构建器则将构造过程表达为纯复制函数对目标对象的组合。“乌龟叠乌龟,一路向下”,两者最终都落在同一个 Order 上。
装饰器
装饰器(Decorator)设计模式属于结构型模式,其目的是通过将对象包装在另一个共享相同接口的对象中,动态地为对象附加额外职责。它是通过子类化扩展行为的灵活替代方案:与其创建爆炸式增长的 DiscountedTaxedGiftWrappedProduct 子类,不如按需在一个产品上随意包装多个独立装饰器,并让它们层层叠加。我们复用相同的 Product 领域。每个装饰器改变 price() 和 description(),同时保持其他一切不变。为了让模式与访问者(其规则随产品类型而变化)明显区分,这里装饰器对每个产品应用相同规则:
- 折扣(Discounted):在包装价格基础上打九折。
- 含税(Taxed):在包装价格基础上加 20% 增值税。
- 礼品包装(GiftWrapped):加收固定
5.00包装费。
由于它们可以叠加,一个 100.00 的图书经过 Discounted → Taxed → GiftWrapped 装饰后,价格变为 100.00 → 90.00 → 108.00 → 113.00,其描述为“A book discounted, VAT incl., gift-wrapped.”(一本打折、含增值税、已礼品包装的书)。
面向对象方法
下图展示了面向对象装饰器的类图:
[LOADING...]
经典的 GoF 装饰器是一个实现组件接口并持有另一个组件引用的对象,它委托不受影响的操作,并覆盖需要增强的操作。抽象 ProductDecorator 将委托逻辑集中处理一次:
然后,每个具体装饰器只覆盖自己所改变的行为:
由于装饰器本身也是 Product,装饰器可以包装装饰器,增强效果通过嵌套组合:
被包装的叶子是 BaseProduct,一个用于将共享 common.Product 适配到装饰器自身接口的小型记录。这是必要的,因为 common.Product 是 sealed 的,所以与面向对象访问者完全相同,装饰器无法让 common 中的记录直接实现自己的接口。
函数式方法
现在看一下函数式风格实现的类图:
[LOADING...]
装饰器的函数式对应物,就是一个将产品映射为增强产品的函数,实现为 UnaryOperator。由于 common 中的记录是不可变的,“增强”某条记录意味着通过 ProductType 工厂重建它(这正是在文章开头看到的),这也是为什么函数式这边可以直接复用 common,而不需要适配器:
作为普通值,这些装饰效果可以用 andThen 组合,正如工厂组合它的 factory 函数、访问者组合它的操作、构建器组合它的步骤一样:
而且,与函数式构建器步骤一样,装饰也是一个可复用的头等值。例如,同一个折扣可以应用两次:Product wrapped = DISCOUNTED.andThen(DISCOUNTED).apply(book); // 100 -> 90 -> 81
面向对象装饰器将组件包装在一堆共享接口的对象中,而函数式装饰器则把完全相同的叠加表达为纯 Product 到 Product 函数的组合。“乌龟叠乌龟,一路向下”,两者最终都落在同一个增强产品上。
策略
策略(Strategy)设计模式属于行为型模式,其目的是定义一族算法,将每个算法分别封装起来,并使它们可以互换,从而使算法可以独立于使用它的客户端而变化。装饰器问的是“这个对象还应该发生什么?”,策略问的是“这些算法中应该应用哪一个?”。我们沿用相同的 Product 领域,并为其计算运费。提供三种可互换的算法:
- 标准(Standard):固定费用
4.99。 - 快递(Express):
9.99加上产品价格的2%。 - 满额免运费(FreeOver):常见的“满 50.00 免运费”商业规则。它以价格阈值和未达到阈值时要应用的策略为参数:如果产品价格大于或等于阈值,则免运费;否则产品不符合条件,费用由另一个策略计算。对于我们的
100.00图书,标准运费为4.99,快递运费为11.99。对于满额免运费策略,阈值为50.00并配合StandardShipping()策略时,费用为0.00,因为100.00高于阈值。将阈值提高到150.00则回退到标准运费,因此费用为4.99。请注意,与访问者不同,这里没有任何与产品类型相关的变化:变化的是算法,并由调用方选择。
面向对象方法
下图展示了面向对象策略的类图:
[LOADING...]
经典的 GoF 策略为一个算法族声明接口,并为每个算法声明一个类:
StandardShipping 和 ExpressShipping 是无状态的,其费用是常量。但一个需要参数化的算法除了实例字段之外无处保存参数,因此会变成一个有状态的类。FreeOverShipping 就是这种情况,它同时持有阈值和低于阈值时回退的策略,每一对这样的值都定义了一种不同的算法:
最后同样重要的是,上下文(context)是使用算法但不知道具体是哪个算法的对象。它只持有一个接口引用,这正是算法可以在运行时被替换的原因:
与访问者和装饰器不同,策略对它处理的对象完全没有任何要求:没有 accept 方法,也没有共享的组件接口。因此,这是面向对象这边第一次出现的情况:模块直接复用了密封的 common.Product,既没有自己的层级结构,也没有适配器。
函数式方法
现在看一下函数式风格实现的类图:
[LOADING...]
在目前所见的所有模式中,这是函数式答案最为激进的一个。面向对象实现中的 ShippingStrategy 接口只声明了一个方法,且不持有状态,因此它告诉我们的全部信息就是:一个 Product 进去,一个 BigDecimal 出来。用函数式的术语来说,它不过是一个 Function 类型。于是每个算法都变成该函数类型的一个普通值,例如:
与面向对象一侧需要 FreeOverShipping 类来保存阈值和运费策略不同,函数式一侧通过闭包捕获它们。因此,面向对象中这个类,在函数式一侧变成了高阶函数,即返回策略本身的函数:
同样的情况也发生在 ShippingCalculator 上,即面向对象一侧的上下文类。它存在的全部意义就是在一个字段中保存策略,以便其cost()和total()操作可以委托给它。但上下文只不过是一个以算法为参数的操作,而这恰恰又是一个高阶函数。因此,ShippingCalculator.total()方法变成了:
于是,面向对象一方的调用:
在函数式一方变成了:BigDecimal total = totalWith(STANDARD).apply(book);
不再有字段来保存策略了,自然也没有了setStrategy()方法。这里的策略只是一个参数,不需要存储在上下文中,只需用正确的值调用函数即可。但策略作为普通值的真正优势在于它们可以被组合。在面向对象一方,要从几种配送方案中选出最便宜的一种还需要另一个类,而在函数式一方,这只是一个简单的组合器:Function best = cheapest(STANDARD, EXPRESS); // 4.99
而且像往常一样,它们可以用andThen来组合,例如对任何已计算出来的费用应用促销:
面向对象的策略模式将每个算法封装在实现公共接口的类中,并把所选算法注入上下文对象;而函数式的做法则观察到,这样的接口描述的无非就是JDK已经提供的函数类型,因此只保留算法本身。“乌龟驮乌龟,一路向下”,两者计算出相同的费用。
项目结构
函数式工厂、函数式访问者和函数式装饰器都直接操作common中的记录,因此那里没有重复代码;而策略模式的两边也都是如此。两个例外是面向对象的访问者和面向对象的装饰器。访问者需要在每个元素上有一个accept方法(双重分派)。装饰器需要一个非sealed的Product接口,以便其包装器实现。在两种情况下,common.Product都是sealed的,无法从另一个模块扩展,因此每个模式都拥有自己的元素/组件类型,并且只复用ProductType枚举。面向对象的装饰器通过一个小型BaseProduct适配器桥接回common。
这种不对称并非偶然。经典的访问者要求每个元素都暴露accept方法,经典的装饰器要求每个组件都共享包装器的接口。两者都将元素耦合到模式的抽象上,因此它们不能是common中定义的sealed记录。函数式方法没有这种耦合:它从外部操作sealed类型,访问者用模式匹配,装饰器通过工厂重建,因此元素对应用于它们的操作一无所知,也正因为如此,它们可以是共享的common记录。策略模式则从另一个方向印证了这条规则:它同样没有把元素耦合到其抽象上,只是把客户端耦合到抽象上,而这恰恰就是为什么它是这里唯一一种面向对象实现能像函数式对应那样自由复用common的模式。
这些示例的完整代码(包括相关的单元测试)可以在这里找到。
祝大家暑假愉快!
面向对象编程 函数式编程(编程语言)Java(编程语言)