重新思考 Java 设计模式:从面向对象到函数式编程

预估阅读时长:26分钟

重新思考 Java 设计模式:从面向对象到函数式编程

本文旨在采用更系统、更实用的方法,将 Java 面向对象原则与函数式风格相结合。

对于那些想知道如何将函数式编程与面向对象编程集成或结合的人来说,函数式编程的回答通常是:乌龟叠乌龟,层层无穷(Turtles all the way down)

这句格言的出处归功于理查德·费曼(Richard Feynman)。在他 1985 年出版的《别闹了,费曼先生!》一书中,他讲述了在一次关于宇宙本质的讲座上,一位听众反驳他说宇宙是站在一只乌龟上的。费曼问那只乌龟站在什么上面,回答是:“另一只更大的乌龟”。当他得意地追问那只更大的乌龟又站在什么上面时,那位听众说:“下面还是乌龟,你骗不了我!”

这个隐喻在函数式编程的语境中经常被用来描述由递归原则支配的无限实体序列。对于来自面向对象思维模式的开发者,函数式编程的回答也同样是:“自底向上都用函数式”。但是,为了采用更系统的方法将面向对象原则与函数式风格结合起来,需要一个更实际的答案,而这正是我在此尝试做的事情。

作为开发者,幸运的是我们不必重新发明轮子。如今所有问题都已得到解决,尤其是在 LLM 智能体成为最常见数字基础设施之后。但令我们年轻的同事(他们离开 AI 活不过 48 小时)可能感到惊讶的是,即使在 LLM 出现之前,也存在一种将解决方案适配问题的通用方法,那就是设计模式

事实上,面向对象编程提出了可重复使用的、经过测试、验证和形式化的解决方案,称为设计模式,即使您没有意识到,您很可能已经使用过它们。

GoF(四人组,Gang of Four)将这些模式分为三类:

  • 行为型模式:处理对象之间的职责和通信。
  • 创建型模式:抽象对象的创建/实例化过程。
  • 结构型模式:组合对象以形成更大或增强的对象。

让我们选取每个类别中最常用的一些模式,看看如何将它们面向对象的固有本性与更函数式的方法结合起来。

工厂模式(Factory)

此设计模式属于创建型,其目的是在不暴露实现细节的情况下实例化对象。

面向对象的方法

下图展示了工厂设计模式的类图:

面向对象方法类图

我们这里的场景很简单:一个 Product 接口,由三个类实现:BookProductElectronicProductFashionProduct。它们可以通过 ProductFactory 类创建,如下所示:

public class ProductFactory
{
    public static Product newProduct (String name, String description, 
        BigDecimal price, ProductType productType)
    {
        Objects.requireNonNull(name, "Name is null");
        ...
        return switch (productType)
        {
            case BOOK -> new BookProduct(name, description, price);
            case ELECTRONIC -> new ElectronicProduct(name, description, price);
            case FASHION -> new FashionProduct(name, description, price);
            default -> throw new IllegalArgumentException ("Unknown type: %s".formatted(productType));
        };
    }
}

使用这个工厂,创建 BookProduct 非常容易,同时避免暴露实现细节:

...
Product product = ProductFactory.newProduct("Book1", "A book",
    new BigDecimal("20.50"), ProductType.BOOK);
...

您可能已经注意到,ProductType 枚举定义了三个类别。如果要引入新产品,则必须修改工厂以反映此业务变更。工厂与枚举之间的这种相互依赖使整个方法变得脆弱。

为了降低这种脆弱性,我们需要引入一种更函数式的方法来进行编译时验证。

函数式的方法

我们的例子是产品管理系统的过度简化情况。所展示的工厂实例化具有相同参数的不同简单记录。这些相同的构造器使我们能够将工厂直接移入 ProductType 枚举中,这样任何新产品都会自动要求相应的工厂。

Java 枚举类型基于常量名称,但我们可以为每个常量附加其对应的值。或者,更好的是,附加一个用于创建具体产品的工厂函数。看下面:

public enum ProductType
{
    ELECTRONIC(ElectronicProduct::new),
    FASHION(FashionProduct::new),
    BOOK(BookProduct::new);

    public final TriFunction<String, String, BigDecimal, Product> factory;

