本文是软件构造系列的第六篇文章。其余文章在:
https://ruanx.net/tag/software-construction/
什么是工厂方法
众所周知,对象的方法大致可以分为 Creator、Producer、Observer、Mutator 四类。其中的 Creator 是创建一个新的实例,静态 Creator 方法一般称为工厂方法。
对于单个类而言,采用工厂方法而不是 new MyClass()
的好处有很多。首先,可以使得代码的语义变得更加通顺,例如 Guava 的数据结构中,提供了 of
这个工厂方法:
Set<String> s = ImmutableSet.of("hello", "www", "blue");
显然,这比起 new ImmutableSet("hello", "www", "blue")
更加容易理解。此外,一个类可以有很多个工厂方法,仍以 Set
为例:我们可以直接在参数中指定几个元素,可以从文件中读取元素,可以从数据库中获取元素……这些可以利用工厂方法,实现为 MySet.of()
, MySet.fromFile(),
Myset.fromDatabase()
. 比起 new
一个集合并手动插入元素,显著节省了 client 的工作量。
另外,工厂方法为单例模式提供了支持。假设我们有一个类,希望所有的客户端都采用同一个实例,也就是说,不允许各个客户端 new
一个实例出来。那我们可以这样写:
工厂方法是好的,不过我们刚刚的讨论仅限于一个单独的类。如果有很多个产品需要创建实例,我们可以实现一个大工厂,让用户指定需要的产品,返回对应产品的实例。这种模式实现了“创建与使用相分离”,称为简单工厂模式。
下面来看一个例子。今有两种笔(分别记为 A, B)可以生产,定义如下:
interface Pen {
void show();
}
class PenA implements Pen{
public void show() {
System.out.println("I am Pen A.");
}
}
class PenB implements Pen{
public void show() {
System.out.println("I am Pen B.");
}
}
接下来,为了统一接口,让用户只需要利用单独的工厂方法就能制造任何一种笔,我们用一个新的类来完成“生产”:
class Factory {
public static Pen getPen(String target) {
if ("A".equals(target))
return new PenA();
else
return new PenB();
}
}
public class SimpleFactory {
public static void main(String[] args) {
Factory.getPen("A").show();
}
}
于是创建和使用得以分离。上面这个 Factory
不仅采用了简单工厂模式,还采用了 facade 模式。
现在,我们有了新的问题。假设我们要添加一个产品 C,那么为了允许 client 创建一个 C 产品,我们必须修改 Factory 类。这违反了开闭原则。
为了在符合开闭原则的情况下,保留工厂方法“client无需知道具体产品的规约,只需要知道工厂方法的规约”这个优点,我们改用工厂方法模式。
工厂方法模式
工厂方法模式对上述问题的解决方案是单一责任。提供很多个工厂类,每个工厂类只负责生产一个产品。当增加产品时,显然只需要增加一个工厂类,无需更改其他工厂、无需更改 client 代码。
看一个例子。 Pen
沿用上述代码,我们创建多个工厂:
interface PenFactory {
public Pen createPen();
}
class CreatorA implements PenFactory {
public Pen createPen() {
return new PenA();
}
}
class CreatorB implements PenFactory {
public Pen createPen() {
return new PenB();
}
}
public class FactoryPattern {
public static void main(String[] args) {
PenFactory fac = new CreatorA();
fac.createPen().show();
}
}
这样,客户端想要产品 A,就用 CreatorA
来生产一个;想要别的,就用其他工厂类来生产。于是达到了“对扩展开放、对修改封闭”的原则。
但是,这种模式比起给每个产品类添加自己的工厂方法,到底好在哪里?
- 接口隔离。客户端不需要知道各个产品的
class
名称,只需要知道对应工厂的名称。有一些复杂系统,产品名称非常冗长,是内部名称,不方便暴露给用户。 - 单一责任。创建与使用分离得很开,这对于创建产品时的额外工作时有利的。例如,假设开发者想对某个类搞单例模式,将这种代码写进对应类里面的静态工厂方法是不太合适的——类里面只应该写那个类该干的事情,而“这个类全局只能有一个”是外部“环境”该考虑的事情,不是这个类本身的责任,所以写在这个类外面更加适当。
- 路由。工厂方法模式并不是固定的“一个产品类对应一个工厂类”;相反地,可以多个工厂类对应一个产品类,甚至一个产品类不对应任何工厂(弃用)。举一个例子,假设 A 产品产自中国,B 产品产自欧盟,我们的 client 需要一支“中国笔”,侧重的是“产自中国”这个要素,而不关心它的编号是 A 还是 B. 此时,可以用下面这个工厂来满足客户需求:
class CreateMadeInChinaPen implements PenFactory {
public Pen createPen() {
return new PenA();
}
}
于是对 client 封闭了自己的实现细节,client 只需要关注自己想要的产品特征,把这些特征交由我们的工厂实现即可。
另外,工厂模式可以轻微地违背开闭原则,来提供“更新”功能。比如,现在 A 笔更贵,所以我们实现一个工厂:
class CreateExpensivePen implements PenFactory {
public Pen createPen() {
return new PenA();
}
}
客户端如果想要一支最贵的笔,只需要调用 CreateExpensivePen
这个工厂。如果我们又研制出了 C 笔,这支笔售价比 A 还贵,我们可以把上面的实现改为:
class CreateExpensivePen implements PenFactory {
public Pen createPen() {
return new PenC();
}
}
版本升级后,client 取得的最贵的笔变成了 C,也就是更新了。与此同时,client 自己的代码是无需修改的!我们轻微地修改了工厂,同时保证了 client 对修改封闭,只需要客户升级库版本,就能得到最新的正确的 ExpensivePen
.
最后,工厂方法模式与简单工厂模式相比,Creator 从静态方法变成了普通方法。这样做的理由是,java 中静态方法不继承,所以我们改用普通方法,以便于继承“父产品”的特征,更好地做到代码复用。
抽象工厂模式
现在我们可以利用工厂方法模式来创建单一产品了。抽象工厂模式在此基础上更进一步,提供了创建“产品族”的接口。
何谓“产品族”也?我们知道,各个厂商的笔记本电脑与手机适配得比较好。例如小米笔记本用户有很多是小米手机用户、苹果电脑用户有很多都是苹果手机用户。我们将这种一致性抽象出来:
- 一个产品族,是指多种产品的组合。
- 如果采用了某个产品族, 则所有产品都得使用这个产品族的。
例如,以下表格的列是“产品”,行是“产品族”:
手机 | 电脑 |
---|
小米产品族 | 小米9 | Mi Air |
苹果产品族 | iphone8 | MacBook |
一个人可以用小米9+Mi Air,但不可以用 iphone8 + Mi Air. 这就是产品族的意义:选择了小米,就选择了它的全套解决方案。
现在,假设一个客户想创建小米的手机和小米的电脑,但不知道这些产品的具体型号。也就是说,客户关注的是“品牌”,而非产品本身的标签。要客户去分别调用小米9、Mi Air 的工厂方法显然是不合适的,因为客户不知道这些单个产品的接口。抽象工厂模式应运而生——它为用户提供了“创建一系列产品”的功能。
首先,我们定义上面这四种产品:
interface Phone {
public void show();
}
class Mi9 implements Phone {
public void show() {
System.out.println("I am phone - Mi 9.");
}
}
class iphone8 implements Phone {
public void show() {
System.out.println("I am phone - iphone 8.");
}
}
interface Laptop {
public void show();
}
class MiAir implements Laptop {
public void show() {
System.out.println("I am laptop - Mi air.");
}
}
class MacBook implements Laptop {
public void show() {
System.out.println("I am laptop - MacBook.");
}
}
接下来,定义抽象工厂的接口:
interface BrandFactory {
Phone createPhone();
Laptop createLaptop();
}
一个抽象工厂可以产出手机和平板电脑,也可以仅产出一个或者根本不产出。所以提供了 createPhone
和 createLaptop
两个接口,让客户按需调用。
小米工厂产出的是 Mi 9
和 Mi Air
产品,苹果工厂产出的是 iphone8
和 MacBook
产品。工厂实现如下:
class MiFactory implements BrandFactory {
public Phone createPhone() {
return new Mi9();
}
public Laptop createLaptop() {
return new MiAir();
}
}
class AppleFactory implements BrandFactory {
public Phone createPhone() {
return new iphone8();
}
public Laptop createLaptop() {
return new MacBook();
}
}
最后,client 只需要选择一个品牌,调用其抽象工厂的接口,即可创建出自己想要的产品,全程无需知道产品的名字等细节。
public class AbstractFactory {
public static void main(String[] args) {
BrandFactory fac = new MiFactory();
fac.createLaptop().show(); // Mi Air
fac.createPhone().show(); // Mi 9
}
}
讨论一下抽象工厂的开闭原则问题。我们首先要有一个 big picture:抽象工厂模式,就像关系型数据库里的一张名为“解决方案”的表。每一列对应一种产品种类(例如手机、电脑);每一行对应一种解决方案(例如小米方案、苹果方案),也就是一个产品族。
新增一个产品族,在这张表上体现为新增一行记录,这是非常方便的,无需改动表的其他部分。这方面是完美地符合开闭原则。
要改动一个产品族的某个产品(例如把小米方案的手机改成 Mi 10),相当于在表上修改一条记录。只需要改动小米方案的抽象工厂,无需改动 client 代码。这里轻微地违反了开闭原则,但客户端无需改动。
但如果想要引入某个产品种类(例如,引入电视机产品),那就麻烦了——这个任务相当于在表上添加一个列,是要改动所有行的。每一个产品族,都得为电视机产品添加创建策略,所以引入新产品种类是严重违背开闭原则的。不过,引入电视机之前的 client 代码仍然无需改动,因为它们无需使用电视。
最后需要提一句,抽象工厂模式没有限制“一个产品只能被一个产品族包含”。例如,有些产品小米根本没有,这种情况下抽象工厂可以返回其他厂商的对应产品。抽象工厂也可以实现“最佳推荐单”——每个产品取自不同的厂商,最后组合成一个产品族,一起发挥效用。通过继承、decorator 模式等操作,还可以实现不同业务场景的组合——例如“小米的电子设备解决方案+Jeep的出行解决方案+晨光的办公文具解决方案”。
抽象工厂是很灵活的设计模式,但我认为其核心思想是不变的:“减少用户的选择余地”。本来用户的选择方案空间,是各种产品集合的笛卡尔积;抽象工厂模式大大降低了组合数量,仅提供几套工作较好的选择方案给用户。这可以避免用户在不知道各种产品细节的情况下作出较坏决策,也向用户隐藏了自己的内部实现。