跳转到内容

以工厂函数取代构造函数

以工厂函数取代构造函数(Replace Constructor with Factory Function)用一个有名字的普通函数来创建对象,调用方不再直接调用构造函数。 它解决的痛是:构造函数的名字固定是类名,说不出“创建的是哪一种”,只能靠参数里的类型码暗示;它也只能返回这个类本身的新实例,不能返回子类,也不能复用已有对象。

人事系统里,员工用 Employee(name, type_code) 创建,类型码 "E" 是工程师,"M" 是经理,"S" 是销售。新员工入职时,类型来自入职表单;而项目组创建技术负责人时,代码里直接写死了 Employee("老周", "E")。

"""员工创建:重构前。"""
class Employee:
TYPE_NAMES = {"E": "工程师", "M": "经理", "S": "销售"}
def __init__(self, name: str, type_code: str) -> None:
self.name = name
self.type_code = type_code
@property
def type_name(self) -> str:
return self.TYPE_NAMES[self.type_code]
if __name__ == "__main__":
doc = {"name": "小孙", "emp_type": "S"}
candidate = Employee(doc["name"], doc["emp_type"])
lead_engineer = Employee("老周", "E")
for employee in (candidate, lead_engineer):
print(employee.name, employee.type_name)
# 输出:
# 小孙 销售
# 老周 工程师
// 员工创建:重构前。
const TYPE_NAMES: Record<string, string> = { E: "工程师", M: "经理", S: "销售" };
export class Employee {
constructor(
readonly name: string,
readonly typeCode: string,
) {}
get typeName(): string {
return TYPE_NAMES[this.typeCode];
}
}
const doc = { name: "小孙", empType: "S" };
const candidate = new Employee(doc.name, doc.empType);
const leadEngineer = new Employee("老周", "E");
for (const employee of [candidate, leadEngineer]) {
console.log(employee.name, employee.typeName);
}
// 输出:
// 小孙 销售
// 老周 工程师

如果现在要加一个需求:工程师要多一个“技术职级”字段和一套自己的考核规则,打算做成 Employee 的子类 Engineer。可是所有创建员工的地方写的都是 Employee(...),构造函数只能返回 Employee 本身,没法根据类型码返回子类。于是每个调用方都得改成自己判断:类型码是 "E" 就 Engineer(...),否则 Employee(...),这段判断会散落在入职、导入、测试数据等每个创建员工的地方。

Employee("老周", "E") 本身也难读。"E" 是什么,要去类里查类型码表。写错成 "e" 或 "EN",构造时不会报错,要等到读 type_name 时才抛 KeyError,离出错的那一行已经很远。

用第 2 章的名字说,这是基本类型偏执:一个字符串类型码承担了“员工是哪一种”这个概念,而构造函数让调用方不得不直接面对它。

"""员工创建:重构后。"""
class Employee:
TYPE_NAMES = {"E": "工程师", "M": "经理", "S": "销售"}
def __init__(self, name: str, type_code: str) -> None:
self.name = name
self.type_code = type_code
@property
def type_name(self) -> str:
return self.TYPE_NAMES[self.type_code]
def create_employee(name: str, type_code: str) -> Employee:
return Employee(name, type_code)
def create_engineer(name: str) -> Employee:
return Employee(name, "E")
if __name__ == "__main__":
doc = {"name": "小孙", "emp_type": "S"}
candidate = create_employee(doc["name"], doc["emp_type"])
lead_engineer = create_engineer("老周")
for employee in (candidate, lead_engineer):
print(employee.name, employee.type_name)
# 输出:
# 小孙 销售
# 老周 工程师
// 员工创建:重构后。
const TYPE_NAMES: Record<string, string> = { E: "工程师", M: "经理", S: "销售" };
export class Employee {
constructor(
readonly name: string,
readonly typeCode: string,
) {}
get typeName(): string {
return TYPE_NAMES[this.typeCode];
}
}
export function createEmployee(name: string, typeCode: string): Employee {
return new Employee(name, typeCode);
}
export function createEngineer(name: string): Employee {
return new Employee(name, "E");
}
const doc = { name: "小孙", empType: "S" };
const candidate = createEmployee(doc.name, doc.empType);
const leadEngineer = createEngineer("老周");
for (const employee of [candidate, leadEngineer]) {
console.log(employee.name, employee.typeName);
}
// 输出:
// 小孙 销售
// 老周 工程师