    ProductType (TriFunction<String, String, BigDecimal, Product> factory)
    {
        this.factory = factory;
    }

    public Product newInstance (String name, String description, BigDecimal price)
    {
        Objects.requireNonNull(name, "Name is null");
        ...
        return this.factory.apply (name, description, price);
    }
}

现在创建新的 Product 实例更加容易:

Product product = ProductType.BOOK.newInstance("Book1",
    "A book", new BigDecimal("20.45"));

现在有了专门用于实例创建的方法,公共属性 factory 似乎有些多余。但它提供了一种非常方便的函数式方式来进一步与工厂交互。例如:

ProductType.BOOK.factory.andThen(showThePrice).apply("Book1",
    "A book", new BigDecimal("20.45"));

fp_design_paterns.factory 包中的 TestProductFactory 类所示。当然,鉴于我们的产品需要三个参数的构造器,并且 Java 没有提供与 BiFunction 等价但具有三个输入参数的类,您需要自己编写一个 TriFunction 类,如下所示:

@FunctionalInterface
public interface TriFunction<A, B, C, R>
{
    R apply(A a, B b, C c);
    default <K> TriFunction<A, B, C, K> andThen(Function<? super R, ? extends K> f) 
    {
        Objects.requireNonNull(f);
        return (A a, B b, C c) -> f.apply(apply(a, b, c));
    }
}

您可以这样做,或者如果您像我一样更喜欢使用可靠的库,那么 Vavr 已经定义了一个具有您所需行为的 Function3 接口。只需包含以下 Maven 依赖:

<dependency>
    <groupId>io.vavr</groupId>
    <artifactId>vavr</artifactId>
    <version>1.0.1</version>
</dependency>

如果您需要定义最多 8 个参数的函数,这个库是一个不错的选择。然后,您只需在 ProductType 中替换以下定义:

public final TriFunction<String, String, BigDecimal, Product> factory;

ProductType (TriFunction<String, String, BigDecimal, Product> factory)
{
    this.factory = factory;
}

替换为:

public final Function3<String, String, BigDecimal, Product> factory;

ProductType (Function3<String, String, BigDecimal, Product> factory)
{
    this.factory = factory;
}

访问者模式(Visitor)

此设计模式属于行为型,其目的是向现有的对象层次结构添加新操作,而无需修改该层次结构的类。它是表达式问题(expression problem)的经典答案:当类型集合稳定但操作集合增长时,访问者允许您廉价地持续添加操作。

我们复用与工厂相同的领域:由 BookProductElectronicProductFashionProduct 实现的 Product。为了让访问者有存在的理由,每个操作现在根据产品类型表现不同:

  • 增值税(VAT):图书为 5.5% 的低税率,其他产品为标准的 20%。
  • 运费(Shipping):电子产品(易碎、需保险)为 10.00 + 价格的 2%,图书为固定 3.00,时尚品类为固定 5.00。
  • 折扣(Discount):电子产品 10%,图书 5%,时尚品类 15%。

面向对象的方法

经典的访问者模式依赖于双重分派(double dispatch)。每个 Product 接受一个访问者,并回调匹配其自身类型的重载方法:

public interface Product
{
    ...
    <R> R accept(ProductVisitor<R> visitor);
}

public record BookProduct (String name, String description, BigDecimal price) implements Product
{
    ...
    public <R> R accept(ProductVisitor<R> visitor)
    {
        return visitor.visit(this);
    }
}

操作存在于一个通用的访问者中,每个具体类型对应一个 visit 重载:

public interface ProductVisitor<R>
{
    R visit(ElectronicProduct product);
    R visit(BookProduct product);
    R visit(FashionProduct product);
}

计算任何产品的增值税只需应用一个具体的访问者:

BigDecimal vat = book.accept(new VatVisitor());

添加新操作(运费、折扣等)只需要一个新的 ProductVisitor 实现,而 Product 实现类从不改变。这与工厂模式所做的权衡正好相反:它使得添加新操作变得容易,但添加新产品类型成本更高,因为您必须修改其中心的 switch 语句。访问者模式使添加新操作成本为零,但将相同的成本转移到类型上,因为新产品类型现在迫使每个访问者都要更新。这是经典的表达式问题:您可以使类型易于添加,或使操作易于添加,但不能同时兼顾。