看上去只是多包了一层,好处在于以后。Engineer 子类来了,只改 create_employee 和 create_engineer 这两个函数的函数体,让它们在类型码是 "E" 时返回 Engineer,所有调用方一行不动。这就是第 52 章以子类取代类型码的第一步。类型码的校验也有了地方放:create_employee 里先查类型码表,写错的 "EN" 在创建时就报错。

create_engineer("老周") 不需要解释,类型码 "E" 从调用方消失了。工厂函数还能做构造函数做不到的另外两件事:返回已有对象(比如同一个员工编号只创建一次,和第 33 章将值对象改为引用对象里从仓库按编号取客户是同一个思路),以及根据参数返回不同的子类。

每一步之后都跑测试。

  1. 新建工厂函数 create_employee(name, type_code),函数体只调用构造函数。
  2. 逐个把调用构造函数的地方改成调用工厂函数。搜索 Employee( 就能找全;Java 的 IntelliJ IDEA 有现成的 Replace Constructor with Factory Method,Python 和 TS 的 IDE 一般没有,靠查找替换。
  3. 传字面量类型码的调用方,比如 Employee("老周", "E"),可以再建一个更具体的工厂函数 create_engineer(name) 改用它。
  4. 尽量限制构造函数的可见性。TS 可以把构造函数标成 private,此时工厂要写成类的静态方法;Python 没有私有构造函数,靠约定和文档说明“请用工厂创建”。

以工厂函数取代构造函数:用一个普通函数代替构造函数来创建对象,调用方只调用这个函数。

它适合三种情况:创建时要根据参数决定返回哪个子类;创建时想复用已有对象或做缓存;同一个类有几种常见的创建方式,需要各自有个名字。Python 的 classmethod 备用构造器就是第三种的惯用写法,比如 datetime.fromtimestamp(ts)、dict.fromkeys(keys)、int.from_bytes(data, "big"),类名后面跟一个说明“从什么创建”的名字。

本章的结果是简单工厂,也常被看作《设计模式》里工厂方法(Factory Method)模式的入门形态。严格地说,GoF 的工厂方法是父类里一个由子类重写的创建方法,让子类决定实例化哪个类;本章的 create_employee 是一个独立函数,没有这层继承。两者的共同点是“调用方不直接 new”,这也是本章所有好处的来源:一旦创建过程被收进一个函数,它就可以返回子类、可以缓存、可以起一个说明意图的名字。等到 create_employee 要根据类型码返回不同子类、而这个选择又需要随场景替换时,就该看工厂方法和抽象工厂了。工厂方法模式见《设计模式》书第 3 章。

new Array(...) 是构造函数难以表达意图的经典例子:只传一个数字时,它表示数组长度;传多个参数时,它们是数组元素。new Array(3) 得到长度为 3 的空数组,new Array(3, 4) 得到 [3, 4]。ES2015 为此加了两个工厂函数:Array.of 总是把参数当元素,Array.from 从可迭代对象或类数组对象创建。

// 构造函数的参数含义随个数变化;工厂函数的名字说清了意图。
const a = new Array(3); // 长度为 3 的空数组
const b = new Array(3, 4); // 元素 3 和 4
const c = Array.of(3); // 元素 3
const d = Array.from({ length: 3 }, (_, i) => i * 2); // 按长度生成
console.log(a.length, JSON.stringify(b));
console.log(JSON.stringify(c), JSON.stringify(d));
// 输出:
// 3 [3,4]
// [3] [0,2,4]

Array.of(3) 和 Array.from(...) 的名字本身就是文档。构造函数的毛病修不掉,因为要兼容旧代码,于是标准的做法正是本章的手法:保留构造函数,另给工厂函数,引导新代码用工厂。

类只有一种创建方式、也没有子类和缓存的打算时,别加工厂。一个 Point(x, y) 包一层 create_point(x, y),只是多了一个名字要记,读代码的人还会好奇工厂里是不是藏着别的逻辑。Python 的数据类和 TS 的普通类,直接调用构造函数是最清楚的写法。等到真的需要返回子类或复用对象时再加,那时改动也只是本章四步。

  • 构造函数的名字固定、只能返回本类的新实例;工厂函数能起说明意图的名字、能返回子类、能复用对象。
  • 传字面量类型码的地方,换成 create_engineer 这类具体的工厂函数,类型码从调用方消失。
  • 先建工厂、再逐个替换调用方,最后尽量限制构造函数的使用。
  • 它是第 52 章以子类取代类型码的准备步骤,也是工厂方法模式的起点。