下图展示了面向对象实现的类图:

面向对象方法类图

函数式的方法

现在来看访问者函数式风格实现的类图:

访问者函数式风格实现类图

在现代 Java 中,访问者的函数式对应物是对密封类型(sealed type)的穷举模式匹配(exhaustive pattern matching)。我们首先密封层次结构:

public sealed interface Product permits ElectronicProduct, BookProduct, FashionProduct
{
    ...
}

然后一个操作就是一个 Function<Product, R>,构建在一个对每个记录进行解构的 switch 表达式之上。由于 Product 是密封的,编译器可以证明 switch 是穷举的——不需要默认分支,无需双重分派,无需 accept

public static final Function<Product, BigDecimal> VAT = product -> switch (product)
{
    case BookProduct(String name, String description, BigDecimal price) -> 
        amount(price, "0.055");
    case ElectronicProduct(String name, String description, BigDecimal price) ->
        amount(price, "0.20");
    case FashionProduct(String name, String description, BigDecimal price) -> 
        amount(price, "0.20");
};

作为普通函数,这些操作可以组合:

ProductOperations.DISCOUNT.andThen(amount -> "discount=" + amount).apply(fashion);

在经典的访问者模式和纯粹的模式匹配之间,存在一个中间步骤:将访问者视为一个函数包,每个类型一个 lambda,而不是每个类型一个方法的接口:

public record ProductVisitor<R>(
    Function<ElectronicProduct, R> onElectronic,
    Function<BookProduct, R> onBook,
    Function<FashionProduct, R> onFashion)
{
    public R visit(Product product)
    {
        return switch (product)
        {
            case ElectronicProduct e -> onElectronic.apply(e);
            case BookProduct b -> onBook.apply(b);
            case FashionProduct f -> onFashion.apply(f);
        };
    }
}

这使得操作成为可以在运行时动态组装的值:

ProductVisitor<BigDecimal> vat = new ProductVisitor<>(
    e -> ..., b -> ..., f -> ...);
BigDecimal amount = vat.visit(book);

建造者模式(Builder)

此设计模式也属于创建型,与工厂类似,但它解决的是不同的问题。工厂隐藏了实例化哪个具体类型,而建造者则逐步组装一个单一的复杂对象,将其构造与表示分离。它是** telescoping constructor 问题**的经典答案:一个对象具有许多参数,其中一些是必需的,大多数是可选的,否则其构造器会爆炸式地产生一系列重载。

我们的 Product 记录只有三个必需字段,因此不足以体现建造者模式。因此我们引入 Order:一个客户订单,将常见产品聚合为行项目,并添加了几个可选属性:优惠券代码、礼品包装标志和自由文本备注。无论采用哪种风格,目标都是相同的不可变值:

public record Order(
    String customer,
    String currency,
    List<Product> items,
    Optional<String> coupon,
    boolean giftWrapped,
    Optional<String> note)
{
    public Order
    {
        Objects.requireNonNull(customer, "Customer is null");
        Objects.requireNonNull(currency, "Currency is null");
        items = items == null ? List.of() : List.copyOf(items);
        coupon = coupon == null ? Optional.empty() : coupon;
        note = note == null ? Optional.empty() : note;
    }

    public BigDecimal subtotal() { ... }
}

面向对象的方法

下图展示了面向对象建造者的类图:

面向对象建造者类图

经典的 GoF 建造者是一个可变累加器(mutable accumulator)。必需参数预先捕获;可选参数通过流畅的调用链添加,每个调用都返回 thisbuild() 方法将累加的状态冻结为不可变的 Order

public final class OrderBuilder
{
    private final String customer;
    private final String currency;
    private final List<Product> items = new ArrayList<>();
    private String coupon;
    private boolean giftWrapped;
    private String note;

    public static OrderBuilder of(String customer, String currency) { ... }

    public OrderBuilder addItem(Product item) { items.add(item); return this; }

    public OrderBuilder coupon(String coupon) { this.coupon = coupon; return this; }

    public OrderBuilder giftWrap() { this.giftWrapped = true; return this; }

    public OrderBuilder note(String note) { this.note = note; return this; }

    public Order build()
    {
        return new Order(customer, currency, items,
            Optional.ofNullable(coupon), giftWrapped, Optional.ofNullable(note));
    }
}

构建订单读起来像一个句子,您只需提及实际需要的部分:

Order order = OrderBuilder.of("Alice", "EUR")
    .addItem(book).addItem(phone)
    .coupon("SUMMER").giftWrap()
    .build();

函数式的方法

现在来看函数式风格实现的类图:

函数式风格实现类图

函数式的对应物保持相同的不可变 Order 目标,但丢弃了可变累加器。每个构建步骤都成为一个一等公民的 UnaryOperator<Order>,一个纯函数,通过返回修改后的副本将一种不可变 Order 映射到下一个:

public static UnaryOperator<Order> addItem(Product item)
{
    return order -> new Order(order.customer(), order.currency(),
        Stream.concat(order.items().stream(), Stream.of(item)).toList(),
        order.coupon(), order.giftWrapped(), order.note());
}

由于这些步骤是普通值,它们不是在建造者上调用,而是使用 andThen 组合,这与工厂组合其工厂函数、访问者组合其操作的方式完全相同:

Function<Order, Order> config = addItem(book)
    .andThen(addItem(phone))
    .andThen(coupon("SUMMER"))
    .andThen(giftWrap());

Order order = config.apply(OrderBuilder.empty("Alice", "EUR"));

这不仅仅是风格上的差异。在面向对象版本中,步骤是一个方法调用,仅在该链的持续时间内存在。在函数式版本中,步骤是一个值,可以存储在变量中、传递给另一个方法、保存在步骤列表中稍后应用,或者重复使用同一个步骤两次:

UnaryOperator<Order> addBook = addItem(book);

Order order = addBook.andThen(addBook).apply(OrderBuilder.empty("Alice", "EUR"));

面向对象的建造者围绕不可变目标包装了一个有状态的对象,而函数式的建造者则将构造表示为在该目标上纯复制函数的组合。“乌龟叠乌龟,层层无穷”,两者最终都落在同一个 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”。

面向对象的方法

下图展示了面向对象装饰器的类图:

面向对象装饰器类图

经典的 GoF 装饰器是一个实现组件接口并持有对另一个组件引用的对象,它委托未触及的操作并覆盖它增强的操作。一个抽象的 ProductDecorator 一次性捕获委托逻辑:

public abstract class ProductDecorator implements Product
{
    protected final Product product;

    protected ProductDecorator(Product product)
    {
        this.product = Objects.requireNonNull(product, "Product is null");
    }

    public String name() { return product.name(); }

    public String description() { return product.description(); }

    public BigDecimal price() { return product.price(); }

    public ProductType type() { return product.type(); }
}

每个具体装饰器只覆盖它改变的部分:

public class Discounted extends ProductDecorator
{
    private static final BigDecimal RATE = new BigDecimal("0.10");

    public Discounted(Product product) { super(product); }

    public BigDecimal price()
    {
        return product.price().subtract(amount(product.price(), RATE));
    }

    public String description()
    {
        return product.description() + " (discounted)";
    }
}

由于装饰器是一个 Product,装饰器可以包装装饰器,增强通过嵌套组合:

Product wrapped = new GiftWrapped(new Taxed(new Discounted(new BaseProduct(book))));
BigDecimal price = wrapped.price();   // 113.00

被包装的叶子是一个 BaseProduct,一个小的记录,将共享的 common.Product 适配到装饰器自己的接口。这是必要的,因为 common.Product 是密封的,因此,与面向对象的访问者完全一样,装饰器无法直接让公共记录实现其接口。

函数式的方法

现在来看函数式风格实现的类图:

函数式风格实现类图

装饰器的函数式对应物只是一个将产品映射到增强产品的函数,实现为 UnaryOperator<Product>。由于公共记录是不可变的,“增强”一个意味着通过 ProductType 工厂重建它,我们在开头就已经看到,这就是为什么函数式这边直接复用 common 而无需适配器:

public static final UnaryOperator<Product> DISCOUNTED = product ->
    product.type().newInstance(product.name(),
        product.description() + " (discounted)",
        product.price().subtract(amount(product.price(), "0.10")));

作为普通值,装饰可以使用 andThen 组合,这与工厂组合其工厂函数、访问者组合其操作、建造者组合其步骤完全一样:

UnaryOperator<Product> decorate = DISCOUNTED.andThen(TAXED).andThen(GIFT_WRAPPED);
Product wrapped = decorate.apply(book);   // price 113.00

而且,就像函数式建造者步骤一样,装饰是一个可重用的一等公民值。例如,可以应用两次相同的折扣:

Product wrapped = DISCOUNTED.andThen(DISCOUNTED).apply(book);   // 100 -> 90 -> 81

面向对象的装饰器将组件包装在一堆共享其接口的对象中,而函数式的装饰器则将相同的堆叠表示为纯 ProductProduct 函数的组合。“乌龟叠乌龟,层层无穷”,两者最终都落在同一个增强产品上。

策略模式(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。

请注意,与访问者不同,这里没有任何东西因产品类型而异:变化的是算法,并且由调用者选择它。

面向对象的方法

下图展示了面向对象策略的类图:

面向对象策略类图

经典的 GoF 策略声明一个算法家族的接口,并为每个算法提供一个类:

public interface ShippingStrategy
{
    BigDecimal cost(Product product);
}

public class ExpressShipping implements ShippingStrategy
{
    private static final BigDecimal FEE = new BigDecimal("9.99");
    private static final BigDecimal RATE = new BigDecimal("0.02");

    public BigDecimal cost(Product product)
    {
        return FEE.add(product.price().multiply(RATE).setScale(2, RoundingMode.HALF_UP));
    }
}

StandardShippingExpressShipping 是无状态的,它们的费用是常量。但一个需要参数化的算法除了实例字段之外没有其他地方可以保存其参数,因此变成了一个有状态的类。FreeOverShipping 就是这种情况,它同时保存其阈值和在低于阈值时回退的策略,每一对这样的组合定义了一个不同的算法:

public class FreeOverShipping implements ShippingStrategy
{
    private final BigDecimal threshold;
    private final ShippingStrategy otherwise;

    public FreeOverShipping(BigDecimal threshold, ShippingStrategy otherwise) { ... }

    public BigDecimal cost(Product product)
    {
        return product.price().compareTo(threshold) >= 0 ? FREE : otherwise.cost(product);
    }
}

最后但同样重要的是,上下文(context) 是使用算法但不知道是哪个算法的对象。它只持有一个对接口的引用,这就是允许在运行时替换算法的原因:

ShippingCalculator calculator = new ShippingCalculator(new StandardShipping());
BigDecimal cost = calculator.cost(book);      // 4.99
BigDecimal total = calculator.total(book);    // 104.99
calculator.setStrategy(new ExpressShipping());
cost = calculator.cost(book);                 // 11.99
total = calculator.total(book);               // 111.99

与访问者和装饰器相反,策略对其处理的元素没有任何要求:没有 accept 方法,也没有共享的组件接口。因此,这是面向对象这边第一次发生的情况,该模块直接复用了密封的 common.Product,既没有自己的层次结构,也没有任何适配器。

函数式的方法

现在来看函数式风格实现的类图:

函数式风格实现类图

在我们迄今所见的所有模式中,这是函数式回答最激进的一个。面向对象实现中的接口 ShippingStrategy 声明了一个单一方法并且不持有状态,因此它告诉我们的全部信息就是:Product 进来,BigDecimal 出去。用函数式术语来说,它无非就是 Function<Product, BigDecimal> 类型。因此每个算法都成为该函数类型的一个普通值,例如:

public static final Function<Product, BigDecimal> EXPRESS = product ->
    EXPRESS_FEE.add(product.price().multiply(EXPRESS_RATE).setScale(2, RoundingMode.HALF_UP));

与面向对象这边需要 FreeOverShipping 类来持有阈值和运费策略不同,函数式这边将它们捕获在闭包中。所以面向对象这边的这个类,在函数式这边变成一个高阶函数,即一个返回策略本身的函数:

public static Function<Product, BigDecimal> freeOver(BigDecimal threshold,
    Function<Product, BigDecimal> otherwise)
{
    return product -> product.price().compareTo(threshold) >= 0 ? FREE : otherwise.apply(product);
}

ShippingCalculator 也是如此,它是面向对象这边的上下文类。它存在的全部原因是在字段中持有一个策略,以便其 cost()total() 操作可以委托给它。但上下文只是一个由算法参数化的操作,这再次正是高阶函数。因此,ShippingCalculator.total() 方法变成:

public static Function<Product, BigDecimal> totalWith(Function<Product, BigDecimal> strategy)
{
    return product -> product.price().add(strategy.apply(product));
}

使得面向对象这边的以下调用:

ShippingCalculator calculator = new ShippingCalculator(new StandardShipping());
...
BigDecimal total = calculator.total(book);

在函数式这边变为:

BigDecimal total = totalWith(STANDARD).apply(book);

不再有字段来持有策略,因此也没有 setStrategy() 方法。这里策略是一个参数,不需要存储在上下文中,只需使用正确的值调用函数即可。

但策略作为普通值的真正优势在于它们可以组合。从多个运输选项中选择最便宜的在面向对象那边还需要另一个类,而在这里只是一个简单的组合器:

Function<Product, BigDecimal> best = cheapest(STANDARD, EXPRESS);   // 4.99

而且像往常一样,它们可以与 andThen 组合,例如对计算出的任何成本应用促销:

Function<Product, BigDecimal> promo =
    EXPRESS.andThen(cost -> cost.divide(TWO, 2, RoundingMode.HALF_UP));   // 6.00

面向对象的策略将每个算法封装在实现公共接口的类中,并将选定的算法注入上下文对象中,而函数式的方法则观察到这样的接口不过描述了 JDK 已经提供的函数类型,因此只保留算法本身。“乌龟叠乌龟,层层无穷”,两者计算出相同的成本。

项目结构(Project Structure)

代码组织为一个多模块 Maven 项目。产品领域位于其自己的 common 模块中:一个密封的 Product 接口、三个产品记录以及 ProductType 枚举(该枚举已经携带了上面看到的 FP 工厂函数)。所有可以复用该领域的地方都复用了:

oop-fp-design-patterns        (父 POM)
├── common                    sealed Product, 记录, ProductType(+factory)
├── factory   (→ common)      ProductFactory (OOP); FP 工厂 *就是* common.ProductType
├── visitor   (→ common)      FP: 对公共记录的操作 (switch + lambda 包)
│                              OOP: 自己的元素层次结构 (见下文)
├── builder   (→ common)      基于公共记录的不可变 Order; OOP: 流畅的
│                              OrderBuilder; FP: 组合的 UnaryOperator<Order> 步骤
├── decorator (→ common)      FP: 对公共记录的组合 UnaryOperator<Product> 装饰;
│                              OOP: 自己的 Product 接口 (见下文)
└── strategy  (→ common)      基于公共记录的运费算法; OOP: 
                               ShippingStrategy 层次结构 + 上下文; FP: 普通
                               Function<Product, BigDecimal> 值

FP 工厂、FP 访问者和 FP 装饰器都直接操作公共记录,因此那里没有重复任何东西,而策略在其两边都这样做。两个例外是面向对象的访问者和面向对象的装饰器。

访问者需要在每个元素上有一个 accept 方法(双重分派)。装饰器需要一个非密封的 Product 接口,其包装器可以实现该接口。在这两种情况下,common.Product 都是密封的,不能从另一个模块扩展,因此每个都有自己的元素/组件类型,并且只复用了 ProductType 枚举。OOP 装饰器通过一个小的 BaseProduct 适配器桥接回 common。这种不对称并非偶然。

经典的访问者要求每个元素公开一个 accept 方法,经典的装饰器要求每个组件共享包装器的接口。两者都将元素耦合到模式的抽象,因此它们不能是 common 中定义的密封记录。

函数式方法没有这种耦合:它从外部操作密封类型,对于访问者进行模式匹配,对于装饰器通过工厂重建,因此元素对应用于它们的操作一无所知,因此可以是共享的公共记录。

策略则反过来证实了规则:它也没有将元素耦合到其抽象,只有客户端耦合到它,这正是为什么它是这里唯一一个面向对象实现像其函数式实现一样自由地复用 common 的模式。

这些示例的完整代码,包括相关的单元测试,可以在此处找到。

【注】本文译自:Rethinking Java Design Patterns: From OOP to FP